Merge df69b0283eb671477693dd7fd315e3c7438e296b into 516a37ba69dad735560189b3f00539f7fc216fb0

This commit is contained in:
Shaheen Gandhi 2026-02-28 12:08:47 +01:00 committed by GitHub
commit ed0db52e51
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
51 changed files with 10461 additions and 2 deletions

3
.gitmodules vendored Normal file
View File

@ -0,0 +1,3 @@
[submodule "third-party/ringrtc"]
path = third-party/ringrtc
url = https://github.com/signalapp/ringrtc

View File

@ -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>("test") {
useJUnitPlatform()
}
configurations {

546
docs/CALL_TUNNEL.md Normal file
View File

@ -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-<random>/`). 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_<inputDeviceName> audio.wav`
- macOS: `sox audio.wav -t coreaudio <inputDeviceName>`
4. **To receive audio** (WebRTC playout): read from the virtual output device
- Linux: `parecord --device=<outputDeviceName>.monitor output.wav`
- macOS: `sox -t coreaudio <outputDeviceName> 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":"<b64>"} |
| | 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_<call_id>`.
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":"<base64-encoded 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_<inputDeviceName>`
- **Output device** (WebRTC -> client): read from PulseAudio monitor `<outputDeviceName>.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_<inputDeviceName> audio.wav`
- **macOS**: `sox audio.wav -t coreaudio <inputDeviceName>`
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=<outputDeviceName>.monitor output.wav`
- **macOS**: `sox -t coreaudio <outputDeviceName> 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-<random>/
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. `<signal-cli install dir>/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.

265
docs/TEST_HARNESS.md Normal file
View File

@ -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.

View File

@ -37,7 +37,11 @@ dependencies {
}
tasks.named<Test>("test") {
useJUnitPlatform()
useJUnitPlatform {
if (!project.hasProperty("includeIntegration")) {
excludeTags("integration")
}
}
}
configurations {

View File

@ -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<CallInfo> 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<byte[]> 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<TurnServer> 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);
}
}

View File

@ -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
}
}

View File

@ -0,0 +1,13 @@
package org.asamk.signal.manager.api;
public record CallOffer(
long callId,
Type type,
byte[] opaque
) {
public enum Type {
AUDIO,
VIDEO
}
}

View File

@ -0,0 +1,10 @@
package org.asamk.signal.manager.api;
import java.util.List;
public record TurnServer(
String username,
String password,
List<String> urls
) {
}

View File

@ -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<Long, CallState> activeCalls = new ConcurrentHashMap<>();
private final List<Manager.CallEventListener> 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<CallInfo> listActiveCalls() {
return activeCalls.values().stream().map(CallState::toCallInfo).toList();
}
public List<TurnServer> 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<TurnServer> 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<TurnServer> 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<byte[]>();
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<byte[]> 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<String> 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);
}
}
}

View File

@ -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();
}

View File

@ -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

View File

@ -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<ReceiveMessageHandler> weakHandlers = new HashSet<>();
private final Set<ReceiveMessageHandler> messageHandlers = new HashSet<>();
private final Set<CallEventListener> callEventListeners = new HashSet<>();
private final List<Runnable> closedListeners = new ArrayList<>();
private final List<Runnable> 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<CallInfo> 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<byte[]> 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<TurnServer> 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();

View File

@ -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(),

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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);
}
}

2858
signal-call-tunnel/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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' }

View File

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

View File

@ -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::<OutFrame>::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::<Frame>::new();

View File

@ -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<String>,
pub output_device_name: Option<String>,
}
#[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<Config, _> = 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<Config, _> = 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);
}
}

View File

