diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..45e16d46 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "third-party/ringrtc"] + path = third-party/ringrtc + url = https://github.com/signalapp/ringrtc diff --git a/build.gradle.kts b/build.gradle.kts index 2ea0a7df..f1f4bba1 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -90,6 +90,14 @@ dependencies { implementation(libs.logback) implementation(libs.zxing) implementation(project(":libsignal-cli")) + + testImplementation(libs.junit.jupiter) + testImplementation(platform(libs.junit.jupiter.bom)) + testRuntimeOnly(libs.junit.launcher) +} + +tasks.named("test") { + useJUnitPlatform() } configurations { diff --git a/docs/CALL_TUNNEL.md b/docs/CALL_TUNNEL.md new file mode 100644 index 00000000..a28b136a --- /dev/null +++ b/docs/CALL_TUNNEL.md @@ -0,0 +1,546 @@ +# Media Tunnel Architecture + +## Overview + +signal-cli uses a Rust subprocess called `signal-call-tunnel` to handle voice +calls. The tunnel wraps RingRTC (Signal's WebRTC layer) and exposes a **control +socket** for call signaling. Audio flows through **virtual audio devices** +created by ringrtc's `VirtualAudioDevicePair`, which the tunnel selects via +cubeb. External processes connect using platform audio APIs (PulseAudio on +Linux, CoreAudio on macOS). + +``` + signal-cli (Java) + | + spawn per call, config via stdin + | + v + signal-call-tunnel (Rust) + / \ + ctrl.sock VirtualAudioDevicePair + (JSON control) / \ + | [virtual input] [virtual output] + signal-cli connects (signal_input_XXX) (signal_output_XXX) + (signaling relay) | | + client writes audio client reads audio + (PulseAudio/CoreAudio) (PulseAudio/CoreAudio) +``` + +Each call gets its own tunnel process and control socket inside a temporary +directory (`/tmp/sc-/`). Virtual audio devices are created per call on +Linux (PulseAudio modules) or use pre-installed BlackHole drivers on macOS. +When the call ends, everything is cleaned up. + +--- + +## Components + +### signal-call-tunnel (Rust binary) + +The subprocess that runs one call. Source: `signal-call-tunnel/src/`. + +| File | Role | +|------|------| +| `main.rs` | Entry point, RingRTC initialization, virtual audio setup, event loop | +| `config.rs` | Deserializes startup config from stdin | +| `control.rs` | Control socket server, JSON message parsing/serialization | +| `platform.rs` | RingRTC trait impls (SignalingSender, CallStateHandler) | + +Audio flows through cubeb with virtual audio devices selected by name. The +tunnel creates a `VirtualAudioDevicePair` from ringrtc's `virtual_audio` module, +waits for cubeb to enumerate the devices, then selects them as the recording +and playout devices. + +### CallManager.java (Java parent) + +`lib/src/main/java/org/asamk/signal/manager/helper/CallManager.java` + +Manages the call lifecycle from the Java side: + +1. Creates a temp directory and generates a random auth token +2. Spawns `signal-call-tunnel` with config JSON on stdin +3. Connects to the control socket (retries up to 50x at 200 ms intervals), + authenticates, and relays signaling between the tunnel and the Signal protocol +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 RingRTC to drop it) +7. Schedules a 60-second ring timeout for both incoming and outgoing calls +8. On hangup: sends hangup message, kills the process, deletes the control socket + +### Audio client (external process) + +Any process that sends/receives audio via the virtual audio devices using +platform audio APIs. + +1. Receives `inputDeviceName` and `outputDeviceName` from a `startCall`/ + `acceptCall` JSON-RPC response or a `callEvent` notification +2. Waits for the call to reach `CONNECTED` state +3. **To send audio** (mic input to WebRTC): write to the virtual input device + - Linux: `paplay --device=sink_for_ audio.wav` + - macOS: `sox audio.wav -t coreaudio ` +4. **To receive audio** (WebRTC playout): read from the virtual output device + - Linux: `parecord --device=.monitor output.wav` + - macOS: `sox -t coreaudio output.wav` +5. Disconnects when the call ends + +--- + +## Startup Sequence + +``` +signal-cli signal-call-tunnel + | | + |-- spawn process ------------------> | + | (config JSON on stdin) | + | | parse config + | | create VirtualAudioDevicePair + | | init RingRTC CallManager + | | start control channel + | | bind ctrl.sock + | | queue "ready" message + | | init cubeb AudioDeviceModule + | | wait for device enumeration + | | select virtual devices by name + | | + |-- connect to ctrl.sock ------------->| + | (retries: 50x @ 200ms) | + |<-------- ready -----------------------| + | {"type":"ready", | + | "inputDeviceName":"...", | + | "outputDeviceName":"..."} | + |-- auth ------------------------------>| + | {"type":"auth","token":""} | + | | constant-time token verify + | | +``` + +Config JSON written to stdin before the process starts: + +```json +{ + "call_id": 12345, + "is_outgoing": true, + "control_socket_path": "/tmp/sc-a1b2c3/ctrl.sock", + "control_token": "dG9rZW4...", + "local_device_id": 1, + "input_device_name": "signal_input", + "output_device_name": "signal_output" +} +``` + +The `input_device_name` and `output_device_name` fields are optional. If +omitted, the tunnel generates per-call names like `signal_input_`. +On macOS, these should match the installed BlackHole driver names. + +--- + +## Control Socket Protocol + +Unix SOCK_STREAM at `ctrl.sock`. Newline-delimited JSON messages. + +### Authentication + +The first message from the parent **must** be an auth message. The token is +a random 32-byte value generated per call and passed in the startup config. + +```json +{"type":"auth","token":""} +``` + +### Parent -> Tunnel + +| Type | When | Fields | +|------|------|--------| +| `auth` | First message | `token` | +| `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 -> Parent + +| Type | When | Fields | +|------|------|--------| +| `ready` | Control socket bound, virtual devices created | `inputDeviceName`, `outputDeviceName` | +| `sendOffer` | RingRTC generated an offer | `callId`, `opaque`, `callMediaType` | +| `sendAnswer` | RingRTC generated an answer | `callId`, `opaque` | +| `sendIce` | ICE candidates gathered | `callId`, `candidates` (array of `{"opaque":"..."}`) | +| `sendHangup` | RingRTC 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"} +``` + +--- + +## Virtual Audio Devices + +The tunnel uses `VirtualAudioDevicePair` from ringrtc's `virtual_audio` module +to create platform-specific virtual audio devices. + +### PCM parameters + +| Parameter | Value | +|-----------|-------| +| Sample rate | 48,000 Hz | +| Channels | 1 (mono) | +| Sample format | 16-bit signed integer, little-endian | + +### Linux (PulseAudio) + +Virtual devices are PulseAudio null sinks/sources created automatically per +call and torn down on drop. No setup required. + +- **Input device** (client -> WebRTC): write to PulseAudio sink `sink_for_` +- **Output device** (WebRTC -> client): read from PulseAudio monitor `.monitor` + +Example: + +```bash +# Send audio to WebRTC +paplay --device=sink_for_signal_input_12345 tone.wav + +# Record from WebRTC +parecord --device=signal_output_12345.monitor --rate=48000 --channels=1 --format=s16le captured.wav +``` + +### macOS (BlackHole) + +Requires one-time root setup to install BlackHole audio drivers: + +```bash +cd third-party/ringrtc +sudo bin/virtual_audio.sh --setup --input-source signal_input --output-sink signal_output +``` + +This installs drivers in `/Library/Audio/Plug-Ins/HAL/` that persist across +reboots. No root is needed after setup. On macOS, use fixed device names +matching the installed drivers via `input_device_name`/`output_device_name` +in the config. + +Example: + +```bash +# Send audio to WebRTC +sox tone.wav -t coreaudio signal_input repeat - + +# Record from WebRTC +sox -t coreaudio signal_output captured.wav trim 0 5 +``` + +--- + +## Call Flows + +### Outgoing call + +``` +signal-cli signal-call-tunnel Remote Phone + | | | + |-- spawn + config ------->| | + |<-- ready ----------------| | + |-- auth ----------------->| | + |-- createOutgoingCall --->| | + |-- proceed (TURN) ------->| | + | | RingRTC creates offer | + |<-- sendOffer ------------| | + |-- offer via Signal -------------------------------->| + |<-- answer via Signal --------------------------------| + |-- receivedAnswer ------->| (+ identity keys) | + | | x25519 DH + HKDF | + |<-- sendIce --------------| | + |-- ICE via Signal -------------------------------> | + |<-- ICE via Signal -------------------------------- | + |-- receivedIce ---------->| | + | | ICE connects, SRTP up | + |<-- stateChange:Connected | | + | | | + | audio client connects to virtual audio devices | + | |<-- audio --- client | + | |--- audio --> client | +``` + +### Incoming call + +``` +signal-cli signal-call-tunnel Remote Phone + | | | + |<-- offer via Signal --------------------------------| + |-- spawn + config ------->| | + |<-- ready ----------------| | + |-- auth ----------------->| | + |-- receivedOffer -------->| (+ identity keys) | + |-- proceed (TURN) ------->| | + | | RingRTC processes offer | + | | x25519 DH + HKDF | + |<-- 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) | + | | RingRTC accepts | + |<-- stateChange:Connected | | + | | | + | audio client connects to virtual audio devices | + | |<-- audio --- client | + | |--- audio --> client | +``` + +### JSON-RPC client perspective + +An external application (bot, UI, test script) interacts via JSON-RPC only. +It never touches the control socket directly. + +``` +JSON-RPC Client signal-cli daemon + | | + |-- startCall(recipient) ------------->| + |<-- {callId, state, -| + | inputDeviceName, | + | outputDeviceName} | + | | + |<-- callEvent: RINGING_OUTGOING ------| + | ... remote answers ... | + |<-- callEvent: CONNECTED -------------| + | | + | connect to virtual audio devices | + | (via PulseAudio/CoreAudio) | + | | + |-- hangupCall(callId) --------------->| (or: receive callEvent ENDED) + |<-- callEvent: ENDED -----------------| + | disconnect from audio devices | +``` + +For incoming calls: + +``` +JSON-RPC Client signal-cli daemon + | | + |<-- callEvent: RINGING_INCOMING ------| (includes callId, device names) + | | + |-- acceptCall(callId) --------------->| + |<-- {callId, state, -| + | inputDeviceName, | + | outputDeviceName} | + | | + |<-- callEvent: CONNECTING ------------| + |<-- callEvent: CONNECTED -------------| + | | + | connect to virtual audio devices | + | (via PulseAudio/CoreAudio) | +``` + +--- + +## Audio Client Integration Guide + +### When to connect + +Connect to the virtual audio devices **after** the call reaches `CONNECTED` +state. Device names are returned in the `startCall`/`acceptCall` response and +in `callEvent` notifications. + +You can connect earlier (the devices exist from tunnel startup), but no +meaningful audio will flow until ICE completes and the call is connected. + +### Sending audio (recording path) + +Write audio to the virtual input device using platform APIs. The tunnel's +cubeb recording captures from this device and feeds it to WebRTC. + +- **Linux**: `paplay --device=sink_for_ audio.wav` +- **macOS**: `sox audio.wav -t coreaudio ` + +If you have nothing to send (muted), simply don't write. WebRTC's Opus DTX +will detect silence and send minimal comfort noise packets. + +### Receiving audio (playout path) + +Read audio from the virtual output device using platform APIs. WebRTC receives +and Opus-decodes remote audio, cubeb plays it to the virtual output device, +and you capture it from the monitor/device. + +- **Linux**: `parecord --device=.monitor output.wav` +- **macOS**: `sox -t coreaudio output.wav` + +--- + +## Encryption and Key Derivation + +The tunnel subprocess handles all encryption. Neither the Java parent nor the +audio client ever sees SRTP keys or encrypted media. + +RingRTC uses a custom key derivation scheme (not DTLS-SRTP): + +1. Each side generates an ephemeral x25519 keypair +2. Public keys are embedded in the opaque offer/answer blobs +3. x25519 DH produces a shared secret +4. HKDF-SHA256 derives SRTP keys with info string: + `Signal_Calling_20200807_SignallingDH_SRTPKey_KDF || caller_identity || callee_identity` +5. Keys are injected into WebRTC with DTLS disabled + +The identity keys are **not** inside the opaque blobs. They are passed +separately via the control protocol (`senderIdentityKey`, `receiverIdentityKey`) +and come from the Signal protocol message envelope. + +Identity keys in `senderIdentityKey` and `receiverIdentityKey` must be **raw +32-byte Curve25519 public keys** (without the 0x05 DJB type prefix). Signal +Android strips this prefix via `WebRtcUtil.getPublicKeyBytes()`. If the 33-byte +serialized form is used instead, SRTP key derivation produces different keys on +each side, causing `srtp_err_status_auth_fail`. + +--- + +## Implementation Notes + +### Peer ID consistency + +The `peerId` field in `createOutgoingCall` and `receivedOffer` must be the actual +remote peer UUID (e.g., `senderAddress.toString()`). RingRTC's +`compare_remotes()` 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 `u64`). 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. + +### VirtualAudioDevicePair lifecycle + +The `VirtualAudioDevicePair` is kept alive for the duration of the tunnel +process. On drop: +- **Linux**: calls `pactl unload-module` to clean up PulseAudio modules +- **macOS**: logs a warning (can't teardown without root) but BlackHole drivers + persist for the next call, which is the intended behavior + +--- + +## State Machine + +Call states as seen by JSON-RPC clients (mapped from RingRTC internal states): + +``` + 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 RingRTC +emits during ICE restarts (not during initial connection). + +--- + +## File Layout + +``` +/tmp/sc-/ + ctrl.sock control socket (signal-cli <-> tunnel) +``` + +The control socket is created with mode `0700` on the parent directory. The +directory and its contents are deleted when the call ends. + +The `signal-call-tunnel` binary is located by searching (in order): + +1. `SIGNAL_CALL_TUNNEL_BIN` environment variable +2. `/bin/signal-call-tunnel` +3. `signal-call-tunnel` on `PATH` + +--- + +## Building + +```bash +# Build signal-cli +./gradlew installDist + +# Build the Rust call tunnel +cd signal-call-tunnel && cargo build --release && cd .. +``` + +The first Rust build downloads a prebuilt WebRTC library (~100 MB) from +Signal's artifact server. Subsequent builds use the cached copy. + +### Prerequisites + +- **macOS**: Install BlackHole virtual audio drivers (one-time, requires root): + ```bash + cd third-party/ringrtc + sudo bin/virtual_audio.sh --setup --input-source signal_input --output-sink signal_output + ``` + Install `sox` for audio playback/recording in tests: `brew install sox` + +- **Linux**: PulseAudio must be running. Virtual audio modules are created + automatically per call. diff --git a/docs/TEST_HARNESS.md b/docs/TEST_HARNESS.md new file mode 100644 index 00000000..3b5363da --- /dev/null +++ b/docs/TEST_HARNESS.md @@ -0,0 +1,265 @@ +# E2E Voice Call Test Harness + +Design document for the automated end-to-end voice call test infrastructure. + +## Architecture Overview + +The test harness has two layers: a shell orchestrator (`run_e2e.sh`) that manages +builds, processes, and cleanup, and a Python test runner (`e2e_test.py`) that +executes the five test scenarios. + +``` +run_e2e.sh (orchestrator) + +-- pre-flight checks (builds, adb, Signal installed) + +-- start signal-cli daemon on test socket + +-- start logcat collection + +-- launch e2e_test.py (test runner) + +-- SignalRPC -- JSON-RPC 2.0 client for signal-cli daemon + +-- EmulatorControl -- ADB-based Signal UI automation + +-- AudioProcessing -- tone generation + Goertzel detection + +-- VirtualAudioHelpers -- platform-aware play/record via sox/paplay + +-- GrpcAudio -- emulator gRPC audio HAL client +``` + +### File layout + +``` +test/ + run_e2e.sh # Shell orchestrator + config.sh # Shared environment config (paths, ports, accounts, frequencies) + requirements.txt # Python deps (grpcio, grpcio-tools) + generate_proto.sh # Compile emulator_controller.proto -> Python stubs + e2e_test.py # Test runner (scenarios A-E) + lib/ + signal_rpc.py # JSON-RPC 2.0 client for signal-cli + audio.py # Tone generation + Goertzel frequency detection + WAV I/O + emulator.py # ADB-based Signal UI automation + grpc_audio.py # Emulator gRPC audio injection/capture + proto/ # Generated protobuf stubs (created by generate_proto.sh) +``` + +--- + +## Component Descriptions + +### `run_e2e.sh` -- Shell Orchestrator + +Handles everything outside the test scenarios themselves: + +- **Pre-flight checks**: builds exist, adb reachable, Signal installed on emulator +- **Binary builds**: `gradlew installDist` + `cargo build --release` +- **Python setup**: virtualenv activation, `pip install` from `requirements.txt`, + proto stub generation +- **Daemon lifecycle**: starts signal-cli on a test socket, captures stdout to log file +- **Logcat collection**: background `adb logcat` filtered to Signal-related tags +- **Cleanup traps**: `trap cleanup EXIT INT TERM` ensures daemon, logcat, and orphaned + tunnel processes are killed on any exit path +- **Stale process detection**: pre-run `lsof`/`pkill` to kill leftover processes from + previous runs that might hold the test socket or tunnel ports + +### `config.sh` -- Shared Configuration + +Sourced by `run_e2e.sh`. Centralizes: + +- Account phone numbers (`SIGNAL_CLI_ACCOUNT`, `EMULATOR_ACCOUNT`) +- Android SDK paths (adb, emulator binary) +- Emulator AVD name, gRPC port +- Test socket path (`/tmp/signal-cli-test.sock`) +- Audio test frequencies (440 Hz outgoing, 1000 Hz incoming) +- Log directory paths + +### `e2e_test.py` -- Test Runner + +Runs scenarios A-E with per-scenario logging, screen recording, and result reporting. + +Key components: + +- **`LogCollector`**: tracks log file positions per scenario; on failure, extracts only + the relevant segment from each of the three log sources (signal-cli, daemon console, + logcat) with categorized line filtering (tunnel messages, call-related, errors) +- **`ScreenRecorder`**: optional `--record` flag captures emulator screen via + `adb screenrecord` for post-mortem debugging +- **Virtual audio helpers**: `play_to_device()` and `record_from_device()` provide + platform-aware audio I/O using `sox` (macOS) or `paplay`/`parecord` (Linux) +- **Fail-fast mode**: default behavior stops after the first failure; `--no-fail-fast` + runs the full suite +- **Clean state**: `wait_for_clean_state()` kills and relaunches Signal between + scenarios, then waits for the WebSocket to reconnect (~20 s) + +### `lib/signal_rpc.py` -- JSON-RPC Client + +JSON-RPC 2.0 client over Unix socket for the signal-cli daemon. + +- `start_call()`, `accept_call()`, `reject_call()`, `hangup_call()` -- call control +- `wait_for_state()` -- blocks until a `callEvent` notification with the target state +- `_read_lines()` -- generator yielding newline-delimited JSON; buffers partial reads +- `_wait_response()` -- waits for matching response ID, queues non-matching notifications + in `_pending_events` for later consumption by `read_event()` + +### `lib/audio.py` -- Audio Processing + +Pure-Python audio utilities (no numpy dependency): + +- `generate_tone(freq, duration)` -- sine wave PCM (48 kHz, 16-bit signed LE, mono) +- `goertzel_magnitude(samples, freq)` -- O(n) single-frequency energy detector +- `detect_tone(pcm, expected_freq)` -- scans a +/-100 Hz window around the target + frequency, compares peak to noise floor (SNR threshold = 3.0) +- `pcm_to_wav()` -- write raw PCM bytes to a WAV file +- `wav_to_pcm()` -- read a WAV file back as raw PCM bytes +- `rms_level()` -- RMS amplitude normalized to [0.0, 1.0] + +The +/-100 Hz scan window compensates for Opus codec frequency shifts (~30-50 Hz). +Noise is measured from bins 400-600 Hz away from the signal to avoid adjacent-band +leakage. + +### `lib/emulator.py` -- Emulator UI Automation + +ADB-based Signal UI automation using dynamic element lookup and logcat polling. + +- `_dump_ui()` -- runs `adb shell uiautomator dump /sdcard/window_dump.xml` then reads + the file back (piping to `/dev/stdout` is unreliable on many emulators). Requires + `adb root` on API 34+. Retries once on failure with a 5-second timeout. +- `_find_element()` / `_tap_element()` -- search the XML hierarchy for elements by + text, content-desc, resource-id, or class name, and tap their center coordinates +- `_dismiss_permission_dialogs()` -- dismiss camera/mic permission prompts on the + pre-join call screen by tapping "Not now" / "Deny" +- `launch_signal()` -- uses `monkey` launcher intent (not unexported `MainActivity`) +- `open_conversation()` -- kill, relaunch, find first conversation row via uiautomator +- `tap_call_button()` -- find call icon by content-desc, dismiss permission dialogs, + then tap "Start Call" on the pre-join screen +- `answer_incoming_call()` -- expand notification shade, find "Answer"/"Accept" button + via uiautomator (heads-up notification is invisible to uiautomator, but shade actions + are visible). Falls back to `HEADSETHOOK` keyevent and `cmd telecom` commands. +- `reject_incoming_call()` -- uses `ENDCALL` keyevent with `cmd telecom end-call` fallback +- `_wait_for_incoming_call()` -- poll logcat for `handleReceivedOffer` -> `LOCAL_RINGING` +- Foreground verification via `dumpsys window | grep mCurrentFocus` + +UI element coordinates are discovered dynamically via `uiautomator dump`, making the +harness portable across emulator display resolutions. + +### `lib/grpc_audio.py` -- gRPC Audio HAL Client + +Client for the Android emulator's gRPC audio streaming API. + +- `inject_audio(pcm)` -- client-streaming RPC to inject PCM into the virtual mic, + paced at ~8 ms per frame (slightly under 10 ms to avoid underruns) +- `capture_audio(seconds)` -- server-streaming RPC to capture speaker output +- **Auth discovery**: tries unauthenticated first (`-grpc` flag); on + `UNAUTHENTICATED` error, searches `~/.android/avd/running/` and `$TMPDIR/avd/running/` + for `grpc.token` or `.jwk` files +- 4-second settling delay after gRPC connection for HAL initialization + +--- + +## Test Scenarios + +| ID | Name | Flow | +|----|------|------| +| A | Outgoing call lifecycle | signal-cli places call -> emulator answers -> signal-cli hangs up | +| B | Incoming call lifecycle | Emulator places call -> signal-cli accepts -> signal-cli hangs up | +| C | Incoming call rejection | Emulator places call -> signal-cli rejects (verify hangup reason) | +| D | Ring timeout | signal-cli places call -> nobody answers -> verify timeout after ~60 s | +| E | Bidirectional audio | Connected call with tone generation via virtual audio devices + Goertzel detection via gRPC | + +Scenario E verifies both directions of the audio pipeline: + +1. **signal-cli -> emulator**: play 440 Hz tone WAV into the virtual input device + (via `sox`/`paplay`), capture from the emulator's speaker via gRPC, verify with + Goertzel +2. **emulator -> signal-cli**: record from the virtual output device (via + `sox`/`parecord`), verify playout data is non-empty and at the expected rate + +--- + +## Key Design Decisions & Lessons Learned + +### Emulator UI Automation + +- **Use `monkey` launcher intent, not unexported `MainActivity`**: the + `am start` command with `MainActivity` throws `SecurityException` because it is not + exported. `monkey -p org.thoughtcrime.securesms 1` uses the default launcher intent. +- **Dynamic element lookup via `uiautomator dump`**: dumps to + `/sdcard/window_dump.xml` then reads back with `cat` (piping to `/dev/stdout` is + unreliable). Requires `adb root` on API 34+ (run once in `run_e2e.sh`). Searches by + text, content-desc, or resource-id. Portable across display resolutions. The dump has + a 5-second timeout and retries once on failure. +- **Wait for `handleLocalRinging` logcat event, not `handleReceivedOffer`**: the offer + event fires before the UI is ready to accept taps. Waiting for `LOCAL_RINGING` ensures + the incoming call notification is visible. +- **Notification shade for call answer**: the heads-up notification is invisible to + `uiautomator dump` (it's rendered by SystemUI, not the app). Expanding the notification + shade with `cmd statusbar expand-notifications` makes the "Answer"/"Accept" action + buttons visible in the SystemUI hierarchy. Falls back to `HEADSETHOOK` keyevent. + Signal does NOT use Android's Telecom framework, so `cmd telecom accept-ringing-call` + is a no-op. +- **Dismiss permission dialogs on pre-join screen**: Signal may show camera/microphone + permission prompts when the pre-join call activity opens. `_dismiss_permission_dialogs()` + taps "Not now" to dismiss them before looking for the "Start Call" button. +- **Non-coordinate call reject**: uses `ENDCALL` keyevent with `cmd telecom end-call` + fallback. No screen coordinates needed. + +### State Management + +- **State-based polling replaces all `time.sleep()` calls**: every wait polls for a + specific state (RPC event, logcat message, or socket data) with a timeout, rather + than sleeping for a fixed duration. +- **`wait_for_clean_state()` uses time-based logcat filtering (`-T timestamp`), not + `logcat -c`**: clearing the logcat buffer races with the system logger writing new + entries. Filtering by timestamp is deterministic. +- **Kill + relaunch Signal between scenarios**: ensures WebSocket reconnection and a + clean call state. Without this, leftover state from a previous call causes the next + scenario to fail. +- **20 s WebSocket reconnect timeout**: after Signal restarts, signal-cli's WebSocket + needs time to reconnect before it can receive incoming calls. + +### Audio & Media Pipeline + +- **Virtual audio device enumeration timing**: cubeb needs time to detect newly created + virtual devices. The tunnel loops on `get_audio_playout_devices()` / + `get_audio_recording_devices()` at 100 ms intervals until devices appear. +- **Platform-specific audio tools**: `sox` on macOS (CoreAudio), `paplay`/`parecord` on + Linux (PulseAudio). The test harness auto-selects based on `platform.system()`. +- **Opus codec shifts frequencies +/-30-50 Hz**: the Goertzel detector scans a +/-100 Hz + window around the target frequency to tolerate codec artifacts. +- **gRPC audio HAL needs 4 s settling delay**: the emulator's audio subsystem needs + time to initialize after a gRPC connection. Without the delay, capture returns silence. +- **Retry logic for HAL flakiness**: scenario E retries once on failure to handle + transient emulator audio issues. +- **gRPC auth: `-grpc` flag for unauthenticated, JWT token auto-discovery as fallback**: + the emulator gRPC port may or may not require authentication depending on how it was + launched. The client tries unauthenticated first, then discovers tokens from the + emulator's runtime directories. + +### Test Infrastructure + +- **Trap `INT`/`TERM`/`EXIT` for cleanup; pre-run stale process detection via + `lsof`/`pkill`**: ensures no orphaned daemons, logcat processes, or tunnel subprocesses + survive across runs. +- **Export (not just set) environment variables for child processes**: `SIGNAL_CALL_TUNNEL_BIN` + must be exported so the signal-cli daemon's subprocess spawner can find the tunnel binary. +- **Buffered I/O must drain buffer before calling `recv()`**: the `_read_lines()` fix + (commit 6085ca0b) -- previously, buffered data from a prior `recv()` call was lost when + the generator was re-entered, causing missed JSON-RPC responses. +- **Fail-fast by default; `--no-fail-fast` for full suite runs**: most development + workflows want to stop at the first failure. CI or full validation runs use + `--no-fail-fast`. +- **`LogCollector`**: per-scenario extraction from three log sources (signal-cli output, + daemon console, logcat) with categorized diagnostics (tunnel lines, call-related, errors). +- **`--record` flag for emulator screen capture**: `adb screenrecord` during test + execution produces video for post-mortem debugging of UI automation failures. + +### Signal-cli Bugs Found via E2E Testing + +These bugs were found and fixed in separate commits after the test harness was complete: + +- **ICE credential mismatch**: RingRTC requires consistent peer ID across all API calls. + Using different IDs for `createOutgoingCall` and `proceed` caused ICE to fail silently. +- **SRTP key mismatch**: identity keys are 33 bytes with a `0x05` prefix in the Signal + protocol, but RingRTC expects 32-byte raw keys. Passing the prefixed key caused SRTP + decryption failure. +- **Multi-device hangup**: `sendHangup` is a protocol message to other devices, not a + local state change. The call manager was treating it as a local hangup. +- **Call ID overflow**: `BigInteger` -> `Long` cast truncated call IDs; unsigned + serialization was needed for Rust's `u64`. +- **Accept race**: calling `acceptCall` before the tunnel reports `Ringing` state causes + RingRTC to drop the accept. The fix defers `acceptCall` until ICE is connected. diff --git a/lib/build.gradle.kts b/lib/build.gradle.kts index 45237064..be1c26f4 100644 --- a/lib/build.gradle.kts +++ b/lib/build.gradle.kts @@ -37,7 +37,11 @@ dependencies { } tasks.named("test") { - useJUnitPlatform() + useJUnitPlatform { + if (!project.hasProperty("includeIntegration")) { + excludeTags("integration") + } + } } configurations { 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 2875ee15..834e5fac 100644 --- a/lib/src/main/java/org/asamk/signal/manager/Manager.java +++ b/lib/src/main/java/org/asamk/signal/manager/Manager.java @@ -64,6 +64,10 @@ import java.util.Map; import java.util.Optional; import java.util.Set; +import org.asamk.signal.manager.api.CallInfo; +import org.asamk.signal.manager.api.CallOffer; +import org.asamk.signal.manager.api.TurnServer; + public interface Manager extends Closeable { static boolean isValidNumber(final String e164Number, final String countryCode) { @@ -388,9 +392,37 @@ public interface Manager extends Closeable { InputStream retrieveSticker(final StickerPackId stickerPackId, final int stickerId) throws IOException; + // --- Voice call methods --- + + CallInfo startCall(RecipientIdentifier.Single recipient) throws IOException, UnregisteredRecipientException; + + CallInfo acceptCall(long callId) throws IOException; + + void hangupCall(long callId) throws IOException; + + void rejectCall(long callId) throws IOException; + + List listActiveCalls(); + + void sendCallOffer(RecipientIdentifier.Single recipient, CallOffer offer) throws IOException, UnregisteredRecipientException; + + void sendCallAnswer(RecipientIdentifier.Single recipient, long callId, byte[] answerOpaque) throws IOException, UnregisteredRecipientException; + + void sendIceUpdate(RecipientIdentifier.Single recipient, long callId, List iceCandidates) throws IOException, UnregisteredRecipientException; + + void sendHangup(RecipientIdentifier.Single recipient, long callId, MessageEnvelope.Call.Hangup.Type type) throws IOException, UnregisteredRecipientException; + + void sendBusy(RecipientIdentifier.Single recipient, long callId) throws IOException, UnregisteredRecipientException; + + List getTurnServerInfo() throws IOException; + @Override void close(); + void addCallEventListener(CallEventListener listener); + + void removeCallEventListener(CallEventListener listener); + interface ReceiveMessageHandler { ReceiveMessageHandler EMPTY = (envelope, e) -> { @@ -398,4 +430,9 @@ public interface Manager extends Closeable { void handleMessage(MessageEnvelope envelope, Throwable e); } + + interface CallEventListener { + + void handleCallEvent(CallInfo callInfo, String reason); + } } diff --git a/lib/src/main/java/org/asamk/signal/manager/api/CallInfo.java b/lib/src/main/java/org/asamk/signal/manager/api/CallInfo.java new file mode 100644 index 00000000..30b5d20d --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/api/CallInfo.java @@ -0,0 +1,21 @@ +package org.asamk.signal.manager.api; + +public record CallInfo( + long callId, + State state, + RecipientAddress recipient, + String inputDeviceName, + String outputDeviceName, + boolean isOutgoing +) { + + public enum State { + IDLE, + RINGING_INCOMING, + RINGING_OUTGOING, + CONNECTING, + CONNECTED, + RECONNECTING, + ENDED + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/api/CallOffer.java b/lib/src/main/java/org/asamk/signal/manager/api/CallOffer.java new file mode 100644 index 00000000..2c4aa251 --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/api/CallOffer.java @@ -0,0 +1,13 @@ +package org.asamk.signal.manager.api; + +public record CallOffer( + long callId, + Type type, + byte[] opaque +) { + + public enum Type { + AUDIO, + VIDEO + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/api/TurnServer.java b/lib/src/main/java/org/asamk/signal/manager/api/TurnServer.java new file mode 100644 index 00000000..8ffd03bf --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/api/TurnServer.java @@ -0,0 +1,10 @@ +package org.asamk.signal.manager.api; + +import java.util.List; + +public record TurnServer( + String username, + String password, + List urls +) { +} diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/CallManager.java b/lib/src/main/java/org/asamk/signal/manager/helper/CallManager.java new file mode 100644 index 00000000..3cde2841 --- /dev/null +++ b/lib/src/main/java/org/asamk/signal/manager/helper/CallManager.java @@ -0,0 +1,858 @@ +package org.asamk.signal.manager.helper; + +import org.asamk.signal.manager.Manager; +import org.asamk.signal.manager.api.CallInfo; +import org.asamk.signal.manager.api.MessageEnvelope; +import org.asamk.signal.manager.api.RecipientIdentifier; +import org.asamk.signal.manager.api.TurnServer; +import org.asamk.signal.manager.api.UnregisteredRecipientException; +import org.asamk.signal.manager.internal.SignalDependencies; +import org.asamk.signal.manager.storage.SignalAccount; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +import java.io.PrintWriter; +import java.net.StandardProtocolFamily; +import java.net.UnixDomainSocketAddress; +import java.nio.channels.Channels; +import java.nio.channels.SocketChannel; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermissions; +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +/** + * Manages active voice calls: tracks state, spawns/monitors the signal-call-tunnel + * Rust subprocess (RingRTC-based), routes incoming call messages, and handles timeouts. + */ +public class CallManager implements AutoCloseable { + + private static final Logger logger = LoggerFactory.getLogger(CallManager.class); + private static final long RING_TIMEOUT_MS = 60_000; + private static final ObjectMapper mapper = new ObjectMapper(); + + private final Context context; + private final SignalAccount account; + private final SignalDependencies dependencies; + private final Map activeCalls = new ConcurrentHashMap<>(); + private final List callEventListeners = new CopyOnWriteArrayList<>(); + private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(r -> { + var t = new Thread(r, "call-timeout-scheduler"); + t.setDaemon(true); + return t; + }); + + public CallManager(final Context context) { + this.context = context; + this.account = context.getAccount(); + this.dependencies = context.getDependencies(); + } + + public void addCallEventListener(Manager.CallEventListener listener) { + callEventListeners.add(listener); + } + + public void removeCallEventListener(Manager.CallEventListener listener) { + callEventListeners.remove(listener); + } + + private void fireCallEvent(CallState state, String reason) { + var callInfo = state.toCallInfo(); + for (var listener : callEventListeners) { + try { + listener.handleCallEvent(callInfo, reason); + } catch (Throwable e) { + logger.warn("Call event listener failed, ignoring", e); + } + } + } + + public CallInfo startOutgoingCall( + final RecipientIdentifier.Single recipient + ) throws IOException, UnregisteredRecipientException { + var callId = generateCallId(); + var recipientId = context.getRecipientHelper().resolveRecipient(recipient); + var recipientAddress = context.getRecipientHelper() + .resolveSignalServiceAddress(recipientId) + .getServiceId(); + var recipientApiAddress = account.getRecipientAddressResolver() + .resolveRecipientAddress(recipientId) + .toApiRecipientAddress(); + + // Create per-call socket directory + var callDir = Files.createTempDirectory(Path.of("/tmp"), "sc-"); + Files.setPosixFilePermissions(callDir, PosixFilePermissions.fromString("rwx------")); + var controlSocketPath = callDir.resolve("ctrl.sock").toString(); + + var state = new CallState(callId, + CallInfo.State.RINGING_OUTGOING, + recipientApiAddress, + recipient, + true, + controlSocketPath, + callDir); + activeCalls.put(callId, state); + fireCallEvent(state, null); + + // Spawn Rust binary and connect control channel + spawnMediaTunnel(state); + + // Fetch TURN servers + var turnServers = getTurnServers(); + + // Send createOutgoingCall + proceed via control channel + var peerIdStr = recipientAddress.toString(); + sendControlMessage(state, "{\"type\":\"createOutgoingCall\",\"callId\":" + callIdJson(callId) + + ",\"peerId\":\"" + escapeJson(peerIdStr) + "\"}"); + sendProceed(state, callId, turnServers); + + // Schedule ring timeout + scheduler.schedule(() -> handleRingTimeout(callId), RING_TIMEOUT_MS, TimeUnit.MILLISECONDS); + + logger.info("Started outgoing call {} to {}", callId, recipient); + return state.toCallInfo(); + } + + public CallInfo acceptIncomingCall(final long callId) throws IOException { + var state = activeCalls.get(callId); + if (state == null) { + throw new IOException("No active call with id " + callId); + } + if (state.state != CallInfo.State.RINGING_INCOMING) { + throw new IOException("Call " + callId + " is not in RINGING_INCOMING state (current: " + state.state + ")"); + } + + // Defer the accept until the tunnel reports Ringing state. + // Sending accept too early (while RingRTC is in ConnectingBeforeAccepted) + // causes it to be silently dropped. + state.acceptPending = true; + // If the tunnel is already in Ringing state, send immediately + sendAcceptIfReady(state); + + state.state = CallInfo.State.CONNECTING; + fireCallEvent(state, null); + + logger.info("Accepted incoming call {}", callId); + return state.toCallInfo(); + } + + public void hangupCall(final long callId) throws IOException { + var state = activeCalls.get(callId); + if (state == null) { + throw new IOException("No active call with id " + callId); + } + endCall(callId, "local_hangup"); + } + + public void rejectCall(final long callId) throws IOException { + var state = activeCalls.get(callId); + if (state == null) { + throw new IOException("No active call with id " + callId); + } + + try { + var recipientId = context.getRecipientHelper().resolveRecipient(state.recipientIdentifier); + var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId); + var busyMessage = new org.whispersystems.signalservice.api.messages.calls.BusyMessage(callId); + var callMessage = org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage.forBusy( + busyMessage, null); + dependencies.getMessageSender().sendCallMessage(address, null, callMessage); + } catch (Exception e) { + logger.warn("Failed to send busy message for call {}", callId, e); + } + + endCall(callId, "rejected"); + } + + public List listActiveCalls() { + return activeCalls.values().stream().map(CallState::toCallInfo).toList(); + } + + public List getTurnServers() throws IOException { + try { + var result = dependencies.getCallingApi().getTurnServerInfo(); + var turnServerList = result.successOrThrow(); + return turnServerList.stream() + .map(info -> new TurnServer(info.getUsername(), info.getPassword(), info.getUrls())) + .toList(); + } catch (Throwable e) { + logger.warn("Failed to get TURN server info, returning empty list", e); + return List.of(); + } + } + + // --- Incoming call message handling --- + + public void handleIncomingOffer( + final org.asamk.signal.manager.storage.recipients.RecipientId senderId, + final long callId, + final MessageEnvelope.Call.Offer.Type type, + final byte[] opaque + ) { + var senderAddress = account.getRecipientAddressResolver() + .resolveRecipientAddress(senderId) + .toApiRecipientAddress(); + + RecipientIdentifier.Single senderIdentifier; + if (senderAddress.number().isPresent()) { + senderIdentifier = new RecipientIdentifier.Number(senderAddress.number().get()); + } else if (senderAddress.uuid().isPresent()) { + senderIdentifier = new RecipientIdentifier.Uuid(senderAddress.uuid().get()); + } else { + logger.warn("Cannot identify sender for call {}", callId); + return; + } + + logger.debug("Incoming offer opaque ({} bytes)", opaque == null ? 0 : opaque.length); + + Path callDir; + try { + callDir = Files.createTempDirectory(Path.of("/tmp"), "sc-"); + Files.setPosixFilePermissions(callDir, PosixFilePermissions.fromString("rwx------")); + } catch (IOException e) { + logger.warn("Failed to create socket directory for incoming call {}", callId, e); + return; + } + var controlSocketPath = callDir.resolve("ctrl.sock").toString(); + + var state = new CallState(callId, + CallInfo.State.RINGING_INCOMING, + senderAddress, + senderIdentifier, + false, + controlSocketPath, + callDir); + state.rawOfferOpaque = opaque; + activeCalls.put(callId, state); + + // Spawn Rust binary immediately + spawnMediaTunnel(state); + + // Get identity keys for the receivedOffer message + // Use raw 32-byte Curve25519 public key (without 0x05 DJB prefix) to match Signal Android + byte[] localIdentityKey = getRawIdentityKeyBytes(account.getAciIdentityKeyPair().getPublicKey().serialize()); + byte[] remoteIdentityKey = getRemoteIdentityKey(state); + + // Fetch TURN servers + List turnServers; + try { + turnServers = getTurnServers(); + } catch (IOException e) { + logger.warn("Failed to get TURN servers for incoming call {}", callId, e); + turnServers = List.of(); + } + + // Send receivedOffer to subprocess + var opaqueB64 = java.util.Base64.getEncoder().encodeToString(opaque); + var senderIdKeyB64 = java.util.Base64.getEncoder().encodeToString(remoteIdentityKey); + var receiverIdKeyB64 = java.util.Base64.getEncoder().encodeToString(localIdentityKey); + var peerIdStr = senderAddress.toString(); + sendControlMessage(state, "{\"type\":\"receivedOffer\",\"callId\":" + callIdJson(callId) + + ",\"peerId\":\"" + escapeJson(peerIdStr) + "\"" + + ",\"senderDeviceId\":1" + + ",\"opaque\":\"" + opaqueB64 + "\"" + + ",\"age\":0" + + ",\"senderIdentityKey\":\"" + senderIdKeyB64 + "\"" + + ",\"receiverIdentityKey\":\"" + receiverIdKeyB64 + "\"" + + "}"); + + // Send proceed with TURN servers + sendProceed(state, callId, turnServers); + + fireCallEvent(state, null); + + // Schedule ring timeout + scheduler.schedule(() -> handleRingTimeout(callId), RING_TIMEOUT_MS, TimeUnit.MILLISECONDS); + + logger.info("Incoming call {} from {}", callId, senderAddress); + } + + public void handleIncomingAnswer(final long callId, final byte[] opaque) { + var state = activeCalls.get(callId); + if (state == null) { + logger.warn("Received answer for unknown call {}", callId); + return; + } + + // Get identity keys + // Use raw 32-byte Curve25519 public key (without 0x05 DJB prefix) to match Signal Android + byte[] localIdentityKey = getRawIdentityKeyBytes(account.getAciIdentityKeyPair().getPublicKey().serialize()); + byte[] remoteIdentityKey = getRemoteIdentityKey(state); + + // Forward raw opaque to subprocess + var opaqueB64 = java.util.Base64.getEncoder().encodeToString(opaque); + var senderIdKeyB64 = java.util.Base64.getEncoder().encodeToString(remoteIdentityKey); + var receiverIdKeyB64 = java.util.Base64.getEncoder().encodeToString(localIdentityKey); + sendControlMessage(state, "{\"type\":\"receivedAnswer\"" + + ",\"opaque\":\"" + opaqueB64 + "\"" + + ",\"senderDeviceId\":1" + + ",\"senderIdentityKey\":\"" + senderIdKeyB64 + "\"" + + ",\"receiverIdentityKey\":\"" + receiverIdKeyB64 + "\"" + + "}"); + + state.state = CallInfo.State.CONNECTING; + fireCallEvent(state, null); + + logger.info("Received answer for call {}", callId); + } + + public void handleIncomingIceCandidate(final long callId, final byte[] opaque) { + var state = activeCalls.get(callId); + if (state == null) { + logger.debug("Received ICE candidate for unknown call {}", callId); + return; + } + + // Forward to subprocess as receivedIce + var b64 = java.util.Base64.getEncoder().encodeToString(opaque); + sendControlMessage(state, "{\"type\":\"receivedIce\",\"candidates\":[\"" + b64 + "\"]}"); + logger.debug("Forwarded ICE candidate to tunnel for call {}", callId); + } + + public void handleIncomingHangup(final long callId) { + endCall(callId, "remote_hangup"); + } + + public void handleIncomingBusy(final long callId) { + endCall(callId, "remote_busy"); + } + + // --- Internal helpers --- + + private void sendControlMessage(CallState state, String json) { + if (state.controlWriter == null) { + logger.debug("Queueing control message for call {} (not yet connected): {}", state.callId, json); + state.pendingControlMessages.add(json); + return; + } + state.controlWriter.println(json); + } + + private void sendProceed(CallState state, long callId, List turnServers) { + var sb = new StringBuilder(); + sb.append("{\"type\":\"proceed\",\"callId\":").append(callIdJson(callId)); + sb.append(",\"hideIp\":false"); + sb.append(",\"iceServers\":["); + for (int i = 0; i < turnServers.size(); i++) { + if (i > 0) sb.append(","); + var ts = turnServers.get(i); + sb.append("{\"username\":\"").append(escapeJson(ts.username())).append("\""); + sb.append(",\"password\":\"").append(escapeJson(ts.password())).append("\""); + sb.append(",\"urls\":["); + for (int j = 0; j < ts.urls().size(); j++) { + if (j > 0) sb.append(","); + sb.append("\"").append(escapeJson(ts.urls().get(j))).append("\""); + } + sb.append("]}"); + } + sb.append("]}"); + sendControlMessage(state, sb.toString()); + } + + private void spawnMediaTunnel(CallState state) { + try { + var command = new ArrayList<>(List.of(findRustBinary())); + // Config is sent via stdin; no --host-audio by default + + var processBuilder = new ProcessBuilder(command); + processBuilder.redirectErrorStream(true); + var process = processBuilder.start(); + + // Write config JSON to stdin + var config = buildConfig(state); + try (var stdin = process.getOutputStream()) { + stdin.write(config.getBytes(StandardCharsets.UTF_8)); + stdin.flush(); + } + + state.tunnelProcess = process; + + // Drain subprocess stdout/stderr to prevent pipe buffer deadlock + Thread.ofVirtual().name("tunnel-output-" + state.callId).start(() -> { + try (var reader = new BufferedReader( + new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + logger.debug("[tunnel-{}] {}", state.callId, line); + } + } catch (IOException ignored) { + } + }); + + // Connect to control socket in background + Thread.ofVirtual().name("control-connect-" + state.callId).start(() -> { + connectToControlSocket(state); + }); + + // Monitor process exit + process.onExit().thenAcceptAsync(p -> { + logger.info("Tunnel for call {} exited with code {}", state.callId, p.exitValue()); + if (activeCalls.containsKey(state.callId)) { + endCall(state.callId, "tunnel_exit"); + } + }); + + logger.info("Spawned signal-call-tunnel for call {}", state.callId); + } catch (Exception e) { + logger.error("Failed to spawn tunnel for call {}", state.callId, e); + endCall(state.callId, "tunnel_spawn_error"); + } + } + + private String findRustBinary() { + // 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 + var installDir = System.getProperty("signal.cli.install.dir"); + if (installDir != null) { + var binPath = Path.of(installDir, "bin", "signal-call-tunnel"); + if (Files.isExecutable(binPath)) { + return binPath.toString(); + } + } + + // Fall back to PATH + return "signal-call-tunnel"; + } + + private String buildConfig(CallState state) { + // Generate control channel authentication token + var tokenBytes = new byte[32]; + new SecureRandom().nextBytes(tokenBytes); + state.controlToken = java.util.Base64.getEncoder().encodeToString(tokenBytes); + + var sb = new StringBuilder(); + sb.append("{"); + sb.append("\"call_id\":").append(callIdJson(state.callId)); + sb.append(",\"is_outgoing\":").append(state.isOutgoing); + sb.append(",\"control_socket_path\":\"").append(escapeJson(state.controlSocketPath)).append("\""); + sb.append(",\"control_token\":\"").append(state.controlToken).append("\""); + sb.append(",\"local_device_id\":1"); + sb.append("}"); + return sb.toString(); + } + + private void connectToControlSocket(CallState state) { + var socketPath = Path.of(state.controlSocketPath); + var addr = UnixDomainSocketAddress.of(socketPath); + + for (int attempt = 0; attempt < 50; attempt++) { + try { + Thread.sleep(200); + if (!Files.exists(socketPath)) continue; + + var channel = SocketChannel.open(StandardProtocolFamily.UNIX); + channel.connect(addr); + state.controlChannel = channel; + state.controlWriter = new PrintWriter( + new OutputStreamWriter(Channels.newOutputStream(channel), StandardCharsets.UTF_8), true); + + // Send authentication token + state.controlWriter.println("{\"type\":\"auth\",\"token\":\"" + state.controlToken + "\"}"); + logger.info("Connected to control socket for call {}", state.callId); + + // Flush any pending control messages + for (var msg : state.pendingControlMessages) { + state.controlWriter.println(msg); + } + state.pendingControlMessages.clear(); + + // Start reading control events + Thread.ofVirtual().name("control-read-" + state.callId).start(() -> { + readControlEvents(state); + }); + return; + } catch (IOException e) { + logger.debug("Control socket connect attempt {} failed: {}", attempt, e.getMessage()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + } + logger.warn("Failed to connect to control socket for call {} after retries", state.callId); + } + + private void readControlEvents(CallState state) { + try (var reader = new BufferedReader( + new InputStreamReader(Channels.newInputStream(state.controlChannel), StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + line = line.trim(); + if (line.isEmpty()) continue; + logger.debug("Control event for call {}: {}", state.callId, line); + + try { + var json = mapper.readTree(line); + var type = json.has("type") ? json.get("type").asText() : ""; + + switch (type) { + case "ready" -> { + if (json.has("inputDeviceName")) { + state.inputDeviceName = json.get("inputDeviceName").asText(); + } + if (json.has("outputDeviceName")) { + state.outputDeviceName = json.get("outputDeviceName").asText(); + } + logger.debug("Tunnel ready for call {}: input={}, output={}", + state.callId, state.inputDeviceName, state.outputDeviceName); + } + case "sendOffer" -> { + var opaqueB64 = json.get("opaque").asText(); + var opaque = java.util.Base64.getDecoder().decode(opaqueB64); + sendOfferViaSignal(state, opaque); + } + case "sendAnswer" -> { + var opaqueB64 = json.get("opaque").asText(); + var opaque = java.util.Base64.getDecoder().decode(opaqueB64); + sendAnswerViaSignal(state, opaque); + } + case "sendIce" -> { + var candidatesArr = json.get("candidates"); + var opaqueList = new ArrayList(); + for (var c : candidatesArr) { + opaqueList.add(java.util.Base64.getDecoder().decode(c.get("opaque").asText())); + } + sendIceViaSignal(state, opaqueList); + } + case "sendHangup" -> { + // RingRTC wants us to send a hangup message via Signal protocol. + // This is NOT a local state change — local state is handled by stateChange events. + var hangupType = json.has("hangupType") ? json.get("hangupType").asText("normal") : "normal"; + // Skip multi-device hangup types — signal-cli is single-device, + // and sending these to the remote peer causes it to terminate the call. + if (hangupType.contains("onanotherdevice")) { + logger.debug("Ignoring multi-device hangup type: {}", hangupType); + } else { + sendHangupViaSignal(state, hangupType); + } + } + case "sendBusy" -> { + sendBusyViaSignal(state); + } + case "stateChange" -> { + var ringrtcState = json.get("state").asText(); + var reason = json.has("reason") ? json.get("reason").asText(null) : null; + handleStateChange(state, ringrtcState, reason); + } + case "error" -> { + var message = json.has("message") ? json.get("message").asText("unknown") : "unknown"; + logger.error("Tunnel error for call {}: {}", state.callId, message); + endCall(state.callId, "tunnel_error"); + } + default -> { + logger.debug("Unknown control event type '{}' for call {}", type, state.callId); + } + } + } catch (Exception e) { + logger.warn("Failed to parse control event JSON for call {}: {}", state.callId, e.getMessage()); + } + } + } catch (IOException e) { + logger.debug("Control read ended for call {}: {}", state.callId, e.getMessage()); + } + } + + private void handleStateChange(CallState state, String ringrtcState, String reason) { + if (ringrtcState.startsWith("Incoming")) { + // Don't downgrade if we've already accepted + if (state.state == CallInfo.State.CONNECTING) return; + state.state = CallInfo.State.RINGING_INCOMING; + } else if (ringrtcState.startsWith("Outgoing")) { + state.state = CallInfo.State.RINGING_OUTGOING; + } else if ("Ringing".equals(ringrtcState)) { + // Tunnel is now ready to accept — flush deferred accept if pending + sendAcceptIfReady(state); + return; + } else if ("Connected".equals(ringrtcState)) { + state.state = CallInfo.State.CONNECTED; + } else if ("Connecting".equals(ringrtcState)) { + state.state = CallInfo.State.RECONNECTING; + } else if ("Ended".equals(ringrtcState) || "Rejected".equals(ringrtcState)) { + endCall(state.callId, reason != null ? reason : ringrtcState.toLowerCase()); + return; + } else if ("Concluded".equals(ringrtcState)) { + // Cleanup, no-op + return; + } + fireCallEvent(state, reason); + } + + private void sendAcceptIfReady(CallState state) { + if (state.acceptPending && state.controlWriter != null) { + state.acceptPending = false; + logger.debug("Sending deferred accept for call {}", state.callId); + state.controlWriter.println("{\"type\":\"accept\"}"); + } + } + + private void sendOfferViaSignal(CallState state, byte[] opaque) { + try { + var recipientId = context.getRecipientHelper().resolveRecipient(state.recipientIdentifier); + var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId); + var offerMessage = new org.whispersystems.signalservice.api.messages.calls.OfferMessage(state.callId, + org.whispersystems.signalservice.api.messages.calls.OfferMessage.Type.AUDIO_CALL, + opaque); + var callMessage = org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage.forOffer( + offerMessage, null); + dependencies.getMessageSender().sendCallMessage(address, null, callMessage); + logger.info("Sent offer via Signal for call {}", state.callId); + } catch (Exception e) { + logger.warn("Failed to send offer for call {}", state.callId, e); + } + } + + private void sendAnswerViaSignal(CallState state, byte[] opaque) { + try { + var recipientId = context.getRecipientHelper().resolveRecipient(state.recipientIdentifier); + var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId); + var answerMessage = new org.whispersystems.signalservice.api.messages.calls.AnswerMessage(state.callId, opaque); + var callMessage = org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage.forAnswer( + answerMessage, null); + dependencies.getMessageSender().sendCallMessage(address, null, callMessage); + logger.info("Sent answer via Signal for call {}", state.callId); + } catch (Exception e) { + logger.warn("Failed to send answer for call {}", state.callId, e); + } + } + + private void sendIceViaSignal(CallState state, List opaqueList) { + try { + var recipientId = context.getRecipientHelper().resolveRecipient(state.recipientIdentifier); + var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId); + var iceUpdates = opaqueList.stream() + .map(opaque -> new org.whispersystems.signalservice.api.messages.calls.IceUpdateMessage( + state.callId, opaque)) + .toList(); + var callMessage = org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage.forIceUpdates( + iceUpdates, null); + dependencies.getMessageSender().sendCallMessage(address, null, callMessage); + logger.info("Sent {} ICE candidates via Signal for call {}", opaqueList.size(), state.callId); + } catch (Exception e) { + logger.warn("Failed to send ICE for call {}", state.callId, e); + } + } + + private void sendBusyViaSignal(CallState state) { + try { + var recipientId = context.getRecipientHelper().resolveRecipient(state.recipientIdentifier); + var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId); + var busyMessage = new org.whispersystems.signalservice.api.messages.calls.BusyMessage(state.callId); + var callMessage = org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage.forBusy( + busyMessage, null); + dependencies.getMessageSender().sendCallMessage(address, null, callMessage); + } catch (Exception e) { + logger.warn("Failed to send busy for call {}", state.callId, e); + } + } + + private void sendHangupViaSignal(CallState state, String hangupType) { + try { + var recipientId = context.getRecipientHelper().resolveRecipient(state.recipientIdentifier); + var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId); + var type = switch (hangupType) { + case "accepted", "acceptedonanotherdevice" -> + org.whispersystems.signalservice.api.messages.calls.HangupMessage.Type.ACCEPTED; + case "declined", "declinedonanotherdevice" -> + org.whispersystems.signalservice.api.messages.calls.HangupMessage.Type.DECLINED; + case "busy", "busyonanotherdevice" -> + org.whispersystems.signalservice.api.messages.calls.HangupMessage.Type.BUSY; + default -> org.whispersystems.signalservice.api.messages.calls.HangupMessage.Type.NORMAL; + }; + var hangupMessage = new org.whispersystems.signalservice.api.messages.calls.HangupMessage( + state.callId, type, 0); + var callMessage = org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage.forHangup( + hangupMessage, null); + dependencies.getMessageSender().sendCallMessage(address, null, callMessage); + logger.info("Sent hangup ({}) via Signal for call {}", hangupType, state.callId); + } catch (Exception e) { + logger.warn("Failed to send hangup for call {}", state.callId, e); + } + } + + private byte[] getRemoteIdentityKey(CallState state) { + try { + var recipientId = context.getRecipientHelper().resolveRecipient(state.recipientIdentifier); + var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId); + var serviceId = address.getServiceId(); + var identityInfo = account.getIdentityKeyStore().getIdentityInfo(serviceId); + if (identityInfo != null) { + return getRawIdentityKeyBytes(identityInfo.getIdentityKey().serialize()); + } + } catch (Exception e) { + logger.warn("Failed to get remote identity key for call {}", state.callId, e); + } + logger.warn("Using local identity key as fallback for remote identity key"); + return getRawIdentityKeyBytes(account.getAciIdentityKeyPair().getPublicKey().serialize()); + } + + /** + * Strip the 0x05 DJB type prefix from a serialized identity key to get the + * raw 32-byte Curve25519 public key. Signal Android does this via + * WebRtcUtil.getPublicKeyBytes() before passing keys to RingRTC. + */ + private static byte[] getRawIdentityKeyBytes(byte[] serializedKey) { + if (serializedKey.length == 33 && serializedKey[0] == 0x05) { + return java.util.Arrays.copyOfRange(serializedKey, 1, serializedKey.length); + } + return serializedKey; + } + + /** Format call ID as unsigned for JSON (Rust tunnel expects u64). */ + private static String callIdJson(long callId) { + return Long.toUnsignedString(callId); + } + + private static String escapeJson(String s) { + if (s == null) return ""; + return s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r"); + } + + private void endCall(final long callId, final String reason) { + var state = activeCalls.remove(callId); + if (state == null) return; + + state.state = CallInfo.State.ENDED; + fireCallEvent(state, reason); + logger.info("Call {} ended: {}", callId, reason); + + // Send Signal protocol hangup to remote peer (unless they initiated the end) + if (!"remote_hangup".equals(reason) && !"rejected".equals(reason) && !"remote_busy".equals(reason) + && !"ringrtc_hangup".equals(reason)) { + try { + var recipientId = context.getRecipientHelper().resolveRecipient(state.recipientIdentifier); + var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId); + var hangupMessage = new org.whispersystems.signalservice.api.messages.calls.HangupMessage(callId, + org.whispersystems.signalservice.api.messages.calls.HangupMessage.Type.NORMAL, 0); + var callMessage = org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage.forHangup( + hangupMessage, null); + dependencies.getMessageSender().sendCallMessage(address, null, callMessage); + } catch (Exception e) { + logger.warn("Failed to send hangup to remote for call {}", callId, e); + } + } + + // Send hangup via control channel before killing process + if (state.controlWriter != null) { + try { + state.controlWriter.println("{\"type\":\"hangup\"}"); + } catch (Exception e) { + logger.debug("Failed to send hangup via control channel", e); + } + } + + // Close control channel + if (state.controlChannel != null) { + try { + state.controlChannel.close(); + } catch (IOException e) { + logger.debug("Failed to close control channel for call {}", callId, e); + } + } + + // Kill tunnel process + if (state.tunnelProcess != null && state.tunnelProcess.isAlive()) { + state.tunnelProcess.destroy(); + } + + // Clean up socket directory + try { + Files.deleteIfExists(Path.of(state.controlSocketPath)); + Files.deleteIfExists(state.socketDir); + } catch (IOException e) { + logger.debug("Failed to clean up socket directory for call {}", callId, e); + } + } + + private void handleRingTimeout(final long callId) { + var state = activeCalls.get(callId); + if (state == null) return; + + if (state.state == CallInfo.State.RINGING_INCOMING || state.state == CallInfo.State.RINGING_OUTGOING) { + logger.info("Call {} ring timeout", callId); + try { + hangupCall(callId); + } catch (IOException e) { + logger.warn("Failed to hangup timed-out call {}", callId, e); + endCall(callId, "ring_timeout"); + } + } + } + + private static long generateCallId() { + return new SecureRandom().nextLong() & Long.MAX_VALUE; + } + + @Override + public void close() { + scheduler.shutdownNow(); + for (var callId : new ArrayList<>(activeCalls.keySet())) { + endCall(callId, "shutdown"); + } + } + + // --- Internal call state tracking --- + + static class CallState { + + final long callId; + volatile CallInfo.State state; + final org.asamk.signal.manager.api.RecipientAddress recipientAddress; + final RecipientIdentifier.Single recipientIdentifier; + final boolean isOutgoing; + final String controlSocketPath; + final Path socketDir; + volatile String inputDeviceName; + volatile String outputDeviceName; + volatile Process tunnelProcess; + volatile SocketChannel controlChannel; + volatile PrintWriter controlWriter; + volatile String controlToken; + // Raw offer opaque for incoming calls (forwarded to subprocess) + volatile byte[] rawOfferOpaque; + // Control messages queued before the control channel connects + final List pendingControlMessages = java.util.Collections.synchronizedList(new ArrayList<>()); + // Accept deferred until tunnel reports Ringing state + volatile boolean acceptPending = false; + + CallState( + long callId, + CallInfo.State state, + org.asamk.signal.manager.api.RecipientAddress recipientAddress, + RecipientIdentifier.Single recipientIdentifier, + boolean isOutgoing, + String controlSocketPath, + Path socketDir + ) { + this.callId = callId; + this.state = state; + this.recipientAddress = recipientAddress; + this.recipientIdentifier = recipientIdentifier; + this.isOutgoing = isOutgoing; + this.controlSocketPath = controlSocketPath; + this.socketDir = socketDir; + } + + CallInfo toCallInfo() { + return new CallInfo(callId, state, recipientAddress, inputDeviceName, outputDeviceName, isOutgoing); + } + } +} diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/Context.java b/lib/src/main/java/org/asamk/signal/manager/helper/Context.java index 2ff9c7e4..e75378eb 100644 --- a/lib/src/main/java/org/asamk/signal/manager/helper/Context.java +++ b/lib/src/main/java/org/asamk/signal/manager/helper/Context.java @@ -23,6 +23,7 @@ public class Context implements AutoCloseable { private AccountHelper accountHelper; private AttachmentHelper attachmentHelper; + private CallManager callManager; private ContactHelper contactHelper; private GroupHelper groupHelper; private GroupV2Helper groupV2Helper; @@ -92,6 +93,10 @@ public class Context implements AutoCloseable { return getOrCreate(() -> attachmentHelper, () -> attachmentHelper = new AttachmentHelper(this)); } + public CallManager getCallManager() { + return getOrCreate(() -> callManager, () -> callManager = new CallManager(this)); + } + public ContactHelper getContactHelper() { return getOrCreate(() -> contactHelper, () -> contactHelper = new ContactHelper(account)); } @@ -172,6 +177,9 @@ public class Context implements AutoCloseable { @Override public void close() { + if (callManager != null) { + callManager.close(); + } jobExecutor.close(); } diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/IncomingMessageHandler.java b/lib/src/main/java/org/asamk/signal/manager/helper/IncomingMessageHandler.java index e3dfdc27..0c89a68f 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 @@ -385,9 +385,49 @@ public final class IncomingMessageHandler { actions.addAll(handleSyncMessage(envelope, syncMessage, senderDeviceAddress, receiveConfig)); } + if (content.getCallMessage().isPresent()) { + handleCallMessage(content.getCallMessage().get(), sender); + } + return actions; } + private void handleCallMessage( + final org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage callMessage, + final org.asamk.signal.manager.storage.recipients.RecipientId sender + ) { + var callManager = context.getCallManager(); + + callMessage.getOfferMessage().ifPresent(offer -> { + var type = offer.getType() == org.whispersystems.signalservice.api.messages.calls.OfferMessage.Type.VIDEO_CALL + ? org.asamk.signal.manager.api.MessageEnvelope.Call.Offer.Type.VIDEO_CALL + : org.asamk.signal.manager.api.MessageEnvelope.Call.Offer.Type.AUDIO_CALL; + callManager.handleIncomingOffer(sender, offer.getId(), type, offer.getOpaque()); + }); + + callMessage.getAnswerMessage().ifPresent(answer -> + callManager.handleIncomingAnswer(answer.getId(), answer.getOpaque())); + + callMessage.getIceUpdateMessages().ifPresent(iceUpdates -> { + for (var ice : iceUpdates) { + callManager.handleIncomingIceCandidate(ice.getId(), ice.getOpaque()); + } + }); + + callMessage.getHangupMessage().ifPresent(hangup -> { + // Only NORMAL hangups actually end the call. ACCEPTED/DECLINED/BUSY + // are multi-device notifications irrelevant for single-device signal-cli. + var hangupType = hangup.getType(); + if (hangupType == org.whispersystems.signalservice.api.messages.calls.HangupMessage.Type.NORMAL + || hangupType == null) { + callManager.handleIncomingHangup(hangup.getId()); + } + }); + + callMessage.getBusyMessage().ifPresent(busy -> + callManager.handleIncomingBusy(busy.getId())); + } + private boolean handlePniSignatureMessage( final SignalServicePniSignatureMessage message, final SignalServiceAddress senderAddress diff --git a/lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java b/lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java index f8db4305..74a4556c 100644 --- a/lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java +++ b/lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java @@ -19,6 +19,9 @@ package org.asamk.signal.manager.internal; import org.asamk.signal.manager.Manager; import org.asamk.signal.manager.api.AlreadyReceivingException; import org.asamk.signal.manager.api.AttachmentInvalidException; +import org.asamk.signal.manager.api.CallInfo; +import org.asamk.signal.manager.api.CallOffer; +import org.asamk.signal.manager.api.TurnServer; import org.asamk.signal.manager.api.CaptchaRejectedException; import org.asamk.signal.manager.api.CaptchaRequiredException; import org.asamk.signal.manager.api.Configuration; @@ -105,6 +108,12 @@ import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; import org.whispersystems.signalservice.api.messages.SignalServicePreview; import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage; import org.whispersystems.signalservice.api.messages.SignalServiceTypingMessage; +import org.whispersystems.signalservice.api.messages.calls.AnswerMessage; +import org.whispersystems.signalservice.api.messages.calls.BusyMessage; +import org.whispersystems.signalservice.api.messages.calls.HangupMessage; +import org.whispersystems.signalservice.api.messages.calls.IceUpdateMessage; +import org.whispersystems.signalservice.api.messages.calls.OfferMessage; +import org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage; import org.whispersystems.signalservice.api.messages.multidevice.DeviceInfo; import org.whispersystems.signalservice.api.push.ServiceIdType; import org.whispersystems.signalservice.api.push.exceptions.CdsiResourceExhaustedException; @@ -163,6 +172,7 @@ public class ManagerImpl implements Manager { private boolean isReceivingSynchronous; private final Set weakHandlers = new HashSet<>(); private final Set messageHandlers = new HashSet<>(); + private final Set callEventListeners = new HashSet<>(); private final List closedListeners = new ArrayList<>(); private final List addressChangedListeners = new ArrayList<>(); private final CompositeDisposable disposable = new CompositeDisposable(); @@ -1631,6 +1641,22 @@ public class ManagerImpl implements Manager { } } + @Override + public void addCallEventListener(final CallEventListener listener) { + synchronized (callEventListeners) { + callEventListeners.add(listener); + } + context.getCallManager().addCallEventListener(listener); + } + + @Override + public void removeCallEventListener(final CallEventListener listener) { + synchronized (callEventListeners) { + callEventListeners.remove(listener); + } + context.getCallManager().removeCallEventListener(listener); + } + @Override public InputStream retrieveAttachment(final String id) throws IOException { return context.getAttachmentHelper().retrieveAttachment(id).getStream(); @@ -1688,6 +1714,132 @@ public class ManagerImpl implements Manager { return streamDetails.getStream(); } + // --- Voice call methods --- + + @Override + public CallInfo startCall(final RecipientIdentifier.Single recipient) throws IOException, UnregisteredRecipientException { + return context.getCallManager().startOutgoingCall(recipient); + } + + @Override + public CallInfo acceptCall(final long callId) throws IOException { + return context.getCallManager().acceptIncomingCall(callId); + } + + @Override + public void hangupCall(final long callId) throws IOException { + context.getCallManager().hangupCall(callId); + } + + @Override + public void rejectCall(final long callId) throws IOException { + context.getCallManager().rejectCall(callId); + } + + @Override + public List listActiveCalls() { + return context.getCallManager().listActiveCalls(); + } + + @Override + public void sendCallOffer( + final RecipientIdentifier.Single recipient, + final CallOffer offer + ) throws IOException, UnregisteredRecipientException { + final var recipientId = context.getRecipientHelper().resolveRecipient(recipient); + final var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId); + var offerMessage = new OfferMessage(offer.callId(), + offer.type() == CallOffer.Type.VIDEO ? OfferMessage.Type.VIDEO_CALL : OfferMessage.Type.AUDIO_CALL, + offer.opaque()); + var callMessage = SignalServiceCallMessage.forOffer(offerMessage, null); + try { + dependencies.getMessageSender().sendCallMessage(address, null, callMessage); + } catch (org.whispersystems.signalservice.api.crypto.UntrustedIdentityException e) { + throw new IOException("Untrusted identity for call recipient", e); + } + } + + @Override + public void sendCallAnswer( + final RecipientIdentifier.Single recipient, + final long callId, + final byte[] answerOpaque + ) throws IOException, UnregisteredRecipientException { + final var recipientId = context.getRecipientHelper().resolveRecipient(recipient); + final var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId); + var answerMessage = new AnswerMessage(callId, answerOpaque); + var callMessage = SignalServiceCallMessage.forAnswer(answerMessage, null); + try { + dependencies.getMessageSender().sendCallMessage(address, null, callMessage); + } catch (org.whispersystems.signalservice.api.crypto.UntrustedIdentityException e) { + throw new IOException("Untrusted identity for call recipient", e); + } + } + + @Override + public void sendIceUpdate( + final RecipientIdentifier.Single recipient, + final long callId, + final List iceCandidates + ) throws IOException, UnregisteredRecipientException { + final var recipientId = context.getRecipientHelper().resolveRecipient(recipient); + final var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId); + var iceUpdates = iceCandidates.stream() + .map(opaque -> new IceUpdateMessage(callId, opaque)) + .toList(); + var callMessage = SignalServiceCallMessage.forIceUpdates(iceUpdates, null); + try { + dependencies.getMessageSender().sendCallMessage(address, null, callMessage); + } catch (org.whispersystems.signalservice.api.crypto.UntrustedIdentityException e) { + throw new IOException("Untrusted identity for call recipient", e); + } + } + + @Override + public void sendHangup( + final RecipientIdentifier.Single recipient, + final long callId, + final MessageEnvelope.Call.Hangup.Type type + ) throws IOException, UnregisteredRecipientException { + final var recipientId = context.getRecipientHelper().resolveRecipient(recipient); + final var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId); + var hangupType = switch (type) { + case NORMAL -> HangupMessage.Type.NORMAL; + case ACCEPTED -> HangupMessage.Type.ACCEPTED; + case DECLINED -> HangupMessage.Type.DECLINED; + case BUSY -> HangupMessage.Type.BUSY; + case NEED_PERMISSION -> HangupMessage.Type.NEED_PERMISSION; + }; + var hangupMessage = new HangupMessage(callId, hangupType, 0); + var callMessage = SignalServiceCallMessage.forHangup(hangupMessage, null); + try { + dependencies.getMessageSender().sendCallMessage(address, null, callMessage); + } catch (org.whispersystems.signalservice.api.crypto.UntrustedIdentityException e) { + throw new IOException("Untrusted identity for call recipient", e); + } + } + + @Override + public void sendBusy( + final RecipientIdentifier.Single recipient, + final long callId + ) throws IOException, UnregisteredRecipientException { + final var recipientId = context.getRecipientHelper().resolveRecipient(recipient); + final var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId); + var busyMessage = new BusyMessage(callId); + var callMessage = SignalServiceCallMessage.forBusy(busyMessage, null); + try { + dependencies.getMessageSender().sendCallMessage(address, null, callMessage); + } catch (org.whispersystems.signalservice.api.crypto.UntrustedIdentityException e) { + throw new IOException("Untrusted identity for call recipient", e); + } + } + + @Override + public List getTurnServerInfo() throws IOException { + return context.getCallManager().getTurnServers(); + } + @Override public void close() { Thread thread; @@ -1700,6 +1852,12 @@ public class ManagerImpl implements Manager { if (thread != null) { stopReceiveThread(thread); } + synchronized (callEventListeners) { + for (var listener : callEventListeners) { + context.getCallManager().removeCallEventListener(listener); + } + callEventListeners.clear(); + } context.close(); executor.close(); diff --git a/lib/src/main/java/org/asamk/signal/manager/internal/SignalDependencies.java b/lib/src/main/java/org/asamk/signal/manager/internal/SignalDependencies.java index 47eec0c0..b94bf8ba 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/proto/rtp_data.proto b/lib/src/main/proto/rtp_data.proto new file mode 100644 index 00000000..d0b96913 --- /dev/null +++ b/lib/src/main/proto/rtp_data.proto @@ -0,0 +1,32 @@ +// In-call control messages carried over the RTP data channel. +// signal-cli hand-codes the parsing in RtpDataProtobuf.java rather than using protoc. +syntax = "proto2"; + +package rtp_data; + +option java_package = "org.asamk.signal.manager.calling.proto"; +option java_outer_classname = "RtpDataProtos"; + +message Accepted {} + +message Hangup { + optional uint32 id = 1; +} + +message SenderStatus { + optional bool audio_enabled = 1; + optional bool video_enabled = 2; + optional bool sharing_screen = 3; +} + +message Receiver { + optional uint32 id = 1; +} + +// Top-level RTP data message +message Data { + optional Accepted accepted = 1; + optional Hangup hangup = 2; + optional SenderStatus sender_status = 3; + optional Receiver receiver = 4; +} diff --git a/lib/src/main/proto/signaling.proto b/lib/src/main/proto/signaling.proto new file mode 100644 index 00000000..2e180f27 --- /dev/null +++ b/lib/src/main/proto/signaling.proto @@ -0,0 +1,32 @@ +// RingRTC signaling protobuf definitions +// These define the structure of the opaque blobs inside Signal call Offer/Answer messages. +// signal-cli hand-codes the parsing in SignalingProtobuf.java rather than using protoc. +syntax = "proto2"; + +package signaling; + +option java_package = "org.asamk.signal.manager.calling.proto"; +option java_outer_classname = "SignalingProtos"; + +message VideoCodec { + enum Type { + VP8 = 0; + H264 = 1; + VP9 = 2; + } + optional Type type = 1; + optional uint32 level = 2; +} + +message ConnectionParametersV4 { + optional bytes public_key = 1; // x25519 public key (32 bytes) + optional string ice_ufrag = 2; + optional string ice_pwd = 3; + repeated VideoCodec receive_video_codecs = 4; + optional uint64 max_bitrate_bps = 5; +} + +// The top-level opaque blob inside an OfferMessage or AnswerMessage +message Opaque { + optional ConnectionParametersV4 connection_parameters_v4 = 1; +} 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..6e6a5156 --- /dev/null +++ b/lib/src/test/java/org/asamk/signal/manager/helper/CallManagerTest.java @@ -0,0 +1,464 @@ +package org.asamk.signal.manager.helper; + +import org.asamk.signal.manager.api.CallInfo; +import org.asamk.signal.manager.api.RecipientAddress; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; +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.assertNotEquals; +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_JSON; + private static final MethodHandle ESCAPE_JSON; + private static final MethodHandle GENERATE_CALL_ID; + + static { + try { + var lookup = MethodHandles.privateLookupIn(CallManager.class, MethodHandles.lookup()); + + GET_RAW_IDENTITY_KEY_BYTES = lookup.findStatic(CallManager.class, "getRawIdentityKeyBytes", + MethodType.methodType(byte[].class, byte[].class)); + + CALL_ID_JSON = lookup.findStatic(CallManager.class, "callIdJson", + MethodType.methodType(String.class, long.class)); + + ESCAPE_JSON = lookup.findStatic(CallManager.class, "escapeJson", + MethodType.methodType(String.class, String.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 String callIdJson(long callId) throws Throwable { + return (String) CALL_ID_JSON.invokeExact(callId); + } + + private static String escapeJson(String s) throws Throwable { + return (String) ESCAPE_JSON.invokeExact(s); + } + + private static long generateCallId() throws Throwable { + return (long) GENERATE_CALL_ID.invokeExact(); + } + + // --- Helper to create a minimal CallState for state machine tests --- + + private static CallManager.CallState makeCallState(long callId, CallInfo.State initialState) { + var address = new RecipientAddress("a1b2c3d4-e5f6-7890-abcd-ef1234567890", null, "+15551234567", null); + return new CallManager.CallState( + callId, + initialState, + address, + new org.asamk.signal.manager.api.RecipientIdentifier.Number("+15551234567"), + true, + "/tmp/sc-test/ctrl.sock", + Path.of("/tmp/sc-test") + ); + } + + // ======================================================================== + // 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); + } + + // ======================================================================== + // callIdJson tests + // ======================================================================== + + @Test + void callIdJson_zero() throws Throwable { + assertEquals("0", callIdJson(0L)); + } + + @Test + void callIdJson_positiveLong() throws Throwable { + assertEquals("8230211930154373276", callIdJson(8230211930154373276L)); + } + + @Test + void callIdJson_negativeLongBecomesUnsigned() throws Throwable { + // -1L as unsigned is 2^64 - 1 = 18446744073709551615 + assertEquals("18446744073709551615", callIdJson(-1L)); + } + + @Test + void callIdJson_longMinValueBecomesUnsigned() throws Throwable { + // Long.MIN_VALUE as unsigned is 2^63 = 9223372036854775808 + assertEquals("9223372036854775808", callIdJson(Long.MIN_VALUE)); + } + + @Test + void callIdJson_longMaxValue() throws Throwable { + assertEquals("9223372036854775807", callIdJson(Long.MAX_VALUE)); + } + + // ======================================================================== + // escapeJson tests + // ======================================================================== + + @Test + void escapeJson_null() throws Throwable { + assertEquals("", escapeJson(null)); + } + + @Test + void escapeJson_empty() throws Throwable { + assertEquals("", escapeJson("")); + } + + @Test + void escapeJson_noSpecialChars() throws Throwable { + assertEquals("hello world", escapeJson("hello world")); + } + + @Test + void escapeJson_backslash() throws Throwable { + assertEquals("path\\\\to\\\\file", escapeJson("path\\to\\file")); + } + + @Test + void escapeJson_doubleQuote() throws Throwable { + assertEquals("say \\\"hello\\\"", escapeJson("say \"hello\"")); + } + + @Test + void escapeJson_newline() throws Throwable { + assertEquals("line1\\nline2", escapeJson("line1\nline2")); + } + + @Test + void escapeJson_carriageReturn() throws Throwable { + assertEquals("line1\\rline2", escapeJson("line1\rline2")); + } + + @Test + void escapeJson_allSpecialChars() throws Throwable { + assertEquals("a\\\\b\\\"c\\nd\\re", escapeJson("a\\b\"c\nd\re")); + } + + // ======================================================================== + // generateCallId tests + // ======================================================================== + + @Test + void generateCallId_alwaysNonNegative() throws Throwable { + for (int i = 0; i < 200; i++) { + long id = generateCallId(); + assertTrue(id >= 0, "generateCallId returned negative: " + id); + } + } + + @Test + void generateCallId_producesVariation() throws Throwable { + long first = generateCallId(); + boolean foundDifferent = false; + for (int i = 0; i < 20; i++) { + if (generateCallId() != first) { + foundDifferent = true; + break; + } + } + assertTrue(foundDifferent, "generateCallId returned same value 21 times in a row"); + } + + // ======================================================================== + // handleStateChange state machine tests + // + // Since handleStateChange is a private instance method requiring a full + // CallManager (which needs Context), we test the state transition logic + // directly by reproducing its documented rules against CallState. + // The rules are: + // "Incoming*" -> RINGING_INCOMING (unless already CONNECTING) + // "Outgoing*" -> RINGING_OUTGOING + // "Ringing" -> triggers deferred accept (no state change) + // "Connected" -> CONNECTED + // "Connecting"-> RECONNECTING + // "Ended"/"Rejected" -> would call endCall (sets ENDED) + // "Concluded" -> no-op + // ======================================================================== + + @Test + void stateTransition_incomingToRingingIncoming() { + var state = makeCallState(1L, CallInfo.State.IDLE); + applyStateTransition(state, "Incoming(Audio)", null); + assertEquals(CallInfo.State.RINGING_INCOMING, state.state); + } + + @Test + void stateTransition_incomingWithMediaType() { + var state = makeCallState(1L, CallInfo.State.IDLE); + applyStateTransition(state, "Incoming(Video)", null); + assertEquals(CallInfo.State.RINGING_INCOMING, state.state); + } + + @Test + void stateTransition_incomingDoesNotDowngradeFromConnecting() { + var state = makeCallState(1L, CallInfo.State.CONNECTING); + applyStateTransition(state, "Incoming(Audio)", null); + // Must remain CONNECTING, not downgraded to RINGING_INCOMING + assertEquals(CallInfo.State.CONNECTING, state.state); + } + + @Test + void stateTransition_outgoing() { + var state = makeCallState(1L, CallInfo.State.IDLE); + applyStateTransition(state, "Outgoing(Audio)", null); + assertEquals(CallInfo.State.RINGING_OUTGOING, state.state); + } + + @Test + void stateTransition_connected() { + var state = makeCallState(1L, CallInfo.State.CONNECTING); + applyStateTransition(state, "Connected", null); + assertEquals(CallInfo.State.CONNECTED, state.state); + } + + @Test + void stateTransition_connectingMapsToReconnecting() { + // "Connecting" from RingRTC means ICE reconnection, not initial connect + var state = makeCallState(1L, CallInfo.State.CONNECTED); + applyStateTransition(state, "Connecting", null); + assertEquals(CallInfo.State.RECONNECTING, state.state); + } + + @Test + void stateTransition_ringingDoesNotChangeState() { + var state = makeCallState(1L, CallInfo.State.RINGING_INCOMING); + applyStateTransition(state, "Ringing", null); + // "Ringing" triggers sendAcceptIfReady but doesn't change state + assertEquals(CallInfo.State.RINGING_INCOMING, state.state); + } + + @Test + void stateTransition_ringSetsAcceptPendingFalseWhenReady() { + var state = makeCallState(1L, CallInfo.State.RINGING_INCOMING); + state.acceptPending = true; + // No controlWriter set, so accept won't actually send but acceptPending stays true + // This documents the behavior: without a controlWriter, deferred accept stays pending + applyStateTransition(state, "Ringing", null); + assertTrue(state.acceptPending, "acceptPending should remain true when controlWriter is null"); + } + + @Test + void stateTransition_concludedIsNoop() { + var state = makeCallState(1L, CallInfo.State.CONNECTED); + applyStateTransition(state, "Concluded", null); + // State should NOT change + assertEquals(CallInfo.State.CONNECTED, state.state); + } + + @Test + void stateTransition_endedSetsEnded() { + var state = makeCallState(1L, CallInfo.State.CONNECTED); + applyStateTransition(state, "Ended", "Timeout"); + // endCall would set ENDED (we simulate that since endCall is instance method) + assertEquals(CallInfo.State.ENDED, state.state); + } + + @Test + void stateTransition_rejectedSetsEnded() { + var state = makeCallState(1L, CallInfo.State.RINGING_INCOMING); + applyStateTransition(state, "Rejected", "BusyOnAnotherDevice"); + assertEquals(CallInfo.State.ENDED, state.state); + } + + @Test + void stateTransition_endedWithNullReasonUsesStateName() { + var state = makeCallState(1L, CallInfo.State.CONNECTED); + // When reason is null, endCall should be called with state name lowercased + // We verify state becomes ENDED (the reason defaulting logic is in handleStateChange) + applyStateTransition(state, "Ended", null); + assertEquals(CallInfo.State.ENDED, state.state); + } + + @Test + void stateTransition_unknownStateIsNoop() { + var state = makeCallState(1L, CallInfo.State.CONNECTED); + applyStateTransition(state, "SomeUnknownState", null); + // No matching branch, state unchanged + assertEquals(CallInfo.State.CONNECTED, state.state); + } + + // ======================================================================== + // endCall guard condition tests + // + // endCall sends a Signal protocol hangup UNLESS the reason indicates the + // remote side already knows (remote_hangup, rejected, remote_busy, ringrtc_hangup). + // We test this logic directly. + // ======================================================================== + + @ParameterizedTest + @ValueSource(strings = {"remote_hangup", "rejected", "remote_busy", "ringrtc_hangup"}) + void endCallGuard_remoteCausesSkipHangup(String reason) { + // These reasons should NOT trigger sending a hangup to the remote + assertTrue(shouldSkipRemoteHangup(reason)); + } + + @ParameterizedTest + @ValueSource(strings = {"local_hangup", "ring_timeout", "tunnel_exit", "tunnel_error", "shutdown"}) + void endCallGuard_localCausesSendHangup(String reason) { + // These reasons SHOULD trigger sending a hangup to the remote + assertTrue(shouldSendRemoteHangup(reason)); + } + + // ======================================================================== + // CallState.toCallInfo tests + // ======================================================================== + + @Test + void callState_toCallInfo() { + var state = makeCallState(42L, CallInfo.State.CONNECTED); + state.inputDeviceName = "test_input"; + state.outputDeviceName = "test_output"; + + var info = state.toCallInfo(); + + assertEquals(42L, info.callId()); + assertEquals(CallInfo.State.CONNECTED, info.state()); + assertEquals("+15551234567", info.recipient().number().orElse(null)); + assertTrue(info.isOutgoing()); + assertEquals("test_input", info.inputDeviceName()); + assertEquals("test_output", info.outputDeviceName()); + } + + @Test + void callState_toCallInfoNullDeviceNames() { + var state = makeCallState(1L, CallInfo.State.RINGING_INCOMING); + + var info = state.toCallInfo(); + + assertEquals(CallInfo.State.RINGING_INCOMING, info.state()); + assertEquals(null, info.inputDeviceName()); + assertEquals(null, info.outputDeviceName()); + } + + // ======================================================================== + // 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/signal-call-tunnel/Cargo.lock b/signal-call-tunnel/Cargo.lock new file mode 100644 index 00000000..43a705ee --- /dev/null +++ b/signal-call-tunnel/Cargo.lock @@ -0,0 +1,2858 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", + "zeroize", +] + +[[package]] +name = "aes-gcm-siv" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae0784134ba9375416d469ec31e7c5f9fa94405049cf08c5ce5b4698be673e0d" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "polyval", + "subtle", + "zeroize", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", + "zeroize", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.2.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link 0.2.1", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "const-str" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18f12cc9948ed9604230cdddc7c86e270f9401ccbe3c2e98a4378c5e7632212f" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-models" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "657f625ff361906f779745d08375ae3cc9fef87a35fba5f22874cf773010daf4" +dependencies = [ + "hax-lib", + "pastey", + "rand 0.9.2", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "cubeb" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99aeb3c93cdecdcad9a4d22cb4496ddee52fd4c83cdb5e41d5cce3fe5102026d" +dependencies = [ + "cubeb-core", +] + +[[package]] +name = "cubeb-core" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "740c14febbf94c5321f50f4fd28a8fae7db38fd8e321ee497083e841c9f821b3" +dependencies = [ + "bitflags 1.3.2", + "cc", + "cubeb-sys", +] + +[[package]] +name = "cubeb-sys" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a361f8e991dbe3f01dac3e10713e19defe618beaf0844ed005272c2075fef547" +dependencies = [ + "cmake", + "pkg-config", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "git+https://github.com/signalapp/curve25519-dalek?tag=signal-curve25519-4.1.3#7c6d34756355a3566a704da84dce7b1c039a6572" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "serde", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "git+https://github.com/signalapp/curve25519-dalek?tag=signal-curve25519-4.1.3#7c6d34756355a3566a704da84dce7b1c039a6572" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "deranged" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc3dc5ad92c2e2d1c193bbbbdf2ea477cb81331de4f3103f267ca18368b988c4" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "derive-where" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef941ded77d15ca19b40374869ac6000af1c9f2a4c0f3d4c70926287e6364a8f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "env_filter" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", + "zeroize", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "hax-lib" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "543f93241d32b3f00569201bfce9d7a93c92c6421b23c77864ac929dc947b9fc" +dependencies = [ + "hax-lib-macros", + "num-bigint", + "num-traits", +] + +[[package]] +name = "hax-lib-macros" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8755751e760b11021765bb04cb4a6c4e24742688d9f3aa14c2079638f537b0f" +dependencies = [ + "hax-lib-macros-types", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "hax-lib-macros-types" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f177c9ae8ea456e2f71ff3c1ea47bf4464f772a05133fcbba56cd5ba169035a2" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "serde_json", + "uuid", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +dependencies = [ + "serde", +] + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "hpke-rs" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fcd4b22e7fc3318a1674085f943a35794023ecfe8b24a1691d1d1e016f869c8" +dependencies = [ + "hpke-rs-crypto", + "libcrux-sha3", + "log", + "rand_core 0.9.5", + "zeroize", +] + +[[package]] +name = "hpke-rs-crypto" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dd92b7d7f0deaae59c152e01c01f5280ea92dfac82090e5c025879b32df9193" +dependencies = [ + "rand_core 0.9.5", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "jiff" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c867c356cc096b33f4981825ab281ecba3db0acefe60329f044c1789d94c6543" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7946b4325269738f270bb55b3c19ab5c5040525f83fd625259422a9d25d9be5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.182" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" + +[[package]] +name = "libcrux-intrinsics" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa4779454e853d1de200cd12f19a8185aac47d99a5ec404cea3295c943d48f1" +dependencies = [ + "core-models", + "hax-lib", +] + +[[package]] +name = "libcrux-platform" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9e21d7ed31a92ac539bd69a8c970b183ee883872d2d19ce27036e24cb8ecc4" +dependencies = [ + "libc", +] + +[[package]] +name = "libcrux-secrets" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ce650f3041b44ba40d4263852347d007cd2cd9d1cc856a6f6c8b2e10c3fd40b" +dependencies = [ + "hax-lib", +] + +[[package]] +name = "libcrux-sha3" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3dabce2795479bd7294f853f7966a678cadf7a26d3d29f61cf15f5123e7ba4f" +dependencies = [ + "hax-lib", + "libcrux-intrinsics", + "libcrux-platform", + "libcrux-traits", +] + +[[package]] +name = "libcrux-traits" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "695ff2fb97627e4d57315a2fdfbfe50df1c80c6ef7d91ba34216169bd6f41c00" +dependencies = [ + "libcrux-secrets", + "rand 0.9.2", +] + +[[package]] +name = "libsignal-account-keys" +version = "0.1.0" +source = "git+https://github.com/signalapp/libsignal?tag=v0.87.1#f08390b0e2f67d5faf47bb9d1a3db191314db93c" +dependencies = [ + "argon2", + "derive_more", + "displaydoc", + "hkdf", + "libsignal-core", + "partial-default", + "protobuf 3.7.2", + "protobuf-codegen", + "rand 0.9.2", + "rand_core 0.9.5", + "serde", + "sha2", + "signal-crypto", + "static_assertions", + "thiserror 2.0.18", + "zerocopy", +] + +[[package]] +name = "libsignal-core" +version = "0.1.0" +source = "git+https://github.com/signalapp/libsignal?tag=v0.87.1#f08390b0e2f67d5faf47bb9d1a3db191314db93c" +dependencies = [ + "curve25519-dalek", + "derive_more", + "displaydoc", + "log", + "rand 0.9.2", + "sha2", + "subtle", + "thiserror 2.0.18", + "uuid", + "x25519-dalek", + "zerocopy", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mrp" +version = "2.65.1" +dependencies = [ + "anyhow", + "log", + "thiserror 2.0.18", +] + +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "partial-default" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "124dc3c21ffb6fb3a0562d129929a8a54998766ef7adc1ba09ddc467d092c14b" +dependencies = [ + "partial-default-derive", +] + +[[package]] +name = "partial-default-derive" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7459127d7a18cb202d418e4b7df1103ffd6d82a106e9b2091c250624c2ace70d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "pastey" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b867cad97c0791bbd3aaa6472142568c6c9e8f71937e98379f584cfb0cf35bec" + +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap 2.13.0", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "poksho" +version = "0.7.0" +source = "git+https://github.com/signalapp/libsignal?tag=v0.87.1#f08390b0e2f67d5faf47bb9d1a3db191314db93c" +dependencies = [ + "curve25519-dalek", + "hmac", + "sha2", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9db96d7fa8782dd8c15ce32ffe8680bbd1e978a43bf51a34d39483540495f5" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" +dependencies = [ + "heck", + "itertools", + "log", + "multimap", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "pulldown-cmark", + "pulldown-cmark-to-cmark", + "regex", + "syn", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "prost-types" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" +dependencies = [ + "prost", +] + +[[package]] +name = "protobuf" +version = "2.65.1" +dependencies = [ + "prost-build", + "tonic-prost-build", +] + +[[package]] +name = "protobuf" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d65a1d4ddae7d8b5de68153b48f6aa3bba8cb002b243dbdbc55a5afbc98f99f4" +dependencies = [ + "once_cell", + "protobuf-support", + "thiserror 1.0.69", +] + +[[package]] +name = "protobuf-codegen" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d3976825c0014bbd2f3b34f0001876604fe87e0c86cd8fa54251530f1544ace" +dependencies = [ + "anyhow", + "once_cell", + "protobuf 3.7.2", + "protobuf-parse", + "regex", + "tempfile", + "thiserror 1.0.69", +] + +[[package]] +name = "protobuf-parse" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4aeaa1f2460f1d348eeaeed86aea999ce98c1bded6f089ff8514c9d9dbdc973" +dependencies = [ + "anyhow", + "indexmap 2.13.0", + "log", + "protobuf 3.7.2", + "protobuf-support", + "tempfile", + "thiserror 1.0.69", + "which", +] + +[[package]] +name = "protobuf-support" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e36c2f31e0a47f9280fb347ef5e461ffcd2c52dd520d8e216b52f93b0b0d7d6" +dependencies = [ + "thiserror 1.0.69", +] + +[[package]] +name = "pulldown-cmark" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e8bbe1a966bd2f362681a44f6edce3c2310ac21e4d5067a6e7ec396297a6ea0" +dependencies = [ + "bitflags 2.11.0", + "memchr", + "unicase", +] + +[[package]] +name = "pulldown-cmark-to-cmark" +version = "22.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50793def1b900256624a709439404384204a5dc3a6ec580281bfaac35e882e90" +dependencies = [ + "pulldown-cmark", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-aot" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "regex-automata", + "syn", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" + +[[package]] +name = "ringrtc" +version = "2.65.1" +dependencies = [ + "aes", + "aes-gcm-siv", + "anyhow", + "base64", + "bincode", + "bytes", + "ctr", + "cubeb", + "cubeb-core", + "hex", + "hkdf", + "hmac", + "jni", + "lazy_static", + "libc", + "log", + "mrp", + "num_enum", + "prost", + "protobuf 2.65.1", + "rand 0.8.5", + "regex", + "regex-aot", + "regex-automata", + "serde", + "serde_json", + "serde_with", + "sha2", + "sketches-ddsketch", + "static_assertions", + "strum", + "strum_macros", + "subtle", + "sysinfo", + "thiserror 2.0.18", + "windows 0.62.2", + "x25519-dalek", + "zkgroup", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys 0.11.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_with" +version = "3.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fa237f2807440d238e0364a218270b98f767a00d3dada77b1c53ae88940e2e7" +dependencies = [ + "base64", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.13.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52a8e3ca0ca629121f70ab50f95249e5a6f925cc0f6ffe8256c45b728875706c" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-call-tunnel" +version = "0.1.0" +dependencies = [ + "anyhow", + "base64", + "env_logger", + "log", + "ringrtc", + "serde", + "serde_json", + "subtle", +] + +[[package]] +name = "signal-crypto" +version = "0.1.0" +source = "git+https://github.com/signalapp/libsignal?tag=v0.87.1#f08390b0e2f67d5faf47bb9d1a3db191314db93c" +dependencies = [ + "aes", + "cbc", + "ctr", + "derive_more", + "displaydoc", + "ghash", + "hkdf", + "hmac", + "hpke-rs", + "hpke-rs-crypto", + "libsignal-core", + "rand_chacha 0.9.0", + "rand_core 0.9.5", + "sha1", + "sha2", + "subtle", + "thiserror 2.0.18", +] + +[[package]] +name = "sketches-ddsketch" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1e9a774a6c28142ac54bb25d25562e6bcf957493a184f15ad4eebccb23e410a" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3df424c70518695237746f84cede799c9c58fcb37450d7b23716568cc8bc69cb" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sysinfo" +version = "0.37.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16607d5caffd1c07ce073528f9ed972d88db15dd44023fa57142963be3feb11f" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows 0.61.3", +] + +[[package]] +name = "tempfile" +version = "3.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" +dependencies = [ + "fastrand", + "getrandom 0.4.1", + "once_cell", + "rustix 1.1.3", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.23.10+spec-1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +dependencies = [ + "indexmap 2.13.0", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.9+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" +dependencies = [ + "winnow", +] + +[[package]] +name = "tonic-build" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6d8958ed3be404120ca43ffa0fb1e1fc7be214e96c8d33bd43a131b6eebc9e" +dependencies = [ + "prettyplease", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tonic-prost-build" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65873ace111e90344b8973e94a1fc817c924473affff24629281f90daed1cd2e" +dependencies = [ + "prettyplease", + "proc-macro2", + "prost-build", + "prost-types", + "quote", + "syn", + "tempfile", + "tonic-build", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" +dependencies = [ + "getrandom 0.4.1", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.13.0", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.15.5", + "indexmap 2.13.0", + "semver", +] + +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.44", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections 0.2.0", + "windows-core 0.61.2", + "windows-future 0.2.1", + "windows-link 0.1.3", + "windows-numerics 0.2.0", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections 0.3.2", + "windows-core 0.62.2", + "windows-future 0.3.2", + "windows-numerics 0.3.1", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading 0.1.0", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading 0.2.1", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap 2.13.0", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.0", + "indexmap 2.13.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.13.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "serde", + "zeroize", +] + +[[package]] +name = "zerocopy" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zkcredential" +version = "0.1.0" +source = "git+https://github.com/signalapp/libsignal?tag=v0.87.1#f08390b0e2f67d5faf47bb9d1a3db191314db93c" +dependencies = [ + "cfg-if", + "curve25519-dalek", + "derive-where", + "displaydoc", + "partial-default", + "poksho", + "rayon", + "serde", + "sha2", + "subtle", + "thiserror 2.0.18", +] + +[[package]] +name = "zkgroup" +version = "0.9.0" +source = "git+https://github.com/signalapp/libsignal?tag=v0.87.1#f08390b0e2f67d5faf47bb9d1a3db191314db93c" +dependencies = [ + "aes", + "aes-gcm-siv", + "bincode", + "const-str", + "curve25519-dalek", + "derive-where", + "derive_more", + "displaydoc", + "hex", + "hkdf", + "libsignal-account-keys", + "libsignal-core", + "partial-default", + "poksho", + "rand 0.9.2", + "rayon", + "serde", + "sha2", + "static_assertions", + "subtle", + "thiserror 2.0.18", + "uuid", + "zkcredential", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/signal-call-tunnel/Cargo.toml b/signal-call-tunnel/Cargo.toml new file mode 100644 index 00000000..c63dabcf --- /dev/null +++ b/signal-call-tunnel/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "signal-call-tunnel" +version = "0.1.0" +edition = "2024" + +[dependencies] +ringrtc = { path = "../third-party/ringrtc/src/rust", features = ["prebuilt_webrtc", "virtual_audio"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +log = "0.4" +env_logger = "0.11" +base64 = "0.22" +anyhow = "1" + +subtle = "2" + +[patch.crates-io] +# Use Signal's fork of curve25519-dalek for zkgroup compatibility (matches ringrtc workspace). +curve25519-dalek = { git = 'https://github.com/signalapp/curve25519-dalek', tag = 'signal-curve25519-4.1.3' } diff --git a/signal-call-tunnel/build.rs b/signal-call-tunnel/build.rs new file mode 100644 index 00000000..f153efd1 --- /dev/null +++ b/signal-call-tunnel/build.rs @@ -0,0 +1,66 @@ +use std::path::Path; +use std::process::Command; + +fn main() { + // Apply the VPIO-disable patch to ringrtc if it hasn't been applied yet. + // This is a build-time patch: cargo re-runs build.rs when the patch file + // or the target source file changes. + let ringrtc_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../third-party/ringrtc"); + let patch_file = Path::new(env!("CARGO_MANIFEST_DIR")).join("patches/ringrtc-disable-vpio.patch"); + + let adm_file = ringrtc_dir.join("src/rust/src/webrtc/audio_device_module.rs"); + + println!("cargo::rerun-if-changed={}", patch_file.display()); + println!("cargo::rerun-if-changed={}", adm_file.display()); + + if !patch_file.exists() { + return; + } + + // Check if the patch is already applied by looking for the marker function. + if let Ok(content) = std::fs::read_to_string(&adm_file) { + if content.contains("RINGRTC_NO_VOICE_PROCESSING") { + // Already applied + return; + } + } + + // Canonicalize paths so git apply works regardless of how cargo sets cwd. + // Run from within the ringrtc directory to avoid parent-repo submodule issues. + let ringrtc_canonical = match ringrtc_dir.canonicalize() { + Ok(p) => p, + Err(e) => { + eprintln!("cargo:warning=Cannot resolve ringrtc path: {e}"); + return; + } + }; + let patch_canonical = match patch_file.canonicalize() { + Ok(p) => p, + Err(e) => { + eprintln!("cargo:warning=Cannot resolve patch path: {e}"); + return; + } + }; + + let status = Command::new("git") + .arg("apply") + .arg(&patch_canonical) + .current_dir(&ringrtc_canonical) + .status(); + + match status { + Ok(s) if s.success() => { + eprintln!("cargo:warning=Applied ringrtc VPIO-disable patch for virtual audio support"); + } + Ok(s) => { + eprintln!( + "cargo:warning=Failed to apply ringrtc patch (exit {}); \ + VPIO may hang with virtual audio devices", + s + ); + } + Err(e) => { + eprintln!("cargo:warning=Could not run git apply: {e}"); + } + } +} diff --git a/signal-call-tunnel/patches/ringrtc-disable-vpio.patch b/signal-call-tunnel/patches/ringrtc-disable-vpio.patch new file mode 100644 index 00000000..46a46cee --- /dev/null +++ b/signal-call-tunnel/patches/ringrtc-disable-vpio.patch @@ -0,0 +1,41 @@ +diff --git a/src/rust/src/webrtc/audio_device_module.rs b/src/rust/src/webrtc/audio_device_module.rs +index 5e3a6ecf..a9b76c12 100644 +--- a/src/rust/src/webrtc/audio_device_module.rs ++++ b/src/rust/src/webrtc/audio_device_module.rs +@@ -265,6 +265,18 @@ impl Worker { + } + } + ++ /// Returns the stream preferences for cubeb audio streams. ++ /// ++ /// When `RINGRTC_NO_VOICE_PROCESSING` is set, returns `StreamPrefs::NONE` ++ /// to skip macOS VoiceProcessingIO which hangs with virtual audio drivers. ++ fn stream_prefs() -> StreamPrefs { ++ if std::env::var("RINGRTC_NO_VOICE_PROCESSING").is_ok() { ++ StreamPrefs::NONE ++ } else { ++ StreamPrefs::VOICE ++ } ++ } ++ + fn init_playout(&mut self) -> anyhow::Result<()> { + let out_device = if let Some(device) = self.playout_device { + device +@@ -276,7 +288,7 @@ impl Worker { + .rate(SAMPLE_FREQUENCY) + .channels(2) + .layout(cubeb::ChannelLayout::STEREO) +- .prefs(StreamPrefs::VOICE) ++ .prefs(Self::stream_prefs()) + .take(); + let mut builder = cubeb::StreamBuilder::::new(); + let transport = Arc::clone(&self.audio_transport); +@@ -411,7 +423,7 @@ impl Worker { + .rate(SAMPLE_FREQUENCY) + .channels(NUM_CHANNELS) + .layout(cubeb::ChannelLayout::MONO) +- .prefs(StreamPrefs::VOICE) ++ .prefs(Self::stream_prefs()) + .take(); + + let mut builder = cubeb::StreamBuilder::::new(); diff --git a/signal-call-tunnel/src/config.rs b/signal-call-tunnel/src/config.rs new file mode 100644 index 00000000..105eacda --- /dev/null +++ b/signal-call-tunnel/src/config.rs @@ -0,0 +1,92 @@ +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +pub struct Config { + pub call_id: u64, + pub is_outgoing: bool, + pub control_socket_path: String, + pub control_token: String, + pub local_device_id: u32, + pub input_device_name: Option, + pub output_device_name: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deserialize_valid_config() { + let json = r#"{ + "call_id": 12345678, + "is_outgoing": true, + "control_socket_path": "/tmp/sc-abc/ctrl.sock", + "control_token": "dG9rZW4=", + "local_device_id": 1 + }"#; + let config: Config = serde_json::from_str(json).unwrap(); + assert_eq!(config.call_id, 12345678); + assert!(config.is_outgoing); + assert_eq!(config.control_socket_path, "/tmp/sc-abc/ctrl.sock"); + assert_eq!(config.control_token, "dG9rZW4="); + assert_eq!(config.local_device_id, 1); + assert!(config.input_device_name.is_none()); + assert!(config.output_device_name.is_none()); + } + + #[test] + fn deserialize_with_device_names() { + let json = r#"{ + "call_id": 99, + "is_outgoing": false, + "control_socket_path": "/tmp/ctrl.sock", + "control_token": "tok", + "local_device_id": 2, + "input_device_name": "signal_input", + "output_device_name": "signal_output" + }"#; + let config: Config = serde_json::from_str(json).unwrap(); + assert_eq!(config.call_id, 99); + assert!(!config.is_outgoing); + assert_eq!(config.local_device_id, 2); + assert_eq!(config.input_device_name.as_deref(), Some("signal_input")); + assert_eq!(config.output_device_name.as_deref(), Some("signal_output")); + } + + #[test] + fn deserialize_missing_field_fails() { + let json = r#"{ + "call_id": 1, + "is_outgoing": true + }"#; + let result: Result = serde_json::from_str(json); + assert!(result.is_err()); + } + + #[test] + fn deserialize_wrong_type_fails() { + let json = r#"{ + "call_id": "not_a_number", + "is_outgoing": true, + "control_socket_path": "/tmp/ctrl.sock", + "control_token": "tok", + "local_device_id": 1 + }"#; + let result: Result = serde_json::from_str(json); + assert!(result.is_err()); + } + + #[test] + fn deserialize_extra_fields_ok() { + let json = r#"{ + "call_id": 1, + "is_outgoing": true, + "control_socket_path": "/tmp/ctrl.sock", + "control_token": "tok", + "local_device_id": 1, + "extra_field": "ignored" + }"#; + let config: Config = serde_json::from_str(json).unwrap(); + assert_eq!(config.call_id, 1); + } +} diff --git a/signal-call-tunnel/src/control.rs b/signal-call-tunnel/src/control.rs new file mode 100644 index 00000000..0ab1e792 --- /dev/null +++ b/signal-call-tunnel/src/control.rs @@ -0,0 +1,521 @@ +use std::io::{BufRead, BufReader, Write}; +use std::os::unix::net::UnixListener; +use std::sync::mpsc; + +use anyhow::{Context, Result, bail}; +use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64; +use log::{error, info, warn}; +use serde_json::Value; +use subtle::ConstantTimeEq; + +use crate::platform::PlatformEvent; + +/// Messages parsed from the parent process. +#[derive(Debug)] +pub enum ControlMessage { + Auth { token: String }, + CreateOutgoingCall { call_id: u64, peer_id: String }, + Proceed { call_id: u64, ice_servers: Vec, hide_ip: bool }, + ReceivedOffer { + call_id: u64, + peer_id: String, + sender_device_id: u32, + opaque: Vec, + age_ms: u64, + sender_identity_key: Vec, + receiver_identity_key: Vec, + }, + ReceivedAnswer { + opaque: Vec, + sender_device_id: u32, + sender_identity_key: Vec, + receiver_identity_key: Vec, + }, + ReceivedIce { candidates: Vec> }, + Accept, + Hangup, +} + +#[derive(Debug, Clone)] +pub struct IceServerConfig { + pub username: String, + pub password: String, + pub urls: Vec, +} + +/// Parse a JSON line into a ControlMessage. +pub fn parse_message(line: &str) -> Result { + let v: Value = serde_json::from_str(line).context("invalid JSON")?; + let msg_type = v["type"].as_str().unwrap_or(""); + + match msg_type { + "auth" => Ok(ControlMessage::Auth { + token: v["token"].as_str().unwrap_or("").to_string(), + }), + "createOutgoingCall" => Ok(ControlMessage::CreateOutgoingCall { + call_id: v["callId"].as_u64().unwrap_or(0), + peer_id: v["peerId"].as_str().unwrap_or("").to_string(), + }), + "proceed" => { + let ice_servers = if let Some(servers) = v["iceServers"].as_array() { + servers + .iter() + .map(|s| IceServerConfig { + username: s["username"].as_str().unwrap_or("").to_string(), + password: s["password"].as_str().unwrap_or("").to_string(), + urls: s["urls"] + .as_array() + .map(|urls| { + urls.iter() + .filter_map(|u| u.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(), + }) + .collect() + } else { + Vec::new() + }; + Ok(ControlMessage::Proceed { + call_id: v["callId"].as_u64().unwrap_or(0), + ice_servers, + hide_ip: v["hideIp"].as_bool().unwrap_or(false), + }) + } + "receivedOffer" => Ok(ControlMessage::ReceivedOffer { + call_id: v["callId"].as_u64().unwrap_or(0), + peer_id: v["peerId"].as_str().unwrap_or("remote").to_string(), + sender_device_id: v["senderDeviceId"].as_u64().unwrap_or(1) as u32, + opaque: BASE64 + .decode(v["opaque"].as_str().unwrap_or("")) + .unwrap_or_default(), + age_ms: v["age"].as_u64().unwrap_or(0), + sender_identity_key: BASE64 + .decode(v["senderIdentityKey"].as_str().unwrap_or("")) + .unwrap_or_default(), + receiver_identity_key: BASE64 + .decode(v["receiverIdentityKey"].as_str().unwrap_or("")) + .unwrap_or_default(), + }), + "receivedAnswer" => Ok(ControlMessage::ReceivedAnswer { + opaque: BASE64 + .decode(v["opaque"].as_str().unwrap_or("")) + .unwrap_or_default(), + sender_device_id: v["senderDeviceId"].as_u64().unwrap_or(1) as u32, + sender_identity_key: BASE64 + .decode(v["senderIdentityKey"].as_str().unwrap_or("")) + .unwrap_or_default(), + receiver_identity_key: BASE64 + .decode(v["receiverIdentityKey"].as_str().unwrap_or("")) + .unwrap_or_default(), + }), + "receivedIce" => { + let candidates = if let Some(arr) = v["candidates"].as_array() { + arr.iter() + .filter_map(|c| { + let b64 = c.as_str()?; + BASE64.decode(b64).ok() + }) + .collect() + } else { + Vec::new() + }; + Ok(ControlMessage::ReceivedIce { candidates }) + } + "accept" => Ok(ControlMessage::Accept), + "hangup" => Ok(ControlMessage::Hangup), + _ => bail!("unknown message type: {}", msg_type), + } +} + +/// Validate the auth token using constant-time comparison. +pub fn validate_token(received: &str, expected: &str) -> bool { + let received_bytes = received.as_bytes(); + let expected_bytes = expected.as_bytes(); + if received_bytes.len() != expected_bytes.len() { + return false; + } + received_bytes.ct_eq(expected_bytes).into() +} + +/// Runs the control channel server. Binds a Unix socket, accepts one connection, +/// validates auth, then reads messages and sends events. +/// +/// Returns a channel receiver for incoming control messages and a writer for +/// sending events to the parent. +pub struct ControlChannel { + pub msg_receiver: mpsc::Receiver, + pub writer: ControlWriter, +} + +#[derive(Clone)] +pub struct ControlWriter { + sender: mpsc::Sender, +} + +impl ControlWriter { + pub fn send_line(&self, line: &str) { + if let Err(e) = self.sender.send(line.to_string()) { + error!("Failed to send to control writer: {}", e); + } + } + + pub fn send_event(&self, event: &PlatformEvent) { + self.send_line(&event.to_json()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // --- parse_message tests --- + + #[test] + fn parse_auth() { + let msg = parse_message(r#"{"type":"auth","token":"secret123"}"#).unwrap(); + match msg { + ControlMessage::Auth { token } => assert_eq!(token, "secret123"), + _ => panic!("expected Auth, got {:?}", msg), + } + } + + #[test] + fn parse_create_outgoing_call() { + let msg = parse_message( + r#"{"type":"createOutgoingCall","callId":42,"peerId":"abc-def"}"#, + ) + .unwrap(); + match msg { + ControlMessage::CreateOutgoingCall { call_id, peer_id } => { + assert_eq!(call_id, 42); + assert_eq!(peer_id, "abc-def"); + } + _ => panic!("expected CreateOutgoingCall, got {:?}", msg), + } + } + + #[test] + fn parse_proceed_with_ice_servers() { + let json = r#"{ + "type": "proceed", + "callId": 99, + "hideIp": true, + "iceServers": [ + { + "username": "user1", + "password": "pass1", + "urls": ["turn:example.com:3478", "stun:example.com:3478"] + }, + { + "username": "user2", + "password": "pass2", + "urls": ["turn:other.com:443"] + } + ] + }"#; + let msg = parse_message(json).unwrap(); + match msg { + ControlMessage::Proceed { + call_id, + ice_servers, + hide_ip, + } => { + assert_eq!(call_id, 99); + assert!(hide_ip); + assert_eq!(ice_servers.len(), 2); + assert_eq!(ice_servers[0].username, "user1"); + assert_eq!(ice_servers[0].password, "pass1"); + assert_eq!(ice_servers[0].urls.len(), 2); + assert_eq!(ice_servers[0].urls[0], "turn:example.com:3478"); + assert_eq!(ice_servers[1].username, "user2"); + assert_eq!(ice_servers[1].urls.len(), 1); + } + _ => panic!("expected Proceed, got {:?}", msg), + } + } + + #[test] + fn parse_proceed_no_ice_servers() { + let msg = parse_message(r#"{"type":"proceed","callId":1}"#).unwrap(); + match msg { + ControlMessage::Proceed { + call_id, + ice_servers, + hide_ip, + } => { + assert_eq!(call_id, 1); + assert!(!hide_ip); + assert!(ice_servers.is_empty()); + } + _ => panic!("expected Proceed, got {:?}", msg), + } + } + + #[test] + fn parse_received_offer() { + // "aGVsbG8=" is base64 for "hello" + let json = r#"{ + "type": "receivedOffer", + "callId": 100, + "peerId": "0b949a17-dc53-41b1-9ebc-dea99cb93920", + "senderDeviceId": 3, + "opaque": "aGVsbG8=", + "age": 500, + "senderIdentityKey": "AQID", + "receiverIdentityKey": "BAUG" + }"#; + let msg = parse_message(json).unwrap(); + match msg { + ControlMessage::ReceivedOffer { + call_id, + peer_id, + sender_device_id, + opaque, + age_ms, + sender_identity_key, + receiver_identity_key, + } => { + assert_eq!(call_id, 100); + assert_eq!(peer_id, "0b949a17-dc53-41b1-9ebc-dea99cb93920"); + assert_eq!(sender_device_id, 3); + assert_eq!(opaque, b"hello"); + assert_eq!(age_ms, 500); + assert_eq!(sender_identity_key, vec![1, 2, 3]); + assert_eq!(receiver_identity_key, vec![4, 5, 6]); + } + _ => panic!("expected ReceivedOffer, got {:?}", msg), + } + } + + #[test] + fn parse_received_answer() { + let json = r#"{ + "type": "receivedAnswer", + "opaque": "AQID", + "senderDeviceId": 2, + "senderIdentityKey": "BAUG", + "receiverIdentityKey": "BwgJ" + }"#; + let msg = parse_message(json).unwrap(); + match msg { + ControlMessage::ReceivedAnswer { + opaque, + sender_device_id, + sender_identity_key, + receiver_identity_key, + } => { + assert_eq!(opaque, vec![1, 2, 3]); + assert_eq!(sender_device_id, 2); + assert_eq!(sender_identity_key, vec![4, 5, 6]); + assert_eq!(receiver_identity_key, vec![7, 8, 9]); + } + _ => panic!("expected ReceivedAnswer, got {:?}", msg), + } + } + + #[test] + fn parse_received_ice() { + // Two base64-encoded candidates + let json = r#"{"type":"receivedIce","candidates":["AQID","BAUG"]}"#; + let msg = parse_message(json).unwrap(); + match msg { + ControlMessage::ReceivedIce { candidates } => { + assert_eq!(candidates.len(), 2); + assert_eq!(candidates[0], vec![1, 2, 3]); + assert_eq!(candidates[1], vec![4, 5, 6]); + } + _ => panic!("expected ReceivedIce, got {:?}", msg), + } + } + + #[test] + fn parse_received_ice_empty() { + let msg = parse_message(r#"{"type":"receivedIce","candidates":[]}"#).unwrap(); + match msg { + ControlMessage::ReceivedIce { candidates } => { + assert!(candidates.is_empty()); + } + _ => panic!("expected ReceivedIce, got {:?}", msg), + } + } + + #[test] + fn parse_accept() { + let msg = parse_message(r#"{"type":"accept"}"#).unwrap(); + assert!(matches!(msg, ControlMessage::Accept)); + } + + #[test] + fn parse_hangup() { + let msg = parse_message(r#"{"type":"hangup"}"#).unwrap(); + assert!(matches!(msg, ControlMessage::Hangup)); + } + + #[test] + fn parse_unknown_type_fails() { + let result = parse_message(r#"{"type":"foobar"}"#); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("unknown message type")); + } + + #[test] + fn parse_invalid_json_fails() { + let result = parse_message("not json at all"); + assert!(result.is_err()); + } + + #[test] + fn parse_missing_type_fails() { + let result = parse_message(r#"{"callId":1}"#); + assert!(result.is_err()); + } + + // --- validate_token tests --- + + #[test] + fn validate_token_matching() { + assert!(validate_token("my-secret-token", "my-secret-token")); + } + + #[test] + fn validate_token_mismatch() { + assert!(!validate_token("wrong-token", "my-secret-token")); + } + + #[test] + fn validate_token_different_lengths() { + assert!(!validate_token("short", "a-much-longer-token")); + } + + #[test] + fn validate_token_empty() { + assert!(validate_token("", "")); + } + + #[test] + fn validate_token_one_empty() { + assert!(!validate_token("", "notempty")); + assert!(!validate_token("notempty", "")); + } +} + +pub fn start_control_channel( + control_socket_path: &str, + expected_token: &str, + input_device_name: &str, + output_device_name: &str, +) -> Result { + // Remove stale socket file + let _ = std::fs::remove_file(control_socket_path); + + let listener = UnixListener::bind(control_socket_path) + .with_context(|| format!("failed to bind control socket at {}", control_socket_path))?; + info!("Control channel listening on {}", control_socket_path); + + let (msg_sender, msg_receiver) = mpsc::channel::(); + let (write_sender, write_receiver) = mpsc::channel::(); + let writer = ControlWriter { + sender: write_sender, + }; + + // Send ready message immediately (parent can connect after this) + let ready_msg = format!( + r#"{{"type":"ready","inputDeviceName":"{}","outputDeviceName":"{}"}}"#, + input_device_name, output_device_name + ); + + let expected_token = expected_token.to_string(); + + // Spawn reader thread + std::thread::spawn(move || { + // Accept one connection + let (stream, _) = match listener.accept() { + Ok(s) => s, + Err(e) => { + error!("Failed to accept control connection: {}", e); + return; + } + }; + info!("Control channel: parent connected"); + + let mut writer_stream = match stream.try_clone() { + Ok(s) => s, + Err(e) => { + error!("Failed to clone control stream: {}", e); + return; + } + }; + + // Spawn writer thread + std::thread::spawn(move || { + for line in write_receiver { + if let Err(e) = writeln!(writer_stream, "{}", line) { + error!("Failed to write to control channel: {}", e); + break; + } + if let Err(e) = writer_stream.flush() { + error!("Failed to flush control channel: {}", e); + break; + } + } + }); + + let reader = BufReader::new(stream); + let mut authenticated = false; + + for line in reader.lines() { + let line = match line { + Ok(l) => l, + Err(e) => { + info!("Control channel read ended: {}", e); + break; + } + }; + + if line.trim().is_empty() { + continue; + } + + let msg = match parse_message(&line) { + Ok(m) => m, + Err(e) => { + warn!("Failed to parse control message: {} (line: {})", e, line); + continue; + } + }; + + // First message must be auth + if !authenticated { + if let ControlMessage::Auth { ref token } = msg { + if validate_token(token, &expected_token) { + authenticated = true; + info!("Control channel: authenticated"); + continue; + } else { + error!("Control channel: auth failed"); + break; + } + } else { + error!("Control channel: first message must be auth"); + break; + } + } + + if let Err(e) = msg_sender.send(msg) { + info!("Control message receiver dropped: {}", e); + break; + } + } + }); + + // Send the ready message through the writer channel + // (it will be sent once the writer thread starts) + writer.send_line(&ready_msg); + + Ok(ControlChannel { + msg_receiver, + writer, + }) +} diff --git a/signal-call-tunnel/src/main.rs b/signal-call-tunnel/src/main.rs new file mode 100644 index 00000000..bc253f41 --- /dev/null +++ b/signal-call-tunnel/src/main.rs @@ -0,0 +1,410 @@ +mod config; +mod control; +mod platform; + +use std::io::Read; +use std::sync::mpsc; +use std::time::Duration; + +use anyhow::{Context, Result}; +use log::{debug, error, info}; + +use ringrtc::common::{CallConfig, CallId, CallMediaType, DataMode, DeviceId}; +use ringrtc::core::{call_manager::CallManager, signaling}; +use ringrtc::lite::http; +use ringrtc::native::{NativeCallContext, NativePlatform, PeerId}; +use ringrtc::virtual_audio::VirtualAudioDevicePair; +use ringrtc::webrtc::{ + media::{VideoFrame, VideoSink}, + peer_connection_factory::{AudioConfig, IceServer, PeerConnectionFactory}, +}; + +use crate::config::Config; +use crate::control::{ControlMessage, start_control_channel}; +use crate::platform::{ + PlatformEvent, TunnelGroupHandler, TunnelSignalingSender, TunnelStateHandler, +}; + +/// Dummy video sink that discards all frames. +#[derive(Debug)] +struct NullVideoSink; + +impl VideoSink for NullVideoSink { + fn on_video_frame(&self, _track_id: u32, _frame: VideoFrame) {} + fn box_clone(&self) -> Box { + Box::new(NullVideoSink) + } +} + +/// Dummy HTTP client for CallManager (no SFU needed for 1:1 calls). +#[derive(Clone)] +struct NullHttpClient; + +impl http::Delegate for NullHttpClient { + fn send_request(&self, _request_id: u32, _request: http::Request) { + // No-op -- no group call SFU requests + } +} + +fn main() -> Result<()> { + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")) + .format_timestamp_millis() + .init(); + + // Read config from stdin + let mut config_str = String::new(); + std::io::stdin() + .read_to_string(&mut config_str) + .context("failed to read config from stdin")?; + let config: Config = + serde_json::from_str(&config_str).context("failed to parse config JSON")?; + + info!( + "signal-call-tunnel starting: call_id={}, is_outgoing={}", + config.call_id, config.is_outgoing + ); + + // Create virtual audio devices (signal-call-tunnel owns their lifecycle). + // On macOS, BlackHole drivers must be pre-installed with matching names (requires + // root), so we default to fixed names. On Linux, PulseAudio virtual sinks are + // created dynamically, so per-call unique names avoid collisions. + let input_name = config.input_device_name.clone().unwrap_or_else(|| { + if cfg!(target_os = "macos") { + "signal_input".to_string() + } else { + format!("signal_input_{}", config.call_id) + } + }); + let output_name = config.output_device_name.clone().unwrap_or_else(|| { + if cfg!(target_os = "macos") { + "signal_output".to_string() + } else { + format!("signal_output_{}", config.call_id) + } + }); + + let virtual_audio = VirtualAudioDevicePair::new(&input_name, &output_name)?; + info!( + "Virtual audio devices: input={}, output={}", + virtual_audio.input_source(), + virtual_audio.output_sink() + ); + + // Channel for platform events (signaling + state changes) + let (event_sender, event_receiver) = mpsc::channel::(); + + // Start control channel + let control = start_control_channel( + &config.control_socket_path, + &config.control_token, + virtual_audio.input_source(), + virtual_audio.output_sink(), + )?; + + // Show WebRTC logs while debugging + #[cfg(debug_assertions)] + ringrtc::webrtc::logging::set_logger(log::LevelFilter::Debug); + + #[cfg(not(debug_assertions))] + ringrtc::webrtc::logging::set_logger(log::LevelFilter::Warn); + + // Disable macOS VPIO (VoiceProcessingIO) for cubeb audio streams. + // VPIO creates an aggregate device that hangs with BlackHole virtual audio + // drivers. Voice processing (AEC/AGC/NS) is unnecessary for virtual audio. + // Safety: called before any threads are spawned, single-threaded at this point. + unsafe { std::env::set_var("RINGRTC_NO_VOICE_PROCESSING", "1") }; + + let audio_config = AudioConfig::default(); + let mut pcf = PeerConnectionFactory::new(&audio_config, false, "", None)?; + + // Wait for cubeb to enumerate the virtual devices + loop { + std::thread::sleep(Duration::from_millis(100)); + if pcf + .get_audio_playout_devices() + .is_ok_and(|d| !d.is_empty()) + && pcf + .get_audio_recording_devices() + .is_ok_and(|d| !d.is_empty()) + { + break; + } + } + + // Select virtual devices by name. + // + // We can't use set_audio_*_device_by_id() because the ADM matches on the + // cubeb unique_id (e.g. "signal_input2ch_UID"), not the friendly name we + // know ("signal_input"). Instead, enumerate and find the index by name. + let input_name = virtual_audio.input_source(); + let recording_devices = pcf.get_audio_recording_devices()?; + let recording_index = recording_devices + .iter() + .position(|d| d.name == input_name) + .ok_or_else(|| anyhow::anyhow!("recording device '{}' not found", input_name))? + as u16; + pcf.set_audio_recording_device(recording_index)?; + info!("Selected recording device: index={}, name={}", recording_index, input_name); + + let output_name = virtual_audio.output_sink(); + let playout_devices = pcf.get_audio_playout_devices()?; + let playout_index = playout_devices + .iter() + .position(|d| d.name == output_name) + .ok_or_else(|| anyhow::anyhow!("playout device '{}' not found", output_name))? + as u16; + pcf.set_audio_playout_device(playout_index)?; + info!("Selected playout device: index={}, name={}", playout_index, output_name); + + // Create platform with our trait implementations + let signaling_sender = Box::new(TunnelSignalingSender { + event_sender: event_sender.clone(), + }); + let state_handler = Box::new(TunnelStateHandler { + event_sender: event_sender.clone(), + }); + let group_handler = Box::new(TunnelGroupHandler); + + let platform = NativePlatform::new( + pcf.clone(), + signaling_sender, + true, // should_assume_messages_sent + state_handler, + group_handler, + ); + + let http_client = http::DelegatingClient::new(NullHttpClient); + let mut call_manager = CallManager::new(platform, http_client)?; + + // Peer ID is set by the first createOutgoingCall or receivedOffer message. + let mut active_peer_id = PeerId::from("remote"); + let call_id = CallId::from(config.call_id); + let local_device_id = config.local_device_id as DeviceId; + + info!("CallManager initialized, entering event loop"); + + // Spawn a thread to forward platform events to control channel + let control_writer = control.writer.clone(); + std::thread::spawn(move || { + for event in event_receiver { + control_writer.send_event(&event); + } + }); + + // Main event loop: process control messages + loop { + let msg = match control.msg_receiver.recv_timeout(Duration::from_millis(100)) { + Ok(msg) => msg, + Err(mpsc::RecvTimeoutError::Timeout) => continue, + Err(mpsc::RecvTimeoutError::Disconnected) => { + info!("Control channel disconnected, exiting"); + break; + } + }; + + match msg { + ControlMessage::Auth { .. } => { + // Already handled by control channel + } + ControlMessage::CreateOutgoingCall { + call_id: cid, + peer_id: pid, + } => { + let call_id = CallId::from(cid); + let peer_id = PeerId::from(pid.as_str()); + active_peer_id = peer_id.clone(); + info!("Creating outgoing call: call_id={}, peer_id={}", cid, pid); + if let Err(e) = call_manager.create_outgoing_call( + peer_id, + call_id, + CallMediaType::Audio, + local_device_id, + ) { + error!("Failed to create outgoing call: {}", e); + control.writer.send_line(&format!( + r#"{{"type":"error","message":"failed to create outgoing call: {}"}}"#, + e + )); + } + } + ControlMessage::Proceed { + call_id: cid, + ice_servers, + hide_ip, + } => { + let call_id = CallId::from(cid); + info!("Proceeding with call: call_id={}", cid); + + let ice_server_list: Vec = ice_servers + .iter() + .map(|s| IceServer::new( + s.username.clone(), + s.password.clone(), + String::new(), + s.urls.clone(), + )) + .collect(); + + let outgoing_audio_track = match pcf.create_outgoing_audio_track() { + Ok(t) => t, + Err(e) => { + error!("Failed to create audio track: {}", e); + continue; + } + }; + let outgoing_video_source = match pcf.create_outgoing_video_source() { + Ok(s) => s, + Err(e) => { + error!("Failed to create video source: {}", e); + continue; + } + }; + let outgoing_video_track = + match pcf.create_outgoing_video_track(&outgoing_video_source) { + Ok(t) => t, + Err(e) => { + error!("Failed to create video track: {}", e); + continue; + } + }; + + let call_context = NativeCallContext::new( + hide_ip, + ice_server_list, + outgoing_audio_track, + outgoing_video_track, + Box::new(NullVideoSink), + ); + + let call_config = CallConfig { + data_mode: DataMode::Low, + ..Default::default() + }; + + if let Err(e) = + call_manager.proceed(call_id, call_context, call_config, None) + { + error!("Failed to proceed: {}", e); + control.writer.send_line(&format!( + r#"{{"type":"error","message":"failed to proceed: {}"}}"#, + e + )); + } + } + ControlMessage::ReceivedOffer { + call_id: cid, + peer_id: pid, + sender_device_id, + opaque, + age_ms, + sender_identity_key, + receiver_identity_key, + } => { + let call_id = CallId::from(cid); + let peer_id = PeerId::from(pid.as_str()); + active_peer_id = peer_id.clone(); + info!( + "Received offer: call_id={}, peer_id={}, sender_device={}", + cid, pid, sender_device_id + ); + + let offer = match signaling::Offer::new(CallMediaType::Audio, opaque) { + Ok(o) => o, + Err(e) => { + error!("Failed to parse offer: {}", e); + continue; + } + }; + + let received = signaling::ReceivedOffer { + offer, + age: Duration::from_millis(age_ms), + sender_device_id: sender_device_id as DeviceId, + receiver_device_id: local_device_id, + sender_identity_key, + receiver_identity_key, + }; + + if let Err(e) = call_manager.received_offer( + peer_id, + call_id, + received, + ) { + error!("Failed to process received offer: {}", e); + } + } + ControlMessage::ReceivedAnswer { + opaque, + sender_device_id, + sender_identity_key, + receiver_identity_key, + } => { + info!("Received answer from device {}", sender_device_id); + + let answer = match signaling::Answer::new(opaque) { + Ok(a) => a, + Err(e) => { + error!("Failed to parse answer: {}", e); + continue; + } + }; + + let received = signaling::ReceivedAnswer { + answer, + sender_device_id: sender_device_id as DeviceId, + sender_identity_key, + receiver_identity_key, + }; + + if let Err(e) = call_manager.received_answer( + active_peer_id.clone(), + call_id, + received, + ) { + error!("Failed to process received answer: {}", e); + } + } + ControlMessage::ReceivedIce { candidates } => { + debug!("Received {} ICE candidates", candidates.len()); + + let ice_candidates: Vec = candidates + .into_iter() + .map(signaling::IceCandidate::new) + .collect(); + + let received = signaling::ReceivedIce { + ice: signaling::Ice { + candidates: ice_candidates, + }, + sender_device_id: 1 as DeviceId, + }; + + if let Err(e) = call_manager.received_ice( + active_peer_id.clone(), + call_id, + received, + ) { + error!("Failed to process received ICE: {}", e); + } + } + ControlMessage::Accept => { + info!("Accepting call"); + if let Err(e) = call_manager.accept_call(call_id) { + error!("Failed to accept call: {}", e); + } + } + ControlMessage::Hangup => { + info!("Hanging up"); + if let Err(e) = call_manager.hangup() { + error!("Failed to hangup: {}", e); + } + // Give time for hangup to be sent + std::thread::sleep(Duration::from_millis(500)); + break; + } + } + } + + info!("signal-call-tunnel exiting"); + Ok(()) +} diff --git a/signal-call-tunnel/src/platform.rs b/signal-call-tunnel/src/platform.rs new file mode 100644 index 00000000..f9e25036 --- /dev/null +++ b/signal-call-tunnel/src/platform.rs @@ -0,0 +1,481 @@ +use std::collections::{HashMap, HashSet}; +use std::sync::mpsc; + +use anyhow::Result; +use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64; +use log::{debug, error, info, warn}; + +use ringrtc::common::{CallId, CallMediaType, DeviceId}; +use ringrtc::core::{group_call, signaling}; +use ringrtc::lite::sfu::UserId; +use ringrtc::native::{ + CallState, CallStateHandler, GroupUpdate, GroupUpdateHandler, SignalingSender, +}; +use ringrtc::webrtc::peer_connection::AudioLevel; +use ringrtc::webrtc::peer_connection_observer::NetworkRoute; + +/// Events sent from the platform callbacks to the control channel writer. +#[derive(Debug)] +pub enum PlatformEvent { + /// A signaling message to send to the parent process. + SendSignaling { + call_id: CallId, + message: SignalingEvent, + }, + /// A call state change. + StateChange { + state: String, + reason: Option, + }, +} + +#[derive(Debug)] +pub enum SignalingEvent { + SendOffer { + opaque: Vec, + call_media_type: CallMediaType, + }, + SendAnswer { + opaque: Vec, + }, + SendIce { + candidates: Vec>, + }, + SendHangup { + hangup_type: String, + }, + SendBusy, +} + +impl PlatformEvent { + pub fn to_json(&self) -> String { + match self { + PlatformEvent::SendSignaling { call_id, message } => match message { + SignalingEvent::SendOffer { + opaque, + call_media_type, + } => { + let media_type = match call_media_type { + CallMediaType::Audio => "audio", + CallMediaType::Video => "video", + }; + format!( + r#"{{"type":"sendOffer","callId":{},"opaque":"{}","callMediaType":"{}"}}"#, + u64::from(*call_id), + BASE64.encode(opaque), + media_type, + ) + } + SignalingEvent::SendAnswer { opaque } => { + format!( + r#"{{"type":"sendAnswer","callId":{},"opaque":"{}"}}"#, + u64::from(*call_id), + BASE64.encode(opaque), + ) + } + SignalingEvent::SendIce { candidates } => { + let candidates_json: Vec = candidates + .iter() + .map(|c| format!(r#"{{"opaque":"{}"}}"#, BASE64.encode(c))) + .collect(); + format!( + r#"{{"type":"sendIce","callId":{},"candidates":[{}]}}"#, + u64::from(*call_id), + candidates_json.join(","), + ) + } + SignalingEvent::SendHangup { hangup_type } => { + format!( + r#"{{"type":"sendHangup","callId":{},"hangupType":"{}"}}"#, + u64::from(*call_id), + hangup_type, + ) + } + SignalingEvent::SendBusy => { + format!( + r#"{{"type":"sendBusy","callId":{}}}"#, + u64::from(*call_id), + ) + } + }, + PlatformEvent::StateChange { state, reason } => { + if let Some(reason) = reason { + format!( + r#"{{"type":"stateChange","state":"{}","reason":"{}"}}"#, + state, reason, + ) + } else { + format!(r#"{{"type":"stateChange","state":"{}"}}"#, state) + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::Value; + + fn parse_event_json(event: &PlatformEvent) -> Value { + serde_json::from_str(&event.to_json()).expect("event JSON should be valid") + } + + #[test] + fn send_offer_json() { + let event = PlatformEvent::SendSignaling { + call_id: CallId::from(42u64), + message: SignalingEvent::SendOffer { + opaque: vec![1, 2, 3], + call_media_type: CallMediaType::Audio, + }, + }; + let json = parse_event_json(&event); + assert_eq!(json["type"], "sendOffer"); + assert_eq!(json["callId"], 42); + assert_eq!(json["callMediaType"], "audio"); + // Verify opaque is valid base64 that decodes back + let opaque_b64 = json["opaque"].as_str().unwrap(); + let decoded = BASE64.decode(opaque_b64).unwrap(); + assert_eq!(decoded, vec![1, 2, 3]); + } + + #[test] + fn send_offer_video_json() { + let event = PlatformEvent::SendSignaling { + call_id: CallId::from(1u64), + message: SignalingEvent::SendOffer { + opaque: vec![], + call_media_type: CallMediaType::Video, + }, + }; + let json = parse_event_json(&event); + assert_eq!(json["callMediaType"], "video"); + } + + #[test] + fn send_answer_json() { + let event = PlatformEvent::SendSignaling { + call_id: CallId::from(99u64), + message: SignalingEvent::SendAnswer { + opaque: vec![4, 5, 6], + }, + }; + let json = parse_event_json(&event); + assert_eq!(json["type"], "sendAnswer"); + assert_eq!(json["callId"], 99); + let decoded = BASE64.decode(json["opaque"].as_str().unwrap()).unwrap(); + assert_eq!(decoded, vec![4, 5, 6]); + } + + #[test] + fn send_ice_json() { + let event = PlatformEvent::SendSignaling { + call_id: CallId::from(7u64), + message: SignalingEvent::SendIce { + candidates: vec![vec![10, 20], vec![30, 40]], + }, + }; + let json = parse_event_json(&event); + assert_eq!(json["type"], "sendIce"); + assert_eq!(json["callId"], 7); + let candidates = json["candidates"].as_array().unwrap(); + assert_eq!(candidates.len(), 2); + let c0 = BASE64 + .decode(candidates[0]["opaque"].as_str().unwrap()) + .unwrap(); + assert_eq!(c0, vec![10, 20]); + let c1 = BASE64 + .decode(candidates[1]["opaque"].as_str().unwrap()) + .unwrap(); + assert_eq!(c1, vec![30, 40]); + } + + #[test] + fn send_ice_empty_candidates_json() { + let event = PlatformEvent::SendSignaling { + call_id: CallId::from(1u64), + message: SignalingEvent::SendIce { + candidates: vec![], + }, + }; + let json = parse_event_json(&event); + assert_eq!(json["candidates"].as_array().unwrap().len(), 0); + } + + #[test] + fn send_hangup_json() { + let event = PlatformEvent::SendSignaling { + call_id: CallId::from(50u64), + message: SignalingEvent::SendHangup { + hangup_type: "normal".to_string(), + }, + }; + let json = parse_event_json(&event); + assert_eq!(json["type"], "sendHangup"); + assert_eq!(json["callId"], 50); + assert_eq!(json["hangupType"], "normal"); + } + + #[test] + fn send_busy_json() { + let event = PlatformEvent::SendSignaling { + call_id: CallId::from(8u64), + message: SignalingEvent::SendBusy, + }; + let json = parse_event_json(&event); + assert_eq!(json["type"], "sendBusy"); + assert_eq!(json["callId"], 8); + } + + #[test] + fn state_change_with_reason_json() { + let event = PlatformEvent::StateChange { + state: "Ended".to_string(), + reason: Some("Timeout".to_string()), + }; + let json = parse_event_json(&event); + assert_eq!(json["type"], "stateChange"); + assert_eq!(json["state"], "Ended"); + assert_eq!(json["reason"], "Timeout"); + } + + #[test] + fn state_change_without_reason_json() { + let event = PlatformEvent::StateChange { + state: "Connected".to_string(), + reason: None, + }; + let json = parse_event_json(&event); + assert_eq!(json["type"], "stateChange"); + assert_eq!(json["state"], "Connected"); + assert!(json.get("reason").is_none()); + } + + // --- Round-trip tests: platform event -> JSON -> parse as control message --- + + #[test] + fn round_trip_offer() { + let opaque = vec![0xDE, 0xAD, 0xBE, 0xEF]; + let event = PlatformEvent::SendSignaling { + call_id: CallId::from(123u64), + message: SignalingEvent::SendOffer { + opaque: opaque.clone(), + call_media_type: CallMediaType::Audio, + }, + }; + let json_str = event.to_json(); + // The parent would receive this JSON and could extract the opaque + let parsed: Value = serde_json::from_str(&json_str).unwrap(); + let decoded = BASE64 + .decode(parsed["opaque"].as_str().unwrap()) + .unwrap(); + assert_eq!(decoded, opaque); + } + + #[test] + fn round_trip_ice() { + let candidates = vec![vec![1, 2, 3], vec![4, 5, 6, 7, 8]]; + let event = PlatformEvent::SendSignaling { + call_id: CallId::from(456u64), + message: SignalingEvent::SendIce { + candidates: candidates.clone(), + }, + }; + let json_str = event.to_json(); + let parsed: Value = serde_json::from_str(&json_str).unwrap(); + let arr = parsed["candidates"].as_array().unwrap(); + assert_eq!(arr.len(), 2); + for (i, c) in arr.iter().enumerate() { + let decoded = BASE64.decode(c["opaque"].as_str().unwrap()).unwrap(); + assert_eq!(decoded, candidates[i]); + } + } +} + +/// Implements SignalingSender -- relays signaling messages to the control channel. +pub struct TunnelSignalingSender { + pub event_sender: mpsc::Sender, +} + +impl SignalingSender for TunnelSignalingSender { + fn send_signaling( + &self, + _recipient_id: &str, + call_id: CallId, + _receiver_device_id: Option, + message: signaling::Message, + ) -> Result<()> { + let event = match message { + signaling::Message::Offer(offer) => SignalingEvent::SendOffer { + opaque: offer.opaque, + call_media_type: offer.call_media_type, + }, + signaling::Message::Answer(answer) => SignalingEvent::SendAnswer { + opaque: answer.opaque, + }, + signaling::Message::Ice(ice) => SignalingEvent::SendIce { + candidates: ice.candidates.into_iter().map(|c| c.opaque).collect(), + }, + signaling::Message::Hangup(hangup) => { + let (hangup_type, _device_id) = hangup.to_type_and_device_id(); + SignalingEvent::SendHangup { + hangup_type: format!("{:?}", hangup_type).to_lowercase(), + } + } + signaling::Message::Busy => SignalingEvent::SendBusy, + }; + + if let Err(e) = self.event_sender.send(PlatformEvent::SendSignaling { + call_id, + message: event, + }) { + error!("Failed to send signaling event: {}", e); + } + Ok(()) + } + + fn send_call_message( + &self, + _recipient_id: UserId, + _message: Vec, + _urgency: group_call::SignalingMessageUrgency, + ) -> Result<()> { + // No-op for 1:1 calls + Ok(()) + } + + fn send_call_message_to_group( + &self, + _group_id: group_call::GroupId, + _message: Vec, + _urgency: group_call::SignalingMessageUrgency, + _recipients_override: HashSet, + ) -> Result<()> { + // No-op for 1:1 calls + Ok(()) + } + + fn send_call_message_to_adhoc_group( + &self, + _message: Vec, + _urgency: group_call::SignalingMessageUrgency, + _expiration: u64, + _recipients_to_endorsements: HashMap>, + ) -> Result<()> { + // No-op for 1:1 calls + Ok(()) + } +} + +/// Implements CallStateHandler -- relays state changes to the control channel. +pub struct TunnelStateHandler { + pub event_sender: mpsc::Sender, +} + +impl CallStateHandler for TunnelStateHandler { + fn handle_call_state( + &self, + _remote_peer_id: &str, + _call_id: CallId, + call_state: CallState, + ) -> Result<()> { + let (state, reason) = match call_state { + CallState::Incoming(media_type) => { + (format!("Incoming({:?})", media_type), None) + } + CallState::Outgoing(media_type) => { + (format!("Outgoing({:?})", media_type), None) + } + CallState::Ringing => ("Ringing".to_string(), None), + CallState::Connected => ("Connected".to_string(), None), + CallState::Connecting => ("Connecting".to_string(), None), + CallState::Ended(reason, _summary) => { + ("Ended".to_string(), Some(format!("{:?}", reason))) + } + CallState::Rejected(reason) => { + ("Rejected".to_string(), Some(format!("{:?}", reason))) + } + CallState::Concluded => ("Concluded".to_string(), None), + }; + + info!("Call state: {} (reason: {:?})", state, reason); + + if let Err(e) = self + .event_sender + .send(PlatformEvent::StateChange { state, reason }) + { + error!("Failed to send state change event: {}", e); + } + Ok(()) + } + + fn handle_remote_audio_state( + &self, + _remote_peer_id: &str, + enabled: bool, + ) -> Result<()> { + debug!("Remote audio state: {}", enabled); + Ok(()) + } + + fn handle_remote_video_state( + &self, + _remote_peer_id: &str, + enabled: bool, + ) -> Result<()> { + debug!("Remote video state: {}", enabled); + Ok(()) + } + + fn handle_remote_sharing_screen( + &self, + _remote_peer_id: &str, + enabled: bool, + ) -> Result<()> { + debug!("Remote sharing screen: {}", enabled); + Ok(()) + } + + fn handle_network_route( + &self, + _remote_peer_id: &str, + network_route: NetworkRoute, + ) -> Result<()> { + info!("Network route: {:?}", network_route); + Ok(()) + } + + fn handle_audio_levels( + &self, + _remote_peer_id: &str, + _captured_level: AudioLevel, + _received_level: AudioLevel, + ) -> Result<()> { + // Don't log -- too noisy + Ok(()) + } + + fn handle_low_bandwidth_for_video( + &self, + _remote_peer_id: &str, + recovered: bool, + ) -> Result<()> { + if recovered { + info!("Low bandwidth for video: recovered"); + } else { + warn!("Low bandwidth for video"); + } + Ok(()) + } +} + +/// Implements GroupUpdateHandler -- all no-ops for 1:1 calls. +pub struct TunnelGroupHandler; + +impl GroupUpdateHandler for TunnelGroupHandler { + fn handle_group_update(&self, _update: GroupUpdate) -> Result<()> { + Ok(()) + } +} 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..f39fbea3 --- /dev/null +++ b/src/main/java/org/asamk/signal/commands/AcceptCallCommand.java @@ -0,0 +1,78 @@ +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 { + final var callIdNumber = ns.get("call-id"); + if (callIdNumber == null) { + throw new UserErrorException("No call ID given"); + } + final long callId = ((Number) 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 052de562..6a40e887 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()); @@ -49,6 +53,7 @@ public class Commands { addCommand(new SendSyncRequestCommand()); addCommand(new SendTypingCommand()); 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..4254e073 --- /dev/null +++ b/src/main/java/org/asamk/signal/commands/HangupCallCommand.java @@ -0,0 +1,56 @@ +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 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 { + final var callIdNumber = ns.get("call-id"); + if (callIdNumber == null) { + throw new UserErrorException("No call ID given"); + } + final long callId = ((Number) callIdNumber).longValue(); + + try { + m.hangupCall(callId); + switch (outputWriter) { + case PlainTextWriter writer -> writer.println("Call {} hung up.", callId); + case JsonWriter writer -> writer.write(new JsonResult(callId, "hung_up")); + } + } catch (IOException e) { + throw new IOErrorException("Failed to hang up call: " + e.getMessage(), e); + } + } + + private record JsonResult(long callId, String status) {} +} 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..85d1b7b4 --- /dev/null +++ b/src/main/java/org/asamk/signal/commands/RejectCallCommand.java @@ -0,0 +1,56 @@ +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 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 { + final var callIdNumber = ns.get("call-id"); + if (callIdNumber == null) { + throw new UserErrorException("No call ID given"); + } + final long callId = ((Number) callIdNumber).longValue(); + + try { + m.rejectCall(callId); + switch (outputWriter) { + case PlainTextWriter writer -> writer.println("Call {} rejected.", callId); + case JsonWriter writer -> writer.write(new JsonResult(callId, "rejected")); + } + } catch (IOException e) { + throw new IOErrorException("Failed to reject call: " + e.getMessage(), e); + } + } + + private record JsonResult(long callId, String status) {} +} 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 6428ee2e..460f903b 100644 --- a/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java +++ b/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java @@ -879,6 +879,73 @@ public class DbusManagerImpl implements Manager { } } + @Override + public void addCallEventListener(final CallEventListener listener) { + // Not supported over DBus + } + + @Override + public void removeCallEventListener(final CallEventListener listener) { + // Not supported over DBus + } + + // --- Voice call methods (not supported over DBus) --- + + @Override + public org.asamk.signal.manager.api.CallInfo startCall(final org.asamk.signal.manager.api.RecipientIdentifier.Single recipient) { + throw new UnsupportedOperationException("Voice calls are not supported over DBus"); + } + + @Override + public org.asamk.signal.manager.api.CallInfo acceptCall(final long callId) { + throw new UnsupportedOperationException("Voice calls are not supported over DBus"); + } + + @Override + public void hangupCall(final long callId) { + throw new UnsupportedOperationException("Voice calls are not supported over DBus"); + } + + @Override + public void rejectCall(final long callId) { + throw new UnsupportedOperationException("Voice calls are not supported over DBus"); + } + + @Override + public java.util.List listActiveCalls() { + return java.util.List.of(); + } + + @Override + public void sendCallOffer(final org.asamk.signal.manager.api.RecipientIdentifier.Single recipient, final org.asamk.signal.manager.api.CallOffer offer) { + throw new UnsupportedOperationException("Voice calls are not supported over DBus"); + } + + @Override + public void sendCallAnswer(final org.asamk.signal.manager.api.RecipientIdentifier.Single recipient, final long callId, final byte[] answerOpaque) { + throw new UnsupportedOperationException("Voice calls are not supported over DBus"); + } + + @Override + public void sendIceUpdate(final org.asamk.signal.manager.api.RecipientIdentifier.Single recipient, final long callId, final java.util.List iceCandidates) { + throw new UnsupportedOperationException("Voice calls are not supported over DBus"); + } + + @Override + public void sendHangup(final org.asamk.signal.manager.api.RecipientIdentifier.Single recipient, final long callId, final org.asamk.signal.manager.api.MessageEnvelope.Call.Hangup.Type type) { + throw new UnsupportedOperationException("Voice calls are not supported over DBus"); + } + + @Override + public void sendBusy(final org.asamk.signal.manager.api.RecipientIdentifier.Single recipient, final long callId) { + throw new UnsupportedOperationException("Voice calls are not supported over DBus"); + } + + @Override + public java.util.List getTurnServerInfo() { + throw new UnsupportedOperationException("Voice calls are not supported over DBus"); + } + @Override public void close() { synchronized (this) { diff --git a/src/main/java/org/asamk/signal/json/JsonCallEvent.java b/src/main/java/org/asamk/signal/json/JsonCallEvent.java new file mode 100644 index 00000000..dba6b77a --- /dev/null +++ b/src/main/java/org/asamk/signal/json/JsonCallEvent.java @@ -0,0 +1,32 @@ +package org.asamk.signal.json; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import org.asamk.signal.manager.api.CallInfo; + +import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL; + +public record JsonCallEvent( + long callId, + String state, + @JsonInclude(NON_NULL) String number, + @JsonInclude(NON_NULL) String uuid, + boolean isOutgoing, + @JsonInclude(NON_NULL) String inputDeviceName, + @JsonInclude(NON_NULL) String outputDeviceName, + @JsonInclude(NON_NULL) String reason +) { + + public static JsonCallEvent from(CallInfo callInfo, String reason) { + return new JsonCallEvent( + callInfo.callId(), + callInfo.state().name(), + callInfo.recipient().number().orElse(null), + callInfo.recipient().aci().orElse(null), + callInfo.isOutgoing(), + callInfo.inputDeviceName(), + callInfo.outputDeviceName(), + reason + ); + } +} diff --git a/src/main/java/org/asamk/signal/jsonrpc/SignalJsonRpcDispatcherHandler.java b/src/main/java/org/asamk/signal/jsonrpc/SignalJsonRpcDispatcherHandler.java index 5d3fa261..1a7d3973 100644 --- a/src/main/java/org/asamk/signal/jsonrpc/SignalJsonRpcDispatcherHandler.java +++ b/src/main/java/org/asamk/signal/jsonrpc/SignalJsonRpcDispatcherHandler.java @@ -24,6 +24,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.nio.channels.ClosedChannelException; +import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -40,6 +41,7 @@ public class SignalJsonRpcDispatcherHandler { private final boolean noReceiveOnStart; private final Map>> receiveHandlers = new HashMap<>(); + private final List> callEventHandlers = new ArrayList<>(); private SignalJsonRpcCommandHandler commandHandler; public SignalJsonRpcDispatcherHandler( @@ -62,6 +64,11 @@ public class SignalJsonRpcDispatcherHandler { c.addOnManagerRemovedHandler(this::unsubscribeReceive); } + for (var m : c.getManagers()) { + subscribeCallEvents(m); + } + c.addOnManagerAddedHandler(this::subscribeCallEvents); + handleConnection(); } @@ -72,12 +79,33 @@ public class SignalJsonRpcDispatcherHandler { subscribeReceive(m, true); } + subscribeCallEvents(m); + final var currentThread = Thread.currentThread(); m.addClosedListener(currentThread::interrupt); handleConnection(); } + private void subscribeCallEvents(final Manager manager) { + Manager.CallEventListener listener = (callInfo, reason) -> { + final var params = new ObjectNode(objectMapper.getNodeFactory()); + params.set("account", params.textNode(manager.getSelfNumber())); + params.set("callEvent", objectMapper.valueToTree( + org.asamk.signal.json.JsonCallEvent.from(callInfo, reason))); + final var jsonRpcRequest = JsonRpcRequest.forNotification("callEvent", params, null); + try { + jsonRpcSender.sendRequest(jsonRpcRequest); + } catch (AssertionError e) { + if (e.getCause() instanceof ClosedChannelException) { + logger.debug("Call event channel closed, removing listener"); + } + } + }; + manager.addCallEventListener(listener); + callEventHandlers.add(new Pair<>(manager, listener)); + } + private static final AtomicInteger nextSubscriptionId = new AtomicInteger(0); private int subscribeReceive(final Manager manager, boolean internalSubscription) { @@ -141,6 +169,10 @@ public class SignalJsonRpcDispatcherHandler { } finally { receiveHandlers.forEach((_subscriptionId, handlers) -> handlers.forEach(this::unsubscribeReceiveHandler)); receiveHandlers.clear(); + for (var pair : callEventHandlers) { + pair.first().removeCallEventListener(pair.second()); + } + callEventHandlers.clear(); } } 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 5024add2..9b98138f 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 @@ -1935,6 +1935,40 @@ } ] }, + { + "type": "org.asamk.signal.commands.AcceptCallCommand$JsonCallInfo", + "allDeclaredFields": true, + "methods": [ + { + "name": "callId", + "parameterTypes": [] + }, + { + "name": "channels", + "parameterTypes": [] + }, + { + "name": "codec", + "parameterTypes": [] + }, + { + "name": "mediaSocketPath", + "parameterTypes": [] + }, + { + "name": "ptimeMs", + "parameterTypes": [] + }, + { + "name": "sampleRate", + "parameterTypes": [] + }, + { + "name": "state", + "parameterTypes": [] + } + ] + }, { "type": "org.asamk.signal.commands.FinishLinkCommand$FinishLinkParams", "allDeclaredFields": true, @@ -1972,6 +2006,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, @@ -1982,6 +2030,39 @@ } ] }, + { + "type": "org.asamk.signal.commands.ListCallsCommand$JsonCall", + "allDeclaredFields": true, + "methods": [ + { + "name": "callId", + "parameterTypes": [] + }, + { + "name": "isOutgoing", + "parameterTypes": [] + }, + { + "name": "mediaSocketPath", + "parameterTypes": [] + }, + { + "name": "number", + "parameterTypes": [] + }, + { + "name": "state", + "parameterTypes": [] + }, + { + "name": "uuid", + "parameterTypes": [] + } + ] + }, + { + "type": "org.asamk.signal.commands.ListCallsCommand$JsonCall[]" + }, { "type": "org.asamk.signal.commands.ListContactsCommand$JsonContact", "allDeclaredFields": true, @@ -2147,6 +2228,54 @@ } ] }, + { + "type": "org.asamk.signal.commands.RejectCallCommand$JsonResult", + "allDeclaredFields": true, + "methods": [ + { + "name": "callId", + "parameterTypes": [] + }, + { + "name": "status", + "parameterTypes": [] + } + ] + }, + { + "type": "org.asamk.signal.commands.StartCallCommand$JsonCallInfo", + "allDeclaredFields": true, + "methods": [ + { + "name": "callId", + "parameterTypes": [] + }, + { + "name": "channels", + "parameterTypes": [] + }, + { + "name": "codec", + "parameterTypes": [] + }, + { + "name": "mediaSocketPath", + "parameterTypes": [] + }, + { + "name": "ptimeMs", + "parameterTypes": [] + }, + { + "name": "sampleRate", + "parameterTypes": [] + }, + { + "name": "state", + "parameterTypes": [] + } + ] + }, { "type": "org.asamk.signal.commands.StartLinkCommand$JsonLink", "allDeclaredFields": true, @@ -9645,4 +9774,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/third-party/ringrtc b/third-party/ringrtc new file mode 160000 index 00000000..a86f8a68 --- /dev/null +++ b/third-party/ringrtc @@ -0,0 +1 @@ +Subproject commit a86f8a6832cec291ef4f77e4ee9b941e5b91a01f diff --git a/voice-test/README.md b/voice-test/README.md new file mode 100644 index 00000000..77f65348 --- /dev/null +++ b/voice-test/README.md @@ -0,0 +1,344 @@ +This is a test plan for voice calling. + +# Prerequisites + +- signal-cli is registered on this computer +- You have Signal on your phone (or emulator) with a separate account +- You know the phone number of both accounts +- `signal-call-tunnel` Rust binary is built (see Build section) +- **macOS**: BlackHole virtual audio drivers installed (one-time setup): + ```bash + cd third-party/ringrtc + sudo bin/virtual_audio.sh --setup --input-source signal_input --output-sink signal_output + ``` + Install `sox`: `brew install sox` +- **Linux**: PulseAudio running (virtual audio modules are created automatically) + +# Using the test harness + +This directory contains a fully automated test harness that runs +scenarios A-E against an Android emulator with Signal installed, requiring +no manual interaction. + +## Environment + +| Component | Value | +|-----------|-------| +| signal-cli account | Set via `--signal-cli-account` or `SIGNAL_CLI_ACCOUNT` env var | +| Emulator Signal account | Set via `--emulator-account` or `EMULATOR_ACCOUNT` env var | +| Emulator AVD | `signal-test` (emulator-5554) | +| Emulator gRPC | localhost:8554 | +| Python virtualenv | `signal-cli-dev` (pyenv, Python 3.9.6) | + +## Quick start + +```bash +# Run all five scenarios: +bash voice-test/run_e2e.sh --signal-cli-account +1... --emulator-account +1... + +# Run specific scenarios: +bash voice-test/run_e2e.sh --signal-cli-account +1... --emulator-account +1... --scenarios A,B,E +``` + +`run_e2e.sh` handles everything automatically: +1. Pre-flight checks (builds exist, adb reachable, Signal installed) +2. Installs Python deps (`grpcio`, `grpcio-tools`) +3. Compiles emulator gRPC proto stubs (if needed) +4. Starts the emulator if not already running +5. Launches Signal on the emulator +6. Starts a signal-cli daemon on a test socket +7. Runs the selected test scenarios +8. Cleans up (kills daemon, orphaned tunnel processes) + +## Scenarios + +| ID | Name | What it tests | +|----|------|---------------| +| A | Outgoing call lifecycle | signal-cli places call -> emulator answers -> signal-cli hangs up | +| B | Incoming call lifecycle | Emulator places call -> signal-cli accepts -> signal-cli hangs up | +| C | Incoming call rejection | Emulator places call -> signal-cli rejects | +| D | Ring timeout | signal-cli places call -> nobody answers -> timeout after ~60s | +| E | Bidirectional audio | Connected call with 440Hz tone via virtual audio devices, Goertzel detection via emulator gRPC audio API | + +## File structure + +``` +voice-test/ + run_e2e.sh # Master orchestrator + config.sh # Shared environment config + requirements.txt # Python deps (grpcio, grpcio-tools) + generate_proto.sh # Compile emulator_controller.proto -> Python stubs + e2e_test.py # Main test runner (scenarios A-E) + lib/ + signal_rpc.py # signal-cli JSON-RPC client + audio.py # Tone generation + Goertzel frequency detection + WAV I/O + emulator.py # ADB-based Signal UI automation + grpc_audio.py # Emulator gRPC audio injection/capture + proto/ # Generated protobuf stubs (created by generate_proto.sh) +``` + +## Debugging audio (Scenario E) + +Scenario E saves WAV files to `voice-test/output/` for manual inspection: + +```bash +ls voice-test/output/*.wav +afplay voice-test/output/e_tone_out_captured.wav # 440Hz captured from emulator speaker +afplay voice-test/output/e_playout_received.wav # Playout recorded from virtual output device +``` + +--- + +## Likely failure points + +1. **TURN credentials** -- `getTurnServerInfo()` must fetch credentials from Signal's server. Without TURN, ICE may fail behind NAT. +2. **ICE connectivity** -- Symmetric NAT on both sides with no TURN = ICE failure. On the same LAN, ICE should complete within ~100ms. +3. **signal-call-tunnel not found** -- Set `SIGNAL_CALL_TUNNEL_BIN` env var to the binary path, or ensure it is on `PATH` or in `/bin/`. +4. **Ring timeout** -- 60 seconds to accept before auto-hangup. +5. **Virtual audio devices not found** -- macOS: BlackHole drivers not installed (run `sudo virtual_audio.sh --setup` first). Linux: PulseAudio not running. + +# Manual Testing + +## Build + +```bash +# Build signal-cli +direnv exec . ./gradlew installDist + +# Build the Rust call tunnel binary +cd signal-call-tunnel && cargo build --release && cd .. +``` + +The first Rust build will automatically download the prebuilt WebRTC library +(~100 MB) from Signal's artifact server. This is cached for subsequent builds. + +## Start the daemon + +Terminal 1 -- start the daemon with a JSON-RPC socket and verbose logging: + +```bash +./build/install/signal-cli/bin/signal-cli -v daemon --socket +``` + +This binds a Unix domain socket at `$XDG_RUNTIME_DIR/signal-cli/socket` +(typically `~/.cache/signal-cli/socket` or `/run/user/$(id -u)/signal-cli/socket`). +Logs and received message notifications print to stdout. + +You can specify a custom path: `--socket /tmp/signal-cli.sock` + +Terminal 2 -- send JSON-RPC commands. Set the socket path to match: + +```bash +SOCKET="${XDG_RUNTIME_DIR:-$HOME/.cache}/signal-cli/socket" +``` + +To send a one-shot command and get the response: + +```bash +echo '{"jsonrpc":"2.0","method":"METHOD","id":1,"params":{}}' | socat - UNIX-CONNECT:$SOCKET +``` + +To open a persistent connection (needed for receiving notifications like incoming calls): + +```bash +socat STDIO UNIX-CONNECT:$SOCKET +``` + +Then type JSON-RPC requests directly. Notifications (incoming calls, state changes) +will appear interleaved with responses. + +--- + +## Test A: Outgoing call signaling and tunnel lifecycle + +### A1. Start the call + +```bash +echo '{"jsonrpc":"2.0","method":"startCall","id":1,"params":{"recipient":"+1YOURPHONENUMBER"}}' \ + | socat - UNIX-CONNECT:$SOCKET +``` + +**Expect in response:** +```json +{"jsonrpc":"2.0","result":{"callId":...,"state":"RINGING_OUTGOING","inputDeviceName":"signal_input_...","outputDeviceName":"signal_output_..."},"id":1} +``` + +**Expect in daemon logs (terminal 1):** +``` +Started outgoing call {callId} to {recipient} +Spawned media tunnel for call {callId} +Tunnel ready for call {callId} +``` + +**Expect on phone:** Incoming call notification from the signal-cli account. + +### A2. Answer on your phone + +Pick up the call. + +**Expect in daemon logs (key lines, in order):** +``` +Received answer for call {callId} +Control event: sendOffer (outgoing call offer generated by RingRTC) +Control event: sendIce (repeated, ICE candidates from RingRTC) +Control event: stateChange state=Connecting +Control event: stateChange state=Connected +``` + +### A3. Verify the media tunnel is running + +```bash +ps aux | grep signal-call-tunnel +``` + +Should show a `signal-call-tunnel` process. + +Check the socket directory (path from the `startCall` response): + +```bash +ls -la /tmp/sc-*/ +``` + +Should show `ctrl.sock` for the active call. + +### A4. Hang up + +From signal-cli (replace CALL_ID with the actual call ID): + +```bash +echo '{"jsonrpc":"2.0","method":"hangupCall","id":2,"params":{"callId":CALL_ID}}' \ + | socat - UNIX-CONNECT:$SOCKET +``` + +Or hang up on your phone. + +**Expect in daemon logs:** +``` +Call {callId} ended: local_hangup (if you hung up from signal-cli) +Call {callId} ended: remote_hangup (if you hung up from phone) +Media tunnel for call {callId} exited with code 0 +``` + +--- + +## Test B: Incoming call signaling, accept, and tunnel lifecycle + +### B1. Open a persistent connection + +You need to see incoming call notifications, so open a persistent connection: + +```bash +socat STDIO UNIX-CONNECT:$SOCKET +``` + +### B2. Call from your phone + +On your phone, start a Signal voice call to the signal-cli account. + +**Expect in daemon logs (terminal 1):** +``` +Incoming call {callId} from {yourPhoneNumber} +Spawned media tunnel for call {callId} +Tunnel ready for call {callId} +``` + +**Expect on the socat connection:** A `receive` notification containing a `callMessage` +with an `offerMessage`. The `callId` is in the offer's `id` field. + +### B3. List calls to get the call ID + +Type into the socat session: + +```json +{"jsonrpc":"2.0","method":"listCalls","id":3} +``` + +**Expect:** A response with a call in state `RINGING_INCOMING`. Note the `callId`. + +### B4. Accept the call + +Type into the socat session (replace CALL_ID): + +```json +{"jsonrpc":"2.0","method":"acceptCall","id":4,"params":{"callId":CALL_ID}} +``` + +**Expect in response:** +```json +{"jsonrpc":"2.0","result":{"callId":...,"state":"CONNECTING","inputDeviceName":"...","outputDeviceName":"..."},"id":4} +``` + +**Expect in daemon logs (key lines, in order):** +``` +Accepted incoming call {callId} +Control event: sendAnswer (RingRTC generated answer with DH key) +Control event: sendIce (repeated, ICE candidates from RingRTC) +Control event: stateChange state=Connecting +Control event: stateChange state=Connected +``` + +**Expect on phone:** Call shows as connected. + +### B5. Verify the media tunnel is running + +Same as A3. + +### B6. Hang up + +Same as A4. + +--- + +## Test C: Incoming call rejection + +### C1. Call from your phone (same as B1-B2) + +### C2. Reject it + +```json +{"jsonrpc":"2.0","method":"rejectCall","id":5,"params":{"callId":CALL_ID}} +``` + +**Expect in daemon logs:** +``` +Call {callId} ended: rejected +``` + +**Expect on phone:** Call ends, shown as declined/busy. + +--- + +## Test D: Unanswered call ring timeout + +### D1. Start the call (same as A1) + +Place an outgoing call from signal-cli. Do **not** answer on your phone. + +```bash +echo '{"jsonrpc":"2.0","method":"startCall","id":1,"params":{"recipient":"+1YOURPHONENUMBER"}}' \ + | socat - UNIX-CONNECT:$SOCKET +``` + +### D2. Wait 60 seconds without answering + +**Expect in daemon logs after ~60s:** +``` +Call {callId} ring timeout +Call {callId} ended: ring_timeout +``` + +**Expect on phone:** Incoming call stops ringing. + +--- + +## Success criteria + +| Stage | How to verify | +|---|---| +| Signaling (offer/answer) | `sendOffer`/`sendAnswer` control events in logs | +| Media tunnel spawn | `Spawned media tunnel` in logs, `ps aux \| grep signal-call-tunnel` shows process | +| ICE connectivity | `stateChange state=Connected` in logs | +| Key derivation | Handled internally by RingRTC (x25519 DH + HKDF); no errors in tunnel stderr | +| Virtual audio | Devices enumerated by cubeb (check tunnel logs for device selection) | +| Call connected | Phone shows connected call, tunnel process alive | +| Clean teardown | `ended` in logs, `exited with code 0`, socket dir cleaned up | diff --git a/voice-test/__init__.py b/voice-test/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/voice-test/config.sh b/voice-test/config.sh new file mode 100644 index 00000000..45da5c0e --- /dev/null +++ b/voice-test/config.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Shared environment configuration for E2E voice call tests. + +SIGNAL_CLI_ACCOUNT="${SIGNAL_CLI_ACCOUNT:?ERROR: SIGNAL_CLI_ACCOUNT not set (use --signal-cli-account or export SIGNAL_CLI_ACCOUNT)}" +EMULATOR_ACCOUNT="${EMULATOR_ACCOUNT:?ERROR: EMULATOR_ACCOUNT not set (use --emulator-account or export EMULATOR_ACCOUNT)}" +ANDROID_SDK="${ANDROID_SDK:-/opt/homebrew/share/android-commandlinetools}" +ADB="${ADB:-$(command -v adb || echo "$ANDROID_SDK/platform-tools/adb")}" +EMULATOR_BIN="${EMULATOR_BIN:-$(command -v emulator || echo "$ANDROID_SDK/emulator/emulator")}" +EMULATOR_AVD="${EMULATOR_AVD:-signal-test}" +EMULATOR_GRPC_PORT=8554 +SIGNAL_CLI_SOCKET="/tmp/signal-cli-test.sock" +SIGNAL_CLI_BIN="./build/install/signal-cli/bin/signal-cli" +SIGNAL_CALL_TUNNEL_BIN="./signal-call-tunnel/target/debug/signal-call-tunnel" +TEST_TONE_FREQ_OUT=440 # signal-cli -> emulator (Hz) +TEST_TONE_FREQ_IN=1000 # emulator -> signal-cli (Hz) +TEST_TONE_DURATION=3 # seconds + +# Log collection +LOG_DIR="voice-test/output/logs" +SIGNAL_CLI_LOG="$LOG_DIR/signal-cli.log" +DAEMON_CONSOLE_LOG="$LOG_DIR/daemon-console.log" +LOGCAT_LOG="$LOG_DIR/logcat.log" diff --git a/voice-test/e2e_test.py b/voice-test/e2e_test.py new file mode 100644 index 00000000..2cae58ed --- /dev/null +++ b/voice-test/e2e_test.py @@ -0,0 +1,916 @@ +#!/usr/bin/env python3 +"""E2E voice call test runner. + +Runs test scenarios A-E against a signal-cli daemon and an Android emulator +with Signal installed. + +Usage: + python3 voice-test/e2e_test.py --socket /tmp/signal-cli-test.sock --scenarios A,B,C,D,E +""" + +import argparse +import os +import platform +import shutil +import signal +import subprocess +import sys +import threading +import time +import traceback +from pathlib import Path + +# Add voice-test dir so we can import lib.* +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from lib.signal_rpc import SignalRPC +from lib.audio import generate_tone, detect_tone, pcm_to_wav, rms_level, wav_to_pcm +from lib.emulator import EmulatorControl + +# Optional: gRPC audio (only needed for scenario E) +try: + from lib.grpc_audio import EmulatorAudio + HAS_GRPC_AUDIO = True +except ImportError: + HAS_GRPC_AUDIO = False + + +# -- Configuration (accounts required via env, others overridable) -- +EMULATOR_ACCOUNT = os.environ["EMULATOR_ACCOUNT"] +SIGNAL_CLI_ACCOUNT = os.environ["SIGNAL_CLI_ACCOUNT"] +ADB_PATH = os.environ.get("ADB") or shutil.which("adb") +if not ADB_PATH: + sys.exit("ERROR: 'adb' not found. Set ADB env var or add adb to PATH.") +EMULATOR_GRPC_PORT = int(os.environ.get("EMULATOR_GRPC_PORT", "8554")) +TEST_TONE_FREQ_OUT = int(os.environ.get("TEST_TONE_FREQ_OUT", "440")) +TEST_TONE_FREQ_IN = int(os.environ.get("TEST_TONE_FREQ_IN", "1000")) +TEST_TONE_DURATION = int(os.environ.get("TEST_TONE_DURATION", "3")) +OUTPUT_DIR = Path(__file__).resolve().parent / "output" +IS_MACOS = platform.system() == "Darwin" + + +def setup_output_dir(): + OUTPUT_DIR.mkdir(exist_ok=True) + + +class TestResult: + def __init__(self, name, passed, message="", duration=0): + self.name = name + self.passed = passed + self.message = message + self.duration = duration + + def __str__(self): + status = "PASS" if self.passed else "FAIL" + return f"[{status}] {self.name} ({self.duration:.1f}s) {self.message}" + + +# --------------------------------------------------------------------------- +# Log collection +# --------------------------------------------------------------------------- +class LogCollector: + """Tracks log file positions per scenario and extracts relevant segments on failure. + + Monitors three log sources: + - signal-cli log file (includes tunnel output as [tunnel-{callId}] lines) + - daemon console log (stdout/stderr from the daemon process) + - Android logcat log (full device logcat) + """ + + # Lines to show in failure diagnostics (per log source) + TAIL_LINES = 80 + + # Logcat patterns relevant to call diagnostics + LOGCAT_FILTERS = [ + "WebRtcCallService", + "RingRTC", + "CallManager", + "org.thoughtcrime.securesms", + "signal", + "webrtc", + "AudioManager", + "AudioTrack", + "AudioRecord", + ] + + def __init__(self, log_dir): + self.log_dir = Path(log_dir) if log_dir else None + self.signal_cli_log = self.log_dir / "signal-cli.log" if self.log_dir else None + self.daemon_console_log = self.log_dir / "daemon-console.log" if self.log_dir else None + self.logcat_log = self.log_dir / "logcat.log" if self.log_dir else None + self._positions = {} # scenario_id -> {file: byte_offset} + + @property + def enabled(self): + return self.log_dir is not None and self.log_dir.is_dir() + + def _file_size(self, path): + try: + return path.stat().st_size if path and path.exists() else 0 + except OSError: + return 0 + + def mark_start(self, scenario_id): + """Record current end-of-file positions for all log files.""" + if not self.enabled: + return + self._positions[scenario_id] = { + "signal_cli": self._file_size(self.signal_cli_log), + "daemon_console": self._file_size(self.daemon_console_log), + "logcat": self._file_size(self.logcat_log), + } + + def extract_scenario_logs(self, scenario_id): + """Extract log segments written during this scenario. + + Returns dict of {source_name: text}. + """ + if not self.enabled or scenario_id not in self._positions: + return {} + + starts = self._positions[scenario_id] + segments = {} + + for name, log_path, start_pos in [ + ("signal-cli", self.signal_cli_log, starts["signal_cli"]), + ("daemon-console", self.daemon_console_log, starts["daemon_console"]), + ("logcat", self.logcat_log, starts["logcat"]), + ]: + if not log_path or not log_path.exists(): + continue + try: + end_pos = log_path.stat().st_size + if end_pos <= start_pos: + continue + with open(log_path, "r", errors="replace") as f: + f.seek(start_pos) + text = f.read(end_pos - start_pos) + if text.strip(): + segments[name] = text + except OSError: + continue + + return segments + + def save_scenario_logs(self, scenario_id, result): + """On failure, save per-scenario log excerpts and print diagnostics.""" + if not self.enabled: + return + + segments = self.extract_scenario_logs(scenario_id) + if not segments: + return + + # Save full per-scenario logs to files + for source, text in segments.items(): + out_path = self.log_dir / f"scenario_{scenario_id}_{source}.log" + with open(out_path, "w") as f: + f.write(text) + + if not result.passed: + self._print_diagnostics(scenario_id, segments) + + def _print_diagnostics(self, scenario_id, segments): + """Print relevant log excerpts for a failed scenario.""" + print(f"\n {'='*60}") + print(f" DIAGNOSTIC LOGS FOR SCENARIO {scenario_id}") + print(f" {'='*60}") + + # signal-cli log (includes [tunnel-*] lines) + if "signal-cli" in segments: + text = segments["signal-cli"] + lines = text.splitlines() + + # Separate tunnel lines from daemon lines + tunnel_lines = [l for l in lines if "[tunnel-" in l] + call_lines = [l for l in lines + if any(kw in l.lower() for kw in + ["call", "tunnel", "ice", "ring", "offer", + "answer", "hangup", "error", "exception", + "failed", "timeout", "media", "virtual", "audio"])] + + if tunnel_lines: + print(f"\n --- signal-call-tunnel ({len(tunnel_lines)} lines) ---") + for line in tunnel_lines[-self.TAIL_LINES:]: + print(f" | {line}") + + if call_lines: + # Deduplicate: skip lines already shown as tunnel lines + daemon_call_lines = [l for l in call_lines if "[tunnel-" not in l] + if daemon_call_lines: + print(f"\n --- signal-cli daemon (call-related, {len(daemon_call_lines)} lines) ---") + for line in daemon_call_lines[-self.TAIL_LINES:]: + print(f" | {line}") + + # Any ERROR/WARN lines not yet shown + error_lines = [l for l in lines + if any(lvl in l for lvl in [" ERROR ", " WARN "]) + and l not in call_lines and l not in tunnel_lines] + if error_lines: + print(f"\n --- signal-cli errors/warnings ({len(error_lines)} lines) ---") + for line in error_lines[-20:]: + print(f" | {line}") + + # Logcat (filtered for Signal/WebRTC) + if "logcat" in segments: + text = segments["logcat"] + lines = text.splitlines() + relevant = [l for l in lines + if any(f.lower() in l.lower() for f in self.LOGCAT_FILTERS)] + if relevant: + print(f"\n --- Android logcat (Signal/WebRTC, {len(relevant)} lines) ---") + for line in relevant[-self.TAIL_LINES:]: + print(f" | {line}") + elif lines: + # No filtered matches; show tail of raw logcat + print(f"\n --- Android logcat (tail, {len(lines)} total lines) ---") + for line in lines[-30:]: + print(f" | {line}") + + # Daemon console (startup errors, crashes) + if "daemon-console" in segments: + text = segments["daemon-console"].strip() + if text: + lines = text.splitlines() + print(f"\n --- daemon console output ({len(lines)} lines) ---") + for line in lines[-20:]: + print(f" | {line}") + + print(f"\n {'='*60}") + print(f" Full logs: {self.log_dir}/scenario_{scenario_id}_*.log") + print(f" {'='*60}\n") + + +# --------------------------------------------------------------------------- +# Screen recording +# --------------------------------------------------------------------------- +class ScreenRecorder: + """Records the emulator screen via `adb screenrecord` for a scenario.""" + + def __init__(self, adb_path, output_dir): + self.adb = adb_path + self.output_dir = Path(output_dir) + self.output_dir.mkdir(parents=True, exist_ok=True) + self._proc = None + self._device_path = "/sdcard/scenario_recording.mp4" + + def start(self, scenario_id): + """Start recording the emulator screen.""" + self.stop() # ensure no leftover recording + self._scenario_id = scenario_id + try: + self._proc = subprocess.Popen( + [self.adb, "shell", "screenrecord", "--time-limit", "120", + self._device_path], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + print(f" [rec] Screen recording started (PID {self._proc.pid})") + except Exception as e: + print(f" [rec] Failed to start screen recording: {e}") + self._proc = None + + def stop(self): + """Stop recording and pull the video to the output directory.""" + if self._proc is None: + return None + # Send SIGINT to gracefully stop screenrecord (finalizes the mp4) + try: + self._proc.send_signal(signal.SIGINT) + self._proc.wait(timeout=5) + except Exception: + self._proc.kill() + self._proc.wait(timeout=3) + self._proc = None + + # Give adb a moment to finalize the file + time.sleep(1) + + # Pull the recording from the device + local_path = self.output_dir / f"scenario_{self._scenario_id}_screen.mp4" + try: + subprocess.run( + [self.adb, "pull", self._device_path, str(local_path)], + capture_output=True, timeout=15, + ) + subprocess.run( + [self.adb, "shell", "rm", "-f", self._device_path], + capture_output=True, timeout=5, + ) + if local_path.exists() and local_path.stat().st_size > 0: + print(f" [rec] Screen recording saved: {local_path}") + return local_path + else: + print(f" [rec] Screen recording file is empty or missing") + except Exception as e: + print(f" [rec] Failed to pull screen recording: {e}") + return None + + +# --------------------------------------------------------------------------- +# Virtual audio device helpers +# --------------------------------------------------------------------------- +def play_to_device(device_name, wav_path, duration=None): + """Play a WAV file to a virtual audio input device (platform-aware). + + Returns the subprocess.Popen object for the background player process. + """ + if IS_MACOS: + # On macOS, use sox to play into the CoreAudio device + cmd = ["sox", str(wav_path), "-t", "coreaudio", device_name, "repeat", "-"] + return subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + else: + # On Linux, play to the PulseAudio sink associated with the input device + sink_name = f"sink_for_{device_name}" + cmd = ["paplay", f"--device={sink_name}", str(wav_path)] + return subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + +def record_from_device(device_name, wav_path, duration): + """Record audio from a virtual audio output device (platform-aware). + + Blocks for `duration` seconds, then returns. + """ + if IS_MACOS: + # On macOS, use sox to record from the CoreAudio device. + # Force mono 16-bit 48kHz output so Python's wave module can read it + # (BlackHole is 2ch, which makes sox emit WAVE_FORMAT_EXTENSIBLE). + cmd = ["sox", "-t", "coreaudio", device_name, + "-b", "16", "-c", "1", "-r", "48000", str(wav_path), + "trim", "0", str(duration)] + else: + # On Linux, record from the PulseAudio monitor source + monitor_name = f"{device_name}.monitor" + cmd = ["parecord", f"--device={monitor_name}", + f"--rate=48000", "--channels=1", "--format=s16le", + f"--file-format=wav", str(wav_path)] + + proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + if IS_MACOS: + # sox with trim will stop after duration + proc.wait(timeout=duration + 10) + else: + # parecord runs indefinitely; kill after duration + time.sleep(duration) + proc.terminate() + proc.wait(timeout=5) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- +def assert_call_stable(rpc, duration=2): + """Verify the call stays connected for at least duration seconds. + + Reads events from the RPC connection, raising AssertionError if an + unexpected ENDED event arrives. Replaces arbitrary time.sleep() pauses + with an actual state-based check. + """ + deadline = time.monotonic() + duration + while time.monotonic() < deadline: + remaining = deadline - time.monotonic() + if remaining <= 0: + break + try: + msg = rpc.read_event(timeout=remaining) + except TimeoutError: + break # No events — call is stable + if msg and msg.get("method") == "callEvent": + event = msg.get("params", {}).get("callEvent", {}) + state = event.get("state") + if state == "ENDED": + raise AssertionError( + f"Call dropped during stability check: reason={event.get('reason')}" + ) + print(f" [rpc] callEvent during stability check: {state}") + + +def wait_for_clean_state(): + """Ensure no lingering call state on the emulator between scenarios. + + Kills Signal, relaunches it, and waits for the WebSocket to reconnect + so the next scenario can receive calls. Raises RuntimeError on timeout. + """ + emu = EmulatorControl(ADB_PATH, output_dir=str(OUTPUT_DIR)) + emu.kill_signal() + # Record a timestamp before launch so we can filter logcat by time + start_ts = emu._shell("date '+%m-%d %H:%M:%S.000'") + emu.launch_signal() + # Wait for Signal's authenticated WebSocket to connect. + deadline = time.monotonic() + 20 + while time.monotonic() < deadline: + logcat = emu._shell( + f"logcat -d -T '{start_ts}' " + "-s SignalWebSocketHealthMo:V IncomingMessageObserver:D " + "2>/dev/null", + timeout=5, + ) + if "CONNECTED" in logcat: + return + time.sleep(1) + raise RuntimeError( + "Signal did not reconnect WebSocket within 20s after restart" + ) + + +# --------------------------------------------------------------------------- +# Scenario A: Outgoing call -- signaling and lifecycle +# --------------------------------------------------------------------------- +def scenario_a(socket_path): + """Outgoing call: signal-cli places call, emulator answers, signal-cli hangs up.""" + emu = EmulatorControl(ADB_PATH, output_dir=str(OUTPUT_DIR)) + rpc = SignalRPC(socket_path) + call_id = None + try: + rpc.subscribe_receive() + + # Place the call + print(" [A] Starting outgoing call to emulator...") + result = rpc.start_call(EMULATOR_ACCOUNT) + call_id = result.get("callId") + state = result.get("state") + input_dev = result.get("inputDeviceName") + output_dev = result.get("outputDeviceName") + print(f" [A] startCall => callId={call_id}, state={state}") + assert call_id, "No callId returned" + + # Wait for RINGING_OUTGOING + if state != "RINGING_OUTGOING": + rpc.wait_for_state("RINGING_OUTGOING", timeout=15) + + # Answer on emulator (polls internally for call to arrive) + print(" [A] Answering call on emulator...") + emu.answer_incoming_call() + + # Wait for CONNECTED + print(" [A] Waiting for CONNECTED...") + rpc.wait_for_state("CONNECTED", timeout=30) + print(" [A] Call connected! Waiting 5s before hanging up from signal-cli...") + assert_call_stable(rpc, duration=5) + + # Hang up + print(" [A] Hanging up from signal-cli...") + rpc.hangup_call(call_id) + call_id = None # Don't double-hangup in cleanup + + # Wait for ENDED + rpc.wait_for_state("ENDED", timeout=10) + print(" [A] Call ended normally.") + + return TestResult("A: Outgoing call lifecycle", True) + + except Exception as e: + return TestResult("A: Outgoing call lifecycle", False, str(e)) + finally: + if call_id: + try: + rpc.hangup_call(call_id) + except Exception: + pass + rpc.close() + + +# --------------------------------------------------------------------------- +# Scenario B: Incoming call -- emulator calls signal-cli +# --------------------------------------------------------------------------- +def scenario_b(socket_path): + """Incoming call: emulator places call, signal-cli accepts and hangs up.""" + emu = EmulatorControl(ADB_PATH, output_dir=str(OUTPUT_DIR)) + rpc = SignalRPC(socket_path) + call_id = None + try: + rpc.subscribe_receive() + + # Navigate emulator to conversation and place call + print(" [B] Opening conversation on emulator...") + emu.open_conversation(SIGNAL_CLI_ACCOUNT) + + print(" [B] Tapping call button on emulator...") + emu.tap_call_button() + + # Wait for incoming call event + print(" [B] Waiting for RINGING_INCOMING...") + params = rpc.wait_for_state("RINGING_INCOMING", timeout=30) + event = params.get("callEvent", {}) + call_id = event.get("callId") + print(f" [B] Incoming call: callId={call_id}") + assert call_id, "No callId in incoming call event" + + # Accept the call + print(" [B] Accepting call...") + rpc.accept_call(call_id) + + # Wait for CONNECTED + print(" [B] Waiting for CONNECTED...") + rpc.wait_for_state("CONNECTED", timeout=30) + print(" [B] Call connected!") + + # Verify call remains stable + print(" [B] Verifying call stability...") + assert_call_stable(rpc, duration=2) + + # Hang up from signal-cli + print(" [B] Hanging up...") + rpc.hangup_call(call_id) + call_id = None + + rpc.wait_for_state("ENDED", timeout=10) + print(" [B] Call ended normally.") + + return TestResult("B: Incoming call lifecycle", True) + + except Exception as e: + return TestResult("B: Incoming call lifecycle", False, str(e)) + finally: + if call_id: + try: + rpc.hangup_call(call_id) + except Exception: + pass + rpc.close() + + +# --------------------------------------------------------------------------- +# Scenario C: Incoming call rejection +# --------------------------------------------------------------------------- +def scenario_c(socket_path): + """Incoming call: emulator places call, signal-cli rejects it.""" + emu = EmulatorControl(ADB_PATH, output_dir=str(OUTPUT_DIR)) + rpc = SignalRPC(socket_path) + call_id = None + try: + rpc.subscribe_receive() + + # Emulator places call + print(" [C] Opening conversation on emulator...") + emu.open_conversation(SIGNAL_CLI_ACCOUNT) + + print(" [C] Tapping call button on emulator...") + emu.tap_call_button() + + # Wait for incoming ring + print(" [C] Waiting for RINGING_INCOMING...") + params = rpc.wait_for_state("RINGING_INCOMING", timeout=30) + event = params.get("callEvent", {}) + call_id = event.get("callId") + assert call_id, "No callId in incoming call event" + + # Reject the call + print(" [C] Rejecting call...") + rpc.reject_call(call_id) + call_id = None + + # Wait for ENDED and verify rejection reason + params = rpc.wait_for_state("ENDED", timeout=10) + event = params.get("callEvent", {}) + reason = event.get("reason", "") + print(f" [C] Call ended: reason={reason}") + assert any(kw in reason.lower() for kw in ("reject", "busy", "decline")), \ + f"Expected rejection reason, got: {reason}" + + return TestResult("C: Incoming call rejection", True, f"reason={reason}") + + except Exception as e: + return TestResult("C: Incoming call rejection", False, str(e)) + finally: + if call_id: + try: + rpc.hangup_call(call_id) + except Exception: + pass + rpc.close() + + +# --------------------------------------------------------------------------- +# Scenario D: Ring timeout +# --------------------------------------------------------------------------- +def scenario_d(socket_path): + """Outgoing call that is never answered -- should timeout.""" + emu = EmulatorControl(ADB_PATH, output_dir=str(OUTPUT_DIR)) + rpc = SignalRPC(socket_path) + call_id = None + try: + # Restart Signal to a clean main screen so it can receive the call + # (but nobody will tap Answer) + emu.ensure_signal_foreground() + + rpc.subscribe_receive() + + # Place call, don't answer on emulator + print(" [D] Starting call (will NOT answer)...") + result = rpc.start_call(EMULATOR_ACCOUNT) + call_id = result.get("callId") + state = result.get("state") + print(f" [D] startCall => callId={call_id}, state={state}") + + if state != "RINGING_OUTGOING": + rpc.wait_for_state("RINGING_OUTGOING", timeout=15) + + # Wait for timeout (Signal typically times out after ~60s) + print(" [D] Waiting for ring timeout (up to 90s)...") + params = rpc.wait_for_state("ENDED", timeout=90) + event = params.get("callEvent", {}) + reason = event.get("reason", "") + print(f" [D] Call ended: reason={reason}") + call_id = None + + assert "timeout" in reason.lower(), \ + f"Expected timeout reason, got: {reason}" + + return TestResult("D: Ring timeout", True, f"reason={reason}") + + except Exception as e: + return TestResult("D: Ring timeout", False, str(e)) + finally: + if call_id: + try: + rpc.hangup_call(call_id) + except Exception: + pass + rpc.close() + + +# --------------------------------------------------------------------------- +# Scenario E: Bidirectional audio verification +# --------------------------------------------------------------------------- +def scenario_e(socket_path): + """Connected call with bidirectional audio: tone generation and detection.""" + if not HAS_GRPC_AUDIO: + return TestResult("E: Bidirectional audio", False, + "grpc_audio not available (run generate_proto.sh first)") + + emu = EmulatorControl(ADB_PATH, output_dir=str(OUTPUT_DIR)) + rpc = SignalRPC(socket_path) + call_id = None + grpc_audio = None + player_proc = None + try: + # Bring Signal to foreground without killing it. + emu.launch_signal() + + rpc.subscribe_receive() + + # Place call and connect + print(" [E] Starting outgoing call...") + result = rpc.start_call(EMULATOR_ACCOUNT) + call_id = result.get("callId") + input_device = result.get("inputDeviceName") + output_device = result.get("outputDeviceName") + assert call_id, "Missing callId" + + state = result.get("state") + if state != "RINGING_OUTGOING": + rpc.wait_for_state("RINGING_OUTGOING", timeout=15) + + # Answer on emulator (polls internally for call to arrive) + print(" [E] Answering on emulator...") + emu.answer_incoming_call() + + print(" [E] Waiting for CONNECTED...") + event_params = rpc.wait_for_state("CONNECTED", timeout=30) + print(" [E] Call connected!") + + # Get device names from the CONNECTED event if not in startCall response + if not input_device or not output_device: + event = event_params.get("callEvent", {}) + input_device = input_device or event.get("inputDeviceName") + output_device = output_device or event.get("outputDeviceName") + + assert input_device, "No inputDeviceName available" + assert output_device, "No outputDeviceName available" + print(f" [E] Virtual audio: input={input_device}, output={output_device}") + + # Set in-call volume to max (default is often 3/15 on emulators) + emu.set_call_volume_max() + + # Settling delay: let WebRTC/Opus codec stabilize before audio tests + print(" [E] Waiting 2s for WebRTC/Opus to stabilize...") + assert_call_stable(rpc, duration=2) + + grpc_audio = EmulatorAudio(port=EMULATOR_GRPC_PORT) + + # --- Direction 1: signal-cli -> emulator (440 Hz) --- + # Generate test tone as WAV file, play it into the virtual input device + print(f" [E] Direction 1: Sending {TEST_TONE_FREQ_OUT}Hz tone via virtual audio device...") + play_duration = TEST_TONE_DURATION + 5 # extra time for settling + tone_pcm = generate_tone(TEST_TONE_FREQ_OUT, play_duration) + setup_output_dir() + + tone_wav_path = OUTPUT_DIR / "e_tone_out_source.wav" + pcm_to_wav(tone_pcm, tone_wav_path) + + # Start playing tone into the virtual input device + player_proc = play_to_device(input_device, tone_wav_path) + + # Wait for tone to flow through WebRTC (encode + network + decode) + time.sleep(4) + + # Capture from emulator speaker while tone is playing + capture_duration = TEST_TONE_DURATION + 1 + min_capture_bytes = 48000 * 2 * 2 # at least 2s of PCM + captured_pcm = grpc_audio.capture_audio(capture_duration) + + # Stop player + if player_proc and player_proc.poll() is None: + player_proc.terminate() + player_proc.wait(timeout=5) + player_proc = None + + print(f" [E] Captured {len(captured_pcm)} bytes from emulator speaker") + + if captured_pcm: + pcm_to_wav(captured_pcm, OUTPUT_DIR / "e_tone_out_captured.wav") + + dir1_ok = False + dir1_rms = 0.0 + if captured_pcm and len(captured_pcm) > 1920: + dir1_rms = rms_level(captured_pcm) + dir1_ok = detect_tone(captured_pcm, TEST_TONE_FREQ_OUT) + print(f" [E] Direction 1: detect={dir1_ok}, RMS={dir1_rms:.4f}") + assert dir1_rms > 0.001, \ + f"Direction 1: captured audio is silent (RMS={dir1_rms:.6f})" + else: + print(f" [E] Direction 1: insufficient captured audio") + + # --- Direction 2: emulator -> signal-cli (playout path) --- + # Record from the virtual output device to verify playout works + print(" [E] Direction 2: Recording from virtual output device...") + record_duration = 3 + recorded_wav_path = OUTPUT_DIR / "e_playout_received.wav" + record_from_device(output_device, recorded_wav_path, record_duration) + + recorded_pcm = b"" + if recorded_wav_path.exists() and recorded_wav_path.stat().st_size > 44: + recorded_pcm = wav_to_pcm(recorded_wav_path) + + recorded_bytes = len(recorded_pcm) + expected_bytes = 48000 * 2 * record_duration + min_bytes = expected_bytes // 2 + + print(f" [E] Direction 2: received {recorded_bytes} bytes " + f"(expected ~{expected_bytes})") + + dir2_ok = recorded_bytes >= min_bytes + dir2_rms = rms_level(recorded_pcm) if recorded_pcm else 0.0 + + # Hang up + print(" [E] Hanging up...") + rpc.hangup_call(call_id) + call_id = None + params = rpc.wait_for_state("ENDED", timeout=10) + + # Validate call ended normally (not a crash) + event = params.get("callEvent", {}) + reason = event.get("reason", "") + print(f" [E] Call ended: reason={reason}") + assert reason and "error" not in reason.lower(), \ + f"Call ended abnormally: reason={reason}" + + # Report results + msgs = [] + msgs.append(f"dir1(signal-cli->emu):{'OK' if dir1_ok else 'FAIL'} RMS={dir1_rms:.4f}") + msgs.append(f"dir2(playout-path):{'OK' if dir2_ok else 'FAIL'} " + f"{recorded_bytes}B/{expected_bytes}B") + + passed = dir1_ok and dir2_ok + return TestResult("E: Bidirectional audio", passed, ", ".join(msgs)) + + except Exception as e: + return TestResult("E: Bidirectional audio", False, str(e)) + finally: + if player_proc and player_proc.poll() is None: + player_proc.terminate() + if call_id: + try: + rpc.hangup_call(call_id) + except Exception: + pass + if grpc_audio: + grpc_audio.close() + rpc.close() + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- +SCENARIOS = { + "A": ("Outgoing call lifecycle", scenario_a), + "B": ("Incoming call lifecycle", scenario_b), + "C": ("Incoming call rejection", scenario_c), + "D": ("Ring timeout", scenario_d), + "E": ("Bidirectional audio", scenario_e), +} + + +def main(): + parser = argparse.ArgumentParser(description="E2E voice call test runner") + parser.add_argument("--socket", required=True, help="Path to signal-cli JSON-RPC socket") + parser.add_argument("--scenarios", default="A,B,C,D,E", + help="Comma-separated list of scenarios to run (default: A,B,C,D,E)") + parser.add_argument("--log-dir", default=None, + help="Directory containing log files for diagnostic collection") + parser.add_argument("--record", action="store_true", + help="Record emulator screen during each scenario (saved to output dir)") + parser.add_argument("--no-fail-fast", action="store_true", + help="Continue running scenarios after a failure (default: stop on first failure)") + args = parser.parse_args() + + selected = [s.strip().upper() for s in args.scenarios.split(",")] + for s in selected: + if s not in SCENARIOS: + print(f"Unknown scenario: {s}") + print(f"Available: {', '.join(SCENARIOS.keys())}") + sys.exit(1) + + logs = LogCollector(args.log_dir) + recorder = ScreenRecorder(ADB_PATH, OUTPUT_DIR) if args.record else None + + print(f"=== E2E Voice Call Tests ===") + print(f"Socket: {args.socket}") + print(f"Scenarios: {', '.join(selected)}") + print(f"Emulator account: {EMULATOR_ACCOUNT}") + print(f"signal-cli account: {SIGNAL_CLI_ACCOUNT}") + if logs.enabled: + print(f"Log collection: {args.log_dir}") + if recorder: + print(f"Screen recording: enabled") + print() + + results = [] + for s in selected: + name, func = SCENARIOS[s] + print(f"--- Scenario {s}: {name} ---") + logs.mark_start(s) + if recorder: + recorder.start(s) + t0 = time.monotonic() + try: + result = func(args.socket) + except Exception as e: + result = TestResult(f"{s}: {name}", False, f"Unhandled: {e}") + traceback.print_exc() + result.duration = time.monotonic() - t0 + + # Retry once on failure for scenarios with flaky external dependencies + if not result.passed and s == "E": + print(f" => {result}") + print(f" [E] Retrying scenario E (emulator audio HAL may need reset)...") + try: + wait_for_clean_state() + except RuntimeError as e: + print(f" [E] Cannot retry: {e}") + else: + logs.mark_start(s) + t0 = time.monotonic() + try: + result = func(args.socket) + except Exception as e: + result = TestResult(f"{s}: {name}", False, f"Unhandled: {e}") + traceback.print_exc() + result.duration = time.monotonic() - t0 + + if recorder: + recorder.stop() + results.append(result) + print(f" => {result}") + + # Collect and save logs for this scenario (prints diagnostics on failure) + logs.save_scenario_logs(s, result) + print() + + # Stop on first failure unless --no-fail-fast is set + if not result.passed and not args.no_fail_fast: + print(f"Stopping after scenario {s} failure (use --no-fail-fast to continue)") + break + + # Wait for clean state between scenarios + if s != selected[-1]: + try: + wait_for_clean_state() + except RuntimeError as e: + print(f" WARNING: {e}") + print(f" Continuing anyway — next scenario may fail.") + + # Summary + print("=" * 50) + print("RESULTS:") + passed = 0 + failed_ids = [] + for i, r in enumerate(results): + print(f" {r}") + if r.passed: + passed += 1 + else: + failed_ids.append(selected[i]) + total = len(results) + print(f"\n{passed}/{total} passed") + + if failed_ids and logs.enabled: + print(f"\nDiagnostic logs for failed scenarios:") + for fid in failed_ids: + print(f" Scenario {fid}: {args.log_dir}/scenario_{fid}_*.log") + + sys.exit(0 if passed == total else 1) + + +if __name__ == "__main__": + main() diff --git a/voice-test/generate_proto.sh b/voice-test/generate_proto.sh new file mode 100755 index 00000000..6a496444 --- /dev/null +++ b/voice-test/generate_proto.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Compile emulator_controller.proto into Python stubs. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ANDROID_SDK="${ANDROID_SDK:-/opt/homebrew/share/android-commandlinetools}" +PROTO_DIR="$ANDROID_SDK/emulator/lib" +PROTO_FILE="$PROTO_DIR/emulator_controller.proto" +OUT_DIR="$SCRIPT_DIR/lib/proto" + +if [ ! -f "$PROTO_FILE" ]; then + echo "ERROR: Proto file not found: $PROTO_FILE" + echo "Make sure Android emulator is installed via commandlinetools." + exit 1 +fi + +mkdir -p "$OUT_DIR" + +echo "Compiling $PROTO_FILE -> $OUT_DIR ..." +python3 -m grpc_tools.protoc \ + "-I$PROTO_DIR" \ + "--python_out=$OUT_DIR" \ + "--grpc_python_out=$OUT_DIR" \ + "$PROTO_FILE" + +# Fix the generated import path (grpc_tools generates absolute imports) +# The grpc stub file imports the pb2 module; ensure it works as a local import. +if [ -f "$OUT_DIR/emulator_controller_pb2_grpc.py" ]; then + # On macOS, sed -i requires '' argument + sed -i '' 's/^import emulator_controller_pb2/from . import emulator_controller_pb2/' \ + "$OUT_DIR/emulator_controller_pb2_grpc.py" 2>/dev/null || \ + sed -i 's/^import emulator_controller_pb2/from . import emulator_controller_pb2/' \ + "$OUT_DIR/emulator_controller_pb2_grpc.py" +fi + +echo "Proto stubs generated in $OUT_DIR:" +ls -la "$OUT_DIR"/emulator_controller_pb2*.py diff --git a/voice-test/lib/__init__.py b/voice-test/lib/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/voice-test/lib/audio.py b/voice-test/lib/audio.py new file mode 100644 index 00000000..b1ff0897 --- /dev/null +++ b/voice-test/lib/audio.py @@ -0,0 +1,126 @@ +"""Audio utilities: tone generation, frequency detection (Goertzel), WAV I/O. + +Pure Python, no numpy dependency. +""" + +import math +import struct +import wave + + +def generate_tone(freq_hz, duration_s, sample_rate=48000, amplitude=0.8): + """Generate PCM bytes (S16LE mono) for a sine wave at freq_hz.""" + n_samples = int(sample_rate * duration_s) + samples = [] + for i in range(n_samples): + t = i / sample_rate + value = amplitude * math.sin(2 * math.pi * freq_hz * t) + sample = int(value * 32767) + sample = max(-32768, min(32767, sample)) + samples.append(struct.pack("= sample_rate // 2: + continue + mag = goertzel_magnitude(pcm_bytes, f, sample_rate) + if mag > peak_mag: + peak_mag = mag + peak_freq = f + + # Measure noise at frequencies well outside the signal window. + noise_freqs = [] + for offset in [-600, -400, 400, 600]: + f = expected_freq + offset + if 50 < f < sample_rate // 2: + noise_freqs.append(f) + + if not noise_freqs: + return peak_mag > 0.001 + + avg_noise = sum( + goertzel_magnitude(pcm_bytes, f, sample_rate) for f in noise_freqs + ) / len(noise_freqs) + + if avg_noise < 1e-8: + return peak_mag > 0.001 + + ratio = peak_mag / avg_noise + print(f" [audio] detect_tone({expected_freq}Hz): peak={peak_mag:.6f}@{peak_freq}Hz, noise={avg_noise:.6f}, ratio={ratio:.1f} (threshold={threshold})") + return ratio >= threshold + + +def pcm_to_wav(pcm_bytes, path, sample_rate=48000): + """Write raw PCM (S16LE mono) to a WAV file.""" + wf = wave.open(str(path), "wb") + wf.setnchannels(1) + wf.setsampwidth(2) + wf.setframerate(sample_rate) + wf.writeframes(pcm_bytes) + wf.close() + + +def wav_to_pcm(path): + """Read a WAV file and return raw PCM bytes (S16LE mono).""" + wf = wave.open(str(path), "rb") + pcm = wf.readframes(wf.getnframes()) + wf.close() + return pcm + + +def rms_level(pcm_bytes): + """RMS amplitude of PCM data (0.0 = silence, 1.0 = full scale).""" + n_samples = len(pcm_bytes) // 2 + if n_samples == 0: + return 0.0 + sum_sq = 0.0 + for i in range(n_samples): + sample = struct.unpack_from("") + 1 + xml_text = xml_text[start:end] + if self._output_dir: + try: + dump_path = os.path.join(self._output_dir, "window_dump.xml") + with open(dump_path, "w") as f: + f.write(xml_text) + except OSError: + pass + return ET.fromstring(xml_text) + except subprocess.TimeoutExpired: + if attempt == 0: + print(" [emu] uiautomator dump timed out, retrying...") + continue + print(" [emu] uiautomator dump timed out twice") + return None + except (ET.ParseError, ValueError) as e: + if attempt == 0: + time.sleep(1) + continue + print(f" [emu] uiautomator dump parse error: {e}") + return None + return None + + def _parse_bounds(self, bounds_str): + """Parse a bounds attribute like '[0,0][1080,1920]' into (cx, cy).""" + m = re.match(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", bounds_str) + if not m: + return None + x1, y1, x2, y2 = int(m.group(1)), int(m.group(2)), int(m.group(3)), int(m.group(4)) + return ((x1 + x2) // 2, (y1 + y2) // 2) + + def _find_element(self, root=None, text=None, content_desc=None, + resource_id_contains=None, class_name=None): + """Find a UI element in the hierarchy and return its center (x, y). + + If root is None, calls _dump_ui() to get the current hierarchy. + Searches for a node matching ALL provided criteria. + Returns (center_x, center_y) or None if not found. + """ + if root is None: + root = self._dump_ui() + if root is None: + return None + + for node in root.iter("node"): + if text is not None and node.get("text", "") != text: + continue + if content_desc is not None and content_desc not in node.get("content-desc", ""): + continue + if resource_id_contains is not None and resource_id_contains not in node.get("resource-id", ""): + continue + if class_name is not None and node.get("class", "") != class_name: + continue + + bounds = node.get("bounds", "") + center = self._parse_bounds(bounds) + if center: + return center + return None + + def _tap_element(self, **kwargs): + """Find an element via _find_element() and tap its center. + + Returns True if the element was found and tapped, False otherwise. + Passes all kwargs through to _find_element(). + """ + center = self._find_element(**kwargs) + if center is None: + criteria = {k: v for k, v in kwargs.items() if v is not None and k != "root"} + print(f" [emu] Element not found: {criteria}") + return False + self.tap(*center) + return True + + # ------------------------------------------------------------------ + # Signal lifecycle + # ------------------------------------------------------------------ + + def kill_signal(self): + """Force-stop Signal. Clears any stuck dialogs, notifications, etc.""" + self._shell("am force-stop org.thoughtcrime.securesms") + time.sleep(1) + + def launch_signal(self): + """Launch Signal to its main chat list screen and wait for it.""" + self._shell( + "monkey -p org.thoughtcrime.securesms " + "-c android.intent.category.LAUNCHER 1" + ) + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + focus = self._shell("dumpsys window | grep mCurrentFocus") + if "org.thoughtcrime.securesms" in focus: + return True + time.sleep(0.5) + print(" [emu] Warning: Signal may not be in foreground") + return False + + def restart_signal(self): + """Kill and relaunch Signal to a clean state.""" + print(" [emu] Restarting Signal...") + self.kill_signal() + self.launch_signal() + + # ------------------------------------------------------------------ + # Navigation + # ------------------------------------------------------------------ + + def open_conversation(self, phone_number): + """Open conversation with the given phone number. + + Kills and relaunches Signal to start from a clean chat list, + then taps the first conversation row using uiautomator lookup. + """ + self.restart_signal() + time.sleep(2) # let chat list fully render + + # Try to find and tap the first conversation row + root = self._dump_ui() + tapped = False + if root is not None: + # Try by content-desc containing the contact name/number + tapped = self._tap_element(root=root, content_desc=phone_number) + if not tapped: + # Try finding a conversation item by resource-id + tapped = self._tap_element(root=root, resource_id_contains="conversation") + if not tapped: + # Find the first clickable row below the toolbar area: + # look for clickable FrameLayout/LinearLayout nodes + for node in root.iter("node"): + if node.get("clickable") != "true": + continue + cls = node.get("class", "") + if cls not in ("android.widget.FrameLayout", + "android.widget.LinearLayout", + "android.view.ViewGroup"): + continue + bounds = node.get("bounds", "") + center = self._parse_bounds(bounds) + if center and center[1] > 200: # below toolbar area + self.tap(*center) + tapped = True + break + + if not tapped: + print(" [emu] Warning: could not find conversation row via uiautomator") + + time.sleep(2) # let conversation load + + # Verify we're in a conversation by checking window focus + focus = self._shell("dumpsys window | grep mCurrentFocus") + if "org.thoughtcrime.securesms" in focus: + print(" [emu] Conversation opened") + else: + print(" [emu] Warning: may not be in conversation") + + def ensure_signal_foreground(self): + """Ensure Signal is in the foreground (for Scenario A where + we just need Signal running, not in a specific conversation).""" + self.restart_signal() + + # ------------------------------------------------------------------ + # Placing calls (from emulator) + # ------------------------------------------------------------------ + + def _dismiss_permission_dialogs(self): + """Dismiss any permission dialogs that appear on the call screen. + + Signal may show camera/microphone permission prompts before the + pre-join call screen. Tap "Not now" or "Deny" to dismiss them. + """ + for _ in range(3): # handle up to 3 stacked dialogs + root = self._dump_ui() + if root is None: + return + dismissed = False + for criteria in [ + {"root": root, "text": "Not now"}, + {"root": root, "text": "Deny"}, + {"root": root, "text": "Don\u2019t allow"}, + {"root": root, "text": "Don't allow"}, + ]: + if self._tap_element(**criteria): + print(f" [emu] Dismissed permission dialog") + dismissed = True + time.sleep(0.5) + break + if not dismissed: + return + + def tap_call_button(self): + """Tap the voice call button in the conversation header, + then confirm the 'Start voice call?' dialog.""" + # Tap the call icon in the header (try content-desc patterns) + tapped = self._tap_element(content_desc="Signal call") + if not tapped: + tapped = self._tap_element(content_desc="Voice call") + if not tapped: + tapped = self._tap_element(content_desc="call") + if not tapped: + print(" [emu] Warning: could not find call button via uiautomator") + + time.sleep(1.5) # wait for call screen / dialog + + # Dismiss any permission dialogs (camera, microphone) that may + # appear before the pre-join screen. + self._dismiss_permission_dialogs() + time.sleep(0.5) + + # On newer Signal versions, tapping the call icon opens a pre-join + # call activity instead of a confirmation dialog. Try both flows. + print(" [emu] Confirming call (dialog or pre-join screen)...") + root = self._dump_ui() + tapped = False + if root is not None: + # Try pre-join screen "Start Call" button first, then dialog "Call" + for criteria in [ + {"root": root, "text": "Start Call"}, + {"root": root, "text": "Start call"}, + {"root": root, "content_desc": "Start call"}, + {"root": root, "content_desc": "Start Call"}, + {"root": root, "text": "Call"}, + {"root": root, "text": "Voice call"}, + {"root": root, "content_desc": "Voice call"}, + ]: + if self._tap_element(**criteria): + tapped = True + break + + if not tapped: + print(" [emu] Warning: could not find call start button") + print(" [emu] Elements on screen:") + for node in root.iter("node"): + text = node.get("text", "") + desc = node.get("content-desc", "") + click = node.get("clickable", "") + if text or desc: + bounds = node.get("bounds", "") + print(f" [emu] text={text!r} desc={desc!r} " + f"click={click} bounds={bounds}") + + # ------------------------------------------------------------------ + # Answering / rejecting calls (on emulator) + # ------------------------------------------------------------------ + + def _wait_for_incoming_call(self, timeout=30): + """Poll logcat until the emulator is actually ringing. + + Waits for LocalRinging (the point where the heads-up notification + with answer/decline buttons appears). handleReceivedOffer fires + much earlier during ICE negotiation. + """ + start_ts = self._shell("date '+%m-%d %H:%M:%S.000'") + + deadline = time.monotonic() + timeout + offer_seen = False + while time.monotonic() < deadline: + logcat = self._shell( + f"logcat -d -T '{start_ts}' 2>/dev/null", timeout=5 + ) + + if not offer_seen and "handleReceivedOffer" in logcat: + print(" [emu] Offer received, waiting for ringing...") + offer_seen = True + + if "event: LOCAL_RINGING" in logcat or "handleLocalRinging" in logcat: + print(" [emu] Phone is ringing (LocalRinging)") + return True + + time.sleep(0.5) + print(" [emu] Warning: incoming call did not start ringing within timeout") + return False + + def _check_call_accepted(self, logcat_since): + """Check logcat for handleAcceptCall (call was answered).""" + logcat = self._shell( + f"logcat -d -T '{logcat_since}' 2>/dev/null", timeout=5 + ) + return "handleAcceptCall" in logcat + + def _wait_for_call_accepted(self, logcat_since, timeout=3): + """Poll logcat until handleAcceptCall appears or timeout.""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if self._check_call_accepted(logcat_since): + return True + time.sleep(0.3) + return False + + def answer_incoming_call(self): + """Answer an incoming call on the emulator. + + Waits for the phone to ring, then tries strategies in order: + 1. Expand notification shade, find and tap "Answer" via uiautomator + (the heads-up notification is invisible to uiautomator, but the + shade's notification actions ARE in the SystemUI hierarchy) + 2. KEYCODE_HEADSETHOOK (hardware button simulation) + 3. cmd telecom accept-ringing-call (only works if app uses Telecom) + + Each strategy is verified via logcat (handleAcceptCall). + """ + self._wait_for_incoming_call(timeout=30) + + logcat_since = self._shell("date '+%m-%d %H:%M:%S.000'") + time.sleep(1) # let notification fully render + + # Strategy 1: expand notification shade, find Answer button + print(" [emu] Expanding notification shade...") + self._shell("cmd statusbar expand-notifications") + time.sleep(1) # let shade animate open + + root = self._dump_ui() + tapped = False + if root is not None: + for criteria in [ + {"root": root, "text": "Answer"}, + {"root": root, "text": "Accept"}, + {"root": root, "content_desc": "Answer"}, + {"root": root, "content_desc": "Accept"}, + ]: + if self._tap_element(**criteria): + tapped = True + break + + # Collapse the shade regardless of whether we found the button + self._shell("cmd statusbar collapse") + + if tapped: + if self._wait_for_call_accepted(logcat_since, timeout=3): + print(" [emu] Call answered via notification tap") + return + + # Strategy 2: HEADSETHOOK keyevent + print(" [emu] Fallback: KEYCODE_HEADSETHOOK") + self._shell("input keyevent 79") + if self._wait_for_call_accepted(logcat_since, timeout=3): + print(" [emu] Call answered via HEADSETHOOK") + return + + # Strategy 3: telecom command (works if app registers with Telecom) + print(" [emu] Fallback: cmd telecom accept-ringing-call") + self._shell("cmd telecom accept-ringing-call") + if self._wait_for_call_accepted(logcat_since, timeout=3): + print(" [emu] Call answered via telecom command") + return + + print(" [emu] Warning: could not confirm call was answered") + + def reject_incoming_call(self): + """Reject an incoming call on the emulator. + + Waits for ringing, then uses the ENDCALL keyevent or telecom command. + Verifies via logcat that the call ended. + """ + self._wait_for_incoming_call(timeout=30) + + logcat_since = self._shell("date '+%m-%d %H:%M:%S.000'") + time.sleep(1) + + # Use ENDCALL keyevent (keycode 6) — works without coordinates + print(" [emu] Rejecting call via ENDCALL keyevent...") + self._shell("input keyevent ENDCALL") + + # Verify call ended + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + logcat = self._shell( + f"logcat -d -T '{logcat_since}' 2>/dev/null", timeout=5 + ) + if "call_concluded" in logcat or "onCallConcluded" in logcat: + print(" [emu] Call declined") + return + time.sleep(0.3) + + # Fallback: try telecom end-call + print(" [emu] Fallback: cmd telecom end-call") + self._shell("cmd telecom end-call") + print(" [emu] Warning: could not confirm call was declined") + + # ------------------------------------------------------------------ + # Utilities + # ------------------------------------------------------------------ + + def set_call_volume_max(self): + """Set in-call volume to maximum by simulating volume-up key presses. + + The voice call volume can only be changed during an active call. + We press KEYCODE_VOLUME_UP enough times to reach max (15 steps). + """ + for _ in range(15): + self._shell("input keyevent 24") # KEYCODE_VOLUME_UP + + def tap(self, x, y): + """Tap at screen coordinates.""" + self._shell(f"input tap {x} {y}") + + def is_device_online(self): + """Check if the emulator is reachable via adb.""" + output = self._run("devices") + return "emulator" in output and "device" in output diff --git a/voice-test/lib/grpc_audio.py b/voice-test/lib/grpc_audio.py new file mode 100644 index 00000000..edd025b2 --- /dev/null +++ b/voice-test/lib/grpc_audio.py @@ -0,0 +1,189 @@ +"""Emulator gRPC audio injection and capture client. + +Uses compiled proto stubs from the Android emulator's emulator_controller.proto. +Supports 48kHz mono S16LE audio matching the media socket format. + +Handles both unauthenticated (-grpc flag) and JWT-authenticated emulator gRPC. +""" + +import json +import os +import time +from pathlib import Path + + +def _get_stubs(): + """Lazy-import the generated proto stubs.""" + from lib.proto import emulator_controller_pb2 as pb + from lib.proto import emulator_controller_pb2_grpc as pb_grpc + return pb, pb_grpc + + +class EmulatorAudio: + """Client for the emulator's gRPC audio streaming API.""" + + def __init__(self, host="localhost", port=8554, token=None): + import grpc + self._grpc = grpc + self._token = token + self._host = host + self._port = port + + self.channel = grpc.insecure_channel(f"{host}:{port}") + pb, pb_grpc = _get_stubs() + self.stub = pb_grpc.EmulatorControllerStub(self.channel) + self.pb = pb + + # If no token provided, probe connectivity and auto-discover if needed + if self._token is None: + self._try_connect_or_discover() + + def _metadata(self): + """Return gRPC call metadata with auth header if token is set.""" + if self._token: + return [('authorization', f'Bearer {self._token}')] + return [] + + def _try_connect_or_discover(self): + """Test unauthenticated connectivity; fall back to token discovery.""" + import grpc + try: + grpc.channel_ready_future(self.channel).result(timeout=3) + # Channel connected — try a trivial call to check auth + self.stub.getStatus(self.pb.Empty(), timeout=3) + except grpc.RpcError as e: + if e.code() == grpc.StatusCode.UNAUTHENTICATED: + print(" [grpc] Unauthenticated — attempting token discovery...") + token = self.discover_token() + if token: + self._token = token + print(" [grpc] Token discovered, retrying with auth...") + else: + print(" [grpc] No token found. Restart emulator with: -grpc ") + else: + # Non-auth error (e.g. connection refused) — let caller handle + pass + except Exception: + pass + + @classmethod + def discover_token(cls): + """Search emulator discovery directories for a gRPC auth token. + + The emulator writes discovery files to: + - ~/.android/avd/running/ (Linux/macOS default) + - $TMPDIR/avd/running/ (macOS alternate) + + Each running emulator creates a pid_NNNNN.ini with grpc.token or + a corresponding .jwk file. + """ + search_dirs = [] + + # ~/.android/avd/running/ + android_home = Path.home() / ".android" / "avd" / "running" + if android_home.is_dir(): + search_dirs.append(android_home) + + # $TMPDIR/avd/running/ + tmpdir = os.environ.get("TMPDIR", "/tmp") + tmpdir_running = Path(tmpdir) / "avd" / "running" + if tmpdir_running.is_dir(): + search_dirs.append(tmpdir_running) + + for d in search_dirs: + # Look for pid_*.ini files that contain grpc.token + for ini_file in sorted(d.glob("pid_*.ini"), reverse=True): + try: + text = ini_file.read_text() + for line in text.splitlines(): + if line.startswith("grpc.token="): + token = line.split("=", 1)[1].strip() + if token: + return token + except OSError: + continue + + # Look for .jwk files (JSON Web Key — contains the token) + for jwk_file in sorted(d.glob("*.jwk"), reverse=True): + try: + data = json.loads(jwk_file.read_text()) + # The emulator JWK file format varies; look for common keys + if isinstance(data, dict): + token = data.get("token") or data.get("grpc_token") + if token: + return token + except (OSError, json.JSONDecodeError): + continue + + return None + + def close(self): + self.channel.close() + + def inject_audio(self, pcm_bytes, sample_rate=48000): + """Inject PCM audio into the emulator's virtual microphone. + + Client-streaming RPC: first packet includes AudioFormat, all include audio data. + """ + pb = self.pb + metadata = self._metadata() + audio_format = pb.AudioFormat( + samplingRate=sample_rate, + channels=pb.AudioFormat.Mono, + format=pb.AudioFormat.AUD_FMT_S16, + mode=pb.AudioFormat.MODE_UNSPECIFIED, + ) + + def _packet_generator(): + # Send in chunks matching 10ms frames (960 bytes at 48kHz mono S16LE) + frame_size = sample_rate * 2 // 100 # 10ms worth of bytes + offset = 0 + first = True + while offset < len(pcm_bytes): + chunk = pcm_bytes[offset:offset + frame_size] + if first: + yield pb.AudioPacket(format=audio_format, audio=chunk) + first = False + else: + yield pb.AudioPacket(audio=chunk) + offset += frame_size + # Pace the injection to approximate real-time + time.sleep(0.008) # slightly less than 10ms to avoid underruns + + self.stub.injectAudio(_packet_generator(), metadata=metadata) + + def capture_audio(self, duration_s, sample_rate=48000): + """Capture audio from the emulator's speaker output. + + Server-streaming RPC: returns concatenated PCM bytes. + """ + pb = self.pb + metadata = self._metadata() + audio_format = pb.AudioFormat( + samplingRate=sample_rate, + channels=pb.AudioFormat.Mono, + format=pb.AudioFormat.AUD_FMT_S16, + ) + + deadline = time.monotonic() + duration_s + 1.0 + chunks = [] + total_bytes = 0 + target_bytes = int(sample_rate * 2 * duration_s) # S16 mono + + try: + for packet in self.stub.streamAudio( + audio_format, + timeout=duration_s + 5, + metadata=metadata, + ): + if packet.audio: + chunks.append(packet.audio) + total_bytes += len(packet.audio) + if total_bytes >= target_bytes: + break + if time.monotonic() > deadline: + break + except Exception as e: + print(f" [grpc] streamAudio ended: {e}") + + return b"".join(chunks) diff --git a/voice-test/lib/proto/__init__.py b/voice-test/lib/proto/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/voice-test/lib/signal_rpc.py b/voice-test/lib/signal_rpc.py new file mode 100644 index 00000000..bdd784c9 --- /dev/null +++ b/voice-test/lib/signal_rpc.py @@ -0,0 +1,145 @@ +"""JSON-RPC client for signal-cli daemon over Unix socket.""" + +import json +import socket +import time + + +class SignalRPC: + """Connects to signal-cli's JSON-RPC Unix socket and provides call control methods.""" + + SAMPLE_RATE = 48000 + CHANNELS = 1 + PTIME_MS = 10 + SAMPLES_PER_FRAME = SAMPLE_RATE * PTIME_MS // 1000 # 480 + BYTES_PER_SAMPLE = 2 # 16-bit signed LE + PCM_FRAME_SIZE = SAMPLES_PER_FRAME * BYTES_PER_SAMPLE # 960 + + def __init__(self, socket_path): + self.socket_path = socket_path + self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.sock.connect(socket_path) + self._next_id = 0 + self._buf = b"" + + def close(self): + try: + self.sock.close() + except OSError: + pass + + def _send(self, method, params=None): + self._next_id += 1 + req = {"jsonrpc": "2.0", "method": method, "id": self._next_id} + if params: + req["params"] = params + line = json.dumps(req) + "\n" + self.sock.sendall(line.encode("utf-8")) + return self._next_id + + def _read_lines(self): + """Yield complete newline-delimited JSON strings from socket.""" + # Drain any complete lines already in the buffer from a previous recv + while b"\n" in self._buf: + line, self._buf = self._buf.split(b"\n", 1) + line = line.strip() + if line: + yield line.decode("utf-8") + while True: + data = self.sock.recv(4096) + if not data: + return + self._buf += data + while b"\n" in self._buf: + line, self._buf = self._buf.split(b"\n", 1) + line = line.strip() + if line: + yield line.decode("utf-8") + + def _wait_response(self, req_id, timeout=10): + """Wait for a JSON-RPC response matching req_id, buffering notifications.""" + self.sock.settimeout(timeout) + try: + for line in self._read_lines(): + msg = json.loads(line) + if "id" in msg and msg["id"] == req_id: + if "error" in msg: + raise RuntimeError(f"RPC error: {msg['error']}") + return msg.get("result") + # Buffer notification for later consumption + self._pending_events.append(msg) + except socket.timeout: + raise TimeoutError(f"Timed out waiting for response to request {req_id}") + finally: + self.sock.settimeout(None) + + def subscribe_receive(self): + self._pending_events = getattr(self, "_pending_events", []) + req_id = self._send("subscribeReceive") + return self._wait_response(req_id) + + def start_call(self, recipient): + self._pending_events = getattr(self, "_pending_events", []) + req_id = self._send("startCall", {"recipient": [recipient]}) + return self._wait_response(req_id, timeout=30) + + def accept_call(self, call_id): + self._pending_events = getattr(self, "_pending_events", []) + req_id = self._send("acceptCall", {"call-id": call_id}) + return self._wait_response(req_id, timeout=30) + + def reject_call(self, call_id): + self._pending_events = getattr(self, "_pending_events", []) + req_id = self._send("rejectCall", {"call-id": call_id}) + return self._wait_response(req_id, timeout=10) + + def hangup_call(self, call_id): + self._pending_events = getattr(self, "_pending_events", []) + req_id = self._send("hangupCall", {"call-id": call_id}) + return self._wait_response(req_id, timeout=10) + + def list_calls(self): + self._pending_events = getattr(self, "_pending_events", []) + req_id = self._send("listCalls") + return self._wait_response(req_id, timeout=10) + + def read_event(self, timeout=30): + """Read the next JSON-RPC notification (callEvent, receive, etc.).""" + self._pending_events = getattr(self, "_pending_events", []) + # Return buffered events first + if self._pending_events: + return self._pending_events.pop(0) + self.sock.settimeout(timeout) + try: + for line in self._read_lines(): + msg = json.loads(line) + if "id" in msg and "method" not in msg: + # This is a response, buffer it (shouldn't normally happen here) + self._pending_events.append(msg) + continue + return msg + except socket.timeout: + raise TimeoutError(f"No event received within {timeout}s") + finally: + self.sock.settimeout(None) + return None + + def wait_for_state(self, target_state, timeout=60): + """Block until a callEvent with the given state arrives. Returns the event params.""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + remaining = deadline - time.monotonic() + if remaining <= 0: + break + try: + msg = self.read_event(timeout=remaining) + except TimeoutError: + break + if msg and msg.get("method") == "callEvent": + params = msg.get("params", {}) + event = params.get("callEvent", {}) + state = event.get("state") + print(f" [rpc] callEvent: {state} (reason={event.get('reason')})") + if state == target_state: + return params + raise TimeoutError(f"Did not reach state {target_state} within {timeout}s") diff --git a/voice-test/requirements.txt b/voice-test/requirements.txt new file mode 100644 index 00000000..cc47cf34 --- /dev/null +++ b/voice-test/requirements.txt @@ -0,0 +1,2 @@ +grpcio>=1.60.0 +grpcio-tools>=1.60.0 diff --git a/voice-test/run_e2e.sh b/voice-test/run_e2e.sh new file mode 100755 index 00000000..b34a700e --- /dev/null +++ b/voice-test/run_e2e.sh @@ -0,0 +1,404 @@ +#!/usr/bin/env bash +# Master orchestrator for E2E voice call tests. +# +# Starts signal-cli daemon, ensures emulator is ready, runs test scenarios, +# and cleans up afterwards. +set -euo pipefail + +usage() { + cat </dev/null; then + echo "Stopping test runner (PID $TEST_PID)..." + kill "$TEST_PID" 2>/dev/null || true + wait "$TEST_PID" 2>/dev/null || true + fi + + if [ -n "$LOGCAT_PID" ] && kill -0 "$LOGCAT_PID" 2>/dev/null; then + echo "Stopping logcat collector (PID $LOGCAT_PID)..." + kill "$LOGCAT_PID" 2>/dev/null || true + wait "$LOGCAT_PID" 2>/dev/null || true + fi + + if [ -n "$DAEMON_PID" ] && kill -0 "$DAEMON_PID" 2>/dev/null; then + echo "Stopping signal-cli daemon (PID $DAEMON_PID)..." + kill "$DAEMON_PID" 2>/dev/null || true + wait "$DAEMON_PID" 2>/dev/null || true + fi + + rm -f "$SIGNAL_CLI_SOCKET" + + # Kill any orphaned signal-call-tunnel processes + pkill -f "signal-call-tunnel" 2>/dev/null || true + + echo "Cleanup done." + echo "" + echo "=== Log files ===" + echo " signal-cli + tunnel : $SIGNAL_CLI_LOG" + echo " daemon console : $DAEMON_CONSOLE_LOG" + echo " Android logcat : $LOGCAT_LOG" +} + +# Trap EXIT (normal exit, set -e failures) plus INT/TERM (Ctrl+C, kill) +trap cleanup EXIT INT TERM + +# --- Kill stale processes from previous interrupted runs --- +echo "=== Pre-run Cleanup ===" +STALE=false + +# Kill leftover signal-cli daemon using our test socket +if [ -S "$SIGNAL_CLI_SOCKET" ]; then + echo " Removing stale socket: $SIGNAL_CLI_SOCKET" + # Find the daemon that owns it (if still alive) + STALE_PID=$(lsof -t "$SIGNAL_CLI_SOCKET" 2>/dev/null | head -1 || true) + if [ -n "$STALE_PID" ]; then + echo " Killing stale daemon (PID $STALE_PID)..." + kill "$STALE_PID" 2>/dev/null || true + sleep 1 + fi + rm -f "$SIGNAL_CLI_SOCKET" + STALE=true +fi + +# Kill leftover signal-call-tunnel processes +if pgrep -f "signal-call-tunnel" >/dev/null 2>&1; then + echo " Killing orphaned signal-call-tunnel processes..." + pkill -f "signal-call-tunnel" 2>/dev/null || true + STALE=true +fi + +# Kill leftover logcat collectors from our log file +if pgrep -f "logcat.*threadtime" >/dev/null 2>&1; then + echo " Killing stale logcat collectors..." + pkill -f "logcat.*threadtime" 2>/dev/null || true + STALE=true +fi + +if $STALE; then + echo " Stale processes cleaned up. Waiting 2s..." + sleep 2 +else + echo " No stale processes found." +fi + +# --- Build binaries --- +echo "=== Building Binaries ===" + +echo " Building signal-cli (./gradlew installDist)..." +if ! ./gradlew -q installDist; then + echo "ERROR: signal-cli build failed" + exit 1 +fi +echo " signal-cli: OK" + +echo " Building signal-call-tunnel (cargo build)..." +if ! (cd signal-call-tunnel && cargo build --quiet); then + echo "ERROR: signal-call-tunnel build failed" + exit 1 +fi +echo " signal-call-tunnel: OK" + +# Check for BlackHole virtual audio drivers (macOS only) +if [ "$(uname)" = "Darwin" ]; then + AUDIO_HAL="/Library/Audio/Plug-Ins/HAL" + MISSING_DRIVERS=false + if [ ! -d "$AUDIO_HAL/signal_input.driver" ]; then + MISSING_DRIVERS=true + fi + if [ ! -d "$AUDIO_HAL/signal_output.driver" ]; then + MISSING_DRIVERS=true + fi + if $MISSING_DRIVERS; then + RINGRTC_DIR="$PROJECT_DIR/third-party/ringrtc" + echo "" + echo "ERROR: BlackHole virtual audio drivers are not installed." + echo " signal-call-tunnel requires pre-installed audio drivers on macOS." + echo "" + echo " Run the following command once (requires root):" + echo "" + echo " sudo bash $RINGRTC_DIR/bin/virtual_audio.sh \\" + echo " --setup --input-source signal_input --output-sink signal_output" + echo "" + echo " To remove them later:" + echo "" + echo " sudo bash $RINGRTC_DIR/bin/virtual_audio.sh \\" + echo " --teardown --input-source signal_input --output-sink signal_output" + echo "" + exit 1 + fi + echo " BlackHole audio drivers: OK" +fi + +if ! command -v python3 &>/dev/null; then + echo "ERROR: python3 not found" + exit 1 +fi +echo " python3: $(python3 --version)" + +if ! "$ADB" devices 2>/dev/null | grep -q "emulator"; then + echo "WARNING: No emulator detected via adb. Attempting to start..." + # The headed (non-headless) emulator binary is required for gRPC audio + # streaming (scenario E). The headless binary strips audio output support, + # causing streamAudio to block forever. + "$EMULATOR_BIN" -avd "$EMULATOR_AVD" -no-snapshot-load \ + -grpc "$EMULATOR_GRPC_PORT" & + EMU_PID=$! + echo " Waiting for emulator boot (PID $EMU_PID)..." + "$ADB" wait-for-device + # Wait for boot to complete + for i in $(seq 1 60); do + BOOT=$("$ADB" shell getprop sys.boot_completed 2>/dev/null || echo "") + if [ "$BOOT" = "1" ]; then + break + fi + sleep 2 + done + echo " Emulator booted." +else + echo " Emulator: already running" +fi + +# Ensure adbd runs as root (required for uiautomator dump on API 34+) +"$ADB" root 2>/dev/null || true +"$ADB" wait-for-device 2>/dev/null +echo " adb root: $(${ADB} shell id -u 2>/dev/null || echo 'unknown')" + +# Check gRPC connectivity (scenario E needs unauthenticated gRPC) +if echo "$SCENARIOS" | grep -q "E"; then + echo " Checking emulator gRPC on port $EMULATOR_GRPC_PORT..." + if python3 -c " +import grpc, sys +ch = grpc.insecure_channel('localhost:$EMULATOR_GRPC_PORT') +try: + grpc.channel_ready_future(ch).result(timeout=3) +except Exception: + sys.exit(1) +finally: + ch.close() +" 2>/dev/null; then + echo " gRPC: reachable" + else + echo " WARNING: Emulator gRPC on port $EMULATOR_GRPC_PORT is not reachable." + echo " If scenario E fails with UNAUTHENTICATED, restart the emulator with:" + echo " $EMULATOR_BIN -avd $EMULATOR_AVD -no-snapshot-load -no-window -grpc $EMULATOR_GRPC_PORT" + fi +fi + +# Verify Signal is installed +if ! "$ADB" shell pm list packages 2>/dev/null | grep -q "org.thoughtcrime.securesms"; then + echo "ERROR: Signal is not installed on the emulator" + exit 1 +fi +echo " Signal app: installed" + +# --- Install Python deps --- +echo "" +echo "=== Python Dependencies ===" +pip install -q -r "$SCRIPT_DIR/requirements.txt" +echo " grpcio: OK" + +# --- Generate proto stubs --- +echo "" +echo "=== Proto Stubs ===" +if [ ! -f "$SCRIPT_DIR/lib/proto/emulator_controller_pb2.py" ]; then + bash "$SCRIPT_DIR/generate_proto.sh" +else + echo " Proto stubs already generated (use 'bash voice-test/generate_proto.sh' to regenerate)" +fi + +# --- Ensure Signal is on main screen --- +echo "" +echo "=== Preparing Signal App ===" +"$ADB" shell monkey -p org.thoughtcrime.securesms -c android.intent.category.LAUNCHER 1 +sleep 2 +echo " Signal launched" + +# Set media and ring volumes to max (voice call volume is set during the +# active call in scenario E via KEYCODE_VOLUME_UP key events). +"$ADB" shell cmd media_session volume --stream 2 --set 15 >/dev/null 2>&1 # ring +"$ADB" shell cmd media_session volume --stream 3 --set 15 >/dev/null 2>&1 # music +echo " Audio volumes: ring/media set to max" + +# --- Set up log collection --- +echo "" +echo "=== Log Collection ===" +mkdir -p "$LOG_DIR" +# Truncate logs from previous runs +: > "$SIGNAL_CLI_LOG" +: > "$DAEMON_CONSOLE_LOG" +: > "$LOGCAT_LOG" + +# Start logcat collector (Signal app + WebRTC/RingRTC tags) +"$ADB" logcat -c 2>/dev/null || true # Clear old logcat buffer +"$ADB" logcat -v threadtime > "$LOGCAT_LOG" 2>&1 & +LOGCAT_PID=$! +echo " logcat collector PID: $LOGCAT_PID -> $LOGCAT_LOG" + +# --- Start signal-cli daemon --- +echo "" +echo "=== Starting signal-cli Daemon ===" + +# Export tunnel binary path so the daemon subprocess can find it +export SIGNAL_CALL_TUNNEL_BIN + +# -vv for DEBUG+TRACE on org.asamk (includes [tunnel-{callId}] lines) +# --log-file captures detailed logs; stdout/stderr go to console log +$SIGNAL_CLI_BIN -vv -a "$SIGNAL_CLI_ACCOUNT" \ + --log-file "$SIGNAL_CLI_LOG" \ + daemon --socket "$SIGNAL_CLI_SOCKET" \ + > "$DAEMON_CONSOLE_LOG" 2>&1 & +DAEMON_PID=$! +echo " Daemon PID: $DAEMON_PID" +echo " Log file: $SIGNAL_CLI_LOG" + +# Wait for socket to appear +echo " Waiting for daemon socket..." +for i in $(seq 1 30); do + if [ -S "$SIGNAL_CLI_SOCKET" ]; then + break + fi + if ! kill -0 "$DAEMON_PID" 2>/dev/null; then + echo "ERROR: Daemon exited prematurely" + exit 1 + fi + sleep 1 +done + +if [ ! -S "$SIGNAL_CLI_SOCKET" ]; then + echo "ERROR: Daemon socket did not appear within 30s" + exit 1 +fi +echo " Daemon ready." + +# --- Run tests --- +echo "" +echo "=== Running Tests ===" +SCENARIOS="${SCENARIOS:-A,B,C,D,E}" + +TEST_PID="" +set +e +python3 -u "$SCRIPT_DIR/e2e_test.py" \ + --socket "$SIGNAL_CLI_SOCKET" \ + --log-dir "$LOG_DIR" \ + --scenarios "$SCENARIOS" $RECORD_FLAG $NO_FAIL_FAST_FLAG & +TEST_PID=$! +wait "$TEST_PID" +TEST_EXIT=$? +TEST_PID="" +set -e + +echo "" +if [ $TEST_EXIT -eq 0 ]; then + echo "=== ALL TESTS PASSED ===" +else + echo "=== SOME TESTS FAILED ===" + echo "" + echo "Log files for diagnosis:" + echo " signal-cli + tunnel : $SIGNAL_CLI_LOG" + echo " daemon console : $DAEMON_CONSOLE_LOG" + echo " Android logcat : $LOGCAT_LOG" + echo "" + echo "Per-scenario log excerpts are in: $LOG_DIR/scenario_*.log" +fi + +exit $TEST_EXIT