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