@ -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<IceServerConfig>, hide_ip: bool },
ReceivedOffer {
call_id: u64,
peer_id: String,
sender_device_id: u32,
opaque: Vec<u8>,
age_ms: u64,
sender_identity_key: Vec<u8>,
receiver_identity_key: Vec<u8>,
},
ReceivedAnswer {
opaque: Vec<u8>,
sender_device_id: u32,
sender_identity_key: Vec<u8>,
receiver_identity_key: Vec<u8>,
},
ReceivedIce { candidates: Vec<Vec<u8>> },
Accept,
Hangup,
}
#[derive(Debug, Clone)]
pub struct IceServerConfig {
pub username: String,
pub password: String,
pub urls: Vec<String>,
}
/// Parse a JSON line into a ControlMessage.
pub fn parse_message(line: &str) -> Result<ControlMessage> {
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<ControlMessage>,
pub writer: ControlWriter,
}
#[derive(Clone)]
pub struct ControlWriter {
sender: mpsc::Sender<String>,
}
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<ControlChannel> {
// 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::<ControlMessage>();
let (write_sender, write_receiver) = mpsc::channel::<String>();
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,
})
}

View File

@ -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<dyn VideoSink> {
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::<PlatformEvent>();
// 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<IceServer> = 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<signaling::IceCandidate> = 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(())
}

View File

@ -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<String>,
},
}
#[derive(Debug)]
pub enum SignalingEvent {
SendOffer {
opaque: Vec<u8>,
call_media_type: CallMediaType,
},
SendAnswer {
opaque: Vec<u8>,
},
SendIce {
candidates: Vec<Vec<u8>>,
},
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<String> = 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<PlatformEvent>,
}
impl SignalingSender for TunnelSignalingSender {
fn send_signaling(
&self,
_recipient_id: &str,
call_id: CallId,
_receiver_device_id: Option<DeviceId>,
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<u8>,
_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<u8>,
_urgency: group_call::SignalingMessageUrgency,
_recipients_override: HashSet<UserId>,
) -> Result<()> {
// No-op for 1:1 calls
Ok(())
}
fn send_call_message_to_adhoc_group(
&self,
_message: Vec<u8>,
_urgency: group_call::SignalingMessageUrgency,
_expiration: u64,
_recipients_to_endorsements: HashMap<UserId, Vec<u8>>,
) -> 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<PlatformEvent>,
}
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(())
}
}

View File

@ -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
) {}
}

View File

@ -10,18 +10,21 @@ public class Commands {
private static final Map<String, SubparserAttacher> 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());

View File

@ -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) {}
}

View File

@ -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
) {}
}

View File

@ -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) {}
}

View File

@ -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.<String>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
) {}
}

View File

@ -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<org.asamk.signal.manager.api.CallInfo> 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<byte[]> 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<org.asamk.signal.manager.api.TurnServer> getTurnServerInfo() {
throw new UnsupportedOperationException("Voice calls are not supported over DBus");
}
@Override
public void close() {
synchronized (this) {

View File

@ -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
);
}
}

View File

@ -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<Integer, List<Pair<Manager, Manager.ReceiveMessageHandler>>> receiveHandlers = new HashMap<>();
private final List<Pair<Manager, Manager.CallEventListener>> 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();
}
}

View File

@ -1947,6 +1947,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,
@ -1984,6 +2018,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,
@ -1994,6 +2042,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,
@ -2159,6 +2240,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,
@ -9724,4 +9853,4 @@
"bundle": "net.sourceforge.argparse4j.internal.ArgumentParserImpl"
}
]
}
}

View File

@ -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);
}
}

View File

@ -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());
}
}
}

1
third-party/ringrtc vendored Submodule

@ -0,0 +1 @@
Subproject commit a86f8a6832cec291ef4f77e4ee9b941e5b91a01f

344
voice-test/README.md Normal file
View File

