diff --git a/build.gradle.kts b/build.gradle.kts index 97bb127c..631be293 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,7 +5,7 @@ plugins { application eclipse `check-lib-versions` - id("org.graalvm.buildtools.native") version "0.11.5" + id("org.graalvm.buildtools.native") version "1.0.0" } allprojects { @@ -104,6 +104,14 @@ dependencies { implementation(libs.micronaut.json.schema.generator) } 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..b51bc5dd --- /dev/null +++ b/docs/CALL_TUNNEL.md @@ -0,0 +1,359 @@ +# 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. + +**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/gradle/libs.versions.toml b/gradle/libs.versions.toml index d54afdf7..885b1199 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,7 +18,7 @@ slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" } slf4j-jul = { module = "org.slf4j:jul-to-slf4j", version.ref = "slf4j" } logback = "ch.qos.logback:logback-classic:1.5.32" -signalservice = "com.github.turasa:signal-service-java:2.15.3_unofficial_141" +signalservice = "com.github.turasa:signal-service-java:2.15.3_unofficial_142" sqlite = "org.xerial:sqlite-jdbc:3.51.2.0" hikari = "com.zaxxer:HikariCP:7.0.2" junit-jupiter-bom = { module = "org.junit:junit-bom", version.ref = "junit" } diff --git a/lib/src/main/java/org/asamk/signal/manager/Manager.java b/lib/src/main/java/org/asamk/signal/manager/Manager.java index e6a3ae3b..fda3a323 100644 --- a/lib/src/main/java/org/asamk/signal/manager/Manager.java +++ b/lib/src/main/java/org/asamk/signal/manager/Manager.java @@ -4,6 +4,8 @@ import com.google.i18n.phonenumbers.PhoneNumberUtil; import org.asamk.signal.manager.api.AlreadyReceivingException; import org.asamk.signal.manager.api.AttachmentInvalidException; +import org.asamk.signal.manager.api.CallInfo; +import org.asamk.signal.manager.api.CallOffer; import org.asamk.signal.manager.api.CaptchaRejectedException; import org.asamk.signal.manager.api.CaptchaRequiredException; import org.asamk.signal.manager.api.Configuration; @@ -37,11 +39,13 @@ import org.asamk.signal.manager.api.ReceiveConfig; import org.asamk.signal.manager.api.Recipient; import org.asamk.signal.manager.api.RecipientIdentifier; import org.asamk.signal.manager.api.SendGroupMessageResults; +import org.asamk.signal.manager.api.SendMessageResult; import org.asamk.signal.manager.api.SendMessageResults; import org.asamk.signal.manager.api.StickerPack; import org.asamk.signal.manager.api.StickerPackId; import org.asamk.signal.manager.api.StickerPackInvalidException; import org.asamk.signal.manager.api.StickerPackUrl; +import org.asamk.signal.manager.api.TurnServer; import org.asamk.signal.manager.api.TypingAction; import org.asamk.signal.manager.api.UnregisteredRecipientException; import org.asamk.signal.manager.api.UpdateGroup; @@ -413,9 +417,52 @@ 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; + + SendMessageResult 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 +470,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/actions/SendRetryMessageRequestAction.java b/lib/src/main/java/org/asamk/signal/manager/actions/SendRetryMessageRequestAction.java index 96a94128..af2f85be 100644 --- a/lib/src/main/java/org/asamk/signal/manager/actions/SendRetryMessageRequestAction.java +++ b/lib/src/main/java/org/asamk/signal/manager/actions/SendRetryMessageRequestAction.java @@ -60,7 +60,7 @@ public class SendRetryMessageRequestAction implements HandleAction { return CiphertextMessage.WHISPER_TYPE; } return switch (type) { - case PREKEY_BUNDLE -> CiphertextMessage.PREKEY_TYPE; + case PREKEY_MESSAGE -> CiphertextMessage.PREKEY_TYPE; case UNIDENTIFIED_SENDER -> CiphertextMessage.SENDERKEY_TYPE; case PLAINTEXT_CONTENT -> CiphertextMessage.PLAINTEXT_CONTENT_TYPE; default -> CiphertextMessage.WHISPER_TYPE; diff --git a/lib/src/main/java/org/asamk/signal/manager/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..cc2d7064 --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/helper/CallManager.java @@ -0,0 +1,810 @@ +package org.asamk.signal.manager.helper; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import org.asamk.signal.manager.Manager; +import org.asamk.signal.manager.api.CallInfo; +import org.asamk.signal.manager.api.MessageEnvelope; +import org.asamk.signal.manager.api.TurnServer; +import org.asamk.signal.manager.internal.SignalDependencies; +import org.asamk.signal.manager.storage.SignalAccount; +import org.asamk.signal.manager.storage.recipients.RecipientId; +import org.asamk.signal.manager.util.Utils; +import org.signal.libsignal.protocol.IdentityKey; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.whispersystems.signalservice.api.messages.SendMessageResult; +import org.whispersystems.signalservice.api.messages.calls.AnswerMessage; +import org.whispersystems.signalservice.api.messages.calls.BusyMessage; +import org.whispersystems.signalservice.api.messages.calls.HangupMessage; +import org.whispersystems.signalservice.api.messages.calls.IceUpdateMessage; +import org.whispersystems.signalservice.api.messages.calls.OfferMessage; +import org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage; +import org.whispersystems.signalservice.api.push.exceptions.ProofRequiredException; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +import java.io.PrintWriter; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.Collections; +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; +import java.util.stream.Collectors; + +import static org.asamk.signal.manager.util.Utils.callIdUnsigned; +import static org.asamk.signal.manager.util.Utils.handleResponseException; + +/** + * Manages active voice calls: tracks state, spawns/monitors the signal-call-tunnel + * 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(account.getRecipientAddressResolver()); + for (var listener : callEventListeners) { + try { + listener.handleCallEvent(callInfo, reason); + } catch (Throwable e) { + logger.warn("Call event listener failed, ignoring", e); + } + } + } + + public CallInfo startOutgoingCall( + final RecipientId recipientId + ) throws IOException { + var callId = generateCallId(); + var recipientAddress = account.getRecipientAddressResolver().resolveRecipientAddress(recipientId); + + var state = new CallState(callId, CallInfo.State.RINGING_OUTGOING, recipientId, null, true); + logger.debug("Starting outgoing call {} to {} (recipientId: {})", + callIdUnsigned(callId), + recipientAddress, + recipientId); + activeCalls.put(callId, state); + fireCallEvent(state, null); + + // 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", Utils.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.debug("Started outgoing call {} to {}", callIdUnsigned(callId), recipientAddress); + return state.toCallInfo(account.getRecipientAddressResolver()); + } + + public CallInfo acceptIncomingCall(final long callId) throws IOException { + final var state = getActiveCall(callId); + if (state.state != CallInfo.State.RINGING_INCOMING) { + throw new IOException("Call " + + callId + + " is not in RINGING_INCOMING state (current: " + + state.state + + ")"); + } + + // 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.debug("Accepted incoming call {}", callIdUnsigned(callId)); + return state.toCallInfo(account.getRecipientAddressResolver()); + } + + public void hangupCall(final long callId) throws IOException { + getActiveCall(callId); + endCall(callId, "local_hangup"); + } + + public SendMessageResult rejectCall(final long callId) throws IOException { + final var callState = getActiveCall(callId); + + final var result = sendBusyMessage(callState.callId, callState.recipientId, callState.deviceId); + + endCall(callId, "rejected"); + return result; + } + + public List listActiveCalls() { + return activeCalls.values() + .stream() + .map((CallState callState) -> callState.toCallInfo(account.getRecipientAddressResolver())) + .toList(); + } + + public List getTurnServers() throws IOException { + try { + var turnServerList = handleResponseException(dependencies.getCallingApi().getTurnServerInfo()); + 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 RecipientId recipientId, + final int deviceId, + final long callId, + final MessageEnvelope.Call.Offer.Type type, + final byte[] opaque + ) { + if (callEventListeners.isEmpty()) { + logger.debug("Ignoring incoming offer for call {}: no call event listeners registered", + callIdUnsigned(callId)); + + final var result = sendBusyMessage(callId, recipientId, deviceId); + if (!result.isSuccess()) { + logger.warn("Failed to send busy for unhandled call {}", callIdUnsigned(callId)); + } + return; + } + + var senderAddress = account.getRecipientAddressResolver() + .resolveRecipientAddress(recipientId) + .toApiRecipientAddress(); + + logger.debug("Incoming offer opaque ({} bytes)", opaque == null ? 0 : opaque.length); + + var state = new CallState(callId, CallInfo.State.RINGING_INCOMING, recipientId, deviceId, false); + logger.debug("Starting incoming call {} from {} (recipientId: {})", + callIdUnsigned(callId), + senderAddress, + recipientId); + 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()); + 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 {}", callIdUnsigned(callId), e); + turnServers = List.of(); + } + + // Send receivedOffer to subprocess + var offerMsg = mapper.createObjectNode(); + offerMsg.put("type", "receivedOffer"); + offerMsg.put("callId", Utils.callIdUnsigned(callId)); + offerMsg.put("peerId", senderAddress.toString()); + offerMsg.put("senderDeviceId", deviceId); + offerMsg.put("opaque", java.util.Base64.getEncoder().encodeToString(opaque)); + offerMsg.put("age", 0); + offerMsg.put("senderIdentityKey", java.util.Base64.getEncoder().encodeToString(remoteIdentityKey)); + 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.debug("Incoming call {} from {}", callIdUnsigned(callId), senderAddress); + } + + public void handleIncomingAnswer(final long callId, final int deviceId, final byte[] opaque) { + var state = activeCalls.get(callId); + if (state == null) { + logger.warn("Received answer for unknown call {}", callIdUnsigned(callId)); + return; + } + + // Get identity keys + // Use raw 32-byte Curve25519 public key (without 0x05 DJB prefix) to match Signal Android + byte[] localIdentityKey = getRawIdentityKeyBytes(account.getAciIdentityKeyPair().getPublicKey()); + 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", deviceId); + answerMsg.put("senderIdentityKey", java.util.Base64.getEncoder().encodeToString(remoteIdentityKey)); + answerMsg.put("receiverIdentityKey", java.util.Base64.getEncoder().encodeToString(localIdentityKey)); + sendControlMessage(state, writeJson(answerMsg)); + + state.deviceId = deviceId; + state.state = CallInfo.State.CONNECTING; + fireCallEvent(state, null); + + logger.debug("Received answer for call {}", callIdUnsigned(callId)); + } + + public void handleIncomingIceCandidate(final long callId, final byte[] opaque, final int deviceId) { + var state = activeCalls.get(callId); + if (state == null) { + logger.debug("Received ICE candidate for unknown call {}", callIdUnsigned(callId)); + return; + } + + // Forward to subprocess as receivedIce + var iceMsg = mapper.createObjectNode(); + iceMsg.put("type", "receivedIce"); + iceMsg.put("senderDeviceId", deviceId); + var candidates = iceMsg.putArray("candidates"); + candidates.add(java.util.Base64.getEncoder().encodeToString(opaque)); + sendControlMessage(state, writeJson(iceMsg)); + logger.debug("Forwarded ICE candidate to tunnel for call {}", callIdUnsigned(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 CallState getActiveCall(final long callId) throws IOException { + var state = activeCalls.get(callId); + if (state == null) { + throw new IOException("No active call with id " + callIdUnsigned(callId)); + } + return state; + } + + private SendMessageResult sendBusyMessage(final long callId, final RecipientId recipientId, final int deviceId) { + var busyMessage = new BusyMessage(callId); + var callMessage = SignalServiceCallMessage.forBusy(busyMessage, deviceId); + return context.getSendHelper().sendCallMessage(callMessage, recipientId); + } + + private void sendControlMessage(CallState state, String json) { + if (state.controlWriter == null) { + logger.debug("Queueing control message for call {} (not yet connected): {}", + callIdUnsigned(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", Utils.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-" + callIdUnsigned(state.callId)) + .start(() -> readControlEvents(state, process.getInputStream())); + + // Drain subprocess stderr to prevent pipe buffer deadlock + Thread.ofVirtual().name("tunnel-stderr-" + callIdUnsigned(state.callId)).start(() -> { + try (var reader = new BufferedReader(new InputStreamReader(process.getErrorStream(), + StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + logger.debug("[tunnel-{}] {}", callIdUnsigned(state.callId), line); + } + } catch (IOException ignored) { + } + }); + + // Monitor process exit + process.onExit().thenAcceptAsync(p -> { + logger.debug("Tunnel for call {} exited with code {}", callIdUnsigned(state.callId), p.exitValue()); + if (activeCalls.containsKey(state.callId)) { + endCall(state.callId, "tunnel_exit"); + } + }); + + logger.debug("Spawned signal-call-tunnel for call {}", callIdUnsigned(state.callId)); + } catch (Exception e) { + logger.error("Failed to spawn tunnel for call {}", callIdUnsigned(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", Utils.callIdUnsigned(state.callId)); + config.put("is_outgoing", state.isOutgoing); + config.put("local_device_id", 1); + return writeJson(config); + } + + private void readControlEvents(CallState state, java.io.InputStream inputStream) { + try (var reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + line = line.trim(); + if (line.isEmpty()) continue; + logger.debug("Control event for call {}: {}", callIdUnsigned(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={}", + callIdUnsigned(state.callId), + state.inputDeviceName, + state.outputDeviceName); + } + case "sendOffer" -> { + var opaqueB64 = json.get("opaque").asText(); + var opaque = java.util.Base64.getDecoder().decode(opaqueB64); + logSendMessageResult(sendOfferViaSignal(state, opaque)); + } + case "sendAnswer" -> { + var opaqueB64 = json.get("opaque").asText(); + var opaque = java.util.Base64.getDecoder().decode(opaqueB64); + logSendMessageResult(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())); + } + logSendMessageResult(sendIceViaSignal(state, opaqueList)); + } + case "sendHangup" -> { + // RingRTC wants us to send a hangup message via Signal protocol. + // This is NOT a local state change — local state is handled by stateChange events. + var hangupType = json.has("hangupType") + ? json.get("hangupType").asText("normal") + : "normal"; + // 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 { + logSendMessageResult(sendHangupViaSignal(state, hangupType)); + } + } + case "sendBusy" -> { + logSendMessageResult(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 {}: {}", callIdUnsigned(state.callId), message); + endCall(state.callId, "tunnel_error"); + } + default -> { + logger.debug("Unknown control event type '{}' for call {}", + type, + callIdUnsigned(state.callId)); + } + } + } catch (Exception e) { + logger.warn("Failed to parse control event JSON for call {}: {}", + callIdUnsigned(state.callId), + e.getMessage()); + } + } + } catch (IOException e) { + logger.debug("Control read ended for call {}: {}", callIdUnsigned(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); + } + + public static void logSendMessageResult(SendMessageResult result) { + var identifier = result.getAddress().getIdentifier(); + if (result.getProofRequiredFailure() != null) { + final var failure = result.getProofRequiredFailure(); + logger.warn( + "CAPTCHA proof required for sending to \"{}\", available options \"{}\" with challenge token \"{}\", or wait \"{}\" seconds.\n", + identifier, + failure.getOptions() + .stream() + .map(ProofRequiredException.Option::toString) + .collect(Collectors.joining(", ")), + failure.getToken(), + failure.getRetryAfterSeconds()); + } else if (result.isNetworkFailure()) { + logger.warn("Network failure for \"{}\"", identifier); + } else if (result.getRateLimitFailure() != null) { + logger.warn("Rate limit failure for \"{}\"", identifier); + } else if (result.isUnregisteredFailure()) { + logger.warn("Unregistered user \"{}\"", identifier); + } else if (result.getIdentityFailure() != null) { + logger.warn("Untrusted Identity for \"{}\"", identifier); + } + } + + private void sendAcceptIfReady(CallState state) { + if (state.acceptPending && state.tunnelRinging && state.controlWriter != null) { + state.acceptPending = false; + logger.debug("Sending deferred accept for call {}", callIdUnsigned(state.callId)); + var acceptMsg = mapper.createObjectNode(); + acceptMsg.put("type", "accept"); + state.controlWriter.println(writeJson(acceptMsg)); + } + } + + private SendMessageResult sendOfferViaSignal(CallState state, byte[] opaque) { + var offerMessage = new OfferMessage(state.callId, OfferMessage.Type.AUDIO_CALL, opaque); + var callMessage = SignalServiceCallMessage.forOffer(offerMessage, state.deviceId); + final var result = context.getSendHelper().sendCallMessage(callMessage, state.recipientId); + logger.debug("Sent offer via Signal for call {}", callIdUnsigned(state.callId)); + return result; + } + + private SendMessageResult sendAnswerViaSignal(CallState state, byte[] opaque) { + var answerMessage = new AnswerMessage(state.callId, opaque); + var callMessage = SignalServiceCallMessage.forAnswer(answerMessage, state.deviceId); + final var result = context.getSendHelper().sendCallMessage(callMessage, state.recipientId); + logger.debug("Sent answer via Signal for call {}", callIdUnsigned(state.callId)); + return result; + } + + private SendMessageResult sendIceViaSignal(CallState state, List opaqueList) { + var iceUpdates = opaqueList.stream().map(opaque -> new IceUpdateMessage(state.callId, opaque)).toList(); + var callMessage = SignalServiceCallMessage.forIceUpdates(iceUpdates, state.deviceId); + final var result = context.getSendHelper().sendCallMessage(callMessage, state.recipientId); + logger.debug("Sent {} ICE candidates via Signal for call {}", opaqueList.size(), callIdUnsigned(state.callId)); + return result; + } + + private SendMessageResult sendBusyViaSignal(CallState state) { + var busyMessage = new BusyMessage(state.callId); + var callMessage = SignalServiceCallMessage.forBusy(busyMessage, state.deviceId); + return context.getSendHelper().sendCallMessage(callMessage, state.recipientId); + } + + private SendMessageResult sendHangupViaSignal(CallState state, String hangupType) { + var type = switch (hangupType) { + case "accepted", "acceptedonanotherdevice" -> HangupMessage.Type.ACCEPTED; + case "declined", "declinedonanotherdevice" -> HangupMessage.Type.DECLINED; + case "busy", "busyonanotherdevice" -> HangupMessage.Type.BUSY; + default -> HangupMessage.Type.NORMAL; + }; + var hangupMessage = new HangupMessage(state.callId, type, state.deviceId); + var callMessage = SignalServiceCallMessage.forHangup(hangupMessage, state.deviceId); + final var result = context.getSendHelper().sendCallMessage(callMessage, state.recipientId); + logger.debug("Sent hangup ({}) via Signal for call {}", hangupType, callIdUnsigned(state.callId)); + return result; + } + + private byte[] getRemoteIdentityKey(CallState state) { + try { + var address = context.getRecipientHelper().resolveSignalServiceAddress(state.recipientId); + var serviceId = address.getServiceId(); + var identityInfo = account.getIdentityKeyStore().getIdentityInfo(serviceId); + if (identityInfo != null) { + return getRawIdentityKeyBytes(identityInfo.getIdentityKey()); + } + } catch (Exception e) { + logger.warn("Failed to get remote identity key for call {}", callIdUnsigned(state.callId), e); + } + logger.warn("Using local identity key as fallback for remote identity key"); + return getRawIdentityKeyBytes(account.getAciIdentityKeyPair().getPublicKey()); + } + + /** + * 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(IdentityKey identityKey) { + var serializedKey = identityKey.serialize(); + return getRawIdentityKeyBytes(serializedKey); + } + + private static byte[] getRawIdentityKeyBytes(final byte[] serializedKey) { + if (serializedKey.length == 33 && serializedKey[0] == 0x05) { + return java.util.Arrays.copyOfRange(serializedKey, 1, serializedKey.length); + } + return serializedKey; + } + + 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.debug("Call {} ended: {}", callIdUnsigned(callId), reason); + + // Send Signal protocol hangup to remote peer (unless they initiated the end) + if (!"remote_hangup".equals(reason) + && !"rejected".equals(reason) + && !"remote_busy".equals(reason) + && !"ringrtc_hangup".equals(reason)) { + var hangupMessage = new HangupMessage(callId, HangupMessage.Type.NORMAL, state.deviceId); + var callMessage = SignalServiceCallMessage.forHangup(hangupMessage, null); + final var result = context.getSendHelper().sendCallMessage(callMessage, state.recipientId); + if (!result.isSuccess()) { + logger.warn("Failed to send hangup to remote for call {}", callIdUnsigned(callId)); + logSendMessageResult(result); + } + } + + // 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.debug("Call {} ring timeout", callIdUnsigned(callId)); + endCall(callId, "ring_timeout"); + } + } + + private static long generateCallId() { + return new BigInteger(64, new SecureRandom()).longValue(); + } + + @Override + public void close() { + scheduler.shutdownNow(); + for (var callId : new ArrayList<>(activeCalls.keySet())) { + endCall(callId, "shutdown"); + } + synchronized (callEventListeners) { + callEventListeners.clear(); + } + } + + // --- Internal call state tracking --- + + static class CallState { + + final long callId; + volatile CallInfo.State state; + final RecipientId recipientId; + volatile Integer deviceId; + final boolean isOutgoing; + volatile String inputDeviceName; + volatile String outputDeviceName; + volatile Process tunnelProcess; + volatile PrintWriter controlWriter; + // Control messages queued before the tunnel process starts + final List pendingControlMessages = Collections.synchronizedList(new ArrayList<>()); + // Accept deferred until tunnel reports Ringing state + volatile boolean acceptPending = false; + // True once the tunnel has reported "Ringing" (ready to accept) + volatile boolean tunnelRinging = false; + + CallState( + long callId, + CallInfo.State state, + RecipientId recipientId, + final Integer deviceId, + boolean isOutgoing + ) { + this.callId = callId; + this.state = state; + this.recipientId = recipientId; + this.deviceId = deviceId; + this.isOutgoing = isOutgoing; + } + + CallInfo toCallInfo(RecipientAddressResolver addressResolver) { + return new CallInfo(callId, + state, + addressResolver.resolveRecipientAddress(recipientId).toApiRecipientAddress(), + inputDeviceName, + outputDeviceName, + isOutgoing); + } + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/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..92014fa3 100644 --- a/lib/src/main/java/org/asamk/signal/manager/helper/IncomingMessageHandler.java +++ b/lib/src/main/java/org/asamk/signal/manager/helper/IncomingMessageHandler.java @@ -64,6 +64,7 @@ import org.whispersystems.signalservice.api.messages.SignalServiceGroupV2; import org.whispersystems.signalservice.api.messages.SignalServicePniSignatureMessage; import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage; import org.whispersystems.signalservice.api.messages.SignalServiceStoryMessage; +import org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage; import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage; import org.whispersystems.signalservice.api.messages.multidevice.StickerPackOperationMessage; import org.whispersystems.signalservice.api.push.ServiceIdType; @@ -401,9 +402,54 @@ public final class IncomingMessageHandler { longTexts.putAll(syncResults.second()); } + if (content.getCallMessage().isPresent()) { + handleCallMessage(content.getCallMessage().get(), sender, senderDeviceId); + } + return new Pair<>(actions, longTexts); } + private void handleCallMessage( + final SignalServiceCallMessage callMessage, + final RecipientId sender, + final int deviceId + ) { + var callManager = context.getCallManager(); + if (callMessage.getDestinationDeviceId().isPresent() + && callMessage.getDestinationDeviceId().get() != account.getDeviceId()) { + return; + } + + callMessage.getOfferMessage().ifPresent(offer -> { + var type = offer.getType() + == org.whispersystems.signalservice.api.messages.calls.OfferMessage.Type.VIDEO_CALL + ? 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, deviceId, offer.getId(), type, offer.getOpaque()); + }); + + callMessage.getAnswerMessage() + .ifPresent(answer -> callManager.handleIncomingAnswer(answer.getId(), deviceId, answer.getOpaque())); + + callMessage.getIceUpdateMessages().ifPresent(iceUpdates -> { + for (var ice : iceUpdates) { + callManager.handleIncomingIceCandidate(ice.getId(), ice.getOpaque(), deviceId); + } + }); + + 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 @@ -615,10 +661,6 @@ public final class IncomingMessageHandler { final var aep = keysMessage.getAccountEntropyPool(); account.setAccountEntropyPool(aep); actions.add(SyncStorageDataAction.create()); - } else if (keysMessage.getMaster() != null) { - final var masterKey = keysMessage.getMaster(); - account.setMasterKey(masterKey); - actions.add(SyncStorageDataAction.create()); } else if (keysMessage.getStorageService() != null) { final var storageKey = keysMessage.getStorageService(); account.setStorageKey(storageKey); diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/SendHelper.java b/lib/src/main/java/org/asamk/signal/manager/helper/SendHelper.java index 20d74da9..2c85bd59 100644 --- a/lib/src/main/java/org/asamk/signal/manager/helper/SendHelper.java +++ b/lib/src/main/java/org/asamk/signal/manager/helper/SendHelper.java @@ -36,6 +36,7 @@ import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; import org.whispersystems.signalservice.api.messages.SignalServiceEditMessage; import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage; import org.whispersystems.signalservice.api.messages.SignalServiceTypingMessage; +import org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage; import org.whispersystems.signalservice.api.messages.multidevice.SentTranscriptMessage; import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage; import org.whispersystems.signalservice.api.push.DistributionId; @@ -309,6 +310,26 @@ public class SendHelper { return result; } + public SendMessageResult sendCallMessage( + final SignalServiceCallMessage callMessage, + final RecipientId recipientId + ) { + final var messageSendLogStore = account.getMessageSendLogStore(); + final var result = handleSendMessage(recipientId, + (messageSender, address, unidentifiedAccess, includePniSignature) -> messageSender.sendCallMessage( + address, + unidentifiedAccess, + callMessage)); + if (callMessage.getTimestamp().isPresent()) { + messageSendLogStore.insertIfPossible(callMessage.getTimestamp().get(), + result, + ContentHint.IMPLICIT, + callMessage.isUrgent()); + } + handleSendMessageResult(result); + return result; + } + private List sendAsGroupMessage( final SignalServiceDataMessage.Builder messageBuilder, final GroupInfo g, diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/SyncHelper.java b/lib/src/main/java/org/asamk/signal/manager/helper/SyncHelper.java index a13fc38f..332f8ae7 100644 --- a/lib/src/main/java/org/asamk/signal/manager/helper/SyncHelper.java +++ b/lib/src/main/java/org/asamk/signal/manager/helper/SyncHelper.java @@ -258,7 +258,6 @@ public class SyncHelper { public SendMessageResult sendKeysMessage() { var keysMessage = new KeysMessage(account.getOrCreateStorageKey(), - account.getOrCreatePinMasterKey(), account.getOrCreateAccountEntropyPool(), account.getOrCreateMediaRootBackupKey()); return context.getSendHelper().sendSyncMessage(SignalServiceSyncMessage.forKeys(keysMessage)); diff --git a/lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java b/lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java index 7a6785e9..3a3da757 100644 --- a/lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java +++ b/lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java @@ -19,6 +19,8 @@ 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.CaptchaRejectedException; import org.asamk.signal.manager.api.CaptchaRequiredException; import org.asamk.signal.manager.api.Configuration; @@ -62,6 +64,7 @@ import org.asamk.signal.manager.api.StickerPackId; import org.asamk.signal.manager.api.StickerPackInvalidException; import org.asamk.signal.manager.api.StickerPackUrl; import org.asamk.signal.manager.api.TextStyle; +import org.asamk.signal.manager.api.TurnServer; import org.asamk.signal.manager.api.TypingAction; import org.asamk.signal.manager.api.UnregisteredRecipientException; import org.asamk.signal.manager.api.UpdateGroup; @@ -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; @@ -701,9 +710,9 @@ public class ManagerImpl implements Manager { results.put(recipient, List.of(SendMessageResult.unregisteredFailure(single.toPartialRecipientAddress()))); } - } else if (recipient instanceof RecipientIdentifier.Group group) { + } else if (recipient instanceof RecipientIdentifier.Group(GroupId groupId)) { final var result = context.getSendHelper() - .sendAsGroupMessage(messageBuilder, group.groupId(), notifySelf, editTargetTimestamp, urgent); + .sendAsGroupMessage(messageBuilder, groupId, notifySelf, editTargetTimestamp, urgent); results.put(recipient, result.stream().map(this::toSendMessageResult).toList()); } } @@ -843,7 +852,8 @@ public class ManagerImpl implements Manager { messageBuilder.withBody(message.messageText()); } if (!message.attachments().isEmpty()) { - final var uploadedAttachments = context.getAttachmentHelper().uploadAttachments(message.attachments(), message.voiceNote()); + final var uploadedAttachments = context.getAttachmentHelper() + .uploadAttachments(message.attachments(), message.voiceNote()); if (!additionalAttachments.isEmpty()) { additionalAttachments.addAll(uploadedAttachments); messageBuilder.withAttachments(additionalAttachments); @@ -949,12 +959,10 @@ public class ManagerImpl implements Manager { var delete = new SignalServiceDataMessage.RemoteDelete(targetSentTimestamp); final var messageBuilder = SignalServiceDataMessage.newBuilder().withRemoteDelete(delete); for (final var recipient : recipients) { - if (recipient instanceof RecipientIdentifier.Uuid u) { - account.getMessageSendLogStore() - .deleteEntryForRecipientNonGroup(targetSentTimestamp, ACI.from(u.uuid())); - } else if (recipient instanceof RecipientIdentifier.Pni pni) { - account.getMessageSendLogStore() - .deleteEntryForRecipientNonGroup(targetSentTimestamp, PNI.from(pni.pni())); + if (recipient instanceof RecipientIdentifier.Uuid(var uuid)) { + account.getMessageSendLogStore().deleteEntryForRecipientNonGroup(targetSentTimestamp, ACI.from(uuid)); + } else if (recipient instanceof RecipientIdentifier.Pni(var pni)) { + account.getMessageSendLogStore().deleteEntryForRecipientNonGroup(targetSentTimestamp, PNI.from(pni)); } else if (recipient instanceof RecipientIdentifier.Single r) { try { final var recipientId = context.getRecipientHelper().resolveRecipient(r); @@ -965,8 +973,8 @@ public class ManagerImpl implements Manager { } } catch (UnregisteredRecipientException ignored) { } - } else if (recipient instanceof RecipientIdentifier.Group r) { - account.getMessageSendLogStore().deleteEntryForGroup(targetSentTimestamp, r.groupId()); + } else if (recipient instanceof RecipientIdentifier.Group(var groupId)) { + account.getMessageSendLogStore().deleteEntryForGroup(targetSentTimestamp, groupId); } } return sendMessage(messageBuilder, recipients, false); @@ -1139,8 +1147,8 @@ public class ManagerImpl implements Manager { results.put(recipient, List.of(SendMessageResult.unregisteredFailure(single.toPartialRecipientAddress()))); } - } else if (recipient instanceof RecipientIdentifier.Group group) { - final var result = context.getSyncHelper().sendMessageRequestResponse(type, group.groupId()); + } else if (recipient instanceof RecipientIdentifier.Group(GroupId groupId)) { + final var result = context.getSyncHelper().sendMessageRequestResponse(type, groupId); results.put(recipient, List.of(toSendMessageResult(result))); } } @@ -1154,7 +1162,7 @@ public class ManagerImpl implements Manager { final List options, final Set recipients, final boolean notifySelf - ) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException, UnregisteredRecipientException { + ) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException { final var pollCreate = new SignalServiceDataMessage.PollCreate(question, allowMultiple, options); final var messageBuilder = SignalServiceDataMessage.newBuilder().withPollCreate(pollCreate); return sendMessage(messageBuilder, recipients, notifySelf); @@ -1186,7 +1194,7 @@ public class ManagerImpl implements Manager { final long targetSentTimestamp, final Set recipients, final boolean notifySelf - ) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException, UnregisteredRecipientException { + ) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException { final var pollTerminate = new SignalServiceDataMessage.PollTerminate(targetSentTimestamp); final var messageBuilder = SignalServiceDataMessage.newBuilder().withPollTerminate(pollTerminate); return sendMessage(messageBuilder, recipients, notifySelf); @@ -1704,6 +1712,16 @@ public class ManagerImpl implements Manager { } } + @Override + public void addCallEventListener(final CallEventListener listener) { + context.getCallManager().addCallEventListener(listener); + } + + @Override + public void removeCallEventListener(final CallEventListener listener) { + context.getCallManager().removeCallEventListener(listener); + } + @Override public InputStream retrieveAttachment(final String id) throws IOException { return context.getAttachmentHelper().retrieveAttachment(id).getStream(); @@ -1761,6 +1779,132 @@ public class ManagerImpl implements Manager { return streamDetails.getStream(); } + // --- Voice call methods --- + + @Override + public CallInfo startCall(final RecipientIdentifier.Single recipient) throws IOException, UnregisteredRecipientException { + final var recipientId = context.getRecipientHelper().resolveRecipient(recipient); + return context.getCallManager().startOutgoingCall(recipientId); + } + + @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 SendMessageResult rejectCall(final long callId) throws IOException { + final var result = context.getCallManager().rejectCall(callId); + return toSendMessageResult(result); + } + + @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; diff --git a/lib/src/main/java/org/asamk/signal/manager/internal/ProvisioningManagerImpl.java b/lib/src/main/java/org/asamk/signal/manager/internal/ProvisioningManagerImpl.java index eeca842a..b90e08c3 100644 --- a/lib/src/main/java/org/asamk/signal/manager/internal/ProvisioningManagerImpl.java +++ b/lib/src/main/java/org/asamk/signal/manager/internal/ProvisioningManagerImpl.java @@ -145,7 +145,6 @@ public class ProvisioningManagerImpl implements ProvisioningManager { ret.getAciIdentity(), ret.getPniIdentity(), profileKey, - ret.getMasterKey(), ret.getAccountEntropyPool(), ret.getMediaRootBackupKey()); diff --git a/lib/src/main/java/org/asamk/signal/manager/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/main/java/org/asamk/signal/manager/storage/SignalAccount.java b/lib/src/main/java/org/asamk/signal/manager/storage/SignalAccount.java index 4116132a..3e02aed0 100644 --- a/lib/src/main/java/org/asamk/signal/manager/storage/SignalAccount.java +++ b/lib/src/main/java/org/asamk/signal/manager/storage/SignalAccount.java @@ -292,7 +292,6 @@ public class SignalAccount implements Closeable { final IdentityKeyPair aciIdentity, final IdentityKeyPair pniIdentity, final ProfileKey profileKey, - final MasterKey masterKey, final AccountEntropyPool accountEntropyPool, final MediaRootBackupKey mediaRootBackupKey ) { @@ -314,7 +313,7 @@ public class SignalAccount implements Closeable { this.pinMasterKey = null; this.accountEntropyPool = accountEntropyPool; } else { - this.pinMasterKey = masterKey; + this.pinMasterKey = null; this.accountEntropyPool = null; } this.mediaRootBackupKey = mediaRootBackupKey; diff --git a/lib/src/main/java/org/asamk/signal/manager/util/Utils.java b/lib/src/main/java/org/asamk/signal/manager/util/Utils.java index 89bc3962..aee133ab 100644 --- a/lib/src/main/java/org/asamk/signal/manager/util/Utils.java +++ b/lib/src/main/java/org/asamk/signal/manager/util/Utils.java @@ -18,6 +18,7 @@ import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; +import java.math.BigInteger; import java.net.Proxy; import java.net.ProxySelector; import java.net.URI; @@ -235,4 +236,11 @@ public class Utils { return proxies.getFirst(); } } + + /** + * Convert signed long call ID to unsigned BigInteger (tunnel binary expects u64). + */ + public static BigInteger callIdUnsigned(long callId) { + return new BigInteger(Long.toUnsignedString(callId)); + } } diff --git a/lib/src/test/java/org/asamk/signal/manager/helper/CallManagerTest.java b/lib/src/test/java/org/asamk/signal/manager/helper/CallManagerTest.java new file mode 100644 index 00000000..d7731411 --- /dev/null +++ b/lib/src/test/java/org/asamk/signal/manager/helper/CallManagerTest.java @@ -0,0 +1,432 @@ +package org.asamk.signal.manager.helper; + +import org.asamk.signal.manager.api.CallInfo; +import org.asamk.signal.manager.storage.recipients.TestRecipientId; +import org.asamk.signal.manager.util.Utils; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +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; + + final RecipientAddressResolver recipientAddressResolver = (id) -> new org.asamk.signal.manager.storage.recipients.RecipientAddress( + id.toString()); + + static { + try { + var lookup = MethodHandles.privateLookupIn(CallManager.class, MethodHandles.lookup()); + + GET_RAW_IDENTITY_KEY_BYTES = lookup.findStatic(CallManager.class, + "getRawIdentityKeyBytes", + MethodType.methodType(byte[].class, byte[].class)); + + CALL_ID_UNSIGNED = lookup.findStatic(Utils.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) { + return new CallManager.CallState(callId, initialState, TestRecipientId.createTestId(15551234567L), null, 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_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(recipientAddressResolver); + + assertEquals(42L, info.callId()); + assertEquals(CallInfo.State.CONNECTED, info.state()); + assertEquals("RecipientId[id=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(recipientAddressResolver); + + 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/lib/src/test/java/org/asamk/signal/manager/storage/recipients/TestRecipientId.java b/lib/src/test/java/org/asamk/signal/manager/storage/recipients/TestRecipientId.java new file mode 100644 index 00000000..682039d4 --- /dev/null +++ b/lib/src/test/java/org/asamk/signal/manager/storage/recipients/TestRecipientId.java @@ -0,0 +1,8 @@ +package org.asamk.signal.manager.storage.recipients; + +public class TestRecipientId { + + public static RecipientId createTestId(long value) { + return new RecipientId(value, null); + } +} diff --git a/src/main/java/org/asamk/signal/ReceiveMessageHandler.java b/src/main/java/org/asamk/signal/ReceiveMessageHandler.java index 18750c98..b0361551 100644 --- a/src/main/java/org/asamk/signal/ReceiveMessageHandler.java +++ b/src/main/java/org/asamk/signal/ReceiveMessageHandler.java @@ -15,6 +15,8 @@ import org.slf4j.helpers.MessageFormatter; import java.util.ArrayList; import java.util.stream.Collectors; +import static org.asamk.signal.manager.util.Utils.callIdUnsigned; + public class ReceiveMessageHandler implements Manager.ReceiveMessageHandler { final Manager m; @@ -297,26 +299,32 @@ public class ReceiveMessageHandler implements Manager.ReceiveMessageHandler { } if (callMessage.answer().isPresent()) { var answerMessage = callMessage.answer().get(); - writer.println("Answer message: {}, opaque length: {})", answerMessage.id(), answerMessage.opaque().length); + writer.println("Answer message: {}, opaque length: {})", + callIdUnsigned(answerMessage.id()), + answerMessage.opaque().length); } if (callMessage.busy().isPresent()) { var busyMessage = callMessage.busy().get(); - writer.println("Busy message: {}", busyMessage.id()); + writer.println("Busy message: {}", callIdUnsigned(busyMessage.id())); } if (callMessage.hangup().isPresent()) { var hangupMessage = callMessage.hangup().get(); - writer.println("Hangup message: {}", hangupMessage.id()); + writer.println("Hangup message: {}", callIdUnsigned(hangupMessage.id())); } if (!callMessage.iceUpdate().isEmpty()) { writer.println("Ice update messages:"); var iceUpdateMessages = callMessage.iceUpdate(); for (var iceUpdateMessage : iceUpdateMessages) { - writer.println("- {}, opaque length: {}", iceUpdateMessage.id(), iceUpdateMessage.opaque().length); + writer.println("- {}, opaque length: {}", + callIdUnsigned(iceUpdateMessage.id()), + iceUpdateMessage.opaque().length); } } if (callMessage.offer().isPresent()) { var offerMessage = callMessage.offer().get(); - writer.println("Offer message: {}, opaque length: {}", offerMessage.id(), offerMessage.opaque().length); + writer.println("Offer message: {}, opaque length: {}", + callIdUnsigned(offerMessage.id()), + offerMessage.opaque().length); } if (callMessage.opaque().isPresent()) { final var opaqueMessage = callMessage.opaque().get(); diff --git a/src/main/java/org/asamk/signal/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..faf099e4 100644 --- a/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java +++ b/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java @@ -4,6 +4,8 @@ import org.asamk.Signal; import org.asamk.signal.manager.Manager; import org.asamk.signal.manager.api.AlreadyReceivingException; import org.asamk.signal.manager.api.AttachmentInvalidException; +import org.asamk.signal.manager.api.CallInfo; +import org.asamk.signal.manager.api.CallOffer; import org.asamk.signal.manager.api.CaptchaRequiredException; import org.asamk.signal.manager.api.Configuration; import org.asamk.signal.manager.api.Contact; @@ -37,12 +39,14 @@ import org.asamk.signal.manager.api.Recipient; import org.asamk.signal.manager.api.RecipientAddress; import org.asamk.signal.manager.api.RecipientIdentifier; import org.asamk.signal.manager.api.SendGroupMessageResults; +import org.asamk.signal.manager.api.SendMessageResult; import org.asamk.signal.manager.api.SendMessageResults; import org.asamk.signal.manager.api.StickerPack; import org.asamk.signal.manager.api.StickerPackId; import org.asamk.signal.manager.api.StickerPackInvalidException; import org.asamk.signal.manager.api.StickerPackUrl; import org.asamk.signal.manager.api.TrustLevel; +import org.asamk.signal.manager.api.TurnServer; import org.asamk.signal.manager.api.TypingAction; import org.asamk.signal.manager.api.UnregisteredRecipientException; import org.asamk.signal.manager.api.UpdateGroup; @@ -912,6 +916,85 @@ 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 CallInfo startCall(final RecipientIdentifier.Single recipient) { + throw new UnsupportedOperationException("Voice calls are not supported over DBus"); + } + + @Override + public 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 SendMessageResult 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 RecipientIdentifier.Single recipient, final CallOffer offer) { + throw new UnsupportedOperationException("Voice calls are not supported over DBus"); + } + + @Override + public void sendCallAnswer( + final RecipientIdentifier.Single recipient, + final long callId, + final byte[] answerOpaque + ) { + throw new UnsupportedOperationException("Voice calls are not supported over DBus"); + } + + @Override + public void sendIceUpdate( + final 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 RecipientIdentifier.Single recipient, + final long callId, + final MessageEnvelope.Call.Hangup.Type type + ) { + throw new UnsupportedOperationException("Voice calls are not supported over DBus"); + } + + @Override + public void sendBusy(final 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/json/JsonCallMessage.java b/src/main/java/org/asamk/signal/json/JsonCallMessage.java index 6cd94a92..36b2d714 100644 --- a/src/main/java/org/asamk/signal/json/JsonCallMessage.java +++ b/src/main/java/org/asamk/signal/json/JsonCallMessage.java @@ -5,9 +5,13 @@ import io.micronaut.jsonschema.JsonSchema; import org.asamk.signal.manager.api.MessageEnvelope; +import java.math.BigInteger; import java.util.Base64; import java.util.List; + +import static org.asamk.signal.manager.util.Utils.callIdUnsigned; + @JsonSchema(title = "CallMessage") record JsonCallMessage( @JsonInclude(JsonInclude.Include.NON_NULL) Offer offerMessage, @@ -25,38 +29,41 @@ record JsonCallMessage( callMessage.iceUpdate().stream().map(IceUpdate::from).toList()); } - record Offer(long id, String type, String opaque) { + record Offer(BigInteger id, String type, String opaque) { public static Offer from(final MessageEnvelope.Call.Offer offer) { - return new Offer(offer.id(), offer.type().name(), Base64.getEncoder().encodeToString(offer.opaque())); + return new Offer(callIdUnsigned(offer.id()), + offer.type().name(), + Base64.getEncoder().encodeToString(offer.opaque())); } } - public record Answer(long id, String opaque) { + public record Answer(BigInteger id, String opaque) { public static Answer from(final MessageEnvelope.Call.Answer answer) { - return new Answer(answer.id(), Base64.getEncoder().encodeToString(answer.opaque())); + return new Answer(callIdUnsigned(answer.id()), Base64.getEncoder().encodeToString(answer.opaque())); } } - public record Busy(long id) { + public record Busy(BigInteger id) { public static Busy from(final MessageEnvelope.Call.Busy busy) { - return new Busy(busy.id()); + return new Busy(callIdUnsigned(busy.id())); } } - public record Hangup(long id, String type, int deviceId) { + public record Hangup(BigInteger id, String type, int deviceId) { public static Hangup from(final MessageEnvelope.Call.Hangup hangup) { - return new Hangup(hangup.id(), hangup.type().name(), hangup.deviceId()); + return new Hangup(callIdUnsigned(hangup.id()), hangup.type().name(), hangup.deviceId()); } } - public record IceUpdate(long id, String opaque) { + public record IceUpdate(BigInteger id, String opaque) { public static IceUpdate from(final MessageEnvelope.Call.IceUpdate iceUpdate) { - return new IceUpdate(iceUpdate.id(), Base64.getEncoder().encodeToString(iceUpdate.opaque())); + return new IceUpdate(callIdUnsigned(iceUpdate.id()), + Base64.getEncoder().encodeToString(iceUpdate.opaque())); } } } diff --git a/src/main/java/org/asamk/signal/jsonrpc/SignalJsonRpcDispatcherHandler.java b/src/main/java/org/asamk/signal/jsonrpc/SignalJsonRpcDispatcherHandler.java index 5d3fa261..14ff0076 100644 --- a/src/main/java/org/asamk/signal/jsonrpc/SignalJsonRpcDispatcherHandler.java +++ b/src/main/java/org/asamk/signal/jsonrpc/SignalJsonRpcDispatcherHandler.java @@ -14,6 +14,7 @@ import org.asamk.signal.commands.JsonRpcMultiCommand; import org.asamk.signal.commands.JsonRpcSingleCommand; import org.asamk.signal.commands.exceptions.CommandException; import org.asamk.signal.commands.exceptions.UserErrorException; +import org.asamk.signal.json.JsonCallEvent; import org.asamk.signal.json.JsonReceiveMessageHandler; import org.asamk.signal.manager.Manager; import org.asamk.signal.manager.MultiAccountManager; @@ -24,6 +25,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.nio.channels.ClosedChannelException; +import java.util.Collection; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -40,6 +42,7 @@ public class SignalJsonRpcDispatcherHandler { private final boolean noReceiveOnStart; private final Map>> receiveHandlers = new HashMap<>(); + private final Map>> callEventHandlers = new HashMap<>(); private SignalJsonRpcCommandHandler commandHandler; public SignalJsonRpcDispatcherHandler( @@ -61,6 +64,10 @@ public class SignalJsonRpcDispatcherHandler { c.addOnManagerAddedHandler(m -> subscribeReceive(m, true)); c.addOnManagerRemovedHandler(this::unsubscribeReceive); } + c.addOnManagerAddedHandler(m -> receiveHandlers.forEach((subscriptionId, handlers) -> handlers.add( + createReceiveHandler(m, subscriptionId, false)))); + c.addOnManagerAddedHandler(m -> callEventHandlers.forEach((subscriptionId, handlers) -> handlers.add( + createCallEventHandler(m, subscriptionId)))); handleConnection(); } @@ -78,6 +85,57 @@ public class SignalJsonRpcDispatcherHandler { handleConnection(); } + private int subscribeCallEvents(final Manager manager) { + return subscribeCallEvents(List.of(manager)); + } + + private int subscribeCallEvents(final Collection managers) { + final var subscriptionId = nextSubscriptionId.getAndIncrement(); + final var listeners = managers.stream().map(m -> createCallEventHandler(m, subscriptionId)).toList(); + callEventHandlers.put(subscriptionId, listeners); + return subscriptionId; + } + + private Pair createCallEventHandler(final Manager m, final int subscriptionId) { + final Manager.CallEventListener listener = (callInfo, reason) -> { + final var params = new ObjectNode(objectMapper.getNodeFactory()); + params.set("subscription", IntNode.valueOf(subscriptionId)); + params.set("result", objectMapper.valueToTree(JsonCallEvent.from(callInfo, reason))); + final var jsonRpcRequest = JsonRpcRequest.forNotification("callEvent", params, null); + try { + jsonRpcSender.sendRequest(jsonRpcRequest); + } catch (AssertionError e) { + if (e.getCause() instanceof ClosedChannelException) { + unsubscribeReceive(subscriptionId); + } + } + }; + m.addCallEventListener(listener); + return new Pair<>(m, listener); + } + + private boolean unsubscribeCallEvents(final int subscriptionId) { + final var handlers = callEventHandlers.remove(subscriptionId); + if (handlers == null) { + return false; + } + for (final var pair : handlers) { + unsubscribeCallEventHandler(pair); + } + return true; + } + + private void unsubscribeAllCallEvents() { + callEventHandlers.forEach((_subscriptionId, handlers) -> handlers.forEach(this::unsubscribeCallEventHandler)); + callEventHandlers.clear(); + } + + private void unsubscribeCallEventHandler(final Pair pair) { + final var m = pair.first(); + final var handler = pair.second(); + m.removeCallEventListener(handler); + } + private static final AtomicInteger nextSubscriptionId = new AtomicInteger(0); private int subscribeReceive(final Manager manager, boolean internalSubscription) { @@ -86,34 +144,42 @@ public class SignalJsonRpcDispatcherHandler { private int subscribeReceive(final List managers, boolean internalSubscription) { final var subscriptionId = nextSubscriptionId.getAndIncrement(); - final var handlers = managers.stream().map(m -> { - final var receiveMessageHandler = new JsonReceiveMessageHandler(m, s -> { - ContainerNode params; - if (internalSubscription) { - params = objectMapper.valueToTree(s); - } else { - final var paramsNode = new ObjectNode(objectMapper.getNodeFactory()); - paramsNode.set("subscription", IntNode.valueOf(subscriptionId)); - paramsNode.set("result", objectMapper.valueToTree(s)); - params = paramsNode; - } - final var jsonRpcRequest = JsonRpcRequest.forNotification("receive", params, null); - try { - jsonRpcSender.sendRequest(jsonRpcRequest); - } catch (AssertionError e) { - if (e.getCause() instanceof ClosedChannelException) { - unsubscribeReceive(subscriptionId); - } - } - }); - m.addReceiveHandler(receiveMessageHandler); - return new Pair<>(m, (Manager.ReceiveMessageHandler) receiveMessageHandler); - }).toList(); + final var handlers = managers.stream() + .map(m -> createReceiveHandler(m, subscriptionId, internalSubscription)) + .toList(); receiveHandlers.put(subscriptionId, handlers); return subscriptionId; } + private Pair createReceiveHandler( + final Manager m, + final int subscriptionId, + final boolean internalSubscription + ) { + final var receiveMessageHandler = new JsonReceiveMessageHandler(m, s -> { + ContainerNode params; + if (internalSubscription) { + params = objectMapper.valueToTree(s); + } else { + final var paramsNode = new ObjectNode(objectMapper.getNodeFactory()); + paramsNode.set("subscription", IntNode.valueOf(subscriptionId)); + paramsNode.set("result", objectMapper.valueToTree(s)); + params = paramsNode; + } + final var jsonRpcRequest = JsonRpcRequest.forNotification("receive", params, null); + try { + jsonRpcSender.sendRequest(jsonRpcRequest); + } catch (AssertionError e) { + if (e.getCause() instanceof ClosedChannelException) { + unsubscribeReceive(subscriptionId); + } + } + }); + m.addReceiveHandler(receiveMessageHandler); + return new Pair<>(m, receiveMessageHandler); + } + private boolean unsubscribeReceive(final int subscriptionId) { final var handlers = receiveHandlers.remove(subscriptionId); if (handlers == null) { @@ -141,6 +207,7 @@ public class SignalJsonRpcDispatcherHandler { } finally { receiveHandlers.forEach((_subscriptionId, handlers) -> handlers.forEach(this::unsubscribeReceiveHandler)); receiveHandlers.clear(); + unsubscribeAllCallEvents(); } } @@ -157,6 +224,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 +313,85 @@ 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 { + final var subscriptionId = subscribeCallEvents(m); + jsonWriter.write(subscriptionId); + } + + @Override + public void handleCommand( + final Void request, + final MultiAccountManager c, + final JsonWriter jsonWriter + ) throws CommandException { + final var subscriptionId = subscribeCallEvents(c.getManagers()); + jsonWriter.write(subscriptionId); + } + } + + private class UnsubscribeCallEventsCommand implements JsonRpcSingleCommand, JsonRpcMultiCommand { + + @Override + public String getName() { + return "unsubscribeCallEvents"; + } + + @Override + public TypeReference getRequestType() { + return new TypeReference<>() {}; + } + + @Override + public void handleCommand( + final JsonNode request, + final Manager m, + final JsonWriter jsonWriter + ) throws CommandException { + final var subscriptionId = getSubscriptionId(request); + if (subscriptionId == null) { + throw new UserErrorException("Missing subscription parameter with subscription id"); + } else { + if (!unsubscribeCallEvents(subscriptionId)) { + throw new UserErrorException("Unknown subscription id"); + } + } + } + + @Override + public void handleCommand( + final JsonNode request, + final MultiAccountManager c, + final JsonWriter jsonWriter + ) throws CommandException { + final var subscriptionId = getSubscriptionId(request); + if (subscriptionId == null) { + throw new UserErrorException("Missing subscription parameter with subscription id"); + } else { + if (!unsubscribeCallEvents(subscriptionId)) { + throw new UserErrorException("Unknown subscription id"); + } + } + } + + private Integer getSubscriptionId(final JsonNode request) { + return switch (request) { + case ArrayNode req -> req.get(0).asInt(); + case ObjectNode req -> req.get("subscription").asInt(); + case null, default -> null; + }; + } + } } diff --git a/src/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..db19a986 100644 --- a/src/main/resources/META-INF/native-image/org.asamk/signal-cli/reachability-metadata.json +++ b/src/main/resources/META-INF/native-image/org.asamk/signal-cli/reachability-metadata.json @@ -1082,6 +1082,12 @@ } ] }, + { + "type": "java.math.BigInteger" + }, + { + "type": "java.math.BigInteger[]" + }, { "type": "java.net.NetPermission" }, @@ -1961,6 +1967,48 @@ } ] }, + { + "type": "org.asamk.signal.commands.AcceptCallCommand$JsonCallInfo", + "allDeclaredFields": true, + "methods": [ + { + "name": "callId", + "parameterTypes": [] + }, + { + "name": "channels", + "parameterTypes": [] + }, + { + "name": "codec", + "parameterTypes": [] + }, + { + "name": "inputDeviceName", + "parameterTypes": [] + }, + { + "name": "mediaSocketPath", + "parameterTypes": [] + }, + { + "name": "outputDeviceName", + "parameterTypes": [] + }, + { + "name": "ptimeMs", + "parameterTypes": [] + }, + { + "name": "sampleRate", + "parameterTypes": [] + }, + { + "name": "state", + "parameterTypes": [] + } + ] + }, { "type": "org.asamk.signal.commands.FinishLinkCommand$FinishLinkParams", "allDeclaredFields": true, @@ -1998,6 +2046,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 +2070,47 @@ } ] }, + { + "type": "org.asamk.signal.commands.ListCallsCommand$JsonCall", + "allDeclaredFields": true, + "methods": [ + { + "name": "callId", + "parameterTypes": [] + }, + { + "name": "inputDeviceName", + "parameterTypes": [] + }, + { + "name": "isOutgoing", + "parameterTypes": [] + }, + { + "name": "mediaSocketPath", + "parameterTypes": [] + }, + { + "name": "number", + "parameterTypes": [] + }, + { + "name": "outputDeviceName", + "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 +2289,62 @@ } ] }, + { + "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": "inputDeviceName", + "parameterTypes": [] + }, + { + "name": "mediaSocketPath", + "parameterTypes": [] + }, + { + "name": "outputDeviceName", + "parameterTypes": [] + }, + { + "name": "ptimeMs", + "parameterTypes": [] + }, + { + "name": "sampleRate", + "parameterTypes": [] + }, + { + "name": "state", + "parameterTypes": [] + } + ] + }, { "type": "org.asamk.signal.commands.StartLinkCommand$JsonLink", "allDeclaredFields": true, @@ -2292,6 +2451,43 @@ { "type": "org.asamk.signal.json.JsonAttachment[]" }, + { + "type": "org.asamk.signal.json.JsonCallEvent", + "methods": [ + { + "name": "callId", + "parameterTypes": [] + }, + { + "name": "inputDeviceName", + "parameterTypes": [] + }, + { + "name": "isOutgoing", + "parameterTypes": [] + }, + { + "name": "number", + "parameterTypes": [] + }, + { + "name": "outputDeviceName", + "parameterTypes": [] + }, + { + "name": "reason", + "parameterTypes": [] + }, + { + "name": "state", + "parameterTypes": [] + }, + { + "name": "uuid", + "parameterTypes": [] + } + ] + }, { "type": "org.asamk.signal.json.JsonCallMessage", "allDeclaredFields": true, @@ -7355,6 +7551,35 @@ "allDeclaredFields": true, "allDeclaredMethods": true }, + { + "type": "org.whispersystems.signalservice.api.messages.calls.TurnServerInfo", + "fields": [ + { + "name": "hostname" + }, + { + "name": "password" + }, + { + "name": "ttl" + }, + { + "name": "urls" + }, + { + "name": "urlsWithIps" + }, + { + "name": "username" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] + }, { "type": "org.whispersystems.signalservice.api.messages.multidevice.DeviceInfo", "allDeclaredFields": true, @@ -7935,6 +8160,17 @@ } ] }, + { + "type": "org.whispersystems.signalservice.internal.push.GetCallingRelaysResponse", + "methods": [ + { + "name": "", + "parameterTypes": [ + "java.util.List" + ] + } + ] + }, { "type": "org.whispersystems.signalservice.internal.push.GetUsernameFromLinkResponseBody", "allDeclaredFields": true, @@ -9969,4 +10205,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..37c00829 --- /dev/null +++ b/src/test/java/org/asamk/signal/jsonrpc/SubscribeCallEventsTest.java @@ -0,0 +1,782 @@ +package org.asamk.signal.jsonrpc; + +import org.asamk.signal.manager.Manager; +import org.asamk.signal.manager.MultiAccountManager; +import org.asamk.signal.manager.ProvisioningManager; +import org.asamk.signal.manager.RegistrationManager; +import org.asamk.signal.manager.api.CallInfo; +import org.asamk.signal.manager.api.CallOffer; +import org.asamk.signal.manager.api.Configuration; +import org.asamk.signal.manager.api.Device; +import org.asamk.signal.manager.api.DeviceLinkUrl; +import org.asamk.signal.manager.api.Group; +import org.asamk.signal.manager.api.GroupId; +import org.asamk.signal.manager.api.GroupInviteLinkUrl; +import org.asamk.signal.manager.api.Identity; +import org.asamk.signal.manager.api.IdentityVerificationCode; +import org.asamk.signal.manager.api.Message; +import org.asamk.signal.manager.api.MessageEnvelope; +import org.asamk.signal.manager.api.Pair; +import org.asamk.signal.manager.api.ReceiveConfig; +import org.asamk.signal.manager.api.Recipient; +import org.asamk.signal.manager.api.RecipientIdentifier; +import org.asamk.signal.manager.api.SendGroupMessageResults; +import org.asamk.signal.manager.api.SendMessageResult; +import org.asamk.signal.manager.api.SendMessageResults; +import org.asamk.signal.manager.api.StickerPack; +import org.asamk.signal.manager.api.StickerPackId; +import org.asamk.signal.manager.api.StickerPackUrl; +import org.asamk.signal.manager.api.TurnServer; +import org.asamk.signal.manager.api.TypingAction; +import org.asamk.signal.manager.api.UpdateGroup; +import org.asamk.signal.manager.api.UpdateProfile; +import org.asamk.signal.manager.api.UserStatus; +import org.asamk.signal.manager.api.UsernameLinkUrl; +import org.asamk.signal.manager.api.UsernameStatus; +import org.asamk.signal.output.JsonWriter; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.io.InputStream; +import java.net.URI; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Queue; +import java.util.Set; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * 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 SendMessageResult rejectCall(long c) { + return null; + } + + @Override + public List listActiveCalls() { + return List.of(); + } + + @Override + public void sendCallOffer(RecipientIdentifier.Single r, CallOffer o) { + } + + @Override + public void sendCallAnswer(RecipientIdentifier.Single r, long c, byte[] a) { + } + + @Override + public void sendIceUpdate(RecipientIdentifier.Single r, long c, List i) { + } + + @Override + public void sendHangup(RecipientIdentifier.Single r, long c, MessageEnvelope.Call.Hangup.Type t) { + } + + @Override + public void sendBusy(RecipientIdentifier.Single r, long c) { + } + + @Override + public List getTurnServerInfo() { + return List.of(); + } + + @Override + public void close() { + } + } + + /** + * Minimal MultiAccountManager stub for multi-account mode tests. + */ + private static class StubMultiAccountManager implements MultiAccountManager { + + final List managers; + final List> addedHandlers = new ArrayList<>(); + + 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 + "\"}"; + } + + private static String jsonRpcCall(int id, String method, String params) { + return "{\"jsonrpc\":\"2.0\",\"id\":" + id + ",\"method\":\"" + method + "\",\"params\":" + params + "}"; + } + + // --- Single-account mode tests --- + + @Test + 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 subscribeCallEventsCanBeCalledMultipleTimes() { + 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); + + // The implementation allows multiple subscriptions, so two calls add two listeners + assertEquals(2, manager.addCount.get(), "multiple subscribeCallEvents should add multiple listeners"); + } + + @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", "{\"subscription\":0}")); + + 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 for receive and one for call events + assertEquals(2, multi.addedHandlers.size(), "should register onManagerAdded handlers"); + } + + @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", "{\"subscription\":0}")); + + 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"); + } +}