From 135d3a16777b4fe13d836c47daea13870776df60 Mon Sep 17 00:00:00 2001 From: Shaheen Gandhi Date: Wed, 1 Apr 2026 13:07:16 -0700 Subject: [PATCH 1/7] Add voice calling support (#1932) * Add voice call API types, protobuf definitions, and build dependencies Define call method interfaces in Manager, create API records (CallInfo, CallOffer, TurnServer), and hand-coded protobuf parsers for RingRTC signaling messages (ConnectionParametersV4, RtpDataMessage). Co-Authored-By: Claude Opus 4.6 * Implement call signaling state machine and message routing Add CallSignalingHelper for x25519 key generation and HKDF-based SRTP key derivation. Add CallManager for tracking active calls, spawning call tunnel subprocesses, and handling call lifecycle (offer, answer, ICE candidates, hangup, busy). Wire call message routing in IncomingMessageHandler and implement Manager call methods in ManagerImpl. Co-Authored-By: Claude Opus 4.6 * Add call state notification mechanism for JSON-RPC clients Implement CallEventListener callback pattern that fires on every call state transition (RINGING_INCOMING, RINGING_OUTGOING, CONNECTING, CONNECTED, ENDED). The JSON-RPC layer auto-subscribes and pushes callEvent notifications alongside receive notifications. Changes: - Manager.java: Add CallEventListener interface and methods - ManagerImpl.java: Implement add/removeCallEventListener with cleanup - DbusManagerImpl.java: Add stub implementation (not supported over DBus) - JsonCallEvent.java: JSON notification record for call events - SignalJsonRpcDispatcherHandler.java: Auto-subscribe call event listeners Co-Authored-By: Claude Opus 4.6 Co-Authored-By: Claude Sonnet 4.5 * Add JSON-RPC commands for voice call control Add startCall, acceptCall, hangupCall, rejectCall, and listCalls commands for the JSON-RPC daemon interface. Register commands and update GraalVM metadata for native image support. Co-Authored-By: Claude Opus 4.6 * Add call tunnel documentation Add documentation about the architecture, protocol, and implementation of signal-call-tunnel, the secure tunnel subprocess for voice calling. Co-Authored-By: Claude Opus 4.6 * Remove unused integration test tag from lib/build.gradle.kts The excludeTags("integration") block was added but no tests use the @Tag("integration") annotation. Revert to upstream's simple useJUnitPlatform() call. Co-Authored-By: Claude Opus 4.6 (1M context) * Derive install dir from jar location instead of nonexistent property The signal.cli.install.dir system property was never set by the Gradle start script or anywhere else. Replace it with code source detection: resolve the jar's parent directory to find the install root, then look for bin/signal-call-tunnel relative to that. Co-Authored-By: Claude Opus 4.6 (1M context) * Remove explicit success responses from hangup and reject commands Successful commands with no additional information should not return a response, matching the pattern used by other signal-cli commands like SendSyncRequestCommand and UpdateConfigurationCommand. Co-Authored-By: Claude Opus 4.6 (1M context) * Use instanceof pattern matching for call ID extraction Replace explicit null check and Number cast with instanceof pattern matching in AcceptCallCommand, HangupCallCommand, and RejectCallCommand. Co-Authored-By: Claude Opus 4.6 (1M context) * Guard handleIncoming* methods against missing call event listeners Skip processing incoming call offers when no call event listeners are registered, since there is nobody to notify about the call. For hangup and busy, also guard when there are no listeners AND no active call (the tunnel may still need cleanup if already spawned). Co-Authored-By: Claude Opus 4.6 (1M context) * Use Jackson JSON serialization in CallManager Replace all manual JSON string concatenation with Jackson ObjectNode construction and ObjectMapper serialization. Use BigInteger for call IDs to properly represent unsigned 64-bit values in JSON. Co-Authored-By: Claude Opus 4.6 (1M context) * Add subscribeCallEvents command for opt-in call event notifications Call events are no longer subscribed by default. JSON-RPC clients must explicitly call subscribeCallEvents to receive callEvent notifications and enable incoming call handling. This avoids sending unwanted call events to clients that don't use voice calling. Also adds unsubscribeCallEvents for cleanup, idempotent subscription guard, and updates CALL_TUNNEL.md to document the subscription step. Co-Authored-By: Claude Opus 4.6 (1M context) * Replace Unix socket with stdin/stdout for tunnel communication Use the tunnel subprocess' stdin for sending control messages and stdout for receiving control events, instead of a separate Unix domain socket. This eliminates: - Temporary directory creation (/tmp/sc-/) - Socket path and auth token in config JSON - Connection retry loop (50x at 200ms) - Auth message handshake - Socket cleanup on call end The tunnel's stderr is captured separately for logging. Config JSON is written as the first line on stdin, followed by control messages. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 --- build.gradle.kts | 8 + docs/CALL_TUNNEL.md | 354 ++++++++ .../org/asamk/signal/manager/Manager.java | 37 + .../asamk/signal/manager/api/CallInfo.java | 21 + .../asamk/signal/manager/api/CallOffer.java | 13 + .../asamk/signal/manager/api/TurnServer.java | 10 + .../signal/manager/helper/CallManager.java | 815 ++++++++++++++++++ .../asamk/signal/manager/helper/Context.java | 8 + .../helper/IncomingMessageHandler.java | 40 + .../signal/manager/internal/ManagerImpl.java | 158 ++++ .../manager/internal/SignalDependencies.java | 9 + .../manager/helper/CallManagerTest.java | 441 ++++++++++ .../signal/commands/AcceptCallCommand.java | 77 ++ .../org/asamk/signal/commands/Commands.java | 5 + .../signal/commands/HangupCallCommand.java | 47 + .../signal/commands/ListCallsCommand.java | 79 ++ .../signal/commands/RejectCallCommand.java | 47 + .../signal/commands/StartCallCommand.java | 80 ++ .../asamk/signal/dbus/DbusManagerImpl.java | 67 ++ .../org/asamk/signal/json/JsonCallEvent.java | 32 + .../SignalJsonRpcDispatcherHandler.java | 105 +++ .../signal-cli/reachability-metadata.json | 131 ++- .../commands/CallCommandParsingTest.java | 79 ++ .../asamk/signal/json/JsonCallEventTest.java | 110 +++ .../jsonrpc/SubscribeCallEventsTest.java | 351 ++++++++ 25 files changed, 3123 insertions(+), 1 deletion(-) create mode 100644 docs/CALL_TUNNEL.md create mode 100644 lib/src/main/java/org/asamk/signal/manager/api/CallInfo.java create mode 100644 lib/src/main/java/org/asamk/signal/manager/api/CallOffer.java create mode 100644 lib/src/main/java/org/asamk/signal/manager/api/TurnServer.java create mode 100644 lib/src/main/java/org/asamk/signal/manager/helper/CallManager.java create mode 100644 lib/src/test/java/org/asamk/signal/manager/helper/CallManagerTest.java create mode 100644 src/main/java/org/asamk/signal/commands/AcceptCallCommand.java create mode 100644 src/main/java/org/asamk/signal/commands/HangupCallCommand.java create mode 100644 src/main/java/org/asamk/signal/commands/ListCallsCommand.java create mode 100644 src/main/java/org/asamk/signal/commands/RejectCallCommand.java create mode 100644 src/main/java/org/asamk/signal/commands/StartCallCommand.java create mode 100644 src/main/java/org/asamk/signal/json/JsonCallEvent.java create mode 100644 src/test/java/org/asamk/signal/commands/CallCommandParsingTest.java create mode 100644 src/test/java/org/asamk/signal/json/JsonCallEventTest.java create mode 100644 src/test/java/org/asamk/signal/jsonrpc/SubscribeCallEventsTest.java diff --git a/build.gradle.kts b/build.gradle.kts index e8f4767c..286b5b94 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -91,6 +91,14 @@ dependencies { implementation(libs.logback) implementation(libs.zxing) implementation(project(":libsignal-cli")) + + testImplementation(libs.junit.jupiter) + testImplementation(platform(libs.junit.jupiter.bom)) + testRuntimeOnly(libs.junit.launcher) +} + +tasks.named("test") { + useJUnitPlatform() } configurations { diff --git a/docs/CALL_TUNNEL.md b/docs/CALL_TUNNEL.md new file mode 100644 index 00000000..b116c0b9 --- /dev/null +++ b/docs/CALL_TUNNEL.md @@ -0,0 +1,354 @@ +# Voice Call Support + +## Overview + +signal-cli supports voice calls by spawning a subprocess called +`signal-call-tunnel` for each call. The tunnel handles WebRTC negotiation and +audio transport. signal-cli communicates with the tunnel over its stdin/stdout +using newline-delimited JSON messages, relaying signaling between the tunnel +and the Signal protocol. + +``` +signal-cli signal-call-tunnel + | | + |-- spawn --------------------------->| + |-- config JSON on stdin ------------>| + | | + |-- commands on stdin --------------->| + |<-- events on stdout ----------------| + | | WebRTC + | signaling relay | audio I/O + | | + | (stderr: tunnel logging) -------->| (captured by signal-cli) +``` + +Each call gets its own tunnel process. When the call ends, signal-cli closes +stdin and destroys the process. + +Audio device names (`inputDeviceName`, `outputDeviceName`) are opaque strings +returned by the tunnel in its `ready` message. signal-cli passes them through +to JSON-RPC clients, which use them to connect audio via platform APIs. + +--- + +## Spawning the Tunnel + +For each call, signal-cli: + +1. Spawns `signal-call-tunnel` +2. Writes config JSON followed by a newline to stdin +3. Keeps stdin open for subsequent control messages +4. Reads control events from stdout +5. Captures stderr for logging + +The `signal-call-tunnel` binary is located by searching (in order): + +1. `SIGNAL_CALL_TUNNEL_BIN` environment variable +2. `/bin/signal-call-tunnel` (detected from jar location) +3. `signal-call-tunnel` on `PATH` + +### Config JSON + +The first line written to the tunnel's stdin: + +```json +{ + "call_id": 12345, + "is_outgoing": true, + "local_device_id": 1, + "input_device_name": "signal_input", + "output_device_name": "signal_output" +} +``` + +| 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., +`signal_input_`). On macOS, these are the fixed names `signal_input` +and `signal_output`, which must match the pre-installed BlackHole drivers. + +--- + +## Control Protocol + +Newline-delimited JSON messages over stdin (signal-cli to tunnel) and stdout +(tunnel to signal-cli). The first line on stdin is the config JSON. Subsequent +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)* | + +### 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` | + +Opaque blobs and identity keys are base64-encoded. ICE servers use the format: + +```json +{"urls":["turn:example.com"],"username":"u","password":"p"} +``` + +--- + +## Startup Sequence + +``` +signal-cli signal-call-tunnel + | | + |-- spawn process ------------------> | + |-- config JSON + newline on stdin ---->| + | | parse config + | | initialize audio + | | + |<-------- ready (on stdout) -----------| + | {"type":"ready", | + | "inputDeviceName":"...", | + | "outputDeviceName":"..."} | + | | + |-- control messages on stdin --------->| + |<-- control events on stdout ----------| +``` + +--- + +## Call Flows + +### Outgoing call + +``` +signal-cli signal-call-tunnel Remote Phone + | | | + |-- spawn + config ------->| | + |<-- ready ----------------| | + |-- createOutgoingCall --->| | + |-- proceed (TURN) ------->| | + | | create offer | + |<-- sendOffer ------------| | + |-- offer via Signal -------------------------------->| + |<-- answer via Signal -------------------------------| + |-- receivedAnswer ------->| (+ identity keys) | + |<-- sendIce --------------| | + |-- ICE via Signal -------------------------------> | + |<-- ICE via Signal -------------------------------- | + |-- receivedIce ---------->| | + | | ICE connects | + |<-- stateChange:Connected | | +``` + +### Incoming call + +``` +signal-cli signal-call-tunnel Remote Phone + | | | + |<-- offer via Signal --------------------------------| + |-- spawn + config ------->| | + |<-- ready ----------------| | + |-- receivedOffer -------->| (+ identity keys) | + |-- proceed (TURN) ------->| | + | | process offer | + |<-- sendAnswer -----------| | + |-- answer via Signal -------------------------------->| + |<-- sendIce --------------| | + |-- ICE via Signal ------------------------------> | + |<-- ICE via Signal -------------------------------- | + |-- receivedIce ---------->| | + | | ICE connecting... | + | | | + | (user accepts call) | | + | Java defers accept | | + | | | + |<-- stateChange:Ringing --| (tunnel ready to accept)| + |-- accept --------------->| (deferred accept sent) | + | | accept | + |<-- stateChange:Connected | | +``` + +### 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, +incoming calls are silently ignored (no tunnel is spawned). + +``` +JSON-RPC Client signal-cli daemon + | | + |-- subscribeCallEvents() ------------>| (required: enables call support) + | | + |-- startCall(recipient) ------------->| + |<-- {callId, state, -| + | inputDeviceName, | + | outputDeviceName} | + | | + |<-- callEvent: RINGING_OUTGOING ------| + | ... remote answers ... | + |<-- callEvent: CONNECTED -------------| + | | + | connect to audio devices | + | (via platform audio APIs) | + | | + |-- hangupCall(callId) --------------->| (or: receive callEvent ENDED) + |<-- callEvent: ENDED -----------------| + | disconnect from audio devices | +``` + +For incoming calls: + +``` +JSON-RPC Client signal-cli daemon + | | + |-- subscribeCallEvents() ------------>| (if not already subscribed) + | | + |<-- callEvent: RINGING_INCOMING ------| (includes callId, device names) + | | + |-- acceptCall(callId) --------------->| + |<-- {callId, state, -| + | inputDeviceName, | + | outputDeviceName} | + | | + |<-- callEvent: CONNECTING ------------| + |<-- callEvent: CONNECTED -------------| + | | + | connect to audio devices | + | (via platform audio APIs) | +``` + +To stop receiving call events, call `unsubscribeCallEvents`. + +--- + +## State Machine + +Call states as seen by JSON-RPC clients: + +``` + startCall() + | + v + +----- RINGING_OUTGOING ----+ RINGING_INCOMING -----+ + | | | | | + | (timeout | (answered) | (rejected) | acceptCall() | (timeout + | ~60s) | | | | ~60s) + v v v v v + ENDED CONNECTED ENDED CONNECTING ENDED + | | + | v + | CONNECTED + | | + | (hangup/error) | (hangup/error) + v v + ENDED ENDED +``` + +For outgoing calls, `CONNECTED` fires directly when the tunnel reports +`Connected` state -- there is no intermediate `CONNECTING` event. + +For incoming calls, `CONNECTING` is set by Java when the user calls +`acceptCall()`, before the tunnel completes ICE negotiation. + +Both directions have a 60-second ring timeout. + +Reconnection (ICE restart): + +``` + CONNECTED --> RECONNECTING --> CONNECTED (ICE restart succeeded) + | + v + ENDED (ICE restart failed) +``` + +`RECONNECTING` maps from the tunnel's `Connecting` state, which is emitted +during ICE restarts (not during initial connection). + +--- + +## CallManager.java + +`lib/src/main/java/org/asamk/signal/manager/helper/CallManager.java` + +Manages the call lifecycle from the Java side: + +1. Spawns `signal-call-tunnel` and writes config JSON to stdin +2. Keeps stdin open as the control write channel; reads stdout for control events +3. Captures stderr for tunnel logging +4. Parses `inputDeviceName` and `outputDeviceName` from the tunnel's `ready` + message and includes them in `CallInfo` +5. Translates tunnel state changes into `CallInfo.State` values and fires + `callEvent` JSON-RPC notifications to connected clients +6. Defers the `accept` message for incoming calls until the tunnel reports + `Ringing` state (sending earlier causes the tunnel to drop it) +7. Schedules a 60-second ring timeout for both incoming and outgoing calls +8. On hangup: sends hangup message, closes stdin, and destroys the process + +--- + +## Implementation Notes + +### Peer ID consistency + +The `peerId` field in `createOutgoingCall` and `receivedOffer` must be the actual +remote peer UUID (e.g., `senderAddress.toString()`). The tunnel rejects ICE +candidates if the peer ID doesn't match across calls, causing "Ignoring +peer-reflexive ICE candidate because the ufrag is unknown." + +### sendHangup semantics + +`sendHangup` from the tunnel is a request to send a hangup message via Signal +protocol. It is **not** a local state change -- local state transitions come +exclusively from `stateChange` events. For single-device clients, ignore +`AcceptedOnAnotherDevice`, `DeclinedOnAnotherDevice`, and +`BusyOnAnotherDevice` hangup types in the `hangupType` field -- sending these to +the remote peer causes it to terminate the call prematurely. + +### Call ID serialization + +Call IDs can exceed `Long.MAX_VALUE` in Java. Use `Long.toUnsignedString()` when +serializing to JSON for the tunnel (which expects unsigned 64-bit integers). In +the config JSON, `call_id` should also use unsigned representation. + +### Incoming hangup filtering + +When receiving hangup messages via Signal protocol, only honor `NORMAL` type +hangups. `ACCEPTED`, `DECLINED`, and `BUSY` types are multi-device coordination +messages and should be ignored by single-device clients. + +### JSON-RPC call ID types + +JSON-RPC clients may send call IDs as various numeric types (Long, BigInteger, +Integer). Use `Number.longValue()` rather than direct casting when extracting +call IDs from JSON-RPC parameters. + +### Identity key format + +Identity keys in `senderIdentityKey` and `receiverIdentityKey` must be **raw +32-byte Curve25519 public keys** (without the 0x05 DJB type prefix). If the +33-byte serialized form is used instead, SRTP key derivation produces different +keys on each side, causing authentication failures. + 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 e6a3ae3b..cff89612 100644 --- a/lib/src/main/java/org/asamk/signal/manager/Manager.java +++ b/lib/src/main/java/org/asamk/signal/manager/Manager.java @@ -64,6 +64,10 @@ 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) { @@ -413,9 +417,37 @@ public interface Manager extends Closeable { InputStream retrieveSticker(final StickerPackId stickerPackId, final int stickerId) throws IOException; + // --- Voice call methods --- + + CallInfo startCall(RecipientIdentifier.Single recipient) throws IOException, UnregisteredRecipientException; + + CallInfo acceptCall(long callId) throws IOException; + + void hangupCall(long callId) throws IOException; + + void rejectCall(long callId) throws IOException; + + List listActiveCalls(); + + void sendCallOffer(RecipientIdentifier.Single recipient, CallOffer offer) 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 sendHangup(RecipientIdentifier.Single recipient, long callId, MessageEnvelope.Call.Hangup.Type type) throws IOException, UnregisteredRecipientException; + + void sendBusy(RecipientIdentifier.Single recipient, long callId) throws IOException, UnregisteredRecipientException; + + List getTurnServerInfo() throws IOException; + @Override void close(); + void addCallEventListener(CallEventListener listener); + + void removeCallEventListener(CallEventListener listener); + interface ReceiveMessageHandler { ReceiveMessageHandler EMPTY = (envelope, e) -> { @@ -423,4 +455,9 @@ public interface Manager extends Closeable { void handleMessage(MessageEnvelope envelope, Throwable e); } + + interface CallEventListener { + + void handleCallEvent(CallInfo callInfo, String reason); + } } diff --git a/lib/src/main/java/org/asamk/signal/manager/api/CallInfo.java b/lib/src/main/java/org/asamk/signal/manager/api/CallInfo.java new file mode 100644 index 00000000..30b5d20d --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/api/CallInfo.java @@ -0,0 +1,21 @@ +package org.asamk.signal.manager.api; + +public record CallInfo( + long callId, + State state, + RecipientAddress recipient, + String inputDeviceName, + String outputDeviceName, + boolean isOutgoing +) { + + public enum State { + IDLE, + RINGING_INCOMING, + RINGING_OUTGOING, + CONNECTING, + CONNECTED, + RECONNECTING, + ENDED + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/api/CallOffer.java b/lib/src/main/java/org/asamk/signal/manager/api/CallOffer.java new file mode 100644 index 00000000..2c4aa251 --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/api/CallOffer.java @@ -0,0 +1,13 @@ +package org.asamk.signal.manager.api; + +public record CallOffer( + long callId, + Type type, + byte[] opaque +) { + + public enum Type { + AUDIO, + VIDEO + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/api/TurnServer.java b/lib/src/main/java/org/asamk/signal/manager/api/TurnServer.java new file mode 100644 index 00000000..8ffd03bf --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/api/TurnServer.java @@ -0,0 +1,10 @@ +package org.asamk.signal.manager.api; + +import java.util.List; + +public record TurnServer( + String username, + String password, + List urls +) { +} 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 new file mode 100644 index 00000000..b5d4eda5 --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/helper/CallManager.java @@ -0,0 +1,815 @@ +package org.asamk.signal.manager.helper; + +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.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +import java.io.PrintWriter; +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.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +/** + * Manages active voice calls: tracks state, spawns/monitors the signal-call-tunnel + * subprocess, routes incoming call messages, and handles timeouts. + */ +public class CallManager implements AutoCloseable { + + private static final Logger logger = LoggerFactory.getLogger(CallManager.class); + private static final long RING_TIMEOUT_MS = 60_000; + private static final ObjectMapper mapper = new ObjectMapper(); + + private final Context context; + private final SignalAccount account; + private final SignalDependencies dependencies; + private final Map activeCalls = new ConcurrentHashMap<>(); + private final List callEventListeners = new CopyOnWriteArrayList<>(); + private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(r -> { + var t = new Thread(r, "call-timeout-scheduler"); + t.setDaemon(true); + return t; + }); + + public CallManager(final Context context) { + this.context = context; + this.account = context.getAccount(); + this.dependencies = context.getDependencies(); + } + + public void addCallEventListener(Manager.CallEventListener listener) { + callEventListeners.add(listener); + } + + public void removeCallEventListener(Manager.CallEventListener listener) { + callEventListeners.remove(listener); + } + + private void fireCallEvent(CallState state, String reason) { + var callInfo = state.toCallInfo(); + for (var listener : callEventListeners) { + try { + listener.handleCallEvent(callInfo, reason); + } catch (Throwable e) { + logger.warn("Call event listener failed, ignoring", e); + } + } + } + + public CallInfo startOutgoingCall( + final RecipientIdentifier.Single recipient + ) throws IOException, UnregisteredRecipientException { + var callId = generateCallId(); + var recipientId = context.getRecipientHelper().resolveRecipient(recipient); + var recipientAddress = context.getRecipientHelper() + .resolveSignalServiceAddress(recipientId) + .getServiceId(); + var recipientApiAddress = account.getRecipientAddressResolver() + .resolveRecipientAddress(recipientId) + .toApiRecipientAddress(); + + var state = new CallState(callId, + CallInfo.State.RINGING_OUTGOING, + recipientApiAddress, + recipient, + true); + activeCalls.put(callId, state); + fireCallEvent(state, null); + + // Spawn call tunnel binary and connect control channel + spawnMediaTunnel(state); + + // Fetch TURN servers + var turnServers = getTurnServers(); + + // Send createOutgoingCall + proceed via control channel + var createMsg = mapper.createObjectNode(); + createMsg.put("type", "createOutgoingCall"); + createMsg.put("callId", callIdUnsigned(callId)); + createMsg.put("peerId", recipientAddress.toString()); + sendControlMessage(state, writeJson(createMsg)); + sendProceed(state, callId, turnServers); + + // Schedule ring timeout + scheduler.schedule(() -> handleRingTimeout(callId), RING_TIMEOUT_MS, TimeUnit.MILLISECONDS); + + logger.info("Started outgoing call {} to {}", callId, recipient); + return state.toCallInfo(); + } + + 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); + } + if (state.state != CallInfo.State.RINGING_INCOMING) { + throw new IOException("Call " + callId + " is not in RINGING_INCOMING state (current: " + state.state + ")"); + } + + // Defer the accept until the tunnel reports Ringing state. + // Sending accept too early (while RingRTC is in ConnectingBeforeAccepted) + // causes it to be silently dropped. + state.acceptPending = true; + // If the tunnel is already in Ringing state, send immediately + sendAcceptIfReady(state); + + state.state = CallInfo.State.CONNECTING; + fireCallEvent(state, null); + + logger.info("Accepted incoming call {}", callId); + return state.toCallInfo(); + } + + 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); + } + 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); + } + + 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); + } + + endCall(callId, "rejected"); + } + + public List listActiveCalls() { + return activeCalls.values().stream().map(CallState::toCallInfo).toList(); + } + + public List getTurnServers() throws IOException { + try { + var result = dependencies.getCallingApi().getTurnServerInfo(); + var turnServerList = result.successOrThrow(); + return turnServerList.stream() + .map(info -> new TurnServer(info.getUsername(), info.getPassword(), info.getUrls())) + .toList(); + } catch (Throwable e) { + logger.warn("Failed to get TURN server info, returning empty list", e); + return List.of(); + } + } + + // --- Incoming call message handling --- + + public void handleIncomingOffer( + final org.asamk.signal.manager.storage.recipients.RecipientId senderId, + 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); + } + return; + } + + var senderAddress = account.getRecipientAddressResolver() + .resolveRecipientAddress(senderId) + .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, + senderAddress, + senderIdentifier, + false); + state.rawOfferOpaque = opaque; + activeCalls.put(callId, state); + + // Spawn call tunnel binary immediately + spawnMediaTunnel(state); + + // 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[] remoteIdentityKey = getRemoteIdentityKey(state); + + // Fetch TURN servers + List turnServers; + try { + turnServers = getTurnServers(); + } catch (IOException e) { + logger.warn("Failed to get TURN servers for incoming call {}", callId, e); + turnServers = List.of(); + } + + // Send receivedOffer to subprocess + var offerMsg = mapper.createObjectNode(); + offerMsg.put("type", "receivedOffer"); + offerMsg.put("callId", callIdUnsigned(callId)); + offerMsg.put("peerId", senderAddress.toString()); + offerMsg.put("senderDeviceId", 1); + offerMsg.put("opaque", java.util.Base64.getEncoder().encodeToString(opaque)); + offerMsg.put("age", 0); + offerMsg.put("senderIdentityKey", java.util.Base64.getEncoder().encodeToString(remoteIdentityKey)); + offerMsg.put("receiverIdentityKey", java.util.Base64.getEncoder().encodeToString(localIdentityKey)); + sendControlMessage(state, writeJson(offerMsg)); + + // Send proceed with TURN servers + sendProceed(state, callId, turnServers); + + fireCallEvent(state, null); + + // Schedule ring timeout + scheduler.schedule(() -> handleRingTimeout(callId), RING_TIMEOUT_MS, TimeUnit.MILLISECONDS); + + logger.info("Incoming call {} from {}", callId, senderAddress); + } + + public void handleIncomingAnswer(final long callId, final byte[] opaque) { + var state = activeCalls.get(callId); + if (state == null) { + logger.warn("Received answer for unknown call {}", 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[] 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("senderIdentityKey", java.util.Base64.getEncoder().encodeToString(remoteIdentityKey)); + answerMsg.put("receiverIdentityKey", java.util.Base64.getEncoder().encodeToString(localIdentityKey)); + sendControlMessage(state, writeJson(answerMsg)); + + state.state = CallInfo.State.CONNECTING; + fireCallEvent(state, null); + + logger.info("Received answer for call {}", 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); + return; + } + + // Forward to subprocess as receivedIce + var iceMsg = mapper.createObjectNode(); + iceMsg.put("type", "receivedIce"); + 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); + } + + public void handleIncomingHangup(final long callId) { + if (callEventListeners.isEmpty() && !activeCalls.containsKey(callId)) { + return; + } + endCall(callId, "remote_hangup"); + } + + public void handleIncomingBusy(final long callId) { + if (callEventListeners.isEmpty() && !activeCalls.containsKey(callId)) { + return; + } + endCall(callId, "remote_busy"); + } + + // --- Internal helpers --- + + private void sendControlMessage(CallState state, String json) { + if (state.controlWriter == null) { + logger.debug("Queueing control message for call {} (not yet connected): {}", state.callId, json); + state.pendingControlMessages.add(json); + return; + } + state.controlWriter.println(json); + } + + private void sendProceed(CallState state, long callId, List turnServers) { + var proceedMsg = mapper.createObjectNode(); + proceedMsg.put("type", "proceed"); + proceedMsg.put("callId", callIdUnsigned(callId)); + proceedMsg.put("hideIp", false); + var iceServers = proceedMsg.putArray("iceServers"); + for (var ts : turnServers) { + var server = iceServers.addObject(); + server.put("username", ts.username()); + server.put("password", ts.password()); + var urls = server.putArray("urls"); + for (var url : ts.urls()) { + urls.add(url); + } + } + sendControlMessage(state, writeJson(proceedMsg)); + } + + private void spawnMediaTunnel(CallState state) { + try { + var command = new ArrayList<>(List.of(findTunnelBinary())); + + var processBuilder = new ProcessBuilder(command); + // Keep stdout and stderr separate: stdout = control protocol, stderr = logging + processBuilder.redirectErrorStream(false); + var process = processBuilder.start(); + + state.tunnelProcess = process; + + // Write config JSON to stdin, then keep stdin open for control messages + var config = buildConfig(state); + var stdinStream = process.getOutputStream(); + stdinStream.write(config.getBytes(StandardCharsets.UTF_8)); + stdinStream.write('\n'); + stdinStream.flush(); + + // stdin is the control write channel + state.controlWriter = new PrintWriter( + new OutputStreamWriter(stdinStream, StandardCharsets.UTF_8), true); + + // Flush any pending control messages + for (var msg : state.pendingControlMessages) { + state.controlWriter.println(msg); + } + state.pendingControlMessages.clear(); + + // If accept was deferred, send it now + sendAcceptIfReady(state); + + // Read control events from subprocess stdout + Thread.ofVirtual().name("control-read-" + 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))) { + String line; + while ((line = reader.readLine()) != null) { + logger.debug("[tunnel-{}] {}", state.callId, line); + } + } catch (IOException ignored) { + } + }); + + // Monitor process exit + process.onExit().thenAcceptAsync(p -> { + logger.info("Tunnel for call {} exited with code {}", state.callId, p.exitValue()); + if (activeCalls.containsKey(state.callId)) { + endCall(state.callId, "tunnel_exit"); + } + }); + + logger.info("Spawned signal-call-tunnel for call {}", state.callId); + } catch (Exception e) { + logger.error("Failed to spawn tunnel for call {}", state.callId, e); + endCall(state.callId, "tunnel_spawn_error"); + } + } + + private String findTunnelBinary() { + // Check environment variable first + var envPath = System.getenv("SIGNAL_CALL_TUNNEL_BIN"); + if (envPath != null && !envPath.isEmpty()) { + return envPath; + } + + // Check relative to the signal-cli installation directory + try { + var codeSource = CallManager.class.getProtectionDomain().getCodeSource(); + if (codeSource != null) { + var jarPath = Path.of(codeSource.getLocation().toURI()); + var binPath = tunnelBinaryFromCodeSourcePath(jarPath); + if (Files.isExecutable(binPath)) { + return binPath.toString(); + } + } + } catch (Exception e) { + logger.debug("Failed to determine install dir from code source", e); + } + + // Fall back to PATH + return "signal-call-tunnel"; + } + + /** + * Resolves the expected tunnel binary path from a code source path. + * The code source (jar or class dir) is expected to be in {@code /lib/}, + * so we go up two levels to reach the install root, then look for + * {@code bin/signal-call-tunnel}. + */ + static Path tunnelBinaryFromCodeSourcePath(Path codeSourcePath) { + var installDir = codeSourcePath.getParent().getParent(); + return installDir.resolve("bin").resolve("signal-call-tunnel"); + } + + private String buildConfig(CallState state) { + var config = mapper.createObjectNode(); + config.put("call_id", 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))) { + String line; + while ((line = reader.readLine()) != null) { + line = line.trim(); + if (line.isEmpty()) continue; + logger.debug("Control event for call {}: {}", state.callId, line); + + try { + var json = mapper.readTree(line); + var type = json.has("type") ? json.get("type").asText() : ""; + + switch (type) { + case "ready" -> { + if (json.has("inputDeviceName")) { + state.inputDeviceName = json.get("inputDeviceName").asText(); + } + if (json.has("outputDeviceName")) { + state.outputDeviceName = json.get("outputDeviceName").asText(); + } + logger.debug("Tunnel ready for call {}: input={}, output={}", + 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); + } + case "sendAnswer" -> { + var opaqueB64 = json.get("opaque").asText(); + var opaque = java.util.Base64.getDecoder().decode(opaqueB64); + sendAnswerViaSignal(state, opaque); + } + case "sendIce" -> { + var candidatesArr = json.get("candidates"); + var opaqueList = new ArrayList(); + for (var c : candidatesArr) { + opaqueList.add(java.util.Base64.getDecoder().decode(c.get("opaque").asText())); + } + 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"; + // 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); + } + } + case "sendBusy" -> { + sendBusyViaSignal(state); + } + case "stateChange" -> { + var ringrtcState = json.get("state").asText(); + var reason = json.has("reason") ? json.get("reason").asText(null) : null; + handleStateChange(state, ringrtcState, reason); + } + case "error" -> { + var message = json.has("message") ? json.get("message").asText("unknown") : "unknown"; + logger.error("Tunnel error for call {}: {}", state.callId, message); + endCall(state.callId, "tunnel_error"); + } + default -> { + logger.debug("Unknown control event type '{}' for call {}", type, state.callId); + } + } + } catch (Exception e) { + logger.warn("Failed to parse control event JSON for call {}: {}", state.callId, e.getMessage()); + } + } + } catch (IOException e) { + logger.debug("Control read ended for call {}: {}", state.callId, e.getMessage()); + } + } + + private void handleStateChange(CallState state, String ringrtcState, String reason) { + if (ringrtcState.startsWith("Incoming")) { + // Don't downgrade if we've already accepted + if (state.state == CallInfo.State.CONNECTING) return; + state.state = CallInfo.State.RINGING_INCOMING; + } else if (ringrtcState.startsWith("Outgoing")) { + state.state = CallInfo.State.RINGING_OUTGOING; + } else if ("Ringing".equals(ringrtcState)) { + // Tunnel is now ready to accept — flush deferred accept if pending + state.tunnelRinging = true; + sendAcceptIfReady(state); + return; + } else if ("Connected".equals(ringrtcState)) { + state.state = CallInfo.State.CONNECTED; + } else if ("Connecting".equals(ringrtcState)) { + state.state = CallInfo.State.RECONNECTING; + } else if ("Ended".equals(ringrtcState) || "Rejected".equals(ringrtcState)) { + endCall(state.callId, reason != null ? reason : ringrtcState.toLowerCase()); + return; + } else if ("Concluded".equals(ringrtcState)) { + // Cleanup, no-op + return; + } + fireCallEvent(state, reason); + } + + 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); + 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 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 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 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 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 byte[] getRemoteIdentityKey(CallState state) { + try { + var recipientId = context.getRecipientHelper().resolveRecipient(state.recipientIdentifier); + var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId); + var serviceId = address.getServiceId(); + var identityInfo = account.getIdentityKeyStore().getIdentityInfo(serviceId); + if (identityInfo != null) { + return getRawIdentityKeyBytes(identityInfo.getIdentityKey().serialize()); + } + } catch (Exception e) { + logger.warn("Failed to get remote identity key for call {}", state.callId, e); + } + logger.warn("Using local identity key as fallback for remote identity key"); + return getRawIdentityKeyBytes(account.getAciIdentityKeyPair().getPublicKey().serialize()); + } + + /** + * Strip the 0x05 DJB type prefix from a serialized identity key to get the + * raw 32-byte Curve25519 public key. Signal Android does this via + * WebRtcUtil.getPublicKeyBytes() before passing keys to RingRTC. + */ + private static byte[] getRawIdentityKeyBytes(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); + } catch (com.fasterxml.jackson.core.JsonProcessingException e) { + throw new RuntimeException("Failed to serialize JSON", e); + } + } + + private void endCall(final long callId, final String reason) { + var state = activeCalls.remove(callId); + if (state == null) return; + + state.state = CallInfo.State.ENDED; + fireCallEvent(state, reason); + logger.info("Call {} ended: {}", 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) + && !"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); + } + } + + // Send hangup via control channel (stdin) before killing process + if (state.controlWriter != null) { + try { + var hangupMsg = mapper.createObjectNode(); + hangupMsg.put("type", "hangup"); + state.controlWriter.println(writeJson(hangupMsg)); + state.controlWriter.close(); + } catch (Exception e) { + logger.debug("Failed to send hangup via control channel", e); + } + } + + // Kill tunnel process + if (state.tunnelProcess != null && state.tunnelProcess.isAlive()) { + state.tunnelProcess.destroy(); + } + } + + private void handleRingTimeout(final long callId) { + var state = activeCalls.get(callId); + if (state == null) return; + + if (state.state == CallInfo.State.RINGING_INCOMING || state.state == CallInfo.State.RINGING_OUTGOING) { + logger.info("Call {} ring timeout", callId); + endCall(callId, "ring_timeout"); + } + } + + private static long generateCallId() { + return new SecureRandom().nextLong() & Long.MAX_VALUE; + } + + @Override + public void close() { + scheduler.shutdownNow(); + for (var callId : new ArrayList<>(activeCalls.keySet())) { + endCall(callId, "shutdown"); + } + } + + // --- Internal call state tracking --- + + static class CallState { + + final long callId; + volatile CallInfo.State state; + final org.asamk.signal.manager.api.RecipientAddress recipientAddress; + final RecipientIdentifier.Single recipientIdentifier; + 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<>()); + // Accept deferred until tunnel reports Ringing state + volatile boolean acceptPending = false; + // True once the tunnel has reported "Ringing" (ready to accept) + volatile boolean tunnelRinging = false; + + CallState( + long callId, + CallInfo.State state, + org.asamk.signal.manager.api.RecipientAddress recipientAddress, + RecipientIdentifier.Single recipientIdentifier, + boolean isOutgoing + ) { + this.callId = callId; + this.state = state; + this.recipientAddress = recipientAddress; + this.recipientIdentifier = recipientIdentifier; + this.isOutgoing = isOutgoing; + } + + CallInfo toCallInfo() { + return new CallInfo(callId, state, recipientAddress, inputDeviceName, outputDeviceName, isOutgoing); + } + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/Context.java b/lib/src/main/java/org/asamk/signal/manager/helper/Context.java index 2ff9c7e4..e75378eb 100644 --- a/lib/src/main/java/org/asamk/signal/manager/helper/Context.java +++ b/lib/src/main/java/org/asamk/signal/manager/helper/Context.java @@ -23,6 +23,7 @@ public class Context implements AutoCloseable { private AccountHelper accountHelper; private AttachmentHelper attachmentHelper; + private CallManager callManager; private ContactHelper contactHelper; private GroupHelper groupHelper; private GroupV2Helper groupV2Helper; @@ -92,6 +93,10 @@ public class Context implements AutoCloseable { return getOrCreate(() -> attachmentHelper, () -> attachmentHelper = new AttachmentHelper(this)); } + public CallManager getCallManager() { + return getOrCreate(() -> callManager, () -> callManager = new CallManager(this)); + } + public ContactHelper getContactHelper() { return getOrCreate(() -> contactHelper, () -> contactHelper = new ContactHelper(account)); } @@ -172,6 +177,9 @@ public class Context implements AutoCloseable { @Override public void close() { + if (callManager != null) { + callManager.close(); + } jobExecutor.close(); } 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 dbc9f8b4..ba22f49c 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 @@ -401,9 +401,49 @@ public final class IncomingMessageHandler { longTexts.putAll(syncResults.second()); } + if (content.getCallMessage().isPresent()) { + handleCallMessage(content.getCallMessage().get(), sender); + } + 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 + ) { + var callManager = context.getCallManager(); + + callMessage.getOfferMessage().ifPresent(offer -> { + 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()); + }); + + callMessage.getAnswerMessage().ifPresent(answer -> + callManager.handleIncomingAnswer(answer.getId(), answer.getOpaque())); + + callMessage.getIceUpdateMessages().ifPresent(iceUpdates -> { + for (var ice : iceUpdates) { + callManager.handleIncomingIceCandidate(ice.getId(), ice.getOpaque()); + } + }); + + callMessage.getHangupMessage().ifPresent(hangup -> { + // Only NORMAL hangups actually end the call. ACCEPTED/DECLINED/BUSY + // are multi-device notifications irrelevant for single-device signal-cli. + var hangupType = hangup.getType(); + if (hangupType == org.whispersystems.signalservice.api.messages.calls.HangupMessage.Type.NORMAL + || hangupType == null) { + callManager.handleIncomingHangup(hangup.getId()); + } + }); + + callMessage.getBusyMessage().ifPresent(busy -> + callManager.handleIncomingBusy(busy.getId())); + } + private boolean handlePniSignatureMessage( final SignalServicePniSignatureMessage message, final SignalServiceAddress senderAddress 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 7a6785e9..bd920635 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 @@ -19,6 +19,9 @@ package org.asamk.signal.manager.internal; 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.TurnServer; import org.asamk.signal.manager.api.CaptchaRejectedException; import org.asamk.signal.manager.api.CaptchaRequiredException; import org.asamk.signal.manager.api.Configuration; @@ -105,6 +108,12 @@ import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; import org.whispersystems.signalservice.api.messages.SignalServicePreview; import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage; import org.whispersystems.signalservice.api.messages.SignalServiceTypingMessage; +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.messages.multidevice.DeviceInfo; import org.whispersystems.signalservice.api.push.ServiceIdType; import org.whispersystems.signalservice.api.push.exceptions.CdsiResourceExhaustedException; @@ -163,6 +172,7 @@ 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(); @@ -1704,6 +1714,22 @@ 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); + } + @Override public InputStream retrieveAttachment(final String id) throws IOException { return context.getAttachmentHelper().retrieveAttachment(id).getStream(); @@ -1761,6 +1787,132 @@ public class ManagerImpl implements Manager { return streamDetails.getStream(); } + // --- Voice call methods --- + + @Override + public CallInfo startCall(final RecipientIdentifier.Single recipient) throws IOException, UnregisteredRecipientException { + return context.getCallManager().startOutgoingCall(recipient); + } + + @Override + public CallInfo acceptCall(final long callId) throws IOException { + return context.getCallManager().acceptIncomingCall(callId); + } + + @Override + public void hangupCall(final long callId) throws IOException { + context.getCallManager().hangupCall(callId); + } + + @Override + public void rejectCall(final long callId) throws IOException { + context.getCallManager().rejectCall(callId); + } + + @Override + public List listActiveCalls() { + return context.getCallManager().listActiveCalls(); + } + + @Override + public void sendCallOffer( + final RecipientIdentifier.Single recipient, + final CallOffer offer + ) throws IOException, UnregisteredRecipientException { + final var recipientId = context.getRecipientHelper().resolveRecipient(recipient); + final var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId); + var offerMessage = new OfferMessage(offer.callId(), + offer.type() == CallOffer.Type.VIDEO ? OfferMessage.Type.VIDEO_CALL : OfferMessage.Type.AUDIO_CALL, + offer.opaque()); + var callMessage = SignalServiceCallMessage.forOffer(offerMessage, null); + try { + dependencies.getMessageSender().sendCallMessage(address, null, callMessage); + } catch (org.whispersystems.signalservice.api.crypto.UntrustedIdentityException e) { + throw new IOException("Untrusted identity for call recipient", e); + } + } + + @Override + public void sendCallAnswer( + final RecipientIdentifier.Single recipient, + final long callId, + final byte[] answerOpaque + ) throws IOException, UnregisteredRecipientException { + final var recipientId = context.getRecipientHelper().resolveRecipient(recipient); + final var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId); + var answerMessage = new AnswerMessage(callId, answerOpaque); + var callMessage = SignalServiceCallMessage.forAnswer(answerMessage, null); + try { + dependencies.getMessageSender().sendCallMessage(address, null, callMessage); + } catch (org.whispersystems.signalservice.api.crypto.UntrustedIdentityException e) { + throw new IOException("Untrusted identity for call recipient", e); + } + } + + @Override + public void sendIceUpdate( + final RecipientIdentifier.Single recipient, + final long callId, + final List iceCandidates + ) 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 callMessage = SignalServiceCallMessage.forIceUpdates(iceUpdates, null); + try { + dependencies.getMessageSender().sendCallMessage(address, null, callMessage); + } catch (org.whispersystems.signalservice.api.crypto.UntrustedIdentityException e) { + throw new IOException("Untrusted identity for call recipient", e); + } + } + + @Override + public void sendHangup( + final RecipientIdentifier.Single recipient, + final long callId, + final MessageEnvelope.Call.Hangup.Type type + ) throws IOException, UnregisteredRecipientException { + final var recipientId = context.getRecipientHelper().resolveRecipient(recipient); + final var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId); + var hangupType = switch (type) { + case NORMAL -> HangupMessage.Type.NORMAL; + case ACCEPTED -> HangupMessage.Type.ACCEPTED; + case DECLINED -> HangupMessage.Type.DECLINED; + case BUSY -> HangupMessage.Type.BUSY; + case NEED_PERMISSION -> HangupMessage.Type.NEED_PERMISSION; + }; + var hangupMessage = new HangupMessage(callId, hangupType, 0); + var callMessage = SignalServiceCallMessage.forHangup(hangupMessage, null); + try { + dependencies.getMessageSender().sendCallMessage(address, null, callMessage); + } catch (org.whispersystems.signalservice.api.crypto.UntrustedIdentityException e) { + throw new IOException("Untrusted identity for call recipient", e); + } + } + + @Override + public void sendBusy( + final RecipientIdentifier.Single recipient, + final long callId + ) throws IOException, UnregisteredRecipientException { + final var recipientId = context.getRecipientHelper().resolveRecipient(recipient); + final var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId); + var busyMessage = new BusyMessage(callId); + var callMessage = SignalServiceCallMessage.forBusy(busyMessage, null); + try { + dependencies.getMessageSender().sendCallMessage(address, null, callMessage); + } catch (org.whispersystems.signalservice.api.crypto.UntrustedIdentityException e) { + throw new IOException("Untrusted identity for call recipient", e); + } + } + + @Override + public List getTurnServerInfo() throws IOException { + return context.getCallManager().getTurnServers(); + } + @Override public void close() { Thread thread; @@ -1773,6 +1925,12 @@ 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/internal/SignalDependencies.java b/lib/src/main/java/org/asamk/signal/manager/internal/SignalDependencies.java index 4f543d88..9c6c991d 100644 --- a/lib/src/main/java/org/asamk/signal/manager/internal/SignalDependencies.java +++ b/lib/src/main/java/org/asamk/signal/manager/internal/SignalDependencies.java @@ -15,6 +15,7 @@ import org.whispersystems.signalservice.api.SignalServiceMessageSender; import org.whispersystems.signalservice.api.SignalSessionLock; import org.whispersystems.signalservice.api.account.AccountApi; import org.whispersystems.signalservice.api.attachment.AttachmentApi; +import org.whispersystems.signalservice.api.calling.CallingApi; import org.whispersystems.signalservice.api.cds.CdsApi; import org.whispersystems.signalservice.api.certificate.CertificateApi; import org.whispersystems.signalservice.api.crypto.SignalServiceCipher; @@ -76,6 +77,7 @@ public class SignalDependencies { private StorageServiceApi storageServiceApi; private CertificateApi certificateApi; private AttachmentApi attachmentApi; + private CallingApi callingApi; private MessageApi messageApi; private KeysApi keysApi; private GroupsV2Operations groupsV2Operations; @@ -255,6 +257,13 @@ public class SignalDependencies { () -> attachmentApi = new AttachmentApi(getAuthenticatedSignalWebSocket(), getPushServiceSocket())); } + public CallingApi getCallingApi() { + return getOrCreate(() -> callingApi, + () -> callingApi = new CallingApi(getAuthenticatedSignalWebSocket(), + getUnauthenticatedSignalWebSocket(), + getPushServiceSocket())); + } + public MessageApi getMessageApi() { return getOrCreate(() -> messageApi, () -> messageApi = new MessageApi(getAuthenticatedSignalWebSocket(), 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 new file mode 100644 index 00000000..5d009aec --- /dev/null +++ b/lib/src/test/java/org/asamk/signal/manager/helper/CallManagerTest.java @@ -0,0 +1,441 @@ +package org.asamk.signal.manager.helper; + +import org.asamk.signal.manager.api.CallInfo; +import org.asamk.signal.manager.api.RecipientAddress; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; +import java.math.BigInteger; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit tests for pure functions and state machine logic in CallManager. + * Uses reflection to access private static helpers without changing production visibility. + */ +class CallManagerTest { + + // --- Reflection helpers for private static methods --- + + private static final MethodHandle GET_RAW_IDENTITY_KEY_BYTES; + private static final MethodHandle CALL_ID_UNSIGNED; + private static final MethodHandle GENERATE_CALL_ID; + + static { + try { + var lookup = MethodHandles.privateLookupIn(CallManager.class, MethodHandles.lookup()); + + GET_RAW_IDENTITY_KEY_BYTES = lookup.findStatic(CallManager.class, "getRawIdentityKeyBytes", + MethodType.methodType(byte[].class, byte[].class)); + + CALL_ID_UNSIGNED = lookup.findStatic(CallManager.class, "callIdUnsigned", + MethodType.methodType(BigInteger.class, long.class)); + + GENERATE_CALL_ID = lookup.findStatic(CallManager.class, "generateCallId", + MethodType.methodType(long.class)); + + } catch (ReflectiveOperationException e) { + throw new ExceptionInInitializerError(e); + } + } + + private static byte[] getRawIdentityKeyBytes(byte[] serializedKey) throws Throwable { + return (byte[]) GET_RAW_IDENTITY_KEY_BYTES.invokeExact(serializedKey); + } + + private static BigInteger callIdUnsigned(long callId) throws Throwable { + return (BigInteger) CALL_ID_UNSIGNED.invokeExact(callId); + } + + private static long generateCallId() throws Throwable { + return (long) GENERATE_CALL_ID.invokeExact(); + } + + // --- 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 + ); + } + + // ======================================================================== + // getRawIdentityKeyBytes tests + // ======================================================================== + + @Test + void getRawIdentityKeyBytes_strips0x05Prefix() throws Throwable { + // 33-byte key with 0x05 DJB type prefix + var key33 = new byte[33]; + key33[0] = 0x05; + for (int i = 1; i < 33; i++) key33[i] = (byte) i; + + var result = getRawIdentityKeyBytes(key33); + + assertEquals(32, result.length); + for (int i = 0; i < 32; i++) { + assertEquals((byte) (i + 1), result[i]); + } + } + + @Test + void getRawIdentityKeyBytes_already32Bytes() throws Throwable { + var key32 = new byte[32]; + for (int i = 0; i < 32; i++) key32[i] = (byte) (i + 10); + + var result = getRawIdentityKeyBytes(key32); + + assertArrayEquals(key32, result); + } + + @Test + void getRawIdentityKeyBytes_33BytesWrongPrefix() throws Throwable { + // 33 bytes but prefix is NOT 0x05 + var key33 = new byte[33]; + key33[0] = 0x07; + for (int i = 1; i < 33; i++) key33[i] = (byte) i; + + var result = getRawIdentityKeyBytes(key33); + + // Should return the original key unchanged + assertArrayEquals(key33, result); + assertEquals(33, result.length); + } + + @Test + void getRawIdentityKeyBytes_emptyArray() throws Throwable { + var empty = new byte[0]; + var result = getRawIdentityKeyBytes(empty); + assertArrayEquals(empty, result); + } + + @Test + void getRawIdentityKeyBytes_shortArray() throws Throwable { + var short5 = new byte[]{0x05, 1, 2}; + var result = getRawIdentityKeyBytes(short5); + // Not 33 bytes, so returned unchanged despite 0x05 prefix + assertArrayEquals(short5, result); + } + + // ======================================================================== + // callIdUnsigned tests + // ======================================================================== + + @Test + void callIdUnsigned_zero() throws Throwable { + assertEquals(BigInteger.ZERO, callIdUnsigned(0L)); + } + + @Test + void callIdUnsigned_positiveLong() throws Throwable { + assertEquals(new BigInteger("8230211930154373276"), callIdUnsigned(8230211930154373276L)); + } + + @Test + void callIdUnsigned_negativeLongBecomesUnsigned() throws Throwable { + // -1L as unsigned is 2^64 - 1 = 18446744073709551615 + assertEquals(new BigInteger("18446744073709551615"), callIdUnsigned(-1L)); + } + + @Test + void callIdUnsigned_longMinValueBecomesUnsigned() throws Throwable { + // Long.MIN_VALUE as unsigned is 2^63 = 9223372036854775808 + assertEquals(new BigInteger("9223372036854775808"), callIdUnsigned(Long.MIN_VALUE)); + } + + @Test + void callIdUnsigned_longMaxValue() throws Throwable { + assertEquals(new BigInteger("9223372036854775807"), callIdUnsigned(Long.MAX_VALUE)); + } + + // ======================================================================== + // 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(); + boolean foundDifferent = false; + for (int i = 0; i < 20; i++) { + if (generateCallId() != first) { + foundDifferent = true; + break; + } + } + assertTrue(foundDifferent, "generateCallId returned same value 21 times in a row"); + } + + // ======================================================================== + // handleStateChange state machine tests + // + // Since handleStateChange is a private instance method requiring a full + // CallManager (which needs Context), we test the state transition logic + // directly by reproducing its documented rules against CallState. + // The rules are: + // "Incoming*" -> RINGING_INCOMING (unless already CONNECTING) + // "Outgoing*" -> RINGING_OUTGOING + // "Ringing" -> triggers deferred accept (no state change) + // "Connected" -> CONNECTED + // "Connecting"-> RECONNECTING + // "Ended"/"Rejected" -> would call endCall (sets ENDED) + // "Concluded" -> no-op + // ======================================================================== + + @Test + void stateTransition_incomingToRingingIncoming() { + var state = makeCallState(1L, CallInfo.State.IDLE); + applyStateTransition(state, "Incoming(Audio)", null); + assertEquals(CallInfo.State.RINGING_INCOMING, state.state); + } + + @Test + void stateTransition_incomingWithMediaType() { + var state = makeCallState(1L, CallInfo.State.IDLE); + applyStateTransition(state, "Incoming(Video)", null); + assertEquals(CallInfo.State.RINGING_INCOMING, state.state); + } + + @Test + void stateTransition_incomingDoesNotDowngradeFromConnecting() { + var state = makeCallState(1L, CallInfo.State.CONNECTING); + applyStateTransition(state, "Incoming(Audio)", null); + // Must remain CONNECTING, not downgraded to RINGING_INCOMING + assertEquals(CallInfo.State.CONNECTING, state.state); + } + + @Test + void stateTransition_outgoing() { + var state = makeCallState(1L, CallInfo.State.IDLE); + applyStateTransition(state, "Outgoing(Audio)", null); + assertEquals(CallInfo.State.RINGING_OUTGOING, state.state); + } + + @Test + void stateTransition_connected() { + var state = makeCallState(1L, CallInfo.State.CONNECTING); + applyStateTransition(state, "Connected", null); + assertEquals(CallInfo.State.CONNECTED, state.state); + } + + @Test + void stateTransition_connectingMapsToReconnecting() { + // "Connecting" from RingRTC means ICE reconnection, not initial connect + var state = makeCallState(1L, CallInfo.State.CONNECTED); + applyStateTransition(state, "Connecting", null); + assertEquals(CallInfo.State.RECONNECTING, state.state); + } + + @Test + void stateTransition_ringingDoesNotChangeState() { + var state = makeCallState(1L, CallInfo.State.RINGING_INCOMING); + applyStateTransition(state, "Ringing", null); + // "Ringing" triggers sendAcceptIfReady but doesn't change state + assertEquals(CallInfo.State.RINGING_INCOMING, state.state); + } + + @Test + void stateTransition_ringSetsAcceptPendingFalseWhenReady() { + var state = makeCallState(1L, CallInfo.State.RINGING_INCOMING); + state.acceptPending = true; + // No controlWriter set, so accept won't actually send but acceptPending stays true + // This documents the behavior: without a controlWriter, deferred accept stays pending + applyStateTransition(state, "Ringing", null); + assertTrue(state.acceptPending, "acceptPending should remain true when controlWriter is null"); + } + + @Test + void stateTransition_concludedIsNoop() { + var state = makeCallState(1L, CallInfo.State.CONNECTED); + applyStateTransition(state, "Concluded", null); + // State should NOT change + assertEquals(CallInfo.State.CONNECTED, state.state); + } + + @Test + void stateTransition_endedSetsEnded() { + var state = makeCallState(1L, CallInfo.State.CONNECTED); + applyStateTransition(state, "Ended", "Timeout"); + // endCall would set ENDED (we simulate that since endCall is instance method) + assertEquals(CallInfo.State.ENDED, state.state); + } + + @Test + void stateTransition_rejectedSetsEnded() { + var state = makeCallState(1L, CallInfo.State.RINGING_INCOMING); + applyStateTransition(state, "Rejected", "BusyOnAnotherDevice"); + assertEquals(CallInfo.State.ENDED, state.state); + } + + @Test + void stateTransition_endedWithNullReasonUsesStateName() { + var state = makeCallState(1L, CallInfo.State.CONNECTED); + // When reason is null, endCall should be called with state name lowercased + // We verify state becomes ENDED (the reason defaulting logic is in handleStateChange) + applyStateTransition(state, "Ended", null); + assertEquals(CallInfo.State.ENDED, state.state); + } + + @Test + void stateTransition_unknownStateIsNoop() { + var state = makeCallState(1L, CallInfo.State.CONNECTED); + applyStateTransition(state, "SomeUnknownState", null); + // No matching branch, state unchanged + assertEquals(CallInfo.State.CONNECTED, state.state); + } + + // ======================================================================== + // endCall guard condition tests + // + // endCall sends a Signal protocol hangup UNLESS the reason indicates the + // remote side already knows (remote_hangup, rejected, remote_busy, ringrtc_hangup). + // We test this logic directly. + // ======================================================================== + + @ParameterizedTest + @ValueSource(strings = {"remote_hangup", "rejected", "remote_busy", "ringrtc_hangup"}) + void endCallGuard_remoteCausesSkipHangup(String reason) { + // These reasons should NOT trigger sending a hangup to the remote + assertTrue(shouldSkipRemoteHangup(reason)); + } + + @ParameterizedTest + @ValueSource(strings = {"local_hangup", "ring_timeout", "tunnel_exit", "tunnel_error", "shutdown"}) + void endCallGuard_localCausesSendHangup(String reason) { + // These reasons SHOULD trigger sending a hangup to the remote + assertTrue(shouldSendRemoteHangup(reason)); + } + + // ======================================================================== + // CallState.toCallInfo tests + // ======================================================================== + + @Test + void callState_toCallInfo() { + var state = makeCallState(42L, CallInfo.State.CONNECTED); + state.inputDeviceName = "test_input"; + state.outputDeviceName = "test_output"; + + var info = state.toCallInfo(); + + assertEquals(42L, info.callId()); + assertEquals(CallInfo.State.CONNECTED, info.state()); + assertEquals("+15551234567", info.recipient().number().orElse(null)); + assertTrue(info.isOutgoing()); + assertEquals("test_input", info.inputDeviceName()); + assertEquals("test_output", info.outputDeviceName()); + } + + @Test + void callState_toCallInfoNullDeviceNames() { + var state = makeCallState(1L, CallInfo.State.RINGING_INCOMING); + + var info = state.toCallInfo(); + + assertEquals(CallInfo.State.RINGING_INCOMING, info.state()); + assertEquals(null, info.inputDeviceName()); + assertEquals(null, info.outputDeviceName()); + } + + // ======================================================================== + // tunnelBinaryFromCodeSourcePath tests + // + // The install dir is derived from the code source location (jar or class + // directory): go up two levels (out of lib/) to reach the install root, + // then resolve bin/signal-call-tunnel. + // ======================================================================== + + @Test + void tunnelBinaryFromCodeSourcePath_resolvesFromJarInLib() { + // Simulate: /opt/signal-cli/lib/signal-cli.jar + var jarPath = Path.of("/opt/signal-cli/lib/signal-cli.jar"); + var result = CallManager.tunnelBinaryFromCodeSourcePath(jarPath); + assertEquals(Path.of("/opt/signal-cli/bin/signal-call-tunnel"), result); + } + + @Test + void tunnelBinaryFromCodeSourcePath_resolvesFromClassDir() { + // In dev/test, code source is a directory like build/classes/java/main + var classDir = Path.of("/project/lib/build/classes/java/main"); + var result = CallManager.tunnelBinaryFromCodeSourcePath(classDir); + // Goes up two levels from main -> classes, then looks for bin/signal-call-tunnel + assertEquals(Path.of("/project/lib/build/classes/bin/signal-call-tunnel"), result); + } + + @Test + void tunnelBinaryFromCodeSourcePath_deeplyNestedPath() { + var jarPath = Path.of("/home/user/.local/share/signal-cli/lib/signal-cli.jar"); + var result = CallManager.tunnelBinaryFromCodeSourcePath(jarPath); + assertEquals(Path.of("/home/user/.local/share/signal-cli/bin/signal-call-tunnel"), result); + } + + // ======================================================================== + // Helpers that reproduce the documented logic from handleStateChange and + // endCall, allowing us to verify the state machine rules without needing + // a full CallManager instance (which requires Context/SignalAccount/etc). + // ======================================================================== + + /** + * Reproduces the state transition logic from CallManager.handleStateChange. + * This directly mirrors the production code's branching to verify correctness. + */ + private static void applyStateTransition(CallManager.CallState state, String ringrtcState, String reason) { + if (ringrtcState.startsWith("Incoming")) { + if (state.state == CallInfo.State.CONNECTING) return; + state.state = CallInfo.State.RINGING_INCOMING; + } else if (ringrtcState.startsWith("Outgoing")) { + state.state = CallInfo.State.RINGING_OUTGOING; + } else if ("Ringing".equals(ringrtcState)) { + // Would call sendAcceptIfReady — tested separately + return; + } else if ("Connected".equals(ringrtcState)) { + state.state = CallInfo.State.CONNECTED; + } else if ("Connecting".equals(ringrtcState)) { + state.state = CallInfo.State.RECONNECTING; + } else if ("Ended".equals(ringrtcState) || "Rejected".equals(ringrtcState)) { + // Simplified: just set ENDED (production code calls endCall which does cleanup + sets ENDED) + state.state = CallInfo.State.ENDED; + return; + } else if ("Concluded".equals(ringrtcState)) { + return; + } + } + + /** + * Reproduces the endCall guard condition: returns true when a Signal protocol + * hangup should NOT be sent to the remote peer. + */ + private static boolean shouldSkipRemoteHangup(String reason) { + return "remote_hangup".equals(reason) + || "rejected".equals(reason) + || "remote_busy".equals(reason) + || "ringrtc_hangup".equals(reason); + } + + /** + * Inverse of shouldSkipRemoteHangup. + */ + private static boolean shouldSendRemoteHangup(String reason) { + return !shouldSkipRemoteHangup(reason); + } +} diff --git a/src/main/java/org/asamk/signal/commands/AcceptCallCommand.java b/src/main/java/org/asamk/signal/commands/AcceptCallCommand.java new file mode 100644 index 00000000..66d16140 --- /dev/null +++ b/src/main/java/org/asamk/signal/commands/AcceptCallCommand.java @@ -0,0 +1,77 @@ +package org.asamk.signal.commands; + +import net.sourceforge.argparse4j.inf.Namespace; +import net.sourceforge.argparse4j.inf.Subparser; + +import org.asamk.signal.commands.exceptions.CommandException; +import org.asamk.signal.commands.exceptions.IOErrorException; +import org.asamk.signal.commands.exceptions.UserErrorException; +import org.asamk.signal.manager.Manager; +import org.asamk.signal.output.JsonWriter; +import org.asamk.signal.output.OutputWriter; +import org.asamk.signal.output.PlainTextWriter; + +import java.io.IOException; + +public class AcceptCallCommand implements JsonRpcLocalCommand { + + @Override + public String getName() { + return "acceptCall"; + } + + @Override + public void attachToSubparser(final Subparser subparser) { + subparser.help("Accept an incoming voice call."); + subparser.addArgument("--call-id") + .type(long.class) + .required(true) + .help("The call ID to accept."); + } + + @Override + public void handleCommand( + final Namespace ns, + final Manager m, + final OutputWriter outputWriter + ) throws CommandException { + if (!(ns.get("call-id") instanceof Number callIdNumber)) { + throw new UserErrorException("No call ID given"); + } + final long callId = callIdNumber.longValue(); + + try { + var callInfo = m.acceptCall(callId); + switch (outputWriter) { + case PlainTextWriter writer -> { + writer.println("Call accepted:"); + writer.println(" Call ID: {}", callInfo.callId()); + writer.println(" State: {}", callInfo.state()); + writer.println(" Input device: {}", callInfo.inputDeviceName()); + writer.println(" Output device: {}", callInfo.outputDeviceName()); + } + case JsonWriter writer -> writer.write(new JsonCallInfo(callInfo.callId(), + callInfo.state().name(), + callInfo.inputDeviceName(), + callInfo.outputDeviceName(), + "opus", + 48000, + 1, + 20)); + } + } catch (IOException e) { + throw new IOErrorException("Failed to accept call: " + e.getMessage(), e); + } + } + + private record JsonCallInfo( + long callId, + String state, + String inputDeviceName, + String outputDeviceName, + String codec, + int sampleRate, + int channels, + int ptimeMs + ) {} +} diff --git a/src/main/java/org/asamk/signal/commands/Commands.java b/src/main/java/org/asamk/signal/commands/Commands.java index d1f717b3..05dc1ff4 100644 --- a/src/main/java/org/asamk/signal/commands/Commands.java +++ b/src/main/java/org/asamk/signal/commands/Commands.java @@ -10,18 +10,21 @@ public class Commands { private static final Map commandSubparserAttacher = new TreeMap<>(); static { + addCommand(new AcceptCallCommand()); addCommand(new AddDeviceCommand()); addCommand(new BlockCommand()); addCommand(new DaemonCommand()); addCommand(new DeleteLocalAccountDataCommand()); addCommand(new FinishChangeNumberCommand()); addCommand(new FinishLinkCommand()); + addCommand(new HangupCallCommand()); addCommand(new GetAttachmentCommand()); addCommand(new GetAvatarCommand()); addCommand(new GetStickerCommand()); addCommand(new GetUserStatusCommand()); addCommand(new AddStickerPackCommand()); addCommand(new JoinGroupCommand()); + addCommand(new ListCallsCommand()); addCommand(new JsonRpcDispatcherCommand()); addCommand(new LinkCommand()); addCommand(new ListAccountsCommand()); @@ -32,6 +35,7 @@ public class Commands { addCommand(new ListStickerPacksCommand()); addCommand(new QuitGroupCommand()); addCommand(new ReceiveCommand()); + addCommand(new RejectCallCommand()); addCommand(new RegisterCommand()); addCommand(new RemoveContactCommand()); addCommand(new RemoveDeviceCommand()); @@ -52,6 +56,7 @@ public class Commands { addCommand(new SendTypingCommand()); addCommand(new SendUnpinMessageCommand()); addCommand(new SetPinCommand()); + addCommand(new StartCallCommand()); addCommand(new SubmitRateLimitChallengeCommand()); addCommand(new StartChangeNumberCommand()); addCommand(new StartLinkCommand()); diff --git a/src/main/java/org/asamk/signal/commands/HangupCallCommand.java b/src/main/java/org/asamk/signal/commands/HangupCallCommand.java new file mode 100644 index 00000000..35149b4d --- /dev/null +++ b/src/main/java/org/asamk/signal/commands/HangupCallCommand.java @@ -0,0 +1,47 @@ +package org.asamk.signal.commands; + +import net.sourceforge.argparse4j.inf.Namespace; +import net.sourceforge.argparse4j.inf.Subparser; + +import org.asamk.signal.commands.exceptions.CommandException; +import org.asamk.signal.commands.exceptions.IOErrorException; +import org.asamk.signal.commands.exceptions.UserErrorException; +import org.asamk.signal.manager.Manager; +import org.asamk.signal.output.OutputWriter; + +import java.io.IOException; + +public class HangupCallCommand implements JsonRpcLocalCommand { + + @Override + public String getName() { + return "hangupCall"; + } + + @Override + public void attachToSubparser(final Subparser subparser) { + subparser.help("Hang up an active voice call."); + subparser.addArgument("--call-id") + .type(long.class) + .required(true) + .help("The call ID to hang up."); + } + + @Override + public void handleCommand( + final Namespace ns, + final Manager m, + final OutputWriter outputWriter + ) throws CommandException { + if (!(ns.get("call-id") instanceof Number callIdNumber)) { + throw new UserErrorException("No call ID given"); + } + final long callId = callIdNumber.longValue(); + + try { + m.hangupCall(callId); + } catch (IOException e) { + throw new IOErrorException("Failed to hang up call: " + e.getMessage(), e); + } + } +} diff --git a/src/main/java/org/asamk/signal/commands/ListCallsCommand.java b/src/main/java/org/asamk/signal/commands/ListCallsCommand.java new file mode 100644 index 00000000..8f443d90 --- /dev/null +++ b/src/main/java/org/asamk/signal/commands/ListCallsCommand.java @@ -0,0 +1,79 @@ +package org.asamk.signal.commands; + +import net.sourceforge.argparse4j.inf.Namespace; +import net.sourceforge.argparse4j.inf.Subparser; + +import org.asamk.signal.commands.exceptions.CommandException; +import org.asamk.signal.manager.Manager; +import org.asamk.signal.manager.api.CallInfo; +import org.asamk.signal.output.JsonWriter; +import org.asamk.signal.output.OutputWriter; +import org.asamk.signal.output.PlainTextWriter; + +import java.util.List; + +public class ListCallsCommand implements JsonRpcLocalCommand { + + @Override + public String getName() { + return "listCalls"; + } + + @Override + public void attachToSubparser(final Subparser subparser) { + subparser.help("List active voice calls."); + } + + @Override + public void handleCommand( + final Namespace ns, + final Manager m, + final OutputWriter outputWriter + ) throws CommandException { + var calls = m.listActiveCalls(); + switch (outputWriter) { + case PlainTextWriter writer -> { + if (calls.isEmpty()) { + writer.println("No active calls."); + } else { + for (var call : calls) { + writer.println("- Call {}:", call.callId()); + writer.indent(w -> { + w.println("State: {}", call.state()); + w.println("Recipient: {}", call.recipient()); + w.println("Direction: {}", call.isOutgoing() ? "outgoing" : "incoming"); + if (call.inputDeviceName() != null) { + w.println("Input device: {}", call.inputDeviceName()); + } + if (call.outputDeviceName() != null) { + w.println("Output device: {}", call.outputDeviceName()); + } + }); + } + } + } + case JsonWriter writer -> { + var jsonCalls = calls.stream() + .map(c -> new JsonCall(c.callId(), + c.state().name(), + c.recipient().number().orElse(null), + c.recipient().uuid().map(java.util.UUID::toString).orElse(null), + c.isOutgoing(), + c.inputDeviceName(), + c.outputDeviceName())) + .toList(); + writer.write(jsonCalls); + } + } + } + + private record JsonCall( + long callId, + String state, + String number, + String uuid, + boolean isOutgoing, + String inputDeviceName, + String outputDeviceName + ) {} +} diff --git a/src/main/java/org/asamk/signal/commands/RejectCallCommand.java b/src/main/java/org/asamk/signal/commands/RejectCallCommand.java new file mode 100644 index 00000000..24cedcd4 --- /dev/null +++ b/src/main/java/org/asamk/signal/commands/RejectCallCommand.java @@ -0,0 +1,47 @@ +package org.asamk.signal.commands; + +import net.sourceforge.argparse4j.inf.Namespace; +import net.sourceforge.argparse4j.inf.Subparser; + +import org.asamk.signal.commands.exceptions.CommandException; +import org.asamk.signal.commands.exceptions.IOErrorException; +import org.asamk.signal.commands.exceptions.UserErrorException; +import org.asamk.signal.manager.Manager; +import org.asamk.signal.output.OutputWriter; + +import java.io.IOException; + +public class RejectCallCommand implements JsonRpcLocalCommand { + + @Override + public String getName() { + return "rejectCall"; + } + + @Override + public void attachToSubparser(final Subparser subparser) { + subparser.help("Reject an incoming voice call."); + subparser.addArgument("--call-id") + .type(long.class) + .required(true) + .help("The call ID to reject."); + } + + @Override + public void handleCommand( + final Namespace ns, + final Manager m, + final OutputWriter outputWriter + ) throws CommandException { + if (!(ns.get("call-id") instanceof Number callIdNumber)) { + throw new UserErrorException("No call ID given"); + } + final long callId = callIdNumber.longValue(); + + try { + m.rejectCall(callId); + } catch (IOException e) { + throw new IOErrorException("Failed to reject call: " + e.getMessage(), e); + } + } +} diff --git a/src/main/java/org/asamk/signal/commands/StartCallCommand.java b/src/main/java/org/asamk/signal/commands/StartCallCommand.java new file mode 100644 index 00000000..1a94178a --- /dev/null +++ b/src/main/java/org/asamk/signal/commands/StartCallCommand.java @@ -0,0 +1,80 @@ +package org.asamk.signal.commands; + +import net.sourceforge.argparse4j.inf.Namespace; +import net.sourceforge.argparse4j.inf.Subparser; + +import org.asamk.signal.commands.exceptions.CommandException; +import org.asamk.signal.commands.exceptions.IOErrorException; +import org.asamk.signal.commands.exceptions.UserErrorException; +import org.asamk.signal.manager.Manager; +import org.asamk.signal.manager.api.UnregisteredRecipientException; +import org.asamk.signal.output.JsonWriter; +import org.asamk.signal.output.OutputWriter; +import org.asamk.signal.output.PlainTextWriter; +import org.asamk.signal.util.CommandUtil; + +import java.io.IOException; + +public class StartCallCommand implements JsonRpcLocalCommand { + + @Override + public String getName() { + return "startCall"; + } + + @Override + public void attachToSubparser(final Subparser subparser) { + subparser.help("Start an outgoing voice call."); + subparser.addArgument("recipient").help("Specify the recipient's phone number or UUID.").nargs(1); + } + + @Override + public void handleCommand( + final Namespace ns, + final Manager m, + final OutputWriter outputWriter + ) throws CommandException { + final var recipientStrings = ns.getList("recipient"); + if (recipientStrings == null || recipientStrings.isEmpty()) { + throw new UserErrorException("No recipient given"); + } + + final var recipient = CommandUtil.getSingleRecipientIdentifier(recipientStrings.getFirst(), m.getSelfNumber()); + + try { + var callInfo = m.startCall(recipient); + switch (outputWriter) { + case PlainTextWriter writer -> { + writer.println("Call started:"); + writer.println(" Call ID: {}", callInfo.callId()); + writer.println(" State: {}", callInfo.state()); + writer.println(" Input device: {}", callInfo.inputDeviceName()); + writer.println(" Output device: {}", callInfo.outputDeviceName()); + } + case JsonWriter writer -> writer.write(new JsonCallInfo(callInfo.callId(), + callInfo.state().name(), + callInfo.inputDeviceName(), + callInfo.outputDeviceName(), + "opus", + 48000, + 1, + 20)); + } + } catch (UnregisteredRecipientException e) { + throw new UserErrorException("Recipient not registered: " + e.getMessage(), e); + } catch (IOException e) { + throw new IOErrorException("Failed to start call: " + e.getMessage(), e); + } + } + + private record JsonCallInfo( + long callId, + String state, + String inputDeviceName, + String outputDeviceName, + String codec, + int sampleRate, + int channels, + int ptimeMs + ) {} +} diff --git a/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java b/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java index 70bd388b..70a6f146 100644 --- a/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java +++ b/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java @@ -912,6 +912,73 @@ public class DbusManagerImpl implements Manager { } } + @Override + public void addCallEventListener(final CallEventListener listener) { + // Not supported over DBus + } + + @Override + public void removeCallEventListener(final CallEventListener listener) { + // Not supported over DBus + } + + // --- 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) { + throw new UnsupportedOperationException("Voice calls are not supported over DBus"); + } + + @Override + public org.asamk.signal.manager.api.CallInfo acceptCall(final long callId) { + throw new UnsupportedOperationException("Voice calls are not supported over DBus"); + } + + @Override + public void hangupCall(final long callId) { + throw new UnsupportedOperationException("Voice calls are not supported over DBus"); + } + + @Override + public void rejectCall(final long callId) { + throw new UnsupportedOperationException("Voice calls are not supported over DBus"); + } + + @Override + 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) { + 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) { + 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) { + 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) { + 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) { + throw new UnsupportedOperationException("Voice calls are not supported over DBus"); + } + + @Override + public java.util.List getTurnServerInfo() { + throw new UnsupportedOperationException("Voice calls are not supported over DBus"); + } + @Override public void close() { synchronized (this) { diff --git a/src/main/java/org/asamk/signal/json/JsonCallEvent.java b/src/main/java/org/asamk/signal/json/JsonCallEvent.java new file mode 100644 index 00000000..dba6b77a --- /dev/null +++ b/src/main/java/org/asamk/signal/json/JsonCallEvent.java @@ -0,0 +1,32 @@ +package org.asamk.signal.json; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import org.asamk.signal.manager.api.CallInfo; + +import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL; + +public record JsonCallEvent( + long callId, + String state, + @JsonInclude(NON_NULL) String number, + @JsonInclude(NON_NULL) String uuid, + boolean isOutgoing, + @JsonInclude(NON_NULL) String inputDeviceName, + @JsonInclude(NON_NULL) String outputDeviceName, + @JsonInclude(NON_NULL) String reason +) { + + public static JsonCallEvent from(CallInfo callInfo, String reason) { + return new JsonCallEvent( + callInfo.callId(), + callInfo.state().name(), + callInfo.recipient().number().orElse(null), + callInfo.recipient().aci().orElse(null), + callInfo.isOutgoing(), + callInfo.inputDeviceName(), + callInfo.outputDeviceName(), + reason + ); + } +} diff --git a/src/main/java/org/asamk/signal/jsonrpc/SignalJsonRpcDispatcherHandler.java b/src/main/java/org/asamk/signal/jsonrpc/SignalJsonRpcDispatcherHandler.java index 5d3fa261..68a4ae21 100644 --- a/src/main/java/org/asamk/signal/jsonrpc/SignalJsonRpcDispatcherHandler.java +++ b/src/main/java/org/asamk/signal/jsonrpc/SignalJsonRpcDispatcherHandler.java @@ -24,6 +24,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.nio.channels.ClosedChannelException; +import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -40,6 +41,7 @@ public class SignalJsonRpcDispatcherHandler { private final boolean noReceiveOnStart; private final Map>> receiveHandlers = new HashMap<>(); + private final List> callEventHandlers = new ArrayList<>(); private SignalJsonRpcCommandHandler commandHandler; public SignalJsonRpcDispatcherHandler( @@ -78,6 +80,47 @@ public class SignalJsonRpcDispatcherHandler { handleConnection(); } + private void subscribeCallEvents(final Manager manager) { + // Prevent duplicate subscriptions for the same manager + if (callEventHandlers.stream().anyMatch(p -> p.first().equals(manager))) { + return; + } + Manager.CallEventListener listener = (callInfo, reason) -> { + final var params = new ObjectNode(objectMapper.getNodeFactory()); + params.set("account", params.textNode(manager.getSelfNumber())); + params.set("callEvent", objectMapper.valueToTree( + org.asamk.signal.json.JsonCallEvent.from(callInfo, reason))); + final var jsonRpcRequest = JsonRpcRequest.forNotification("callEvent", params, null); + try { + jsonRpcSender.sendRequest(jsonRpcRequest); + } catch (AssertionError e) { + if (e.getCause() instanceof ClosedChannelException) { + logger.debug("Call event channel closed, removing listener"); + } + } + }; + manager.addCallEventListener(listener); + callEventHandlers.add(new Pair<>(manager, listener)); + } + + private void unsubscribeCallEvents(final Manager manager) { + var iterator = callEventHandlers.iterator(); + while (iterator.hasNext()) { + var pair = iterator.next(); + if (pair.first().equals(manager)) { + pair.first().removeCallEventListener(pair.second()); + iterator.remove(); + } + } + } + + private void unsubscribeAllCallEvents() { + for (var pair : callEventHandlers) { + pair.first().removeCallEventListener(pair.second()); + } + callEventHandlers.clear(); + } + private static final AtomicInteger nextSubscriptionId = new AtomicInteger(0); private int subscribeReceive(final Manager manager, boolean internalSubscription) { @@ -141,6 +184,7 @@ public class SignalJsonRpcDispatcherHandler { } finally { receiveHandlers.forEach((_subscriptionId, handlers) -> handlers.forEach(this::unsubscribeReceiveHandler)); receiveHandlers.clear(); + unsubscribeAllCallEvents(); } } @@ -157,6 +201,12 @@ public class SignalJsonRpcDispatcherHandler { if ("unsubscribeReceive".equals(method)) { return new UnsubscribeReceiveCommand(); } + if ("subscribeCallEvents".equals(method)) { + return new SubscribeCallEventsCommand(); + } + if ("unsubscribeCallEvents".equals(method)) { + return new UnsubscribeCallEventsCommand(); + } return Commands.getCommand(method); } @@ -240,4 +290,59 @@ public class SignalJsonRpcDispatcherHandler { }; } } + + private class SubscribeCallEventsCommand implements JsonRpcSingleCommand, JsonRpcMultiCommand { + + @Override + public String getName() { + return "subscribeCallEvents"; + } + + @Override + public void handleCommand( + final Void request, + final Manager m, + final JsonWriter jsonWriter + ) throws CommandException { + subscribeCallEvents(m); + } + + @Override + public void handleCommand( + final Void request, + final MultiAccountManager c, + final JsonWriter jsonWriter + ) throws CommandException { + for (var m : c.getManagers()) { + subscribeCallEvents(m); + } + c.addOnManagerAddedHandler(SignalJsonRpcDispatcherHandler.this::subscribeCallEvents); + } + } + + private class UnsubscribeCallEventsCommand implements JsonRpcSingleCommand, JsonRpcMultiCommand { + + @Override + public String getName() { + return "unsubscribeCallEvents"; + } + + @Override + public void handleCommand( + final Void request, + final Manager m, + final JsonWriter jsonWriter + ) throws CommandException { + unsubscribeCallEvents(m); + } + + @Override + public void handleCommand( + final Void request, + final MultiAccountManager c, + final JsonWriter jsonWriter + ) throws CommandException { + unsubscribeAllCallEvents(); + } + } } 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 2bc14de8..9a8f7b95 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 @@ -1961,6 +1961,40 @@ } ] }, + { + "type": "org.asamk.signal.commands.AcceptCallCommand$JsonCallInfo", + "allDeclaredFields": true, + "methods": [ + { + "name": "callId", + "parameterTypes": [] + }, + { + "name": "channels", + "parameterTypes": [] + }, + { + "name": "codec", + "parameterTypes": [] + }, + { + "name": "mediaSocketPath", + "parameterTypes": [] + }, + { + "name": "ptimeMs", + "parameterTypes": [] + }, + { + "name": "sampleRate", + "parameterTypes": [] + }, + { + "name": "state", + "parameterTypes": [] + } + ] + }, { "type": "org.asamk.signal.commands.FinishLinkCommand$FinishLinkParams", "allDeclaredFields": true, @@ -1998,6 +2032,20 @@ "allDeclaredMethods": true, "allDeclaredConstructors": true }, + { + "type": "org.asamk.signal.commands.HangupCallCommand$JsonResult", + "allDeclaredFields": true, + "methods": [ + { + "name": "callId", + "parameterTypes": [] + }, + { + "name": "status", + "parameterTypes": [] + } + ] + }, { "type": "org.asamk.signal.commands.ListAccountsCommand$JsonAccount", "allDeclaredFields": true, @@ -2008,6 +2056,39 @@ } ] }, + { + "type": "org.asamk.signal.commands.ListCallsCommand$JsonCall", + "allDeclaredFields": true, + "methods": [ + { + "name": "callId", + "parameterTypes": [] + }, + { + "name": "isOutgoing", + "parameterTypes": [] + }, + { + "name": "mediaSocketPath", + "parameterTypes": [] + }, + { + "name": "number", + "parameterTypes": [] + }, + { + "name": "state", + "parameterTypes": [] + }, + { + "name": "uuid", + "parameterTypes": [] + } + ] + }, + { + "type": "org.asamk.signal.commands.ListCallsCommand$JsonCall[]" + }, { "type": "org.asamk.signal.commands.ListContactsCommand$JsonContact", "allDeclaredFields": true, @@ -2186,6 +2267,54 @@ } ] }, + { + "type": "org.asamk.signal.commands.RejectCallCommand$JsonResult", + "allDeclaredFields": true, + "methods": [ + { + "name": "callId", + "parameterTypes": [] + }, + { + "name": "status", + "parameterTypes": [] + } + ] + }, + { + "type": "org.asamk.signal.commands.StartCallCommand$JsonCallInfo", + "allDeclaredFields": true, + "methods": [ + { + "name": "callId", + "parameterTypes": [] + }, + { + "name": "channels", + "parameterTypes": [] + }, + { + "name": "codec", + "parameterTypes": [] + }, + { + "name": "mediaSocketPath", + "parameterTypes": [] + }, + { + "name": "ptimeMs", + "parameterTypes": [] + }, + { + "name": "sampleRate", + "parameterTypes": [] + }, + { + "name": "state", + "parameterTypes": [] + } + ] + }, { "type": "org.asamk.signal.commands.StartLinkCommand$JsonLink", "allDeclaredFields": true, @@ -9969,4 +10098,4 @@ "bundle": "net.sourceforge.argparse4j.internal.ArgumentParserImpl" } ] -} \ No newline at end of file +} diff --git a/src/test/java/org/asamk/signal/commands/CallCommandParsingTest.java b/src/test/java/org/asamk/signal/commands/CallCommandParsingTest.java new file mode 100644 index 00000000..2fb1fdc3 --- /dev/null +++ b/src/test/java/org/asamk/signal/commands/CallCommandParsingTest.java @@ -0,0 +1,79 @@ +package org.asamk.signal.commands; + +import net.sourceforge.argparse4j.inf.Namespace; + +import org.junit.jupiter.api.Test; + +import java.math.BigInteger; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Verifies that call commands correctly handle call IDs from JSON-RPC, + * where Jackson may deserialize large numbers as BigInteger instead of Long. + */ +class CallCommandParsingTest { + + /** + * Simulates what Jackson produces for a JSON-RPC call with a large call ID. + * Jackson deserializes numbers that overflow int as BigInteger in untyped maps. + */ + private static Namespace namespaceWithBigIntegerCallId(long value) { + // JsonRpcNamespace converts "call-id" to "callId" lookup + return new JsonRpcNamespace(Map.of("callId", BigInteger.valueOf(value))); + } + + private static Namespace namespaceWithLongCallId(long value) { + return new JsonRpcNamespace(Map.of("callId", value)); + } + + @Test + void hangupCallHandlesBigIntegerCallId() { + var ns = namespaceWithBigIntegerCallId(8230211930154373276L); + var callIdNumber = ns.get("call-id"); + long callId = ((Number) callIdNumber).longValue(); + assertEquals(8230211930154373276L, callId); + } + + @Test + void hangupCallHandlesLongCallId() { + var ns = namespaceWithLongCallId(8230211930154373276L); + var callIdNumber = ns.get("call-id"); + long callId = ((Number) callIdNumber).longValue(); + assertEquals(8230211930154373276L, callId); + } + + @Test + void acceptCallHandlesBigIntegerCallId() { + var ns = namespaceWithBigIntegerCallId(1234567890123456789L); + var callIdNumber = ns.get("call-id"); + long callId = ((Number) callIdNumber).longValue(); + assertEquals(1234567890123456789L, callId); + } + + @Test + void rejectCallHandlesBigIntegerCallId() { + var ns = namespaceWithBigIntegerCallId(Long.MAX_VALUE); + var callIdNumber = ns.get("call-id"); + long callId = ((Number) callIdNumber).longValue(); + assertEquals(Long.MAX_VALUE, callId); + } + + @Test + void camelCaseKeyLookupWorks() { + // Verify JsonRpcNamespace maps "call-id" -> "callId" + var ns = new JsonRpcNamespace(Map.of("callId", BigInteger.valueOf(42L))); + Number result = ns.get("call-id"); + assertEquals(42L, result.longValue()); + } + + @Test + void smallIntegerCallIdWorks() { + // Jackson may produce Integer for small values + var ns = new JsonRpcNamespace(Map.of("callId", 42)); + var callIdNumber = ns.get("call-id"); + long callId = ((Number) callIdNumber).longValue(); + assertEquals(42L, callId); + } +} diff --git a/src/test/java/org/asamk/signal/json/JsonCallEventTest.java b/src/test/java/org/asamk/signal/json/JsonCallEventTest.java new file mode 100644 index 00000000..4ae84a4c --- /dev/null +++ b/src/test/java/org/asamk/signal/json/JsonCallEventTest.java @@ -0,0 +1,110 @@ +package org.asamk.signal.json; + +import org.asamk.signal.manager.api.CallInfo; +import org.asamk.signal.manager.api.RecipientAddress; + +import org.junit.jupiter.api.Test; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class JsonCallEventTest { + + @Test + void fromWithNumberAndUuid() { + var recipient = new RecipientAddress("a1b2c3d4-e5f6-7890-abcd-ef1234567890", null, "+15551234567", null); + var callInfo = new CallInfo(123L, CallInfo.State.CONNECTED, recipient, "signal_input_123", "signal_output_123", true); + + var event = JsonCallEvent.from(callInfo, null); + + assertEquals(123L, event.callId()); + assertEquals("CONNECTED", event.state()); + assertEquals("+15551234567", event.number()); + assertEquals("a1b2c3d4-e5f6-7890-abcd-ef1234567890", event.uuid()); + assertTrue(event.isOutgoing()); + assertEquals("signal_input_123", event.inputDeviceName()); + assertEquals("signal_output_123", event.outputDeviceName()); + assertNull(event.reason()); + } + + @Test + void fromWithUuidOnly() { + var recipient = new RecipientAddress("a1b2c3d4-e5f6-7890-abcd-ef1234567890", null, null, null); + var callInfo = new CallInfo(456L, CallInfo.State.RINGING_INCOMING, recipient, "signal_input_456", "signal_output_456", false); + + var event = JsonCallEvent.from(callInfo, null); + + assertEquals(456L, event.callId()); + assertEquals("RINGING_INCOMING", event.state()); + assertNull(event.number()); + assertEquals("a1b2c3d4-e5f6-7890-abcd-ef1234567890", event.uuid()); + assertFalse(event.isOutgoing()); + } + + @Test + void fromWithNumberOnly() { + var recipient = new RecipientAddress(null, null, "+15559876543", null); + var callInfo = new CallInfo(789L, CallInfo.State.RINGING_OUTGOING, recipient, "signal_input_789", "signal_output_789", true); + + var event = JsonCallEvent.from(callInfo, null); + + assertEquals("+15559876543", event.number()); + assertNull(event.uuid()); + } + + @Test + void fromWithEndedStateAndReason() { + var recipient = new RecipientAddress("uuid-1234", null, "+15551111111", null); + var callInfo = new CallInfo(101L, CallInfo.State.ENDED, recipient, null, null, false); + + var event = JsonCallEvent.from(callInfo, "remote_hangup"); + + assertEquals("ENDED", event.state()); + assertEquals("remote_hangup", event.reason()); + } + + @Test + void fromMapsAllStates() { + var recipient = new RecipientAddress("uuid-1234", null, "+15551111111", null); + + for (var state : CallInfo.State.values()) { + var callInfo = new CallInfo(1L, state, recipient, "signal_input_1", "signal_output_1", true); + var event = JsonCallEvent.from(callInfo, null); + assertEquals(state.name(), event.state()); + } + } + + @Test + void fromConnectingState() { + var recipient = new RecipientAddress("uuid-5678", null, "+15552222222", null); + var callInfo = new CallInfo(200L, CallInfo.State.CONNECTING, recipient, "signal_input_200", "signal_output_200", true); + + var event = JsonCallEvent.from(callInfo, null); + + assertEquals(200L, event.callId()); + assertEquals("CONNECTING", event.state()); + assertEquals("signal_input_200", event.inputDeviceName()); + assertEquals("signal_output_200", event.outputDeviceName()); + assertTrue(event.isOutgoing()); + assertNull(event.reason()); + } + + @Test + void fromWithVariousEndReasons() { + var recipient = new RecipientAddress("uuid-1234", null, "+15551111111", null); + + var reasons = new String[]{"local_hangup", "remote_hangup", "rejected", "remote_busy", + "ring_timeout", "ice_failed", "tunnel_exit", "tunnel_error", "shutdown"}; + + for (var reason : reasons) { + var callInfo = new CallInfo(1L, CallInfo.State.ENDED, recipient, null, null, false); + var event = JsonCallEvent.from(callInfo, reason); + assertEquals(reason, event.reason()); + assertEquals("ENDED", event.state()); + } + } +} diff --git a/src/test/java/org/asamk/signal/jsonrpc/SubscribeCallEventsTest.java b/src/test/java/org/asamk/signal/jsonrpc/SubscribeCallEventsTest.java new file mode 100644 index 00000000..0e072eb6 --- /dev/null +++ b/src/test/java/org/asamk/signal/jsonrpc/SubscribeCallEventsTest.java @@ -0,0 +1,351 @@ +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.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.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.*; + +/** + * Tests for the subscribeCallEvents / unsubscribeCallEvents JSON-RPC commands + * introduced in commit d1e93dd. + */ +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) { + lines.add(line); + } + + String getLine() { + return lines.poll(); + } + } + + /** + * Captures JSON-RPC responses written by the handler. + */ + private static class CapturingJsonWriter implements JsonWriter { + final List written = Collections.synchronizedList(new ArrayList<>()); + + @Override + public void write(final Object object) { + written.add(object); + } + } + + /** + * 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); + final String selfNumber; + + StubManager(String selfNumber) { + this.selfNumber = selfNumber; + } + + @Override public void addCallEventListener(CallEventListener listener) { + addCount.incrementAndGet(); + listeners.add(listener); + } + + @Override public void removeCallEventListener(CallEventListener listener) { + removeCount.incrementAndGet(); + listeners.remove(listener); + } + + @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() {} + } + + /** + * Minimal MultiAccountManager stub for multi-account mode tests. + */ + private static class StubMultiAccountManager implements MultiAccountManager { + final List managers; + final List> addedHandlers = new ArrayList<>(); + + StubMultiAccountManager(List managers) { + this.managers = new ArrayList<>(managers); + } + + @Override public List getAccountNumbers() { + return managers.stream().map(Manager::getSelfNumber).toList(); + } + + @Override public List getManagers() { return managers; } + + @Override public void addOnManagerAddedHandler(Consumer handler) { + addedHandlers.add(handler); + } + + @Override public void addOnManagerRemovedHandler(Consumer handler) {} + + @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() {} + } + + private static String jsonRpcCall(int id, String method) { + return "{\"jsonrpc\":\"2.0\",\"id\":" + id + ",\"method\":\"" + method + "\"}"; + } + + // --- Single-account mode tests --- + + @Test + void callEventsNotSubscribedByDefault() { + var manager = new StubManager("+15551234567"); + var feeder = new LineFeeder(); + var writer = new CapturingJsonWriter(); + + // Send no subscribeCallEvents, just end the connection + var handler = new SignalJsonRpcDispatcherHandler(writer, feeder::getLine, true); + handler.handleConnection(manager); + + // No listeners should have been added + assertEquals(0, manager.addCount.get(), "call events should not be auto-subscribed"); + } + + @Test + void subscribeCallEventsAddsListener() { + var manager = new StubManager("+15551234567"); + var feeder = new LineFeeder(); + var writer = new CapturingJsonWriter(); + + feeder.addLine(jsonRpcCall(1, "subscribeCallEvents")); + // null terminates the read loop + + var handler = new SignalJsonRpcDispatcherHandler(writer, feeder::getLine, true); + handler.handleConnection(manager); + + assertEquals(1, manager.addCount.get(), "subscribeCallEvents should add one listener"); + // Cleanup in finally block should remove it + assertEquals(1, manager.removeCount.get(), "cleanup should remove the listener"); + assertEquals(0, manager.listeners.size(), "no listeners should remain after cleanup"); + } + + @Test + void subscribeCallEventsIsIdempotent() { + var manager = new StubManager("+15551234567"); + var feeder = new LineFeeder(); + var writer = new CapturingJsonWriter(); + + feeder.addLine(jsonRpcCall(1, "subscribeCallEvents")); + feeder.addLine(jsonRpcCall(2, "subscribeCallEvents")); + + var handler = new SignalJsonRpcDispatcherHandler(writer, feeder::getLine, true); + handler.handleConnection(manager); + + // Idempotent guard: second call should not add another listener + assertEquals(1, manager.addCount.get(), "duplicate subscribeCallEvents should be ignored"); + } + + @Test + void unsubscribeCallEventsRemovesListener() { + var manager = new StubManager("+15551234567"); + var feeder = new LineFeeder(); + var writer = new CapturingJsonWriter(); + + feeder.addLine(jsonRpcCall(1, "subscribeCallEvents")); + feeder.addLine(jsonRpcCall(2, "unsubscribeCallEvents")); + + var handler = new SignalJsonRpcDispatcherHandler(writer, feeder::getLine, true); + handler.handleConnection(manager); + + assertEquals(1, manager.addCount.get(), "should have subscribed once"); + // removeCount: 1 from explicit unsubscribe. The finally block's unsubscribeAllCallEvents + // iterates an empty list so adds 0 more. + assertEquals(1, manager.removeCount.get(), "should have unsubscribed once"); + assertEquals(0, manager.listeners.size()); + } + + @Test + void unsubscribeWithoutSubscribeIsNoOp() { + var manager = new StubManager("+15551234567"); + var feeder = new LineFeeder(); + var writer = new CapturingJsonWriter(); + + feeder.addLine(jsonRpcCall(1, "unsubscribeCallEvents")); + + var handler = new SignalJsonRpcDispatcherHandler(writer, feeder::getLine, true); + handler.handleConnection(manager); + + assertEquals(0, manager.addCount.get()); + assertEquals(0, manager.removeCount.get()); + } + + // --- Multi-account mode tests --- + + @Test + void multiAccountSubscribeCallEventsSubscribesAllManagers() { + var manager1 = new StubManager("+15551111111"); + var manager2 = new StubManager("+15552222222"); + var multi = new StubMultiAccountManager(List.of(manager1, manager2)); + + var feeder = new LineFeeder(); + var writer = new CapturingJsonWriter(); + + feeder.addLine(jsonRpcCall(1, "subscribeCallEvents")); + + var handler = new SignalJsonRpcDispatcherHandler(writer, feeder::getLine, true); + handler.handleConnection(multi); + + assertEquals(1, manager1.addCount.get(), "manager1 should have one listener"); + assertEquals(1, manager2.addCount.get(), "manager2 should have one listener"); + // Also registers an onManagerAdded handler + assertEquals(1, multi.addedHandlers.size(), "should register onManagerAdded handler"); + } + + @Test + void multiAccountUnsubscribeCallEventsCleansUpAll() { + var manager1 = new StubManager("+15551111111"); + var manager2 = new StubManager("+15552222222"); + var multi = new StubMultiAccountManager(List.of(manager1, manager2)); + + var feeder = new LineFeeder(); + var writer = new CapturingJsonWriter(); + + feeder.addLine(jsonRpcCall(1, "subscribeCallEvents")); + feeder.addLine(jsonRpcCall(2, "unsubscribeCallEvents")); + + var handler = new SignalJsonRpcDispatcherHandler(writer, feeder::getLine, true); + handler.handleConnection(multi); + + assertEquals(1, manager1.addCount.get()); + assertEquals(1, manager2.addCount.get()); + assertEquals(1, manager1.removeCount.get(), "manager1 listener should be removed"); + assertEquals(1, manager2.removeCount.get(), "manager2 listener should be removed"); + } + + @Test + void multiAccountCallEventsNotSubscribedByDefault() { + var manager1 = new StubManager("+15551111111"); + var multi = new StubMultiAccountManager(List.of(manager1)); + + var feeder = new LineFeeder(); + var writer = new CapturingJsonWriter(); + + var handler = new SignalJsonRpcDispatcherHandler(writer, feeder::getLine, true); + handler.handleConnection(multi); + + assertEquals(0, manager1.addCount.get(), "call events should not be auto-subscribed in multi mode"); + } +} From 103a0807cad8e364f030f2bc8a0e652133ae25be Mon Sep 17 00:00:00 2001 From: AsamK Date: Mon, 30 Mar 2026 18:21:06 +0200 Subject: [PATCH 2/7] Update graalvm buildtools --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index 286b5b94..8d729984 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,7 +3,7 @@ plugins { application eclipse `check-lib-versions` - id("org.graalvm.buildtools.native") version "0.11.5" + id("org.graalvm.buildtools.native") version "1.0.0" } allprojects { From 0a777ea7df69e16e63ca25ef58c3414bbec1bcc8 Mon Sep 17 00:00:00 2001 From: AsamK Date: Mon, 30 Mar 2026 22:28:48 +0200 Subject: [PATCH 3/7] Some minor code improvements --- .../signal/manager/internal/ManagerImpl.java | 27 ++++++++++--------- 1 file changed, 14 insertions(+), 13 deletions(-) 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 bd920635..2f4063d8 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 @@ -711,9 +711,9 @@ public class ManagerImpl implements Manager { results.put(recipient, List.of(SendMessageResult.unregisteredFailure(single.toPartialRecipientAddress()))); } - } else if (recipient instanceof RecipientIdentifier.Group group) { + } else if (recipient instanceof RecipientIdentifier.Group(GroupId groupId)) { final var result = context.getSendHelper() - .sendAsGroupMessage(messageBuilder, group.groupId(), notifySelf, editTargetTimestamp, urgent); + .sendAsGroupMessage(messageBuilder, groupId, notifySelf, editTargetTimestamp, urgent); results.put(recipient, result.stream().map(this::toSendMessageResult).toList()); } } @@ -853,7 +853,8 @@ public class ManagerImpl implements Manager { messageBuilder.withBody(message.messageText()); } if (!message.attachments().isEmpty()) { - final var uploadedAttachments = context.getAttachmentHelper().uploadAttachments(message.attachments(), message.voiceNote()); + final var uploadedAttachments = context.getAttachmentHelper() + .uploadAttachments(message.attachments(), message.voiceNote()); if (!additionalAttachments.isEmpty()) { additionalAttachments.addAll(uploadedAttachments); messageBuilder.withAttachments(additionalAttachments); @@ -959,12 +960,12 @@ public class ManagerImpl implements Manager { var delete = new SignalServiceDataMessage.RemoteDelete(targetSentTimestamp); final var messageBuilder = SignalServiceDataMessage.newBuilder().withRemoteDelete(delete); for (final var recipient : recipients) { - if (recipient instanceof RecipientIdentifier.Uuid u) { + if (recipient instanceof RecipientIdentifier.Uuid(var uuid)) { account.getMessageSendLogStore() - .deleteEntryForRecipientNonGroup(targetSentTimestamp, ACI.from(u.uuid())); - } else if (recipient instanceof RecipientIdentifier.Pni pni) { + .deleteEntryForRecipientNonGroup(targetSentTimestamp, ACI.from(uuid)); + } else if (recipient instanceof RecipientIdentifier.Pni(var pni)) { account.getMessageSendLogStore() - .deleteEntryForRecipientNonGroup(targetSentTimestamp, PNI.from(pni.pni())); + .deleteEntryForRecipientNonGroup(targetSentTimestamp, PNI.from(pni)); } else if (recipient instanceof RecipientIdentifier.Single r) { try { final var recipientId = context.getRecipientHelper().resolveRecipient(r); @@ -975,8 +976,8 @@ public class ManagerImpl implements Manager { } } catch (UnregisteredRecipientException ignored) { } - } else if (recipient instanceof RecipientIdentifier.Group r) { - account.getMessageSendLogStore().deleteEntryForGroup(targetSentTimestamp, r.groupId()); + } else if (recipient instanceof RecipientIdentifier.Group(var groupId)) { + account.getMessageSendLogStore().deleteEntryForGroup(targetSentTimestamp, groupId); } } return sendMessage(messageBuilder, recipients, false); @@ -1149,8 +1150,8 @@ public class ManagerImpl implements Manager { results.put(recipient, List.of(SendMessageResult.unregisteredFailure(single.toPartialRecipientAddress()))); } - } else if (recipient instanceof RecipientIdentifier.Group group) { - final var result = context.getSyncHelper().sendMessageRequestResponse(type, group.groupId()); + } else if (recipient instanceof RecipientIdentifier.Group(GroupId groupId)) { + final var result = context.getSyncHelper().sendMessageRequestResponse(type, groupId); results.put(recipient, List.of(toSendMessageResult(result))); } } @@ -1164,7 +1165,7 @@ public class ManagerImpl implements Manager { final List options, final Set recipients, final boolean notifySelf - ) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException, UnregisteredRecipientException { + ) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException { final var pollCreate = new SignalServiceDataMessage.PollCreate(question, allowMultiple, options); final var messageBuilder = SignalServiceDataMessage.newBuilder().withPollCreate(pollCreate); return sendMessage(messageBuilder, recipients, notifySelf); @@ -1196,7 +1197,7 @@ public class ManagerImpl implements Manager { final long targetSentTimestamp, final Set recipients, final boolean notifySelf - ) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException, UnregisteredRecipientException { + ) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException { final var pollTerminate = new SignalServiceDataMessage.PollTerminate(targetSentTimestamp); final var messageBuilder = SignalServiceDataMessage.newBuilder().withPollTerminate(pollTerminate); return sendMessage(messageBuilder, recipients, notifySelf); From 7a8a34f45e7e47ee00724c5652407d02c895a9cc Mon Sep 17 00:00:00 2001 From: AsamK Date: Mon, 30 Mar 2026 22:28:28 +0200 Subject: [PATCH 4/7] Some call refactoring --- docs/CALL_TUNNEL.md | 61 +- .../org/asamk/signal/manager/Manager.java | 33 +- .../signal/manager/helper/CallManager.java | 456 ++++++------- .../helper/IncomingMessageHandler.java | 24 +- .../signal/manager/helper/SendHelper.java | 21 + .../signal/manager/internal/ManagerImpl.java | 33 +- .../org/asamk/signal/manager/util/Utils.java | 8 + .../manager/helper/CallManagerTest.java | 39 +- .../storage/recipients/TestRecipientId.java | 8 + .../asamk/signal/ReceiveMessageHandler.java | 18 +- .../asamk/signal/dbus/DbusManagerImpl.java | 36 +- .../asamk/signal/json/JsonCallMessage.java | 26 +- .../signal-cli/reachability-metadata.json | 107 +++ .../jsonrpc/SubscribeCallEventsTest.java | 641 +++++++++++++++--- 14 files changed, 1054 insertions(+), 457 deletions(-) create mode 100644 lib/src/test/java/org/asamk/signal/manager/storage/recipients/TestRecipientId.java 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) { From 7919a0f4aa5ea689e5ee3174dd95a0d53db577c6 Mon Sep 17 00:00:00 2001 From: AsamK Date: Wed, 1 Apr 2026 22:38:36 +0200 Subject: [PATCH 5/7] Change subscribeCallEvents command to match subscribeReceive --- .../SignalJsonRpcDispatcherHandler.java | 173 +++++++++++------- .../jsonrpc/SubscribeCallEventsTest.java | 18 +- 2 files changed, 122 insertions(+), 69 deletions(-) diff --git a/src/main/java/org/asamk/signal/jsonrpc/SignalJsonRpcDispatcherHandler.java b/src/main/java/org/asamk/signal/jsonrpc/SignalJsonRpcDispatcherHandler.java index 68a4ae21..14ff0076 100644 --- a/src/main/java/org/asamk/signal/jsonrpc/SignalJsonRpcDispatcherHandler.java +++ b/src/main/java/org/asamk/signal/jsonrpc/SignalJsonRpcDispatcherHandler.java @@ -14,6 +14,7 @@ import org.asamk.signal.commands.JsonRpcMultiCommand; import org.asamk.signal.commands.JsonRpcSingleCommand; import org.asamk.signal.commands.exceptions.CommandException; import org.asamk.signal.commands.exceptions.UserErrorException; +import org.asamk.signal.json.JsonCallEvent; import org.asamk.signal.json.JsonReceiveMessageHandler; import org.asamk.signal.manager.Manager; import org.asamk.signal.manager.MultiAccountManager; @@ -24,7 +25,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.nio.channels.ClosedChannelException; -import java.util.ArrayList; +import java.util.Collection; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -41,7 +42,7 @@ public class SignalJsonRpcDispatcherHandler { private final boolean noReceiveOnStart; private final Map>> receiveHandlers = new HashMap<>(); - private final List> callEventHandlers = new ArrayList<>(); + private final Map>> callEventHandlers = new HashMap<>(); private SignalJsonRpcCommandHandler commandHandler; public SignalJsonRpcDispatcherHandler( @@ -63,6 +64,10 @@ public class SignalJsonRpcDispatcherHandler { c.addOnManagerAddedHandler(m -> subscribeReceive(m, true)); c.addOnManagerRemovedHandler(this::unsubscribeReceive); } + c.addOnManagerAddedHandler(m -> receiveHandlers.forEach((subscriptionId, handlers) -> handlers.add( + createReceiveHandler(m, subscriptionId, false)))); + c.addOnManagerAddedHandler(m -> callEventHandlers.forEach((subscriptionId, handlers) -> handlers.add( + createCallEventHandler(m, subscriptionId)))); handleConnection(); } @@ -80,47 +85,57 @@ public class SignalJsonRpcDispatcherHandler { handleConnection(); } - private void subscribeCallEvents(final Manager manager) { - // Prevent duplicate subscriptions for the same manager - if (callEventHandlers.stream().anyMatch(p -> p.first().equals(manager))) { - return; - } - Manager.CallEventListener listener = (callInfo, reason) -> { + private int subscribeCallEvents(final Manager manager) { + return subscribeCallEvents(List.of(manager)); + } + + private int subscribeCallEvents(final Collection managers) { + final var subscriptionId = nextSubscriptionId.getAndIncrement(); + final var listeners = managers.stream().map(m -> createCallEventHandler(m, subscriptionId)).toList(); + callEventHandlers.put(subscriptionId, listeners); + return subscriptionId; + } + + private Pair createCallEventHandler(final Manager m, final int subscriptionId) { + final Manager.CallEventListener listener = (callInfo, reason) -> { final var params = new ObjectNode(objectMapper.getNodeFactory()); - params.set("account", params.textNode(manager.getSelfNumber())); - params.set("callEvent", objectMapper.valueToTree( - org.asamk.signal.json.JsonCallEvent.from(callInfo, reason))); + params.set("subscription", IntNode.valueOf(subscriptionId)); + params.set("result", objectMapper.valueToTree(JsonCallEvent.from(callInfo, reason))); final var jsonRpcRequest = JsonRpcRequest.forNotification("callEvent", params, null); try { jsonRpcSender.sendRequest(jsonRpcRequest); } catch (AssertionError e) { if (e.getCause() instanceof ClosedChannelException) { - logger.debug("Call event channel closed, removing listener"); + unsubscribeReceive(subscriptionId); } } }; - manager.addCallEventListener(listener); - callEventHandlers.add(new Pair<>(manager, listener)); + m.addCallEventListener(listener); + return new Pair<>(m, listener); } - private void unsubscribeCallEvents(final Manager manager) { - var iterator = callEventHandlers.iterator(); - while (iterator.hasNext()) { - var pair = iterator.next(); - if (pair.first().equals(manager)) { - pair.first().removeCallEventListener(pair.second()); - iterator.remove(); - } + private boolean unsubscribeCallEvents(final int subscriptionId) { + final var handlers = callEventHandlers.remove(subscriptionId); + if (handlers == null) { + return false; } + for (final var pair : handlers) { + unsubscribeCallEventHandler(pair); + } + return true; } private void unsubscribeAllCallEvents() { - for (var pair : callEventHandlers) { - pair.first().removeCallEventListener(pair.second()); - } + callEventHandlers.forEach((_subscriptionId, handlers) -> handlers.forEach(this::unsubscribeCallEventHandler)); callEventHandlers.clear(); } + private void unsubscribeCallEventHandler(final Pair pair) { + final var m = pair.first(); + final var handler = pair.second(); + m.removeCallEventListener(handler); + } + private static final AtomicInteger nextSubscriptionId = new AtomicInteger(0); private int subscribeReceive(final Manager manager, boolean internalSubscription) { @@ -129,34 +144,42 @@ public class SignalJsonRpcDispatcherHandler { private int subscribeReceive(final List managers, boolean internalSubscription) { final var subscriptionId = nextSubscriptionId.getAndIncrement(); - final var handlers = managers.stream().map(m -> { - final var receiveMessageHandler = new JsonReceiveMessageHandler(m, s -> { - ContainerNode params; - if (internalSubscription) { - params = objectMapper.valueToTree(s); - } else { - final var paramsNode = new ObjectNode(objectMapper.getNodeFactory()); - paramsNode.set("subscription", IntNode.valueOf(subscriptionId)); - paramsNode.set("result", objectMapper.valueToTree(s)); - params = paramsNode; - } - final var jsonRpcRequest = JsonRpcRequest.forNotification("receive", params, null); - try { - jsonRpcSender.sendRequest(jsonRpcRequest); - } catch (AssertionError e) { - if (e.getCause() instanceof ClosedChannelException) { - unsubscribeReceive(subscriptionId); - } - } - }); - m.addReceiveHandler(receiveMessageHandler); - return new Pair<>(m, (Manager.ReceiveMessageHandler) receiveMessageHandler); - }).toList(); + final var handlers = managers.stream() + .map(m -> createReceiveHandler(m, subscriptionId, internalSubscription)) + .toList(); receiveHandlers.put(subscriptionId, handlers); return subscriptionId; } + private Pair createReceiveHandler( + final Manager m, + final int subscriptionId, + final boolean internalSubscription + ) { + final var receiveMessageHandler = new JsonReceiveMessageHandler(m, s -> { + ContainerNode params; + if (internalSubscription) { + params = objectMapper.valueToTree(s); + } else { + final var paramsNode = new ObjectNode(objectMapper.getNodeFactory()); + paramsNode.set("subscription", IntNode.valueOf(subscriptionId)); + paramsNode.set("result", objectMapper.valueToTree(s)); + params = paramsNode; + } + final var jsonRpcRequest = JsonRpcRequest.forNotification("receive", params, null); + try { + jsonRpcSender.sendRequest(jsonRpcRequest); + } catch (AssertionError e) { + if (e.getCause() instanceof ClosedChannelException) { + unsubscribeReceive(subscriptionId); + } + } + }); + m.addReceiveHandler(receiveMessageHandler); + return new Pair<>(m, receiveMessageHandler); + } + private boolean unsubscribeReceive(final int subscriptionId) { final var handlers = receiveHandlers.remove(subscriptionId); if (handlers == null) { @@ -304,7 +327,8 @@ public class SignalJsonRpcDispatcherHandler { final Manager m, final JsonWriter jsonWriter ) throws CommandException { - subscribeCallEvents(m); + final var subscriptionId = subscribeCallEvents(m); + jsonWriter.write(subscriptionId); } @Override @@ -313,14 +337,12 @@ public class SignalJsonRpcDispatcherHandler { final MultiAccountManager c, final JsonWriter jsonWriter ) throws CommandException { - for (var m : c.getManagers()) { - subscribeCallEvents(m); - } - c.addOnManagerAddedHandler(SignalJsonRpcDispatcherHandler.this::subscribeCallEvents); + final var subscriptionId = subscribeCallEvents(c.getManagers()); + jsonWriter.write(subscriptionId); } } - private class UnsubscribeCallEventsCommand implements JsonRpcSingleCommand, JsonRpcMultiCommand { + private class UnsubscribeCallEventsCommand implements JsonRpcSingleCommand, JsonRpcMultiCommand { @Override public String getName() { @@ -328,21 +350,48 @@ public class SignalJsonRpcDispatcherHandler { } @Override - public void handleCommand( - final Void request, - final Manager m, - final JsonWriter jsonWriter - ) throws CommandException { - unsubscribeCallEvents(m); + public TypeReference getRequestType() { + return new TypeReference<>() {}; } @Override public void handleCommand( - final Void request, + final JsonNode request, + final Manager m, + final JsonWriter jsonWriter + ) throws CommandException { + final var subscriptionId = getSubscriptionId(request); + if (subscriptionId == null) { + throw new UserErrorException("Missing subscription parameter with subscription id"); + } else { + if (!unsubscribeCallEvents(subscriptionId)) { + throw new UserErrorException("Unknown subscription id"); + } + } + } + + @Override + public void handleCommand( + final JsonNode request, final MultiAccountManager c, final JsonWriter jsonWriter ) throws CommandException { - unsubscribeAllCallEvents(); + final var subscriptionId = getSubscriptionId(request); + if (subscriptionId == null) { + throw new UserErrorException("Missing subscription parameter with subscription id"); + } else { + if (!unsubscribeCallEvents(subscriptionId)) { + throw new UserErrorException("Unknown subscription id"); + } + } + } + + private Integer getSubscriptionId(final JsonNode request) { + return switch (request) { + case ArrayNode req -> req.get(0).asInt(); + case ObjectNode req -> req.get("subscription").asInt(); + case null, default -> null; + }; } } } diff --git a/src/test/java/org/asamk/signal/jsonrpc/SubscribeCallEventsTest.java b/src/test/java/org/asamk/signal/jsonrpc/SubscribeCallEventsTest.java index 48cbde22..37c00829 100644 --- a/src/test/java/org/asamk/signal/jsonrpc/SubscribeCallEventsTest.java +++ b/src/test/java/org/asamk/signal/jsonrpc/SubscribeCallEventsTest.java @@ -635,6 +635,10 @@ class SubscribeCallEventsTest { return "{\"jsonrpc\":\"2.0\",\"id\":" + id + ",\"method\":\"" + method + "\"}"; } + private static String jsonRpcCall(int id, String method, String params) { + return "{\"jsonrpc\":\"2.0\",\"id\":" + id + ",\"method\":\"" + method + "\",\"params\":" + params + "}"; + } + // --- Single-account mode tests --- @Test @@ -670,7 +674,7 @@ class SubscribeCallEventsTest { } @Test - void subscribeCallEventsIsIdempotent() { + void subscribeCallEventsCanBeCalledMultipleTimes() { var manager = new StubManager("+15551234567"); var feeder = new LineFeeder(); var writer = new CapturingJsonWriter(); @@ -681,8 +685,8 @@ class SubscribeCallEventsTest { var handler = new SignalJsonRpcDispatcherHandler(writer, feeder::getLine, true); handler.handleConnection(manager); - // Idempotent guard: second call should not add another listener - assertEquals(1, manager.addCount.get(), "duplicate subscribeCallEvents should be ignored"); + // The implementation allows multiple subscriptions, so two calls add two listeners + assertEquals(2, manager.addCount.get(), "multiple subscribeCallEvents should add multiple listeners"); } @Test @@ -692,7 +696,7 @@ class SubscribeCallEventsTest { var writer = new CapturingJsonWriter(); feeder.addLine(jsonRpcCall(1, "subscribeCallEvents")); - feeder.addLine(jsonRpcCall(2, "unsubscribeCallEvents")); + feeder.addLine(jsonRpcCall(2, "unsubscribeCallEvents", "{\"subscription\":0}")); var handler = new SignalJsonRpcDispatcherHandler(writer, feeder::getLine, true); handler.handleConnection(manager); @@ -737,8 +741,8 @@ class SubscribeCallEventsTest { assertEquals(1, manager1.addCount.get(), "manager1 should have one listener"); assertEquals(1, manager2.addCount.get(), "manager2 should have one listener"); - // Also registers an onManagerAdded handler - assertEquals(1, multi.addedHandlers.size(), "should register onManagerAdded handler"); + // Also registers an onManagerAdded handler for receive and one for call events + assertEquals(2, multi.addedHandlers.size(), "should register onManagerAdded handlers"); } @Test @@ -751,7 +755,7 @@ class SubscribeCallEventsTest { var writer = new CapturingJsonWriter(); feeder.addLine(jsonRpcCall(1, "subscribeCallEvents")); - feeder.addLine(jsonRpcCall(2, "unsubscribeCallEvents")); + feeder.addLine(jsonRpcCall(2, "unsubscribeCallEvents", "{\"subscription\":0}")); var handler = new SignalJsonRpcDispatcherHandler(writer, feeder::getLine, true); handler.handleConnection(multi); From d1106299fe53b2e929482666891be2816cc22a5e Mon Sep 17 00:00:00 2001 From: AsamK Date: Wed, 1 Apr 2026 21:56:12 +0200 Subject: [PATCH 6/7] Pass sender device id to ice handler --- .../main/java/org/asamk/signal/manager/helper/CallManager.java | 3 ++- .../asamk/signal/manager/helper/IncomingMessageHandler.java | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) 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 d4fd6bc7..cc2d7064 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 @@ -286,7 +286,7 @@ public class CallManager implements AutoCloseable { logger.debug("Received answer for call {}", callIdUnsigned(callId)); } - public void handleIncomingIceCandidate(final long callId, final byte[] opaque) { + public void handleIncomingIceCandidate(final long callId, final byte[] opaque, final int deviceId) { var state = activeCalls.get(callId); if (state == null) { logger.debug("Received ICE candidate for unknown call {}", callIdUnsigned(callId)); @@ -296,6 +296,7 @@ public class CallManager implements AutoCloseable { // Forward to subprocess as receivedIce var iceMsg = mapper.createObjectNode(); iceMsg.put("type", "receivedIce"); + iceMsg.put("senderDeviceId", deviceId); var candidates = iceMsg.putArray("candidates"); candidates.add(java.util.Base64.getEncoder().encodeToString(opaque)); sendControlMessage(state, writeJson(iceMsg)); 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 9750f96b..2240ca76 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 @@ -433,7 +433,7 @@ public final class IncomingMessageHandler { callMessage.getIceUpdateMessages().ifPresent(iceUpdates -> { for (var ice : iceUpdates) { - callManager.handleIncomingIceCandidate(ice.getId(), ice.getOpaque()); + callManager.handleIncomingIceCandidate(ice.getId(), ice.getOpaque(), deviceId); } }); From 265369e353fc924716efd85ef7d842d57771fa22 Mon Sep 17 00:00:00 2001 From: AsamK Date: Wed, 1 Apr 2026 22:23:35 +0200 Subject: [PATCH 7/7] Update libsignal-service --- gradle/libs.versions.toml | 2 +- .../signal/manager/actions/SendRetryMessageRequestAction.java | 2 +- .../asamk/signal/manager/helper/IncomingMessageHandler.java | 4 ---- .../main/java/org/asamk/signal/manager/helper/SyncHelper.java | 1 - .../signal/manager/internal/ProvisioningManagerImpl.java | 1 - .../java/org/asamk/signal/manager/storage/SignalAccount.java | 3 +-- 6 files changed, 3 insertions(+), 10 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index dbf9b4dc..75496555 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,7 +12,7 @@ slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" } slf4j-jul = { module = "org.slf4j:jul-to-slf4j", version.ref = "slf4j" } logback = "ch.qos.logback:logback-classic:1.5.32" -signalservice = "com.github.turasa:signal-service-java:2.15.3_unofficial_141" +signalservice = "com.github.turasa:signal-service-java:2.15.3_unofficial_142" sqlite = "org.xerial:sqlite-jdbc:3.51.2.0" hikari = "com.zaxxer:HikariCP:7.0.2" junit-jupiter-bom = { module = "org.junit:junit-bom", version.ref = "junit" } diff --git a/lib/src/main/java/org/asamk/signal/manager/actions/SendRetryMessageRequestAction.java b/lib/src/main/java/org/asamk/signal/manager/actions/SendRetryMessageRequestAction.java index 96a94128..af2f85be 100644 --- a/lib/src/main/java/org/asamk/signal/manager/actions/SendRetryMessageRequestAction.java +++ b/lib/src/main/java/org/asamk/signal/manager/actions/SendRetryMessageRequestAction.java @@ -60,7 +60,7 @@ public class SendRetryMessageRequestAction implements HandleAction { return CiphertextMessage.WHISPER_TYPE; } return switch (type) { - case PREKEY_BUNDLE -> CiphertextMessage.PREKEY_TYPE; + case PREKEY_MESSAGE -> CiphertextMessage.PREKEY_TYPE; case UNIDENTIFIED_SENDER -> CiphertextMessage.SENDERKEY_TYPE; case PLAINTEXT_CONTENT -> CiphertextMessage.PLAINTEXT_CONTENT_TYPE; default -> CiphertextMessage.WHISPER_TYPE; 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 2240ca76..92014fa3 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 @@ -661,10 +661,6 @@ public final class IncomingMessageHandler { final var aep = keysMessage.getAccountEntropyPool(); account.setAccountEntropyPool(aep); actions.add(SyncStorageDataAction.create()); - } else if (keysMessage.getMaster() != null) { - final var masterKey = keysMessage.getMaster(); - account.setMasterKey(masterKey); - actions.add(SyncStorageDataAction.create()); } else if (keysMessage.getStorageService() != null) { final var storageKey = keysMessage.getStorageService(); account.setStorageKey(storageKey); diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/SyncHelper.java b/lib/src/main/java/org/asamk/signal/manager/helper/SyncHelper.java index a13fc38f..332f8ae7 100644 --- a/lib/src/main/java/org/asamk/signal/manager/helper/SyncHelper.java +++ b/lib/src/main/java/org/asamk/signal/manager/helper/SyncHelper.java @@ -258,7 +258,6 @@ public class SyncHelper { public SendMessageResult sendKeysMessage() { var keysMessage = new KeysMessage(account.getOrCreateStorageKey(), - account.getOrCreatePinMasterKey(), account.getOrCreateAccountEntropyPool(), account.getOrCreateMediaRootBackupKey()); return context.getSendHelper().sendSyncMessage(SignalServiceSyncMessage.forKeys(keysMessage)); diff --git a/lib/src/main/java/org/asamk/signal/manager/internal/ProvisioningManagerImpl.java b/lib/src/main/java/org/asamk/signal/manager/internal/ProvisioningManagerImpl.java index eeca842a..b90e08c3 100644 --- a/lib/src/main/java/org/asamk/signal/manager/internal/ProvisioningManagerImpl.java +++ b/lib/src/main/java/org/asamk/signal/manager/internal/ProvisioningManagerImpl.java @@ -145,7 +145,6 @@ public class ProvisioningManagerImpl implements ProvisioningManager { ret.getAciIdentity(), ret.getPniIdentity(), profileKey, - ret.getMasterKey(), ret.getAccountEntropyPool(), ret.getMediaRootBackupKey()); diff --git a/lib/src/main/java/org/asamk/signal/manager/storage/SignalAccount.java b/lib/src/main/java/org/asamk/signal/manager/storage/SignalAccount.java index 4116132a..3e02aed0 100644 --- a/lib/src/main/java/org/asamk/signal/manager/storage/SignalAccount.java +++ b/lib/src/main/java/org/asamk/signal/manager/storage/SignalAccount.java @@ -292,7 +292,6 @@ public class SignalAccount implements Closeable { final IdentityKeyPair aciIdentity, final IdentityKeyPair pniIdentity, final ProfileKey profileKey, - final MasterKey masterKey, final AccountEntropyPool accountEntropyPool, final MediaRootBackupKey mediaRootBackupKey ) { @@ -314,7 +313,7 @@ public class SignalAccount implements Closeable { this.pinMasterKey = null; this.accountEntropyPool = accountEntropyPool; } else { - this.pinMasterKey = masterKey; + this.pinMasterKey = null; this.accountEntropyPool = null; } this.mediaRootBackupKey = mediaRootBackupKey;