@ -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 `<install-dir>/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 |

0
voice-test/__init__.py Normal file
View File

22
voice-test/config.sh Normal file
View File

@ -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"

916
voice-test/e2e_test.py Normal file
View File

@ -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()

37
voice-test/generate_proto.sh Executable file
View File

@ -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

View File

126
voice-test/lib/audio.py Normal file
View File

@ -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("<h", sample))
return b"".join(samples)
def goertzel_magnitude(pcm_bytes, target_freq, sample_rate=48000):
"""Goertzel algorithm: compute magnitude of a single frequency bin.
More efficient than FFT when only one frequency is needed: O(n) time, O(1) space.
"""
n_samples = len(pcm_bytes) // 2
if n_samples == 0:
return 0.0
k = round(target_freq * n_samples / sample_rate)
w = 2 * math.pi * k / n_samples
coeff = 2 * math.cos(w)
s0 = 0.0
s1 = 0.0
s2 = 0.0
for i in range(n_samples):
sample = struct.unpack_from("<h", pcm_bytes, i * 2)[0] / 32768.0
s0 = sample + coeff * s1 - s2
s2 = s1
s1 = s0
magnitude = math.sqrt(s1 * s1 + s2 * s2 - coeff * s1 * s2)
return magnitude / n_samples
def detect_tone(pcm_bytes, expected_freq, sample_rate=48000, threshold=3.0):
"""Returns True if expected_freq is the dominant frequency in the signal.
Opus encoding and WebRTC audio processing (AGC, NS, AEC) can shift the
tone by up to ~50 Hz and spread energy across nearby bins. To handle this,
we scan a ±100 Hz window around the expected frequency and take the peak
magnitude as the signal level. Noise is measured from bins well outside
this window.
"""
if len(pcm_bytes) < 1920: # Less than 1 frame
return False
# Scan a window around the expected frequency to find the peak.
# Opus codec can shift the tone by ~30-50 Hz.
scan_step = 10
scan_range = 100 # Hz each side
peak_mag = 0.0
peak_freq = expected_freq
for f in range(expected_freq - scan_range, expected_freq + scan_range + 1, scan_step):
if f < 50 or f >= 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("<h", pcm_bytes, i * 2)[0] / 32768.0
sum_sq += sample * sample
return math.sqrt(sum_sq / n_samples)

474
voice-test/lib/emulator.py Normal file
View File

@ -0,0 +1,474 @@
"""ADB-based Signal UI automation for the Android emulator.
Uses a combination of:
- adb shell uiautomator dump for dynamic UI element discovery
- adb shell commands for taps, key events, intents
- logcat polling for state verification
- telecom shell commands for call answer/reject (no coordinates needed)
"""
import os
import re
import subprocess
import time
import xml.etree.ElementTree as ET
class EmulatorControl:
"""Drives Signal on the Android emulator via adb shell commands.
Uses uiautomator dump for dynamic element lookup (with timeout/retry)
and telecom commands for call answer/reject. Falls back to keyevents
when telecom commands fail.
"""
def __init__(self, adb_path, output_dir=None):
self.adb = adb_path
self._output_dir = output_dir
def _run(self, *args, timeout=15):
cmd = [self.adb] + list(args)
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
return result.stdout.strip()
def _shell(self, cmd, timeout=15):
return self._run("shell", cmd, timeout=timeout)
# ------------------------------------------------------------------
# UI element discovery via uiautomator dump
# ------------------------------------------------------------------
_UI_DUMP_PATH = "/sdcard/window_dump.xml"
def _dump_ui(self, timeout=5):
"""Dump the current UI hierarchy as an ElementTree.
Dumps to a file on the device then reads it back (more reliable
than piping to /dev/stdout which drops XML on many emulators).
Retries once on failure. Returns the parsed XML root or None.
"""
for attempt in range(2):
try:
# Dump to file on device
result = subprocess.run(
[self.adb, "shell", "uiautomator", "dump",
self._UI_DUMP_PATH],
capture_output=True, text=True, timeout=timeout,
)
if "dumped to" not in result.stdout.lower():
if attempt == 0:
time.sleep(1)
continue
print(f" [emu] uiautomator dump failed: {result.stdout.strip()}")
return None
# Read the file back
xml_text = self._shell(f"cat {self._UI_DUMP_PATH}", timeout=5)
if not xml_text or "<hierarchy" not in xml_text:
if attempt == 0:
time.sleep(1)
continue
print(" [emu] uiautomator dump returned no hierarchy XML")
return None
start = xml_text.index("<hierarchy")
end = xml_text.rindex(">") + 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

View File

@ -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 <port>")
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)

View File

View File

@ -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")

View File

@ -0,0 +1,2 @@
grpcio>=1.60.0
grpcio-tools>=1.60.0

404
voice-test/run_e2e.sh Executable file
View File

@ -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 <<USAGE
Usage: $(basename "$0") [OPTIONS]
Run E2E voice call tests against signal-cli and an Android emulator.
Options:
--signal-cli-account PHONE Phone number for signal-cli account (e.g. +1234567890)
--emulator-account PHONE Phone number for emulator account (e.g. +1234567890)
-s, --scenario ID Run a single scenario (A, B, C, D, or E)
--scenarios LIST Comma-separated list of scenarios (default: A,B,C,D,E)
--record Record emulator screen during each scenario
--no-fail-fast Continue running scenarios after a failure
-h, --help Show this help message
Environment variables:
SIGNAL_CLI_ACCOUNT Alternative to --signal-cli-account
EMULATOR_ACCOUNT Alternative to --emulator-account
Scenarios:
A Outgoing call lifecycle (signal-cli calls, emulator answers)
B Incoming call lifecycle (emulator calls, signal-cli accepts)
C Incoming call rejection (emulator calls, signal-cli rejects)
D Ring timeout (outgoing call, no answer)
E Bidirectional audio (440Hz/1000Hz tone detection via gRPC)
Examples:
$(basename "$0") --signal-cli-account +1234567890 --emulator-account +0987654321
$(basename "$0") -s A # Run only scenario A
$(basename "$0") --scenarios A,B # Run scenarios A and B
Log files are written to voice-test/output/logs/ and per-scenario excerpts
are saved on failure for diagnosis.
USAGE
}
# --- Parse arguments ---
SCENARIOS="A,B,C,D,E"
RECORD_FLAG=""
NO_FAIL_FAST_FLAG=""
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help)
usage
exit 0
;;
-s|--scenario)
SCENARIOS="${2:?ERROR: --scenario requires an argument}"
shift 2
;;
--scenarios)
SCENARIOS="${2:?ERROR: --scenarios requires an argument}"
shift 2
;;
--signal-cli-account)
export SIGNAL_CLI_ACCOUNT="${2:?ERROR: --signal-cli-account requires an argument}"
shift 2
;;
--emulator-account)
export EMULATOR_ACCOUNT="${2:?ERROR: --emulator-account requires an argument}"
shift 2
;;
--record)
RECORD_FLAG="--record"
shift
;;
--no-fail-fast)
NO_FAIL_FAST_FLAG="--no-fail-fast"
shift
;;
*)
echo "Unknown option: $1"
echo "Run '$(basename "$0") --help' for usage."
exit 1
;;
esac
done
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$PROJECT_DIR"
source "$SCRIPT_DIR/config.sh"
# Export variables so e2e_test.py can read them from env
export SIGNAL_CLI_ACCOUNT
export EMULATOR_ACCOUNT
export ADB
# --- State ---
DAEMON_PID=""
LOGCAT_PID=""
TEST_PID=""
CLEANUP_DONE=false
cleanup() {
if $CLEANUP_DONE; then return; fi
CLEANUP_DONE=true
echo ""
echo "=== Cleanup ==="
# Kill the Python test runner if still going (e.g. on Ctrl+C)
# It's the foreground process so usually gets SIGINT directly, but be safe.
if [ -n "$TEST_PID" ] && kill -0 "$TEST_PID" 2>/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