Conversation
9ed4d4f to
df69b02
Compare
|
@AsamK any interest in reviewing this PR? |
|
I'm interested, but it's a large PR, I currently don't have the capacity to review it fully. |
|
i had a feeling that's where you'd want to go with this. i don't mind, but will take me some time. but, really, only the first commit goes away. the others all affect signal-cli. the call tunnel documentation can be split up to just document each half, so there's that at least. |
df69b02 to
ee99967
Compare
Define call method interfaces in Manager, create API records (CallInfo, CallOffer, TurnServer), and hand-coded protobuf parsers for RingRTC signaling messages (ConnectionParametersV4, RtpDataMessage). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
ee99967 to
962cb6f
Compare
Add CallSignalingHelper for x25519 key generation and HKDF-based SRTP key derivation. Add CallManager for tracking active calls, spawning call tunnel subprocesses, and handling call lifecycle (offer, answer, ICE candidates, hangup, busy). Wire call message routing in IncomingMessageHandler and implement Manager call methods in ManagerImpl. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Implement CallEventListener callback pattern that fires on every call state transition (RINGING_INCOMING, RINGING_OUTGOING, CONNECTING, CONNECTED, ENDED). The JSON-RPC layer auto-subscribes and pushes callEvent notifications alongside receive notifications. Changes: - Manager.java: Add CallEventListener interface and methods - ManagerImpl.java: Implement add/removeCallEventListener with cleanup - DbusManagerImpl.java: Add stub implementation (not supported over DBus) - JsonCallEvent.java: JSON notification record for call events - SignalJsonRpcDispatcherHandler.java: Auto-subscribe call event listeners Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Add startCall, acceptCall, hangupCall, rejectCall, and listCalls commands for the JSON-RPC daemon interface. Register commands and update GraalVM metadata for native image support. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add documentation about the architecture, protocol, and implementation of signal-call-tunnel, the secure tunnel subprocess for voice calling. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
962cb6f to
9493381
Compare
|
@AsamK i have split out the tunnel implementation and test harness to a separate repository (visigoth/signal-call-tunnel). i also refactored the documentation such that docs/CALL_TUNNEL.md only talks about it from signal-cli's perspective. the rest is reasonably straightforward to review. |
AsamK
left a comment
There was a problem hiding this comment.
Thanks, I've done a first rough review.
Please take a look.
| final var callIdNumber = ns.get("call-id"); | ||
| if (callIdNumber == null) { | ||
| throw new UserErrorException("No call ID given"); | ||
| } | ||
| final long callId = ((Number) callIdNumber).longValue(); |
There was a problem hiding this comment.
You can use instanceof to get rid of the Number cast:
| final var callIdNumber = ns.get("call-id"); | |
| if (callIdNumber == null) { | |
| throw new UserErrorException("No call ID given"); | |
| } | |
| final long callId = ((Number) callIdNumber).longValue(); | |
| final var callIdParam = ns.get("call-id"); | |
| if (!(callIdParam instanceof Number callIdNumber)) { | |
| throw new UserErrorException("No call ID given"); | |
| } | |
| final var callId = callIdNumber.longValue(); |
| m.rejectCall(callId); | ||
| switch (outputWriter) { | ||
| case PlainTextWriter writer -> writer.println("Call {} rejected.", callId); | ||
| case JsonWriter writer -> writer.write(new JsonResult(callId, "rejected")); |
There was a problem hiding this comment.
Normally signal-cli commands don't print or return a result for successful command executions, unless there is additional information.
For this command you can remove the output. If the command succeeds, the call was rejected.
|
|
||
| import java.io.IOException; | ||
|
|
||
| public class HangupCallCommand implements JsonRpcLocalCommand { |
There was a problem hiding this comment.
Same comments as in RejectCallCommand:
- you can use
instanceofinstead of casting. - Don't return an explicit response if there is no additional information
| if (callIdNumber == null) { | ||
| throw new UserErrorException("No call ID given"); | ||
| } | ||
| final long callId = ((Number) callIdNumber).longValue(); |
There was a problem hiding this comment.
Same comment, you can use instanceof instead of casting.
| useJUnitPlatform() | ||
| useJUnitPlatform { | ||
| if (!project.hasProperty("includeIntegration")) { | ||
| excludeTags("integration") |
There was a problem hiding this comment.
Is this still needed? I don't see the integration tag anywhere else.
|
|
||
| // Send createOutgoingCall + proceed via control channel | ||
| var peerIdStr = recipientAddress.toString(); | ||
| sendControlMessage(state, "{\"type\":\"createOutgoingCall\",\"callId\":" + callIdJson(callId) |
There was a problem hiding this comment.
Please always use jackson json serialization instead of manually concatenating and escaping strings, which is quite error prone.
If you're doing it for correct serialization of large callIds, can you just use BigInteger for the callId everywhere?
| | | | ||
| |-- spawn (config on stdin) --------->| | ||
| | | | ||
| |<======= ctrl.sock (JSON) ==========>| |
There was a problem hiding this comment.
If I understand this correctly the additional socket is actually unnecessary.
You can just use communication via stdin/stdout with signal-call-tunnel instead.
That way you can also get rid of the additional authentication token
| } | ||
|
|
||
| // Check relative to the signal-cli installation | ||
| var installDir = System.getProperty("signal.cli.install.dir"); |
There was a problem hiding this comment.
Does this property actually exist?
| } | ||
| } | ||
|
|
||
| // --- Incoming call message handling --- |
There was a problem hiding this comment.
All handleIncoming* methods should check if there are actually call listeners before doing anything.
If the callEventListeners list is empty, they should just do nothing. (unless the signal-call-tunnel was already spawned)
| c.addOnManagerRemovedHandler(this::unsubscribeReceive); | ||
| } | ||
|
|
||
| for (var m : c.getManagers()) { |
There was a problem hiding this comment.
I think most signal-cli users will not be interested in call events, so they shouldn't be subscribed to by default.
Can you change this to only subscribe to call events when the user calls a subscribeCallEvents command? (like it's done with subscribeReceive/unsubscribeReceive below)
Voice Call Support via RingRTC Tunnel
Adds 1:1 voice calling to signal-cli using a Rust subprocess (
signal-call-tunnel) that wraps Signal's RingRTC library for ICE negotiation, SRTP encryption, and media transport.Architecture
Calls are handled by spawning a short-lived Rust process per call that manages the WebRTC stack. The Java side handles Signal protocol signaling (offers, answers, ICE candidates) and relays them to the tunnel over a Unix domain socket control channel. Audio flows through platform virtual audio devices (PulseAudio null sinks on Linux, BlackHole drivers on macOS), so any external process can send/receive call audio using standard audio APIs.
What's included
signal-call-tunnel (Rust binary)
VirtualAudioDevicePair--host-audioflag for direct system audio or socket audio modeCall signaling state machine (Java)
CallManagerorchestrates call lifecycle: subprocess spawning, control socket communication, TURN/STUN server provisioning, ring timeouts (60s), and cleanup. TURN/STUN credentials are fetched from Signal's calling API on the Java side and passed to the tunnel via theproceedcontrol message, where RingRTC uses them for ICE negotiation.CallSignalingHelperfor x25519 key exchange and HKDF-based SRTP key derivationIncomingMessageHandlerfor offers, answers, ICE candidates, hangups, and busy signalsJSON-RPC daemon commands
startCall,acceptCall,hangupCall,rejectCall,listCallscallEventnotifications pushed to connected clients on every state transition (RINGING_INCOMING, RINGING_OUTGOING, CONNECTING, CONNECTED, RECONNECTING, ENDED)E2E test harness
Documentation
docs/CALL_TUNNEL.md: architecture, control protocol reference, call flows, virtual audio setup, encryption details, and implementation notesdocs/TEST_HARNESS.md: test harness architecture, component descriptions, design decisions, and usagevoice-test/README.md: describes the tests being performed as well as test harness prerequisites and usageRingRTC build-time patch (macOS)
On macOS, RingRTC's cubeb audio streams default to
StreamPrefs::VOICE, which activates CoreAudio's VoiceProcessingIO (VPIO) aggregate device for echo cancellation and noise suppression. VPIO hangs when the input or output device is a BlackHole virtual driver because it tries to create an aggregate device that BlackHole doesn't support.The patch (
signal-call-tunnel/patches/ringrtc-disable-vpio.patch) adds astream_prefs()helper to RingRTC'saudio_device_module.rsthat checks theRINGRTC_NO_VOICE_PROCESSINGenvironment variable. When set, both the playout and recording streams useStreamPrefs::NONEinstead, bypassing VPIO entirely. Voice processing is unnecessary for virtual audio anyway. The tunnel binary sets this variable at startup before any audio subsystem initialization.The patch is applied automatically during
cargo buildviasignal-call-tunnel/build.rs, which runsgit applyagainst thethird-party/ringrtcsubmodule. It is idempotent:build.rschecks whether the marker (RINGRTC_NO_VOICE_PROCESSING) is already present before applying, andcargore-runs the script when either the patch file or the target source file changes.Platforms
sudo bin/virtual_audio.sh --setup)