mirror of
https://github.com/AsamK/signal-cli.git
synced 2026-09-01 06:28:11 +00:00
Merge df69b0283eb671477693dd7fd315e3c7438e296b into 516a37ba69dad735560189b3f00539f7fc216fb0
This commit is contained in:
commit
ed0db52e51
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
[submodule "third-party/ringrtc"]
|
||||
path = third-party/ringrtc
|
||||
url = https://github.com/signalapp/ringrtc
|
||||
@ -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
546
docs/CALL_TUNNEL.md
Normal 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
265
docs/TEST_HARNESS.md
Normal 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.
|
||||
@ -37,7 +37,11 @@ dependencies {
|
||||
}
|
||||
|
||||
tasks.named<Test>("test") {
|
||||
useJUnitPlatform()
|
||||
useJUnitPlatform {
|
||||
if (!project.hasProperty("includeIntegration")) {
|
||||
excludeTags("integration")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
configurations {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
21
lib/src/main/java/org/asamk/signal/manager/api/CallInfo.java
Normal file
21
lib/src/main/java/org/asamk/signal/manager/api/CallInfo.java
Normal 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
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
package org.asamk.signal.manager.api;
|
||||
|
||||
public record CallOffer(
|
||||
long callId,
|
||||
Type type,
|
||||
byte[] opaque
|
||||
) {
|
||||
|
||||
public enum Type {
|
||||
AUDIO,
|
||||
VIDEO
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
package org.asamk.signal.manager.api;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record TurnServer(
|
||||
String username,
|
||||
String password,
|
||||
List<String> urls
|
||||
) {
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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(),
|
||||
|
||||
32
lib/src/main/proto/rtp_data.proto
Normal file
32
lib/src/main/proto/rtp_data.proto
Normal 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;
|
||||
}
|
||||
32
lib/src/main/proto/signaling.proto
Normal file
32
lib/src/main/proto/signaling.proto
Normal 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;
|
||||
}
|
||||
@ -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
2858
signal-call-tunnel/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
signal-call-tunnel/Cargo.toml
Normal file
19
signal-call-tunnel/Cargo.toml
Normal 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' }
|
||||
66
signal-call-tunnel/build.rs
Normal file
66
signal-call-tunnel/build.rs
Normal 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
41
signal-call-tunnel/patches/ringrtc-disable-vpio.patch
Normal file
41
signal-call-tunnel/patches/ringrtc-disable-vpio.patch
Normal 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();
|
||||
92
signal-call-tunnel/src/config.rs
Normal file
92
signal-call-tunnel/src/config.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
521
signal-call-tunnel/src/control.rs
Normal file
521
signal-call-tunnel/src/control.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
410
signal-call-tunnel/src/main.rs
Normal file
410
signal-call-tunnel/src/main.rs
Normal 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(())
|
||||
}
|
||||
481
signal-call-tunnel/src/platform.rs
Normal file
481
signal-call-tunnel/src/platform.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
) {}
|
||||
}
|
||||
@ -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());
|
||||
|
||||
@ -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) {}
|
||||
}
|
||||
@ -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
|
||||
) {}
|
||||
}
|
||||
@ -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) {}
|
||||
}
|
||||
@ -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
|
||||
) {}
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
32
src/main/java/org/asamk/signal/json/JsonCallEvent.java
Normal file
32
src/main/java/org/asamk/signal/json/JsonCallEvent.java
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
110
src/test/java/org/asamk/signal/json/JsonCallEventTest.java
Normal file
110
src/test/java/org/asamk/signal/json/JsonCallEventTest.java
Normal 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
1
third-party/ringrtc
vendored
Submodule
@ -0,0 +1 @@
|
||||
Subproject commit a86f8a6832cec291ef4f77e4ee9b941e5b91a01f
|
||||
344
voice-test/README.md
Normal file
344
voice-test/README.md
Normal 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
0
voice-test/__init__.py
Normal file
22
voice-test/config.sh
Normal file
22
voice-test/config.sh
Normal 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
916
voice-test/e2e_test.py
Normal 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
37
voice-test/generate_proto.sh
Executable 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
|
||||
0
voice-test/lib/__init__.py
Normal file
0
voice-test/lib/__init__.py
Normal file
126
voice-test/lib/audio.py
Normal file
126
voice-test/lib/audio.py
Normal 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
474
voice-test/lib/emulator.py
Normal 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
|
||||
189
voice-test/lib/grpc_audio.py
Normal file
189
voice-test/lib/grpc_audio.py
Normal 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)
|
||||
0
voice-test/lib/proto/__init__.py
Normal file
0
voice-test/lib/proto/__init__.py
Normal file
145
voice-test/lib/signal_rpc.py
Normal file
145
voice-test/lib/signal_rpc.py
Normal 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")
|
||||
2
voice-test/requirements.txt
Normal file
2
voice-test/requirements.txt
Normal file
@ -0,0 +1,2 @@
|
||||
grpcio>=1.60.0
|
||||
grpcio-tools>=1.60.0
|
||||
404
voice-test/run_e2e.sh
Executable file
404
voice-test/run_e2e.sh
Executable 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
|
||||
Loading…
x
Reference in New Issue
Block a user