From 135d3a16777b4fe13d836c47daea13870776df60 Mon Sep 17 00:00:00 2001 From: Shaheen Gandhi Date: Wed, 1 Apr 2026 13:07:16 -0700 Subject: [PATCH] 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"); + } +}