Add E2E voice call test harness with emulator integration

Automated E2E test framework for bidirectional voice calls between
signal-cli and Signal Android on an emulator. Tests five scenarios:
outgoing/incoming call lifecycle, rejection, ring timeout, and
bidirectional audio verification via Goertzel frequency detection.

See docs/TEST_HARNESS.md for architecture and design decisions.
This commit is contained in:
Shaheen Gandhi 2026-02-13 21:02:10 -08:00
parent 09fef8ec68
commit df69b0283e
14 changed files with 2924 additions and 0 deletions

265
docs/TEST_HARNESS.md Normal file
View File

@ -0,0 +1,265 @@
# E2E Voice Call Test Harness
Design document for the automated end-to-end voice call test infrastructure.
## Architecture Overview
The test harness has two layers: a shell orchestrator (`run_e2e.sh`) that manages
builds, processes, and cleanup, and a Python test runner (`e2e_test.py`) that
executes the five test scenarios.
```
run_e2e.sh (orchestrator)
+-- pre-flight checks (builds, adb, Signal installed)
+-- start signal-cli daemon on test socket
+-- start logcat collection
+-- launch e2e_test.py (test runner)
+-- SignalRPC -- JSON-RPC 2.0 client for signal-cli daemon
+-- EmulatorControl -- ADB-based Signal UI automation
+-- AudioProcessing -- tone generation + Goertzel detection
+-- VirtualAudioHelpers -- platform-aware play/record via sox/paplay
+-- GrpcAudio -- emulator gRPC audio HAL client
```
### File layout
```
test/
run_e2e.sh # Shell orchestrator
config.sh # Shared environment config (paths, ports, accounts, frequencies)
requirements.txt # Python deps (grpcio, grpcio-tools)
generate_proto.sh # Compile emulator_controller.proto -> Python stubs
e2e_test.py # Test runner (scenarios A-E)
lib/
signal_rpc.py # JSON-RPC 2.0 client for signal-cli
audio.py # Tone generation + Goertzel frequency detection + WAV I/O
emulator.py # ADB-based Signal UI automation
grpc_audio.py # Emulator gRPC audio injection/capture
proto/ # Generated protobuf stubs (created by generate_proto.sh)
```
---
## Component Descriptions
### `run_e2e.sh` -- Shell Orchestrator
Handles everything outside the test scenarios themselves:
- **Pre-flight checks**: builds exist, adb reachable, Signal installed on emulator
- **Binary builds**: `gradlew installDist` + `cargo build --release`
- **Python setup**: virtualenv activation, `pip install` from `requirements.txt`,
proto stub generation
- **Daemon lifecycle**: starts signal-cli on a test socket, captures stdout to log file
- **Logcat collection**: background `adb logcat` filtered to Signal-related tags
- **Cleanup traps**: `trap cleanup EXIT INT TERM` ensures daemon, logcat, and orphaned
tunnel processes are killed on any exit path
- **Stale process detection**: pre-run `lsof`/`pkill` to kill leftover processes from
previous runs that might hold the test socket or tunnel ports
### `config.sh` -- Shared Configuration
Sourced by `run_e2e.sh`. Centralizes:
- Account phone numbers (`SIGNAL_CLI_ACCOUNT`, `EMULATOR_ACCOUNT`)
- Android SDK paths (adb, emulator binary)
- Emulator AVD name, gRPC port
- Test socket path (`/tmp/signal-cli-test.sock`)
- Audio test frequencies (440 Hz outgoing, 1000 Hz incoming)
- Log directory paths
### `e2e_test.py` -- Test Runner
Runs scenarios A-E with per-scenario logging, screen recording, and result reporting.
Key components:
- **`LogCollector`**: tracks log file positions per scenario; on failure, extracts only
the relevant segment from each of the three log sources (signal-cli, daemon console,
logcat) with categorized line filtering (tunnel messages, call-related, errors)
- **`ScreenRecorder`**: optional `--record` flag captures emulator screen via
`adb screenrecord` for post-mortem debugging
- **Virtual audio helpers**: `play_to_device()` and `record_from_device()` provide
platform-aware audio I/O using `sox` (macOS) or `paplay`/`parecord` (Linux)
- **Fail-fast mode**: default behavior stops after the first failure; `--no-fail-fast`
runs the full suite
- **Clean state**: `wait_for_clean_state()` kills and relaunches Signal between
scenarios, then waits for the WebSocket to reconnect (~20 s)
### `lib/signal_rpc.py` -- JSON-RPC Client
JSON-RPC 2.0 client over Unix socket for the signal-cli daemon.
- `start_call()`, `accept_call()`, `reject_call()`, `hangup_call()` -- call control
- `wait_for_state()` -- blocks until a `callEvent` notification with the target state
- `_read_lines()` -- generator yielding newline-delimited JSON; buffers partial reads
- `_wait_response()` -- waits for matching response ID, queues non-matching notifications
in `_pending_events` for later consumption by `read_event()`
### `lib/audio.py` -- Audio Processing
Pure-Python audio utilities (no numpy dependency):
- `generate_tone(freq, duration)` -- sine wave PCM (48 kHz, 16-bit signed LE, mono)
- `goertzel_magnitude(samples, freq)` -- O(n) single-frequency energy detector
- `detect_tone(pcm, expected_freq)` -- scans a +/-100 Hz window around the target
frequency, compares peak to noise floor (SNR threshold = 3.0)
- `pcm_to_wav()` -- write raw PCM bytes to a WAV file
- `wav_to_pcm()` -- read a WAV file back as raw PCM bytes
- `rms_level()` -- RMS amplitude normalized to [0.0, 1.0]
The +/-100 Hz scan window compensates for Opus codec frequency shifts (~30-50 Hz).
Noise is measured from bins 400-600 Hz away from the signal to avoid adjacent-band
leakage.
### `lib/emulator.py` -- Emulator UI Automation
ADB-based Signal UI automation using dynamic element lookup and logcat polling.
- `_dump_ui()` -- runs `adb shell uiautomator dump /sdcard/window_dump.xml` then reads
the file back (piping to `/dev/stdout` is unreliable on many emulators). Requires
`adb root` on API 34+. Retries once on failure with a 5-second timeout.
- `_find_element()` / `_tap_element()` -- search the XML hierarchy for elements by
text, content-desc, resource-id, or class name, and tap their center coordinates
- `_dismiss_permission_dialogs()` -- dismiss camera/mic permission prompts on the
pre-join call screen by tapping "Not now" / "Deny"
- `launch_signal()` -- uses `monkey` launcher intent (not unexported `MainActivity`)
- `open_conversation()` -- kill, relaunch, find first conversation row via uiautomator
- `tap_call_button()` -- find call icon by content-desc, dismiss permission dialogs,
then tap "Start Call" on the pre-join screen
- `answer_incoming_call()` -- expand notification shade, find "Answer"/"Accept" button
via uiautomator (heads-up notification is invisible to uiautomator, but shade actions
are visible). Falls back to `HEADSETHOOK` keyevent and `cmd telecom` commands.
- `reject_incoming_call()` -- uses `ENDCALL` keyevent with `cmd telecom end-call` fallback
- `_wait_for_incoming_call()` -- poll logcat for `handleReceivedOffer` -> `LOCAL_RINGING`
- Foreground verification via `dumpsys window | grep mCurrentFocus`
UI element coordinates are discovered dynamically via `uiautomator dump`, making the
harness portable across emulator display resolutions.
### `lib/grpc_audio.py` -- gRPC Audio HAL Client
Client for the Android emulator's gRPC audio streaming API.
- `inject_audio(pcm)` -- client-streaming RPC to inject PCM into the virtual mic,
paced at ~8 ms per frame (slightly under 10 ms to avoid underruns)
- `capture_audio(seconds)` -- server-streaming RPC to capture speaker output
- **Auth discovery**: tries unauthenticated first (`-grpc` flag); on
`UNAUTHENTICATED` error, searches `~/.android/avd/running/` and `$TMPDIR/avd/running/`
for `grpc.token` or `.jwk` files
- 4-second settling delay after gRPC connection for HAL initialization
---
## Test Scenarios
| ID | Name | Flow |
|----|------|------|
| A | Outgoing call lifecycle | signal-cli places call -> emulator answers -> signal-cli hangs up |
| B | Incoming call lifecycle | Emulator places call -> signal-cli accepts -> signal-cli hangs up |
| C | Incoming call rejection | Emulator places call -> signal-cli rejects (verify hangup reason) |
| D | Ring timeout | signal-cli places call -> nobody answers -> verify timeout after ~60 s |
| E | Bidirectional audio | Connected call with tone generation via virtual audio devices + Goertzel detection via gRPC |
Scenario E verifies both directions of the audio pipeline:
1. **signal-cli -> emulator**: play 440 Hz tone WAV into the virtual input device
(via `sox`/`paplay`), capture from the emulator's speaker via gRPC, verify with
Goertzel
2. **emulator -> signal-cli**: record from the virtual output device (via
`sox`/`parecord`), verify playout data is non-empty and at the expected rate
---
## Key Design Decisions & Lessons Learned
### Emulator UI Automation
- **Use `monkey` launcher intent, not unexported `MainActivity`**: the
`am start` command with `MainActivity` throws `SecurityException` because it is not
exported. `monkey -p org.thoughtcrime.securesms 1` uses the default launcher intent.
- **Dynamic element lookup via `uiautomator dump`**: dumps to
`/sdcard/window_dump.xml` then reads back with `cat` (piping to `/dev/stdout` is
unreliable). Requires `adb root` on API 34+ (run once in `run_e2e.sh`). Searches by
text, content-desc, or resource-id. Portable across display resolutions. The dump has
a 5-second timeout and retries once on failure.
- **Wait for `handleLocalRinging` logcat event, not `handleReceivedOffer`**: the offer
event fires before the UI is ready to accept taps. Waiting for `LOCAL_RINGING` ensures
the incoming call notification is visible.
- **Notification shade for call answer**: the heads-up notification is invisible to
`uiautomator dump` (it's rendered by SystemUI, not the app). Expanding the notification
shade with `cmd statusbar expand-notifications` makes the "Answer"/"Accept" action
buttons visible in the SystemUI hierarchy. Falls back to `HEADSETHOOK` keyevent.
Signal does NOT use Android's Telecom framework, so `cmd telecom accept-ringing-call`
is a no-op.
- **Dismiss permission dialogs on pre-join screen**: Signal may show camera/microphone
permission prompts when the pre-join call activity opens. `_dismiss_permission_dialogs()`
taps "Not now" to dismiss them before looking for the "Start Call" button.
- **Non-coordinate call reject**: uses `ENDCALL` keyevent with `cmd telecom end-call`
fallback. No screen coordinates needed.
### State Management
- **State-based polling replaces all `time.sleep()` calls**: every wait polls for a
specific state (RPC event, logcat message, or socket data) with a timeout, rather
than sleeping for a fixed duration.
- **`wait_for_clean_state()` uses time-based logcat filtering (`-T timestamp`), not
`logcat -c`**: clearing the logcat buffer races with the system logger writing new
entries. Filtering by timestamp is deterministic.
- **Kill + relaunch Signal between scenarios**: ensures WebSocket reconnection and a
clean call state. Without this, leftover state from a previous call causes the next
scenario to fail.
- **20 s WebSocket reconnect timeout**: after Signal restarts, signal-cli's WebSocket
needs time to reconnect before it can receive incoming calls.
### Audio & Media Pipeline
- **Virtual audio device enumeration timing**: cubeb needs time to detect newly created
virtual devices. The tunnel loops on `get_audio_playout_devices()` /
`get_audio_recording_devices()` at 100 ms intervals until devices appear.
- **Platform-specific audio tools**: `sox` on macOS (CoreAudio), `paplay`/`parecord` on
Linux (PulseAudio). The test harness auto-selects based on `platform.system()`.
- **Opus codec shifts frequencies +/-30-50 Hz**: the Goertzel detector scans a +/-100 Hz
window around the target frequency to tolerate codec artifacts.
- **gRPC audio HAL needs 4 s settling delay**: the emulator's audio subsystem needs
time to initialize after a gRPC connection. Without the delay, capture returns silence.
- **Retry logic for HAL flakiness**: scenario E retries once on failure to handle
transient emulator audio issues.
- **gRPC auth: `-grpc` flag for unauthenticated, JWT token auto-discovery as fallback**:
the emulator gRPC port may or may not require authentication depending on how it was
launched. The client tries unauthenticated first, then discovers tokens from the
emulator's runtime directories.
### Test Infrastructure
- **Trap `INT`/`TERM`/`EXIT` for cleanup; pre-run stale process detection via
`lsof`/`pkill`**: ensures no orphaned daemons, logcat processes, or tunnel subprocesses
survive across runs.
- **Export (not just set) environment variables for child processes**: `SIGNAL_CALL_TUNNEL_BIN`
must be exported so the signal-cli daemon's subprocess spawner can find the tunnel binary.
- **Buffered I/O must drain buffer before calling `recv()`**: the `_read_lines()` fix
(commit 6085ca0b) -- previously, buffered data from a prior `recv()` call was lost when
the generator was re-entered, causing missed JSON-RPC responses.
- **Fail-fast by default; `--no-fail-fast` for full suite runs**: most development
workflows want to stop at the first failure. CI or full validation runs use
`--no-fail-fast`.
- **`LogCollector`**: per-scenario extraction from three log sources (signal-cli output,
daemon console, logcat) with categorized diagnostics (tunnel lines, call-related, errors).
- **`--record` flag for emulator screen capture**: `adb screenrecord` during test
execution produces video for post-mortem debugging of UI automation failures.
### Signal-cli Bugs Found via E2E Testing
These bugs were found and fixed in separate commits after the test harness was complete:
- **ICE credential mismatch**: RingRTC requires consistent peer ID across all API calls.
Using different IDs for `createOutgoingCall` and `proceed` caused ICE to fail silently.
- **SRTP key mismatch**: identity keys are 33 bytes with a `0x05` prefix in the Signal
protocol, but RingRTC expects 32-byte raw keys. Passing the prefixed key caused SRTP
decryption failure.
- **Multi-device hangup**: `sendHangup` is a protocol message to other devices, not a
local state change. The call manager was treating it as a local hangup.
- **Call ID overflow**: `BigInteger` -> `Long` cast truncated call IDs; unsigned
serialization was needed for Rust's `u64`.
- **Accept race**: calling `acceptCall` before the tunnel reports `Ringing` state causes
RingRTC to drop the accept. The fix defers `acceptCall` until ICE is connected.

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

@ -0,0 +1,344 @@
This is a test plan for voice calling.
# Prerequisites
- signal-cli is registered on this computer
- You have Signal on your phone (or emulator) with a separate account
- You know the phone number of both accounts
- `signal-call-tunnel` Rust binary is built (see Build section)
- **macOS**: BlackHole virtual audio drivers installed (one-time setup):
```bash
cd third-party/ringrtc
sudo bin/virtual_audio.sh --setup --input-source signal_input --output-sink signal_output
```
Install `sox`: `brew install sox`
- **Linux**: PulseAudio running (virtual audio modules are created automatically)
# Using the test harness
This directory contains a fully automated test harness that runs
scenarios A-E against an Android emulator with Signal installed, requiring
no manual interaction.
## Environment
| Component | Value |
|-----------|-------|
| signal-cli account | Set via `--signal-cli-account` or `SIGNAL_CLI_ACCOUNT` env var |
| Emulator Signal account | Set via `--emulator-account` or `EMULATOR_ACCOUNT` env var |
| Emulator AVD | `signal-test` (emulator-5554) |
| Emulator gRPC | localhost:8554 |
| Python virtualenv | `signal-cli-dev` (pyenv, Python 3.9.6) |
## Quick start
```bash
# Run all five scenarios:
bash voice-test/run_e2e.sh --signal-cli-account +1... --emulator-account +1...
# Run specific scenarios:
bash voice-test/run_e2e.sh --signal-cli-account +1... --emulator-account +1... --scenarios A,B,E
```
`run_e2e.sh` handles everything automatically:
1. Pre-flight checks (builds exist, adb reachable, Signal installed)
2. Installs Python deps (`grpcio`, `grpcio-tools`)
3. Compiles emulator gRPC proto stubs (if needed)
4. Starts the emulator if not already running
5. Launches Signal on the emulator
6. Starts a signal-cli daemon on a test socket
7. Runs the selected test scenarios
8. Cleans up (kills daemon, orphaned tunnel processes)
## Scenarios
| ID | Name | What it tests |
|----|------|---------------|
| A | Outgoing call lifecycle | signal-cli places call -> emulator answers -> signal-cli hangs up |
| B | Incoming call lifecycle | Emulator places call -> signal-cli accepts -> signal-cli hangs up |
| C | Incoming call rejection | Emulator places call -> signal-cli rejects |
| D | Ring timeout | signal-cli places call -> nobody answers -> timeout after ~60s |
| E | Bidirectional audio | Connected call with 440Hz tone via virtual audio devices, Goertzel detection via emulator gRPC audio API |
## File structure
```
voice-test/
run_e2e.sh # Master orchestrator
config.sh # Shared environment config
requirements.txt # Python deps (grpcio, grpcio-tools)
generate_proto.sh # Compile emulator_controller.proto -> Python stubs
e2e_test.py # Main test runner (scenarios A-E)
lib/
signal_rpc.py # signal-cli JSON-RPC client
audio.py # Tone generation + Goertzel frequency detection + WAV I/O
emulator.py # ADB-based Signal UI automation
grpc_audio.py # Emulator gRPC audio injection/capture
proto/ # Generated protobuf stubs (created by generate_proto.sh)
```
## Debugging audio (Scenario E)
Scenario E saves WAV files to `voice-test/output/` for manual inspection:
```bash
ls voice-test/output/*.wav
afplay voice-test/output/e_tone_out_captured.wav # 440Hz captured from emulator speaker
afplay voice-test/output/e_playout_received.wav # Playout recorded from virtual output device
```
---
## Likely failure points
1. **TURN credentials** -- `getTurnServerInfo()` must fetch credentials from Signal's server. Without TURN, ICE may fail behind NAT.
2. **ICE connectivity** -- Symmetric NAT on both sides with no TURN = ICE failure. On the same LAN, ICE should complete within ~100ms.
3. **signal-call-tunnel not found** -- Set `SIGNAL_CALL_TUNNEL_BIN` env var to the binary path, or ensure it is on `PATH` or in `<install-dir>/bin/`.
4. **Ring timeout** -- 60 seconds to accept before auto-hangup.
5. **Virtual audio devices not found** -- macOS: BlackHole drivers not installed (run `sudo virtual_audio.sh --setup` first). Linux: PulseAudio not running.
# Manual Testing
## Build
```bash
# Build signal-cli
direnv exec . ./gradlew installDist
# Build the Rust call tunnel binary
cd signal-call-tunnel && cargo build --release && cd ..
```
The first Rust build will automatically download the prebuilt WebRTC library
(~100 MB) from Signal's artifact server. This is cached for subsequent builds.
## Start the daemon
Terminal 1 -- start the daemon with a JSON-RPC socket and verbose logging:
```bash
./build/install/signal-cli/bin/signal-cli -v daemon --socket
```
This binds a Unix domain socket at `$XDG_RUNTIME_DIR/signal-cli/socket`
(typically `~/.cache/signal-cli/socket` or `/run/user/$(id -u)/signal-cli/socket`).
Logs and received message notifications print to stdout.
You can specify a custom path: `--socket /tmp/signal-cli.sock`
Terminal 2 -- send JSON-RPC commands. Set the socket path to match:
```bash
SOCKET="${XDG_RUNTIME_DIR:-$HOME/.cache}/signal-cli/socket"
```
To send a one-shot command and get the response:
```bash
echo '{"jsonrpc":"2.0","method":"METHOD","id":1,"params":{}}' | socat - UNIX-CONNECT:$SOCKET
```
To open a persistent connection (needed for receiving notifications like incoming calls):
```bash
socat STDIO UNIX-CONNECT:$SOCKET
```
Then type JSON-RPC requests directly. Notifications (incoming calls, state changes)
will appear interleaved with responses.
---
## Test A: Outgoing call signaling and tunnel lifecycle
### A1. Start the call
```bash
echo '{"jsonrpc":"2.0","method":"startCall","id":1,"params":{"recipient":"+1YOURPHONENUMBER"}}' \
| socat - UNIX-CONNECT:$SOCKET
```
**Expect in response:**
```json
{"jsonrpc":"2.0","result":{"callId":...,"state":"RINGING_OUTGOING","inputDeviceName":"signal_input_...","outputDeviceName":"signal_output_..."},"id":1}
```
**Expect in daemon logs (terminal 1):**
```
Started outgoing call {callId} to {recipient}
Spawned media tunnel for call {callId}
Tunnel ready for call {callId}
```
**Expect on phone:** Incoming call notification from the signal-cli account.
### A2. Answer on your phone
Pick up the call.
**Expect in daemon logs (key lines, in order):**
```
Received answer for call {callId}
Control event: sendOffer (outgoing call offer generated by RingRTC)
Control event: sendIce (repeated, ICE candidates from RingRTC)
Control event: stateChange state=Connecting
Control event: stateChange state=Connected
```
### A3. Verify the media tunnel is running
```bash
ps aux | grep signal-call-tunnel
```
Should show a `signal-call-tunnel` process.
Check the socket directory (path from the `startCall` response):
```bash
ls -la /tmp/sc-*/
```
Should show `ctrl.sock` for the active call.
### A4. Hang up
From signal-cli (replace CALL_ID with the actual call ID):
```bash
echo '{"jsonrpc":"2.0","method":"hangupCall","id":2,"params":{"callId":CALL_ID}}' \
| socat - UNIX-CONNECT:$SOCKET
```
Or hang up on your phone.
**Expect in daemon logs:**
```
Call {callId} ended: local_hangup (if you hung up from signal-cli)
Call {callId} ended: remote_hangup (if you hung up from phone)
Media tunnel for call {callId} exited with code 0
```
---
## Test B: Incoming call signaling, accept, and tunnel lifecycle
### B1. Open a persistent connection
You need to see incoming call notifications, so open a persistent connection:
```bash
socat STDIO UNIX-CONNECT:$SOCKET
```
### B2. Call from your phone
On your phone, start a Signal voice call to the signal-cli account.
**Expect in daemon logs (terminal 1):**
```
Incoming call {callId} from {yourPhoneNumber}
Spawned media tunnel for call {callId}
Tunnel ready for call {callId}
```
**Expect on the socat connection:** A `receive` notification containing a `callMessage`
with an `offerMessage`. The `callId` is in the offer's `id` field.
### B3. List calls to get the call ID
Type into the socat session:
```json
{"jsonrpc":"2.0","method":"listCalls","id":3}
```
**Expect:** A response with a call in state `RINGING_INCOMING`. Note the `callId`.
### B4. Accept the call
Type into the socat session (replace CALL_ID):
```json
{"jsonrpc":"2.0","method":"acceptCall","id":4,"params":{"callId":CALL_ID}}
```
**Expect in response:**
```json
{"jsonrpc":"2.0","result":{"callId":...,"state":"CONNECTING","inputDeviceName":"...","outputDeviceName":"..."},"id":4}
```
**Expect in daemon logs (key lines, in order):**
```
Accepted incoming call {callId}
Control event: sendAnswer (RingRTC generated answer with DH key)
Control event: sendIce (repeated, ICE candidates from RingRTC)
Control event: stateChange state=Connecting
Control event: stateChange state=Connected
```
**Expect on phone:** Call shows as connected.
### B5. Verify the media tunnel is running
Same as A3.
### B6. Hang up
Same as A4.
---
## Test C: Incoming call rejection
### C1. Call from your phone (same as B1-B2)
### C2. Reject it
```json
{"jsonrpc":"2.0","method":"rejectCall","id":5,"params":{"callId":CALL_ID}}
```
**Expect in daemon logs:**
```
Call {callId} ended: rejected
```
**Expect on phone:** Call ends, shown as declined/busy.
---
## Test D: Unanswered call ring timeout
### D1. Start the call (same as A1)
Place an outgoing call from signal-cli. Do **not** answer on your phone.
```bash
echo '{"jsonrpc":"2.0","method":"startCall","id":1,"params":{"recipient":"+1YOURPHONENUMBER"}}' \
| socat - UNIX-CONNECT:$SOCKET
```
### D2. Wait 60 seconds without answering
**Expect in daemon logs after ~60s:**
```
Call {callId} ring timeout
Call {callId} ended: ring_timeout
```
**Expect on phone:** Incoming call stops ringing.
---
## Success criteria
| Stage | How to verify |
|---|---|
| Signaling (offer/answer) | `sendOffer`/`sendAnswer` control events in logs |
| Media tunnel spawn | `Spawned media tunnel` in logs, `ps aux \| grep signal-call-tunnel` shows process |
| ICE connectivity | `stateChange state=Connected` in logs |
| Key derivation | Handled internally by RingRTC (x25519 DH + HKDF); no errors in tunnel stderr |
| Virtual audio | Devices enumerated by cubeb (check tunnel logs for device selection) |
| Call connected | Phone shows connected call, tunnel process alive |
| Clean teardown | `ended` in logs, `exited with code 0`, socket dir cleaned up |

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

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

@ -0,0 +1,22 @@
#!/usr/bin/env bash
# Shared environment configuration for E2E voice call tests.
SIGNAL_CLI_ACCOUNT="${SIGNAL_CLI_ACCOUNT:?ERROR: SIGNAL_CLI_ACCOUNT not set (use --signal-cli-account or export SIGNAL_CLI_ACCOUNT)}"
EMULATOR_ACCOUNT="${EMULATOR_ACCOUNT:?ERROR: EMULATOR_ACCOUNT not set (use --emulator-account or export EMULATOR_ACCOUNT)}"
ANDROID_SDK="${ANDROID_SDK:-/opt/homebrew/share/android-commandlinetools}"
ADB="${ADB:-$(command -v adb || echo "$ANDROID_SDK/platform-tools/adb")}"
EMULATOR_BIN="${EMULATOR_BIN:-$(command -v emulator || echo "$ANDROID_SDK/emulator/emulator")}"
EMULATOR_AVD="${EMULATOR_AVD:-signal-test}"
EMULATOR_GRPC_PORT=8554
SIGNAL_CLI_SOCKET="/tmp/signal-cli-test.sock"
SIGNAL_CLI_BIN="./build/install/signal-cli/bin/signal-cli"
SIGNAL_CALL_TUNNEL_BIN="./signal-call-tunnel/target/debug/signal-call-tunnel"
TEST_TONE_FREQ_OUT=440 # signal-cli -> emulator (Hz)
TEST_TONE_FREQ_IN=1000 # emulator -> signal-cli (Hz)
TEST_TONE_DURATION=3 # seconds
# Log collection
LOG_DIR="voice-test/output/logs"
SIGNAL_CLI_LOG="$LOG_DIR/signal-cli.log"
DAEMON_CONSOLE_LOG="$LOG_DIR/daemon-console.log"
LOGCAT_LOG="$LOG_DIR/logcat.log"

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

@ -0,0 +1,916 @@
#!/usr/bin/env python3
"""E2E voice call test runner.
Runs test scenarios A-E against a signal-cli daemon and an Android emulator
with Signal installed.
Usage:
python3 voice-test/e2e_test.py --socket /tmp/signal-cli-test.sock --scenarios A,B,C,D,E
"""
import argparse
import os
import platform
import shutil
import signal
import subprocess
import sys
import threading
import time
import traceback
from pathlib import Path
# Add voice-test dir so we can import lib.*
sys.path.insert(0, str(Path(__file__).resolve().parent))
from lib.signal_rpc import SignalRPC
from lib.audio import generate_tone, detect_tone, pcm_to_wav, rms_level, wav_to_pcm
from lib.emulator import EmulatorControl
# Optional: gRPC audio (only needed for scenario E)
try:
from lib.grpc_audio import EmulatorAudio
HAS_GRPC_AUDIO = True
except ImportError:
HAS_GRPC_AUDIO = False
# -- Configuration (accounts required via env, others overridable) --
EMULATOR_ACCOUNT = os.environ["EMULATOR_ACCOUNT"]
SIGNAL_CLI_ACCOUNT = os.environ["SIGNAL_CLI_ACCOUNT"]
ADB_PATH = os.environ.get("ADB") or shutil.which("adb")
if not ADB_PATH:
sys.exit("ERROR: 'adb' not found. Set ADB env var or add adb to PATH.")
EMULATOR_GRPC_PORT = int(os.environ.get("EMULATOR_GRPC_PORT", "8554"))
TEST_TONE_FREQ_OUT = int(os.environ.get("TEST_TONE_FREQ_OUT", "440"))
TEST_TONE_FREQ_IN = int(os.environ.get("TEST_TONE_FREQ_IN", "1000"))
TEST_TONE_DURATION = int(os.environ.get("TEST_TONE_DURATION", "3"))
OUTPUT_DIR = Path(__file__).resolve().parent / "output"
IS_MACOS = platform.system() == "Darwin"
def setup_output_dir():
OUTPUT_DIR.mkdir(exist_ok=True)
class TestResult:
def __init__(self, name, passed, message="", duration=0):
self.name = name
self.passed = passed
self.message = message
self.duration = duration
def __str__(self):
status = "PASS" if self.passed else "FAIL"
return f"[{status}] {self.name} ({self.duration:.1f}s) {self.message}"
# ---------------------------------------------------------------------------
# Log collection
# ---------------------------------------------------------------------------
class LogCollector:
"""Tracks log file positions per scenario and extracts relevant segments on failure.
Monitors three log sources:
- signal-cli log file (includes tunnel output as [tunnel-{callId}] lines)
- daemon console log (stdout/stderr from the daemon process)
- Android logcat log (full device logcat)
"""
# Lines to show in failure diagnostics (per log source)
TAIL_LINES = 80
# Logcat patterns relevant to call diagnostics
LOGCAT_FILTERS = [
"WebRtcCallService",
"RingRTC",
"CallManager",
"org.thoughtcrime.securesms",
"signal",
"webrtc",
"AudioManager",
"AudioTrack",
"AudioRecord",
]
def __init__(self, log_dir):
self.log_dir = Path(log_dir) if log_dir else None
self.signal_cli_log = self.log_dir / "signal-cli.log" if self.log_dir else None
self.daemon_console_log = self.log_dir / "daemon-console.log" if self.log_dir else None
self.logcat_log = self.log_dir / "logcat.log" if self.log_dir else None
self._positions = {} # scenario_id -> {file: byte_offset}
@property
def enabled(self):
return self.log_dir is not None and self.log_dir.is_dir()
def _file_size(self, path):
try:
return path.stat().st_size if path and path.exists() else 0
except OSError:
return 0
def mark_start(self, scenario_id):
"""Record current end-of-file positions for all log files."""
if not self.enabled:
return
self._positions[scenario_id] = {
"signal_cli": self._file_size(self.signal_cli_log),
"daemon_console": self._file_size(self.daemon_console_log),
"logcat": self._file_size(self.logcat_log),
}
def extract_scenario_logs(self, scenario_id):
"""Extract log segments written during this scenario.
Returns dict of {source_name: text}.
"""
if not self.enabled or scenario_id not in self._positions:
return {}
starts = self._positions[scenario_id]
segments = {}
for name, log_path, start_pos in [
("signal-cli", self.signal_cli_log, starts["signal_cli"]),
("daemon-console", self.daemon_console_log, starts["daemon_console"]),
("logcat", self.logcat_log, starts["logcat"]),
]:
if not log_path or not log_path.exists():
continue
try:
end_pos = log_path.stat().st_size
if end_pos <= start_pos:
continue
with open(log_path, "r", errors="replace") as f:
f.seek(start_pos)
text = f.read(end_pos - start_pos)
if text.strip():
segments[name] = text
except OSError:
continue
return segments
def save_scenario_logs(self, scenario_id, result):
"""On failure, save per-scenario log excerpts and print diagnostics."""
if not self.enabled:
return
segments = self.extract_scenario_logs(scenario_id)
if not segments:
return
# Save full per-scenario logs to files
for source, text in segments.items():
out_path = self.log_dir / f"scenario_{scenario_id}_{source}.log"
with open(out_path, "w") as f:
f.write(text)
if not result.passed:
self._print_diagnostics(scenario_id, segments)
def _print_diagnostics(self, scenario_id, segments):
"""Print relevant log excerpts for a failed scenario."""
print(f"\n {'='*60}")
print(f" DIAGNOSTIC LOGS FOR SCENARIO {scenario_id}")
print(f" {'='*60}")
# signal-cli log (includes [tunnel-*] lines)
if "signal-cli" in segments:
text = segments["signal-cli"]
lines = text.splitlines()
# Separate tunnel lines from daemon lines
tunnel_lines = [l for l in lines if "[tunnel-" in l]
call_lines = [l for l in lines
if any(kw in l.lower() for kw in
["call", "tunnel", "ice", "ring", "offer",
"answer", "hangup", "error", "exception",
"failed", "timeout", "media", "virtual", "audio"])]
if tunnel_lines:
print(f"\n --- signal-call-tunnel ({len(tunnel_lines)} lines) ---")
for line in tunnel_lines[-self.TAIL_LINES:]:
print(f" | {line}")
if call_lines:
# Deduplicate: skip lines already shown as tunnel lines
daemon_call_lines = [l for l in call_lines if "[tunnel-" not in l]
if daemon_call_lines:
print(f"\n --- signal-cli daemon (call-related, {len(daemon_call_lines)} lines) ---")
for line in daemon_call_lines[-self.TAIL_LINES:]:
print(f" | {line}")
# Any ERROR/WARN lines not yet shown
error_lines = [l for l in lines
if any(lvl in l for lvl in [" ERROR ", " WARN "])
and l not in call_lines and l not in tunnel_lines]
if error_lines:
print(f"\n --- signal-cli errors/warnings ({len(error_lines)} lines) ---")
for line in error_lines[-20:]:
print(f" | {line}")
# Logcat (filtered for Signal/WebRTC)
if "logcat" in segments:
text = segments["logcat"]
lines = text.splitlines()
relevant = [l for l in lines
if any(f.lower() in l.lower() for f in self.LOGCAT_FILTERS)]
if relevant:
print(f"\n --- Android logcat (Signal/WebRTC, {len(relevant)} lines) ---")
for line in relevant[-self.TAIL_LINES:]:
print(f" | {line}")
elif lines:
# No filtered matches; show tail of raw logcat
print(f"\n --- Android logcat (tail, {len(lines)} total lines) ---")
for line in lines[-30:]:
print(f" | {line}")
# Daemon console (startup errors, crashes)
if "daemon-console" in segments:
text = segments["daemon-console"].strip()
if text:
lines = text.splitlines()
print(f"\n --- daemon console output ({len(lines)} lines) ---")
for line in lines[-20:]:
print(f" | {line}")
print(f"\n {'='*60}")
print(f" Full logs: {self.log_dir}/scenario_{scenario_id}_*.log")
print(f" {'='*60}\n")
# ---------------------------------------------------------------------------
# Screen recording
# ---------------------------------------------------------------------------
class ScreenRecorder:
"""Records the emulator screen via `adb screenrecord` for a scenario."""
def __init__(self, adb_path, output_dir):
self.adb = adb_path
self.output_dir = Path(output_dir)
self.output_dir.mkdir(parents=True, exist_ok=True)
self._proc = None
self._device_path = "/sdcard/scenario_recording.mp4"
def start(self, scenario_id):
"""Start recording the emulator screen."""
self.stop() # ensure no leftover recording
self._scenario_id = scenario_id
try:
self._proc = subprocess.Popen(
[self.adb, "shell", "screenrecord", "--time-limit", "120",
self._device_path],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
print(f" [rec] Screen recording started (PID {self._proc.pid})")
except Exception as e:
print(f" [rec] Failed to start screen recording: {e}")
self._proc = None
def stop(self):
"""Stop recording and pull the video to the output directory."""
if self._proc is None:
return None
# Send SIGINT to gracefully stop screenrecord (finalizes the mp4)
try:
self._proc.send_signal(signal.SIGINT)
self._proc.wait(timeout=5)
except Exception:
self._proc.kill()
self._proc.wait(timeout=3)
self._proc = None
# Give adb a moment to finalize the file
time.sleep(1)
# Pull the recording from the device
local_path = self.output_dir / f"scenario_{self._scenario_id}_screen.mp4"
try:
subprocess.run(
[self.adb, "pull", self._device_path, str(local_path)],
capture_output=True, timeout=15,
)
subprocess.run(
[self.adb, "shell", "rm", "-f", self._device_path],
capture_output=True, timeout=5,
)
if local_path.exists() and local_path.stat().st_size > 0:
print(f" [rec] Screen recording saved: {local_path}")
return local_path
else:
print(f" [rec] Screen recording file is empty or missing")
except Exception as e:
print(f" [rec] Failed to pull screen recording: {e}")
return None
# ---------------------------------------------------------------------------
# Virtual audio device helpers
# ---------------------------------------------------------------------------
def play_to_device(device_name, wav_path, duration=None):
"""Play a WAV file to a virtual audio input device (platform-aware).
Returns the subprocess.Popen object for the background player process.
"""
if IS_MACOS:
# On macOS, use sox to play into the CoreAudio device
cmd = ["sox", str(wav_path), "-t", "coreaudio", device_name, "repeat", "-"]
return subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
else:
# On Linux, play to the PulseAudio sink associated with the input device
sink_name = f"sink_for_{device_name}"
cmd = ["paplay", f"--device={sink_name}", str(wav_path)]
return subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def record_from_device(device_name, wav_path, duration):
"""Record audio from a virtual audio output device (platform-aware).
Blocks for `duration` seconds, then returns.
"""
if IS_MACOS:
# On macOS, use sox to record from the CoreAudio device.
# Force mono 16-bit 48kHz output so Python's wave module can read it
# (BlackHole is 2ch, which makes sox emit WAVE_FORMAT_EXTENSIBLE).
cmd = ["sox", "-t", "coreaudio", device_name,
"-b", "16", "-c", "1", "-r", "48000", str(wav_path),
"trim", "0", str(duration)]
else:
# On Linux, record from the PulseAudio monitor source
monitor_name = f"{device_name}.monitor"
cmd = ["parecord", f"--device={monitor_name}",
f"--rate=48000", "--channels=1", "--format=s16le",
f"--file-format=wav", str(wav_path)]
proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
if IS_MACOS:
# sox with trim will stop after duration
proc.wait(timeout=duration + 10)
else:
# parecord runs indefinitely; kill after duration
time.sleep(duration)
proc.terminate()
proc.wait(timeout=5)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def assert_call_stable(rpc, duration=2):
"""Verify the call stays connected for at least duration seconds.
Reads events from the RPC connection, raising AssertionError if an
unexpected ENDED event arrives. Replaces arbitrary time.sleep() pauses
with an actual state-based check.
"""
deadline = time.monotonic() + duration
while time.monotonic() < deadline:
remaining = deadline - time.monotonic()
if remaining <= 0:
break
try:
msg = rpc.read_event(timeout=remaining)
except TimeoutError:
break # No events — call is stable
if msg and msg.get("method") == "callEvent":
event = msg.get("params", {}).get("callEvent", {})
state = event.get("state")
if state == "ENDED":
raise AssertionError(
f"Call dropped during stability check: reason={event.get('reason')}"
)
print(f" [rpc] callEvent during stability check: {state}")
def wait_for_clean_state():
"""Ensure no lingering call state on the emulator between scenarios.
Kills Signal, relaunches it, and waits for the WebSocket to reconnect
so the next scenario can receive calls. Raises RuntimeError on timeout.
"""
emu = EmulatorControl(ADB_PATH, output_dir=str(OUTPUT_DIR))
emu.kill_signal()
# Record a timestamp before launch so we can filter logcat by time
start_ts = emu._shell("date '+%m-%d %H:%M:%S.000'")
emu.launch_signal()
# Wait for Signal's authenticated WebSocket to connect.
deadline = time.monotonic() + 20
while time.monotonic() < deadline:
logcat = emu._shell(
f"logcat -d -T '{start_ts}' "
"-s SignalWebSocketHealthMo:V IncomingMessageObserver:D "
"2>/dev/null",
timeout=5,
)
if "CONNECTED" in logcat:
return
time.sleep(1)
raise RuntimeError(
"Signal did not reconnect WebSocket within 20s after restart"
)
# ---------------------------------------------------------------------------
# Scenario A: Outgoing call -- signaling and lifecycle
# ---------------------------------------------------------------------------
def scenario_a(socket_path):
"""Outgoing call: signal-cli places call, emulator answers, signal-cli hangs up."""
emu = EmulatorControl(ADB_PATH, output_dir=str(OUTPUT_DIR))
rpc = SignalRPC(socket_path)
call_id = None
try:
rpc.subscribe_receive()
# Place the call
print(" [A] Starting outgoing call to emulator...")
result = rpc.start_call(EMULATOR_ACCOUNT)
call_id = result.get("callId")
state = result.get("state")
input_dev = result.get("inputDeviceName")
output_dev = result.get("outputDeviceName")
print(f" [A] startCall => callId={call_id}, state={state}")
assert call_id, "No callId returned"
# Wait for RINGING_OUTGOING
if state != "RINGING_OUTGOING":
rpc.wait_for_state("RINGING_OUTGOING", timeout=15)
# Answer on emulator (polls internally for call to arrive)
print(" [A] Answering call on emulator...")
emu.answer_incoming_call()
# Wait for CONNECTED
print(" [A] Waiting for CONNECTED...")
rpc.wait_for_state("CONNECTED", timeout=30)
print(" [A] Call connected! Waiting 5s before hanging up from signal-cli...")
assert_call_stable(rpc, duration=5)
# Hang up
print(" [A] Hanging up from signal-cli...")
rpc.hangup_call(call_id)
call_id = None # Don't double-hangup in cleanup
# Wait for ENDED
rpc.wait_for_state("ENDED", timeout=10)
print(" [A] Call ended normally.")
return TestResult("A: Outgoing call lifecycle", True)
except Exception as e:
return TestResult("A: Outgoing call lifecycle", False, str(e))
finally:
if call_id:
try:
rpc.hangup_call(call_id)
except Exception:
pass
rpc.close()
# ---------------------------------------------------------------------------
# Scenario B: Incoming call -- emulator calls signal-cli
# ---------------------------------------------------------------------------
def scenario_b(socket_path):
"""Incoming call: emulator places call, signal-cli accepts and hangs up."""
emu = EmulatorControl(ADB_PATH, output_dir=str(OUTPUT_DIR))
rpc = SignalRPC(socket_path)
call_id = None
try:
rpc.subscribe_receive()
# Navigate emulator to conversation and place call
print(" [B] Opening conversation on emulator...")
emu.open_conversation(SIGNAL_CLI_ACCOUNT)
print(" [B] Tapping call button on emulator...")
emu.tap_call_button()
# Wait for incoming call event
print(" [B] Waiting for RINGING_INCOMING...")
params = rpc.wait_for_state("RINGING_INCOMING", timeout=30)
event = params.get("callEvent", {})
call_id = event.get("callId")
print(f" [B] Incoming call: callId={call_id}")
assert call_id, "No callId in incoming call event"
# Accept the call
print(" [B] Accepting call...")
rpc.accept_call(call_id)
# Wait for CONNECTED
print(" [B] Waiting for CONNECTED...")
rpc.wait_for_state("CONNECTED", timeout=30)
print(" [B] Call connected!")
# Verify call remains stable
print(" [B] Verifying call stability...")
assert_call_stable(rpc, duration=2)
# Hang up from signal-cli
print(" [B] Hanging up...")
rpc.hangup_call(call_id)
call_id = None
rpc.wait_for_state("ENDED", timeout=10)
print(" [B] Call ended normally.")
return TestResult("B: Incoming call lifecycle", True)
except Exception as e:
return TestResult("B: Incoming call lifecycle", False, str(e))
finally:
if call_id:
try:
rpc.hangup_call(call_id)
except Exception:
pass
rpc.close()
# ---------------------------------------------------------------------------
# Scenario C: Incoming call rejection
# ---------------------------------------------------------------------------
def scenario_c(socket_path):
"""Incoming call: emulator places call, signal-cli rejects it."""
emu = EmulatorControl(ADB_PATH, output_dir=str(OUTPUT_DIR))
rpc = SignalRPC(socket_path)
call_id = None
try:
rpc.subscribe_receive()
# Emulator places call
print(" [C] Opening conversation on emulator...")
emu.open_conversation(SIGNAL_CLI_ACCOUNT)
print(" [C] Tapping call button on emulator...")
emu.tap_call_button()
# Wait for incoming ring
print(" [C] Waiting for RINGING_INCOMING...")
params = rpc.wait_for_state("RINGING_INCOMING", timeout=30)
event = params.get("callEvent", {})
call_id = event.get("callId")
assert call_id, "No callId in incoming call event"
# Reject the call
print(" [C] Rejecting call...")
rpc.reject_call(call_id)
call_id = None
# Wait for ENDED and verify rejection reason
params = rpc.wait_for_state("ENDED", timeout=10)
event = params.get("callEvent", {})
reason = event.get("reason", "")
print(f" [C] Call ended: reason={reason}")
assert any(kw in reason.lower() for kw in ("reject", "busy", "decline")), \
f"Expected rejection reason, got: {reason}"
return TestResult("C: Incoming call rejection", True, f"reason={reason}")
except Exception as e:
return TestResult("C: Incoming call rejection", False, str(e))
finally:
if call_id:
try:
rpc.hangup_call(call_id)
except Exception:
pass
rpc.close()
# ---------------------------------------------------------------------------
# Scenario D: Ring timeout
# ---------------------------------------------------------------------------
def scenario_d(socket_path):
"""Outgoing call that is never answered -- should timeout."""
emu = EmulatorControl(ADB_PATH, output_dir=str(OUTPUT_DIR))
rpc = SignalRPC(socket_path)
call_id = None
try:
# Restart Signal to a clean main screen so it can receive the call
# (but nobody will tap Answer)
emu.ensure_signal_foreground()
rpc.subscribe_receive()
# Place call, don't answer on emulator
print(" [D] Starting call (will NOT answer)...")
result = rpc.start_call(EMULATOR_ACCOUNT)
call_id = result.get("callId")
state = result.get("state")
print(f" [D] startCall => callId={call_id}, state={state}")
if state != "RINGING_OUTGOING":
rpc.wait_for_state("RINGING_OUTGOING", timeout=15)
# Wait for timeout (Signal typically times out after ~60s)
print(" [D] Waiting for ring timeout (up to 90s)...")
params = rpc.wait_for_state("ENDED", timeout=90)
event = params.get("callEvent", {})
reason = event.get("reason", "")
print(f" [D] Call ended: reason={reason}")
call_id = None
assert "timeout" in reason.lower(), \
f"Expected timeout reason, got: {reason}"
return TestResult("D: Ring timeout", True, f"reason={reason}")
except Exception as e:
return TestResult("D: Ring timeout", False, str(e))
finally:
if call_id:
try:
rpc.hangup_call(call_id)
except Exception:
pass
rpc.close()
# ---------------------------------------------------------------------------
# Scenario E: Bidirectional audio verification
# ---------------------------------------------------------------------------
def scenario_e(socket_path):
"""Connected call with bidirectional audio: tone generation and detection."""
if not HAS_GRPC_AUDIO:
return TestResult("E: Bidirectional audio", False,
"grpc_audio not available (run generate_proto.sh first)")
emu = EmulatorControl(ADB_PATH, output_dir=str(OUTPUT_DIR))
rpc = SignalRPC(socket_path)
call_id = None
grpc_audio = None
player_proc = None
try:
# Bring Signal to foreground without killing it.
emu.launch_signal()
rpc.subscribe_receive()
# Place call and connect
print(" [E] Starting outgoing call...")
result = rpc.start_call(EMULATOR_ACCOUNT)
call_id = result.get("callId")
input_device = result.get("inputDeviceName")
output_device = result.get("outputDeviceName")
assert call_id, "Missing callId"
state = result.get("state")
if state != "RINGING_OUTGOING":
rpc.wait_for_state("RINGING_OUTGOING", timeout=15)
# Answer on emulator (polls internally for call to arrive)
print(" [E] Answering on emulator...")
emu.answer_incoming_call()
print(" [E] Waiting for CONNECTED...")
event_params = rpc.wait_for_state("CONNECTED", timeout=30)
print(" [E] Call connected!")
# Get device names from the CONNECTED event if not in startCall response
if not input_device or not output_device:
event = event_params.get("callEvent", {})
input_device = input_device or event.get("inputDeviceName")
output_device = output_device or event.get("outputDeviceName")
assert input_device, "No inputDeviceName available"
assert output_device, "No outputDeviceName available"
print(f" [E] Virtual audio: input={input_device}, output={output_device}")
# Set in-call volume to max (default is often 3/15 on emulators)
emu.set_call_volume_max()
# Settling delay: let WebRTC/Opus codec stabilize before audio tests
print(" [E] Waiting 2s for WebRTC/Opus to stabilize...")
assert_call_stable(rpc, duration=2)
grpc_audio = EmulatorAudio(port=EMULATOR_GRPC_PORT)
# --- Direction 1: signal-cli -> emulator (440 Hz) ---
# Generate test tone as WAV file, play it into the virtual input device
print(f" [E] Direction 1: Sending {TEST_TONE_FREQ_OUT}Hz tone via virtual audio device...")
play_duration = TEST_TONE_DURATION + 5 # extra time for settling
tone_pcm = generate_tone(TEST_TONE_FREQ_OUT, play_duration)
setup_output_dir()
tone_wav_path = OUTPUT_DIR / "e_tone_out_source.wav"
pcm_to_wav(tone_pcm, tone_wav_path)
# Start playing tone into the virtual input device
player_proc = play_to_device(input_device, tone_wav_path)
# Wait for tone to flow through WebRTC (encode + network + decode)
time.sleep(4)
# Capture from emulator speaker while tone is playing
capture_duration = TEST_TONE_DURATION + 1
min_capture_bytes = 48000 * 2 * 2 # at least 2s of PCM
captured_pcm = grpc_audio.capture_audio(capture_duration)
# Stop player
if player_proc and player_proc.poll() is None:
player_proc.terminate()
player_proc.wait(timeout=5)
player_proc = None
print(f" [E] Captured {len(captured_pcm)} bytes from emulator speaker")
if captured_pcm:
pcm_to_wav(captured_pcm, OUTPUT_DIR / "e_tone_out_captured.wav")
dir1_ok = False
dir1_rms = 0.0
if captured_pcm and len(captured_pcm) > 1920:
dir1_rms = rms_level(captured_pcm)
dir1_ok = detect_tone(captured_pcm, TEST_TONE_FREQ_OUT)
print(f" [E] Direction 1: detect={dir1_ok}, RMS={dir1_rms:.4f}")
assert dir1_rms > 0.001, \
f"Direction 1: captured audio is silent (RMS={dir1_rms:.6f})"
else:
print(f" [E] Direction 1: insufficient captured audio")
# --- Direction 2: emulator -> signal-cli (playout path) ---
# Record from the virtual output device to verify playout works
print(" [E] Direction 2: Recording from virtual output device...")
record_duration = 3
recorded_wav_path = OUTPUT_DIR / "e_playout_received.wav"
record_from_device(output_device, recorded_wav_path, record_duration)
recorded_pcm = b""
if recorded_wav_path.exists() and recorded_wav_path.stat().st_size > 44:
recorded_pcm = wav_to_pcm(recorded_wav_path)
recorded_bytes = len(recorded_pcm)
expected_bytes = 48000 * 2 * record_duration
min_bytes = expected_bytes // 2
print(f" [E] Direction 2: received {recorded_bytes} bytes "
f"(expected ~{expected_bytes})")
dir2_ok = recorded_bytes >= min_bytes
dir2_rms = rms_level(recorded_pcm) if recorded_pcm else 0.0
# Hang up
print(" [E] Hanging up...")
rpc.hangup_call(call_id)
call_id = None
params = rpc.wait_for_state("ENDED", timeout=10)
# Validate call ended normally (not a crash)
event = params.get("callEvent", {})
reason = event.get("reason", "")
print(f" [E] Call ended: reason={reason}")
assert reason and "error" not in reason.lower(), \
f"Call ended abnormally: reason={reason}"
# Report results
msgs = []
msgs.append(f"dir1(signal-cli->emu):{'OK' if dir1_ok else 'FAIL'} RMS={dir1_rms:.4f}")
msgs.append(f"dir2(playout-path):{'OK' if dir2_ok else 'FAIL'} "
f"{recorded_bytes}B/{expected_bytes}B")
passed = dir1_ok and dir2_ok
return TestResult("E: Bidirectional audio", passed, ", ".join(msgs))
except Exception as e:
return TestResult("E: Bidirectional audio", False, str(e))
finally:
if player_proc and player_proc.poll() is None:
player_proc.terminate()
if call_id:
try:
rpc.hangup_call(call_id)
except Exception:
pass
if grpc_audio:
grpc_audio.close()
rpc.close()
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
SCENARIOS = {
"A": ("Outgoing call lifecycle", scenario_a),
"B": ("Incoming call lifecycle", scenario_b),
"C": ("Incoming call rejection", scenario_c),
"D": ("Ring timeout", scenario_d),
"E": ("Bidirectional audio", scenario_e),
}
def main():
parser = argparse.ArgumentParser(description="E2E voice call test runner")
parser.add_argument("--socket", required=True, help="Path to signal-cli JSON-RPC socket")
parser.add_argument("--scenarios", default="A,B,C,D,E",
help="Comma-separated list of scenarios to run (default: A,B,C,D,E)")
parser.add_argument("--log-dir", default=None,
help="Directory containing log files for diagnostic collection")
parser.add_argument("--record", action="store_true",
help="Record emulator screen during each scenario (saved to output dir)")
parser.add_argument("--no-fail-fast", action="store_true",
help="Continue running scenarios after a failure (default: stop on first failure)")
args = parser.parse_args()
selected = [s.strip().upper() for s in args.scenarios.split(",")]
for s in selected:
if s not in SCENARIOS:
print(f"Unknown scenario: {s}")
print(f"Available: {', '.join(SCENARIOS.keys())}")
sys.exit(1)
logs = LogCollector(args.log_dir)
recorder = ScreenRecorder(ADB_PATH, OUTPUT_DIR) if args.record else None
print(f"=== E2E Voice Call Tests ===")
print(f"Socket: {args.socket}")
print(f"Scenarios: {', '.join(selected)}")
print(f"Emulator account: {EMULATOR_ACCOUNT}")
print(f"signal-cli account: {SIGNAL_CLI_ACCOUNT}")
if logs.enabled:
print(f"Log collection: {args.log_dir}")
if recorder:
print(f"Screen recording: enabled")
print()
results = []
for s in selected:
name, func = SCENARIOS[s]
print(f"--- Scenario {s}: {name} ---")
logs.mark_start(s)
if recorder:
recorder.start(s)
t0 = time.monotonic()
try:
result = func(args.socket)
except Exception as e:
result = TestResult(f"{s}: {name}", False, f"Unhandled: {e}")
traceback.print_exc()
result.duration = time.monotonic() - t0
# Retry once on failure for scenarios with flaky external dependencies
if not result.passed and s == "E":
print(f" => {result}")
print(f" [E] Retrying scenario E (emulator audio HAL may need reset)...")
try:
wait_for_clean_state()
except RuntimeError as e:
print(f" [E] Cannot retry: {e}")
else:
logs.mark_start(s)
t0 = time.monotonic()
try:
result = func(args.socket)
except Exception as e:
result = TestResult(f"{s}: {name}", False, f"Unhandled: {e}")
traceback.print_exc()
result.duration = time.monotonic() - t0
if recorder:
recorder.stop()
results.append(result)
print(f" => {result}")
# Collect and save logs for this scenario (prints diagnostics on failure)
logs.save_scenario_logs(s, result)
print()
# Stop on first failure unless --no-fail-fast is set
if not result.passed and not args.no_fail_fast:
print(f"Stopping after scenario {s} failure (use --no-fail-fast to continue)")
break
# Wait for clean state between scenarios
if s != selected[-1]:
try:
wait_for_clean_state()
except RuntimeError as e:
print(f" WARNING: {e}")
print(f" Continuing anyway — next scenario may fail.")
# Summary
print("=" * 50)
print("RESULTS:")
passed = 0
failed_ids = []
for i, r in enumerate(results):
print(f" {r}")
if r.passed:
passed += 1
else:
failed_ids.append(selected[i])
total = len(results)
print(f"\n{passed}/{total} passed")
if failed_ids and logs.enabled:
print(f"\nDiagnostic logs for failed scenarios:")
for fid in failed_ids:
print(f" Scenario {fid}: {args.log_dir}/scenario_{fid}_*.log")
sys.exit(0 if passed == total else 1)
if __name__ == "__main__":
main()

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

@ -0,0 +1,37 @@
#!/usr/bin/env bash
# Compile emulator_controller.proto into Python stubs.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ANDROID_SDK="${ANDROID_SDK:-/opt/homebrew/share/android-commandlinetools}"
PROTO_DIR="$ANDROID_SDK/emulator/lib"
PROTO_FILE="$PROTO_DIR/emulator_controller.proto"
OUT_DIR="$SCRIPT_DIR/lib/proto"
if [ ! -f "$PROTO_FILE" ]; then
echo "ERROR: Proto file not found: $PROTO_FILE"
echo "Make sure Android emulator is installed via commandlinetools."
exit 1
fi
mkdir -p "$OUT_DIR"
echo "Compiling $PROTO_FILE -> $OUT_DIR ..."
python3 -m grpc_tools.protoc \
"-I$PROTO_DIR" \
"--python_out=$OUT_DIR" \
"--grpc_python_out=$OUT_DIR" \
"$PROTO_FILE"
# Fix the generated import path (grpc_tools generates absolute imports)
# The grpc stub file imports the pb2 module; ensure it works as a local import.
if [ -f "$OUT_DIR/emulator_controller_pb2_grpc.py" ]; then
# On macOS, sed -i requires '' argument
sed -i '' 's/^import emulator_controller_pb2/from . import emulator_controller_pb2/' \
"$OUT_DIR/emulator_controller_pb2_grpc.py" 2>/dev/null || \
sed -i 's/^import emulator_controller_pb2/from . import emulator_controller_pb2/' \
"$OUT_DIR/emulator_controller_pb2_grpc.py"
fi
echo "Proto stubs generated in $OUT_DIR:"
ls -la "$OUT_DIR"/emulator_controller_pb2*.py

View File

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

@ -0,0 +1,126 @@
"""Audio utilities: tone generation, frequency detection (Goertzel), WAV I/O.
Pure Python, no numpy dependency.
"""
import math
import struct
import wave
def generate_tone(freq_hz, duration_s, sample_rate=48000, amplitude=0.8):
"""Generate PCM bytes (S16LE mono) for a sine wave at freq_hz."""
n_samples = int(sample_rate * duration_s)
samples = []
for i in range(n_samples):
t = i / sample_rate
value = amplitude * math.sin(2 * math.pi * freq_hz * t)
sample = int(value * 32767)
sample = max(-32768, min(32767, sample))
samples.append(struct.pack("<h", sample))
return b"".join(samples)
def goertzel_magnitude(pcm_bytes, target_freq, sample_rate=48000):
"""Goertzel algorithm: compute magnitude of a single frequency bin.
More efficient than FFT when only one frequency is needed: O(n) time, O(1) space.
"""
n_samples = len(pcm_bytes) // 2
if n_samples == 0:
return 0.0
k = round(target_freq * n_samples / sample_rate)
w = 2 * math.pi * k / n_samples
coeff = 2 * math.cos(w)
s0 = 0.0
s1 = 0.0
s2 = 0.0
for i in range(n_samples):
sample = struct.unpack_from("<h", pcm_bytes, i * 2)[0] / 32768.0
s0 = sample + coeff * s1 - s2
s2 = s1
s1 = s0
magnitude = math.sqrt(s1 * s1 + s2 * s2 - coeff * s1 * s2)
return magnitude / n_samples
def detect_tone(pcm_bytes, expected_freq, sample_rate=48000, threshold=3.0):
"""Returns True if expected_freq is the dominant frequency in the signal.
Opus encoding and WebRTC audio processing (AGC, NS, AEC) can shift the
tone by up to ~50 Hz and spread energy across nearby bins. To handle this,
we scan a ±100 Hz window around the expected frequency and take the peak
magnitude as the signal level. Noise is measured from bins well outside
this window.
"""
if len(pcm_bytes) < 1920: # Less than 1 frame
return False
# Scan a window around the expected frequency to find the peak.
# Opus codec can shift the tone by ~30-50 Hz.
scan_step = 10
scan_range = 100 # Hz each side
peak_mag = 0.0
peak_freq = expected_freq
for f in range(expected_freq - scan_range, expected_freq + scan_range + 1, scan_step):
if f < 50 or f >= sample_rate // 2:
continue
mag = goertzel_magnitude(pcm_bytes, f, sample_rate)
if mag > peak_mag:
peak_mag = mag
peak_freq = f
# Measure noise at frequencies well outside the signal window.
noise_freqs = []
for offset in [-600, -400, 400, 600]:
f = expected_freq + offset
if 50 < f < sample_rate // 2:
noise_freqs.append(f)
if not noise_freqs:
return peak_mag > 0.001
avg_noise = sum(
goertzel_magnitude(pcm_bytes, f, sample_rate) for f in noise_freqs
) / len(noise_freqs)
if avg_noise < 1e-8:
return peak_mag > 0.001
ratio = peak_mag / avg_noise
print(f" [audio] detect_tone({expected_freq}Hz): peak={peak_mag:.6f}@{peak_freq}Hz, noise={avg_noise:.6f}, ratio={ratio:.1f} (threshold={threshold})")
return ratio >= threshold
def pcm_to_wav(pcm_bytes, path, sample_rate=48000):
"""Write raw PCM (S16LE mono) to a WAV file."""
wf = wave.open(str(path), "wb")
wf.setnchannels(1)
wf.setsampwidth(2)
wf.setframerate(sample_rate)
wf.writeframes(pcm_bytes)
wf.close()
def wav_to_pcm(path):
"""Read a WAV file and return raw PCM bytes (S16LE mono)."""
wf = wave.open(str(path), "rb")
pcm = wf.readframes(wf.getnframes())
wf.close()
return pcm
def rms_level(pcm_bytes):
"""RMS amplitude of PCM data (0.0 = silence, 1.0 = full scale)."""
n_samples = len(pcm_bytes) // 2
if n_samples == 0:
return 0.0
sum_sq = 0.0
for i in range(n_samples):
sample = struct.unpack_from("<h", pcm_bytes, i * 2)[0] / 32768.0
sum_sq += sample * sample
return math.sqrt(sum_sq / n_samples)

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

@ -0,0 +1,474 @@
"""ADB-based Signal UI automation for the Android emulator.
Uses a combination of:
- adb shell uiautomator dump for dynamic UI element discovery
- adb shell commands for taps, key events, intents
- logcat polling for state verification
- telecom shell commands for call answer/reject (no coordinates needed)
"""
import os
import re
import subprocess
import time
import xml.etree.ElementTree as ET
class EmulatorControl:
"""Drives Signal on the Android emulator via adb shell commands.
Uses uiautomator dump for dynamic element lookup (with timeout/retry)
and telecom commands for call answer/reject. Falls back to keyevents
when telecom commands fail.
"""
def __init__(self, adb_path, output_dir=None):
self.adb = adb_path
self._output_dir = output_dir
def _run(self, *args, timeout=15):
cmd = [self.adb] + list(args)
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
return result.stdout.strip()
def _shell(self, cmd, timeout=15):
return self._run("shell", cmd, timeout=timeout)
# ------------------------------------------------------------------
# UI element discovery via uiautomator dump
# ------------------------------------------------------------------
_UI_DUMP_PATH = "/sdcard/window_dump.xml"
def _dump_ui(self, timeout=5):
"""Dump the current UI hierarchy as an ElementTree.
Dumps to a file on the device then reads it back (more reliable
than piping to /dev/stdout which drops XML on many emulators).
Retries once on failure. Returns the parsed XML root or None.
"""
for attempt in range(2):
try:
# Dump to file on device
result = subprocess.run(
[self.adb, "shell", "uiautomator", "dump",
self._UI_DUMP_PATH],
capture_output=True, text=True, timeout=timeout,
)
if "dumped to" not in result.stdout.lower():
if attempt == 0:
time.sleep(1)
continue
print(f" [emu] uiautomator dump failed: {result.stdout.strip()}")
return None
# Read the file back
xml_text = self._shell(f"cat {self._UI_DUMP_PATH}", timeout=5)
if not xml_text or "<hierarchy" not in xml_text:
if attempt == 0:
time.sleep(1)
continue
print(" [emu] uiautomator dump returned no hierarchy XML")
return None
start = xml_text.index("<hierarchy")
end = xml_text.rindex(">") + 1
xml_text = xml_text[start:end]
if self._output_dir:
try:
dump_path = os.path.join(self._output_dir, "window_dump.xml")
with open(dump_path, "w") as f:
f.write(xml_text)
except OSError:
pass
return ET.fromstring(xml_text)
except subprocess.TimeoutExpired:
if attempt == 0:
print(" [emu] uiautomator dump timed out, retrying...")
continue
print(" [emu] uiautomator dump timed out twice")
return None
except (ET.ParseError, ValueError) as e:
if attempt == 0:
time.sleep(1)
continue
print(f" [emu] uiautomator dump parse error: {e}")
return None
return None
def _parse_bounds(self, bounds_str):
"""Parse a bounds attribute like '[0,0][1080,1920]' into (cx, cy)."""
m = re.match(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", bounds_str)
if not m:
return None
x1, y1, x2, y2 = int(m.group(1)), int(m.group(2)), int(m.group(3)), int(m.group(4))
return ((x1 + x2) // 2, (y1 + y2) // 2)
def _find_element(self, root=None, text=None, content_desc=None,
resource_id_contains=None, class_name=None):
"""Find a UI element in the hierarchy and return its center (x, y).
If root is None, calls _dump_ui() to get the current hierarchy.
Searches for a node matching ALL provided criteria.
Returns (center_x, center_y) or None if not found.
"""
if root is None:
root = self._dump_ui()
if root is None:
return None
for node in root.iter("node"):
if text is not None and node.get("text", "") != text:
continue
if content_desc is not None and content_desc not in node.get("content-desc", ""):
continue
if resource_id_contains is not None and resource_id_contains not in node.get("resource-id", ""):
continue
if class_name is not None and node.get("class", "") != class_name:
continue
bounds = node.get("bounds", "")
center = self._parse_bounds(bounds)
if center:
return center
return None
def _tap_element(self, **kwargs):
"""Find an element via _find_element() and tap its center.
Returns True if the element was found and tapped, False otherwise.
Passes all kwargs through to _find_element().
"""
center = self._find_element(**kwargs)
if center is None:
criteria = {k: v for k, v in kwargs.items() if v is not None and k != "root"}
print(f" [emu] Element not found: {criteria}")
return False
self.tap(*center)
return True
# ------------------------------------------------------------------
# Signal lifecycle
# ------------------------------------------------------------------
def kill_signal(self):
"""Force-stop Signal. Clears any stuck dialogs, notifications, etc."""
self._shell("am force-stop org.thoughtcrime.securesms")
time.sleep(1)
def launch_signal(self):
"""Launch Signal to its main chat list screen and wait for it."""
self._shell(
"monkey -p org.thoughtcrime.securesms "
"-c android.intent.category.LAUNCHER 1"
)
deadline = time.monotonic() + 10
while time.monotonic() < deadline:
focus = self._shell("dumpsys window | grep mCurrentFocus")
if "org.thoughtcrime.securesms" in focus:
return True
time.sleep(0.5)
print(" [emu] Warning: Signal may not be in foreground")
return False
def restart_signal(self):
"""Kill and relaunch Signal to a clean state."""
print(" [emu] Restarting Signal...")
self.kill_signal()
self.launch_signal()
# ------------------------------------------------------------------
# Navigation
# ------------------------------------------------------------------
def open_conversation(self, phone_number):
"""Open conversation with the given phone number.
Kills and relaunches Signal to start from a clean chat list,
then taps the first conversation row using uiautomator lookup.
"""
self.restart_signal()
time.sleep(2) # let chat list fully render
# Try to find and tap the first conversation row
root = self._dump_ui()
tapped = False
if root is not None:
# Try by content-desc containing the contact name/number
tapped = self._tap_element(root=root, content_desc=phone_number)
if not tapped:
# Try finding a conversation item by resource-id
tapped = self._tap_element(root=root, resource_id_contains="conversation")
if not tapped:
# Find the first clickable row below the toolbar area:
# look for clickable FrameLayout/LinearLayout nodes
for node in root.iter("node"):
if node.get("clickable") != "true":
continue
cls = node.get("class", "")
if cls not in ("android.widget.FrameLayout",
"android.widget.LinearLayout",
"android.view.ViewGroup"):
continue
bounds = node.get("bounds", "")
center = self._parse_bounds(bounds)
if center and center[1] > 200: # below toolbar area
self.tap(*center)
tapped = True
break
if not tapped:
print(" [emu] Warning: could not find conversation row via uiautomator")
time.sleep(2) # let conversation load
# Verify we're in a conversation by checking window focus
focus = self._shell("dumpsys window | grep mCurrentFocus")
if "org.thoughtcrime.securesms" in focus:
print(" [emu] Conversation opened")
else:
print(" [emu] Warning: may not be in conversation")
def ensure_signal_foreground(self):
"""Ensure Signal is in the foreground (for Scenario A where
we just need Signal running, not in a specific conversation)."""
self.restart_signal()
# ------------------------------------------------------------------
# Placing calls (from emulator)
# ------------------------------------------------------------------
def _dismiss_permission_dialogs(self):
"""Dismiss any permission dialogs that appear on the call screen.
Signal may show camera/microphone permission prompts before the
pre-join call screen. Tap "Not now" or "Deny" to dismiss them.
"""
for _ in range(3): # handle up to 3 stacked dialogs
root = self._dump_ui()
if root is None:
return
dismissed = False
for criteria in [
{"root": root, "text": "Not now"},
{"root": root, "text": "Deny"},
{"root": root, "text": "Don\u2019t allow"},
{"root": root, "text": "Don't allow"},
]:
if self._tap_element(**criteria):
print(f" [emu] Dismissed permission dialog")
dismissed = True
time.sleep(0.5)
break
if not dismissed:
return
def tap_call_button(self):
"""Tap the voice call button in the conversation header,
then confirm the 'Start voice call?' dialog."""
# Tap the call icon in the header (try content-desc patterns)
tapped = self._tap_element(content_desc="Signal call")
if not tapped:
tapped = self._tap_element(content_desc="Voice call")
if not tapped:
tapped = self._tap_element(content_desc="call")
if not tapped:
print(" [emu] Warning: could not find call button via uiautomator")
time.sleep(1.5) # wait for call screen / dialog
# Dismiss any permission dialogs (camera, microphone) that may
# appear before the pre-join screen.
self._dismiss_permission_dialogs()
time.sleep(0.5)
# On newer Signal versions, tapping the call icon opens a pre-join
# call activity instead of a confirmation dialog. Try both flows.
print(" [emu] Confirming call (dialog or pre-join screen)...")
root = self._dump_ui()
tapped = False
if root is not None:
# Try pre-join screen "Start Call" button first, then dialog "Call"
for criteria in [
{"root": root, "text": "Start Call"},
{"root": root, "text": "Start call"},
{"root": root, "content_desc": "Start call"},
{"root": root, "content_desc": "Start Call"},
{"root": root, "text": "Call"},
{"root": root, "text": "Voice call"},
{"root": root, "content_desc": "Voice call"},
]:
if self._tap_element(**criteria):
tapped = True
break
if not tapped:
print(" [emu] Warning: could not find call start button")
print(" [emu] Elements on screen:")
for node in root.iter("node"):
text = node.get("text", "")
desc = node.get("content-desc", "")
click = node.get("clickable", "")
if text or desc:
bounds = node.get("bounds", "")
print(f" [emu] text={text!r} desc={desc!r} "
f"click={click} bounds={bounds}")
# ------------------------------------------------------------------
# Answering / rejecting calls (on emulator)
# ------------------------------------------------------------------
def _wait_for_incoming_call(self, timeout=30):
"""Poll logcat until the emulator is actually ringing.
Waits for LocalRinging (the point where the heads-up notification
with answer/decline buttons appears). handleReceivedOffer fires
much earlier during ICE negotiation.
"""
start_ts = self._shell("date '+%m-%d %H:%M:%S.000'")
deadline = time.monotonic() + timeout
offer_seen = False
while time.monotonic() < deadline:
logcat = self._shell(
f"logcat -d -T '{start_ts}' 2>/dev/null", timeout=5
)
if not offer_seen and "handleReceivedOffer" in logcat:
print(" [emu] Offer received, waiting for ringing...")
offer_seen = True
if "event: LOCAL_RINGING" in logcat or "handleLocalRinging" in logcat:
print(" [emu] Phone is ringing (LocalRinging)")
return True
time.sleep(0.5)
print(" [emu] Warning: incoming call did not start ringing within timeout")
return False
def _check_call_accepted(self, logcat_since):
"""Check logcat for handleAcceptCall (call was answered)."""
logcat = self._shell(
f"logcat -d -T '{logcat_since}' 2>/dev/null", timeout=5
)
return "handleAcceptCall" in logcat
def _wait_for_call_accepted(self, logcat_since, timeout=3):
"""Poll logcat until handleAcceptCall appears or timeout."""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if self._check_call_accepted(logcat_since):
return True
time.sleep(0.3)
return False
def answer_incoming_call(self):
"""Answer an incoming call on the emulator.
Waits for the phone to ring, then tries strategies in order:
1. Expand notification shade, find and tap "Answer" via uiautomator
(the heads-up notification is invisible to uiautomator, but the
shade's notification actions ARE in the SystemUI hierarchy)
2. KEYCODE_HEADSETHOOK (hardware button simulation)
3. cmd telecom accept-ringing-call (only works if app uses Telecom)
Each strategy is verified via logcat (handleAcceptCall).
"""
self._wait_for_incoming_call(timeout=30)
logcat_since = self._shell("date '+%m-%d %H:%M:%S.000'")
time.sleep(1) # let notification fully render
# Strategy 1: expand notification shade, find Answer button
print(" [emu] Expanding notification shade...")
self._shell("cmd statusbar expand-notifications")
time.sleep(1) # let shade animate open
root = self._dump_ui()
tapped = False
if root is not None:
for criteria in [
{"root": root, "text": "Answer"},
{"root": root, "text": "Accept"},
{"root": root, "content_desc": "Answer"},
{"root": root, "content_desc": "Accept"},
]:
if self._tap_element(**criteria):
tapped = True
break
# Collapse the shade regardless of whether we found the button
self._shell("cmd statusbar collapse")
if tapped:
if self._wait_for_call_accepted(logcat_since, timeout=3):
print(" [emu] Call answered via notification tap")
return
# Strategy 2: HEADSETHOOK keyevent
print(" [emu] Fallback: KEYCODE_HEADSETHOOK")
self._shell("input keyevent 79")
if self._wait_for_call_accepted(logcat_since, timeout=3):
print(" [emu] Call answered via HEADSETHOOK")
return
# Strategy 3: telecom command (works if app registers with Telecom)
print(" [emu] Fallback: cmd telecom accept-ringing-call")
self._shell("cmd telecom accept-ringing-call")
if self._wait_for_call_accepted(logcat_since, timeout=3):
print(" [emu] Call answered via telecom command")
return
print(" [emu] Warning: could not confirm call was answered")
def reject_incoming_call(self):
"""Reject an incoming call on the emulator.
Waits for ringing, then uses the ENDCALL keyevent or telecom command.
Verifies via logcat that the call ended.
"""
self._wait_for_incoming_call(timeout=30)
logcat_since = self._shell("date '+%m-%d %H:%M:%S.000'")
time.sleep(1)
# Use ENDCALL keyevent (keycode 6) — works without coordinates
print(" [emu] Rejecting call via ENDCALL keyevent...")
self._shell("input keyevent ENDCALL")
# Verify call ended
deadline = time.monotonic() + 5
while time.monotonic() < deadline:
logcat = self._shell(
f"logcat -d -T '{logcat_since}' 2>/dev/null", timeout=5
)
if "call_concluded" in logcat or "onCallConcluded" in logcat:
print(" [emu] Call declined")
return
time.sleep(0.3)
# Fallback: try telecom end-call
print(" [emu] Fallback: cmd telecom end-call")
self._shell("cmd telecom end-call")
print(" [emu] Warning: could not confirm call was declined")
# ------------------------------------------------------------------
# Utilities
# ------------------------------------------------------------------
def set_call_volume_max(self):
"""Set in-call volume to maximum by simulating volume-up key presses.
The voice call volume can only be changed during an active call.
We press KEYCODE_VOLUME_UP enough times to reach max (15 steps).
"""
for _ in range(15):
self._shell("input keyevent 24") # KEYCODE_VOLUME_UP
def tap(self, x, y):
"""Tap at screen coordinates."""
self._shell(f"input tap {x} {y}")
def is_device_online(self):
"""Check if the emulator is reachable via adb."""
output = self._run("devices")
return "emulator" in output and "device" in output

View File

@ -0,0 +1,189 @@
"""Emulator gRPC audio injection and capture client.
Uses compiled proto stubs from the Android emulator's emulator_controller.proto.
Supports 48kHz mono S16LE audio matching the media socket format.
Handles both unauthenticated (-grpc flag) and JWT-authenticated emulator gRPC.
"""
import json
import os
import time
from pathlib import Path
def _get_stubs():
"""Lazy-import the generated proto stubs."""
from lib.proto import emulator_controller_pb2 as pb
from lib.proto import emulator_controller_pb2_grpc as pb_grpc
return pb, pb_grpc
class EmulatorAudio:
"""Client for the emulator's gRPC audio streaming API."""
def __init__(self, host="localhost", port=8554, token=None):
import grpc
self._grpc = grpc
self._token = token
self._host = host
self._port = port
self.channel = grpc.insecure_channel(f"{host}:{port}")
pb, pb_grpc = _get_stubs()
self.stub = pb_grpc.EmulatorControllerStub(self.channel)
self.pb = pb
# If no token provided, probe connectivity and auto-discover if needed
if self._token is None:
self._try_connect_or_discover()
def _metadata(self):
"""Return gRPC call metadata with auth header if token is set."""
if self._token:
return [('authorization', f'Bearer {self._token}')]
return []
def _try_connect_or_discover(self):
"""Test unauthenticated connectivity; fall back to token discovery."""
import grpc
try:
grpc.channel_ready_future(self.channel).result(timeout=3)
# Channel connected — try a trivial call to check auth
self.stub.getStatus(self.pb.Empty(), timeout=3)
except grpc.RpcError as e:
if e.code() == grpc.StatusCode.UNAUTHENTICATED:
print(" [grpc] Unauthenticated — attempting token discovery...")
token = self.discover_token()
if token:
self._token = token
print(" [grpc] Token discovered, retrying with auth...")
else:
print(" [grpc] No token found. Restart emulator with: -grpc <port>")
else:
# Non-auth error (e.g. connection refused) — let caller handle
pass
except Exception:
pass
@classmethod
def discover_token(cls):
"""Search emulator discovery directories for a gRPC auth token.
The emulator writes discovery files to:
- ~/.android/avd/running/ (Linux/macOS default)
- $TMPDIR/avd/running/ (macOS alternate)
Each running emulator creates a pid_NNNNN.ini with grpc.token or
a corresponding .jwk file.
"""
search_dirs = []
# ~/.android/avd/running/
android_home = Path.home() / ".android" / "avd" / "running"
if android_home.is_dir():
search_dirs.append(android_home)
# $TMPDIR/avd/running/
tmpdir = os.environ.get("TMPDIR", "/tmp")
tmpdir_running = Path(tmpdir) / "avd" / "running"
if tmpdir_running.is_dir():
search_dirs.append(tmpdir_running)
for d in search_dirs:
# Look for pid_*.ini files that contain grpc.token
for ini_file in sorted(d.glob("pid_*.ini"), reverse=True):
try:
text = ini_file.read_text()
for line in text.splitlines():
if line.startswith("grpc.token="):
token = line.split("=", 1)[1].strip()
if token:
return token
except OSError:
continue
# Look for .jwk files (JSON Web Key — contains the token)
for jwk_file in sorted(d.glob("*.jwk"), reverse=True):
try:
data = json.loads(jwk_file.read_text())
# The emulator JWK file format varies; look for common keys
if isinstance(data, dict):
token = data.get("token") or data.get("grpc_token")
if token:
return token
except (OSError, json.JSONDecodeError):
continue
return None
def close(self):
self.channel.close()
def inject_audio(self, pcm_bytes, sample_rate=48000):
"""Inject PCM audio into the emulator's virtual microphone.
Client-streaming RPC: first packet includes AudioFormat, all include audio data.
"""
pb = self.pb
metadata = self._metadata()
audio_format = pb.AudioFormat(
samplingRate=sample_rate,
channels=pb.AudioFormat.Mono,
format=pb.AudioFormat.AUD_FMT_S16,
mode=pb.AudioFormat.MODE_UNSPECIFIED,
)
def _packet_generator():
# Send in chunks matching 10ms frames (960 bytes at 48kHz mono S16LE)
frame_size = sample_rate * 2 // 100 # 10ms worth of bytes
offset = 0
first = True
while offset < len(pcm_bytes):
chunk = pcm_bytes[offset:offset + frame_size]
if first:
yield pb.AudioPacket(format=audio_format, audio=chunk)
first = False
else:
yield pb.AudioPacket(audio=chunk)
offset += frame_size
# Pace the injection to approximate real-time
time.sleep(0.008) # slightly less than 10ms to avoid underruns
self.stub.injectAudio(_packet_generator(), metadata=metadata)
def capture_audio(self, duration_s, sample_rate=48000):
"""Capture audio from the emulator's speaker output.
Server-streaming RPC: returns concatenated PCM bytes.
"""
pb = self.pb
metadata = self._metadata()
audio_format = pb.AudioFormat(
samplingRate=sample_rate,
channels=pb.AudioFormat.Mono,
format=pb.AudioFormat.AUD_FMT_S16,
)
deadline = time.monotonic() + duration_s + 1.0
chunks = []
total_bytes = 0
target_bytes = int(sample_rate * 2 * duration_s) # S16 mono
try:
for packet in self.stub.streamAudio(
audio_format,
timeout=duration_s + 5,
metadata=metadata,
):
if packet.audio:
chunks.append(packet.audio)
total_bytes += len(packet.audio)
if total_bytes >= target_bytes:
break
if time.monotonic() > deadline:
break
except Exception as e:
print(f" [grpc] streamAudio ended: {e}")
return b"".join(chunks)

View File

View File

@ -0,0 +1,145 @@
"""JSON-RPC client for signal-cli daemon over Unix socket."""
import json
import socket
import time
class SignalRPC:
"""Connects to signal-cli's JSON-RPC Unix socket and provides call control methods."""
SAMPLE_RATE = 48000
CHANNELS = 1
PTIME_MS = 10
SAMPLES_PER_FRAME = SAMPLE_RATE * PTIME_MS // 1000 # 480
BYTES_PER_SAMPLE = 2 # 16-bit signed LE
PCM_FRAME_SIZE = SAMPLES_PER_FRAME * BYTES_PER_SAMPLE # 960
def __init__(self, socket_path):
self.socket_path = socket_path
self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
self.sock.connect(socket_path)
self._next_id = 0
self._buf = b""
def close(self):
try:
self.sock.close()
except OSError:
pass
def _send(self, method, params=None):
self._next_id += 1
req = {"jsonrpc": "2.0", "method": method, "id": self._next_id}
if params:
req["params"] = params
line = json.dumps(req) + "\n"
self.sock.sendall(line.encode("utf-8"))
return self._next_id
def _read_lines(self):
"""Yield complete newline-delimited JSON strings from socket."""
# Drain any complete lines already in the buffer from a previous recv
while b"\n" in self._buf:
line, self._buf = self._buf.split(b"\n", 1)
line = line.strip()
if line:
yield line.decode("utf-8")
while True:
data = self.sock.recv(4096)
if not data:
return
self._buf += data
while b"\n" in self._buf:
line, self._buf = self._buf.split(b"\n", 1)
line = line.strip()
if line:
yield line.decode("utf-8")
def _wait_response(self, req_id, timeout=10):
"""Wait for a JSON-RPC response matching req_id, buffering notifications."""
self.sock.settimeout(timeout)
try:
for line in self._read_lines():
msg = json.loads(line)
if "id" in msg and msg["id"] == req_id:
if "error" in msg:
raise RuntimeError(f"RPC error: {msg['error']}")
return msg.get("result")
# Buffer notification for later consumption
self._pending_events.append(msg)
except socket.timeout:
raise TimeoutError(f"Timed out waiting for response to request {req_id}")
finally:
self.sock.settimeout(None)
def subscribe_receive(self):
self._pending_events = getattr(self, "_pending_events", [])
req_id = self._send("subscribeReceive")
return self._wait_response(req_id)
def start_call(self, recipient):
self._pending_events = getattr(self, "_pending_events", [])
req_id = self._send("startCall", {"recipient": [recipient]})
return self._wait_response(req_id, timeout=30)
def accept_call(self, call_id):
self._pending_events = getattr(self, "_pending_events", [])
req_id = self._send("acceptCall", {"call-id": call_id})
return self._wait_response(req_id, timeout=30)
def reject_call(self, call_id):
self._pending_events = getattr(self, "_pending_events", [])
req_id = self._send("rejectCall", {"call-id": call_id})
return self._wait_response(req_id, timeout=10)
def hangup_call(self, call_id):
self._pending_events = getattr(self, "_pending_events", [])
req_id = self._send("hangupCall", {"call-id": call_id})
return self._wait_response(req_id, timeout=10)
def list_calls(self):
self._pending_events = getattr(self, "_pending_events", [])
req_id = self._send("listCalls")
return self._wait_response(req_id, timeout=10)
def read_event(self, timeout=30):
"""Read the next JSON-RPC notification (callEvent, receive, etc.)."""
self._pending_events = getattr(self, "_pending_events", [])
# Return buffered events first
if self._pending_events:
return self._pending_events.pop(0)
self.sock.settimeout(timeout)
try:
for line in self._read_lines():
msg = json.loads(line)
if "id" in msg and "method" not in msg:
# This is a response, buffer it (shouldn't normally happen here)
self._pending_events.append(msg)
continue
return msg
except socket.timeout:
raise TimeoutError(f"No event received within {timeout}s")
finally:
self.sock.settimeout(None)
return None
def wait_for_state(self, target_state, timeout=60):
"""Block until a callEvent with the given state arrives. Returns the event params."""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
remaining = deadline - time.monotonic()
if remaining <= 0:
break
try:
msg = self.read_event(timeout=remaining)
except TimeoutError:
break
if msg and msg.get("method") == "callEvent":
params = msg.get("params", {})
event = params.get("callEvent", {})
state = event.get("state")
print(f" [rpc] callEvent: {state} (reason={event.get('reason')})")
if state == target_state:
return params
raise TimeoutError(f"Did not reach state {target_state} within {timeout}s")

View File

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

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

@ -0,0 +1,404 @@
#!/usr/bin/env bash
# Master orchestrator for E2E voice call tests.
#
# Starts signal-cli daemon, ensures emulator is ready, runs test scenarios,
# and cleans up afterwards.
set -euo pipefail
usage() {
cat <<USAGE
Usage: $(basename "$0") [OPTIONS]
Run E2E voice call tests against signal-cli and an Android emulator.
Options:
--signal-cli-account PHONE Phone number for signal-cli account (e.g. +1234567890)
--emulator-account PHONE Phone number for emulator account (e.g. +1234567890)
-s, --scenario ID Run a single scenario (A, B, C, D, or E)
--scenarios LIST Comma-separated list of scenarios (default: A,B,C,D,E)
--record Record emulator screen during each scenario
--no-fail-fast Continue running scenarios after a failure
-h, --help Show this help message
Environment variables:
SIGNAL_CLI_ACCOUNT Alternative to --signal-cli-account
EMULATOR_ACCOUNT Alternative to --emulator-account
Scenarios:
A Outgoing call lifecycle (signal-cli calls, emulator answers)
B Incoming call lifecycle (emulator calls, signal-cli accepts)
C Incoming call rejection (emulator calls, signal-cli rejects)
D Ring timeout (outgoing call, no answer)
E Bidirectional audio (440Hz/1000Hz tone detection via gRPC)
Examples:
$(basename "$0") --signal-cli-account +1234567890 --emulator-account +0987654321
$(basename "$0") -s A # Run only scenario A
$(basename "$0") --scenarios A,B # Run scenarios A and B
Log files are written to voice-test/output/logs/ and per-scenario excerpts
are saved on failure for diagnosis.
USAGE
}
# --- Parse arguments ---
SCENARIOS="A,B,C,D,E"
RECORD_FLAG=""
NO_FAIL_FAST_FLAG=""
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help)
usage
exit 0
;;
-s|--scenario)
SCENARIOS="${2:?ERROR: --scenario requires an argument}"
shift 2
;;
--scenarios)
SCENARIOS="${2:?ERROR: --scenarios requires an argument}"
shift 2
;;
--signal-cli-account)
export SIGNAL_CLI_ACCOUNT="${2:?ERROR: --signal-cli-account requires an argument}"
shift 2
;;
--emulator-account)
export EMULATOR_ACCOUNT="${2:?ERROR: --emulator-account requires an argument}"
shift 2
;;
--record)
RECORD_FLAG="--record"
shift
;;
--no-fail-fast)
NO_FAIL_FAST_FLAG="--no-fail-fast"
shift
;;
*)
echo "Unknown option: $1"
echo "Run '$(basename "$0") --help' for usage."
exit 1
;;
esac
done
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$PROJECT_DIR"
source "$SCRIPT_DIR/config.sh"
# Export variables so e2e_test.py can read them from env
export SIGNAL_CLI_ACCOUNT
export EMULATOR_ACCOUNT
export ADB
# --- State ---
DAEMON_PID=""
LOGCAT_PID=""
TEST_PID=""
CLEANUP_DONE=false
cleanup() {
if $CLEANUP_DONE; then return; fi
CLEANUP_DONE=true
echo ""
echo "=== Cleanup ==="
# Kill the Python test runner if still going (e.g. on Ctrl+C)
# It's the foreground process so usually gets SIGINT directly, but be safe.
if [ -n "$TEST_PID" ] && kill -0 "$TEST_PID" 2>/dev/null; then
echo "Stopping test runner (PID $TEST_PID)..."
kill "$TEST_PID" 2>/dev/null || true
wait "$TEST_PID" 2>/dev/null || true
fi
if [ -n "$LOGCAT_PID" ] && kill -0 "$LOGCAT_PID" 2>/dev/null; then
echo "Stopping logcat collector (PID $LOGCAT_PID)..."
kill "$LOGCAT_PID" 2>/dev/null || true
wait "$LOGCAT_PID" 2>/dev/null || true
fi
if [ -n "$DAEMON_PID" ] && kill -0 "$DAEMON_PID" 2>/dev/null; then
echo "Stopping signal-cli daemon (PID $DAEMON_PID)..."
kill "$DAEMON_PID" 2>/dev/null || true
wait "$DAEMON_PID" 2>/dev/null || true
fi
rm -f "$SIGNAL_CLI_SOCKET"
# Kill any orphaned signal-call-tunnel processes
pkill -f "signal-call-tunnel" 2>/dev/null || true
echo "Cleanup done."
echo ""
echo "=== Log files ==="
echo " signal-cli + tunnel : $SIGNAL_CLI_LOG"
echo " daemon console : $DAEMON_CONSOLE_LOG"
echo " Android logcat : $LOGCAT_LOG"
}
# Trap EXIT (normal exit, set -e failures) plus INT/TERM (Ctrl+C, kill)
trap cleanup EXIT INT TERM
# --- Kill stale processes from previous interrupted runs ---
echo "=== Pre-run Cleanup ==="
STALE=false
# Kill leftover signal-cli daemon using our test socket
if [ -S "$SIGNAL_CLI_SOCKET" ]; then
echo " Removing stale socket: $SIGNAL_CLI_SOCKET"
# Find the daemon that owns it (if still alive)
STALE_PID=$(lsof -t "$SIGNAL_CLI_SOCKET" 2>/dev/null | head -1 || true)
if [ -n "$STALE_PID" ]; then
echo " Killing stale daemon (PID $STALE_PID)..."
kill "$STALE_PID" 2>/dev/null || true
sleep 1
fi
rm -f "$SIGNAL_CLI_SOCKET"
STALE=true
fi
# Kill leftover signal-call-tunnel processes
if pgrep -f "signal-call-tunnel" >/dev/null 2>&1; then
echo " Killing orphaned signal-call-tunnel processes..."
pkill -f "signal-call-tunnel" 2>/dev/null || true
STALE=true
fi
# Kill leftover logcat collectors from our log file
if pgrep -f "logcat.*threadtime" >/dev/null 2>&1; then
echo " Killing stale logcat collectors..."
pkill -f "logcat.*threadtime" 2>/dev/null || true
STALE=true
fi
if $STALE; then
echo " Stale processes cleaned up. Waiting 2s..."
sleep 2
else
echo " No stale processes found."
fi
# --- Build binaries ---
echo "=== Building Binaries ==="
echo " Building signal-cli (./gradlew installDist)..."
if ! ./gradlew -q installDist; then
echo "ERROR: signal-cli build failed"
exit 1
fi
echo " signal-cli: OK"
echo " Building signal-call-tunnel (cargo build)..."
if ! (cd signal-call-tunnel && cargo build --quiet); then
echo "ERROR: signal-call-tunnel build failed"
exit 1
fi
echo " signal-call-tunnel: OK"
# Check for BlackHole virtual audio drivers (macOS only)
if [ "$(uname)" = "Darwin" ]; then
AUDIO_HAL="/Library/Audio/Plug-Ins/HAL"
MISSING_DRIVERS=false
if [ ! -d "$AUDIO_HAL/signal_input.driver" ]; then
MISSING_DRIVERS=true
fi
if [ ! -d "$AUDIO_HAL/signal_output.driver" ]; then
MISSING_DRIVERS=true
fi
if $MISSING_DRIVERS; then
RINGRTC_DIR="$PROJECT_DIR/third-party/ringrtc"
echo ""
echo "ERROR: BlackHole virtual audio drivers are not installed."
echo " signal-call-tunnel requires pre-installed audio drivers on macOS."
echo ""
echo " Run the following command once (requires root):"
echo ""
echo " sudo bash $RINGRTC_DIR/bin/virtual_audio.sh \\"
echo " --setup --input-source signal_input --output-sink signal_output"
echo ""
echo " To remove them later:"
echo ""
echo " sudo bash $RINGRTC_DIR/bin/virtual_audio.sh \\"
echo " --teardown --input-source signal_input --output-sink signal_output"
echo ""
exit 1
fi
echo " BlackHole audio drivers: OK"
fi
if ! command -v python3 &>/dev/null; then
echo "ERROR: python3 not found"
exit 1
fi
echo " python3: $(python3 --version)"
if ! "$ADB" devices 2>/dev/null | grep -q "emulator"; then
echo "WARNING: No emulator detected via adb. Attempting to start..."
# The headed (non-headless) emulator binary is required for gRPC audio
# streaming (scenario E). The headless binary strips audio output support,
# causing streamAudio to block forever.
"$EMULATOR_BIN" -avd "$EMULATOR_AVD" -no-snapshot-load \
-grpc "$EMULATOR_GRPC_PORT" &
EMU_PID=$!
echo " Waiting for emulator boot (PID $EMU_PID)..."
"$ADB" wait-for-device
# Wait for boot to complete
for i in $(seq 1 60); do
BOOT=$("$ADB" shell getprop sys.boot_completed 2>/dev/null || echo "")
if [ "$BOOT" = "1" ]; then
break
fi
sleep 2
done
echo " Emulator booted."
else
echo " Emulator: already running"
fi
# Ensure adbd runs as root (required for uiautomator dump on API 34+)
"$ADB" root 2>/dev/null || true
"$ADB" wait-for-device 2>/dev/null
echo " adb root: $(${ADB} shell id -u 2>/dev/null || echo 'unknown')"
# Check gRPC connectivity (scenario E needs unauthenticated gRPC)
if echo "$SCENARIOS" | grep -q "E"; then
echo " Checking emulator gRPC on port $EMULATOR_GRPC_PORT..."
if python3 -c "
import grpc, sys
ch = grpc.insecure_channel('localhost:$EMULATOR_GRPC_PORT')
try:
grpc.channel_ready_future(ch).result(timeout=3)
except Exception:
sys.exit(1)
finally:
ch.close()
" 2>/dev/null; then
echo " gRPC: reachable"
else
echo " WARNING: Emulator gRPC on port $EMULATOR_GRPC_PORT is not reachable."
echo " If scenario E fails with UNAUTHENTICATED, restart the emulator with:"
echo " $EMULATOR_BIN -avd $EMULATOR_AVD -no-snapshot-load -no-window -grpc $EMULATOR_GRPC_PORT"
fi
fi
# Verify Signal is installed
if ! "$ADB" shell pm list packages 2>/dev/null | grep -q "org.thoughtcrime.securesms"; then
echo "ERROR: Signal is not installed on the emulator"
exit 1
fi
echo " Signal app: installed"
# --- Install Python deps ---
echo ""
echo "=== Python Dependencies ==="
pip install -q -r "$SCRIPT_DIR/requirements.txt"
echo " grpcio: OK"
# --- Generate proto stubs ---
echo ""
echo "=== Proto Stubs ==="
if [ ! -f "$SCRIPT_DIR/lib/proto/emulator_controller_pb2.py" ]; then
bash "$SCRIPT_DIR/generate_proto.sh"
else
echo " Proto stubs already generated (use 'bash voice-test/generate_proto.sh' to regenerate)"
fi
# --- Ensure Signal is on main screen ---
echo ""
echo "=== Preparing Signal App ==="
"$ADB" shell monkey -p org.thoughtcrime.securesms -c android.intent.category.LAUNCHER 1
sleep 2
echo " Signal launched"
# Set media and ring volumes to max (voice call volume is set during the
# active call in scenario E via KEYCODE_VOLUME_UP key events).
"$ADB" shell cmd media_session volume --stream 2 --set 15 >/dev/null 2>&1 # ring
"$ADB" shell cmd media_session volume --stream 3 --set 15 >/dev/null 2>&1 # music
echo " Audio volumes: ring/media set to max"
# --- Set up log collection ---
echo ""
echo "=== Log Collection ==="
mkdir -p "$LOG_DIR"
# Truncate logs from previous runs
: > "$SIGNAL_CLI_LOG"
: > "$DAEMON_CONSOLE_LOG"
: > "$LOGCAT_LOG"
# Start logcat collector (Signal app + WebRTC/RingRTC tags)
"$ADB" logcat -c 2>/dev/null || true # Clear old logcat buffer
"$ADB" logcat -v threadtime > "$LOGCAT_LOG" 2>&1 &
LOGCAT_PID=$!
echo " logcat collector PID: $LOGCAT_PID -> $LOGCAT_LOG"
# --- Start signal-cli daemon ---
echo ""
echo "=== Starting signal-cli Daemon ==="
# Export tunnel binary path so the daemon subprocess can find it
export SIGNAL_CALL_TUNNEL_BIN
# -vv for DEBUG+TRACE on org.asamk (includes [tunnel-{callId}] lines)
# --log-file captures detailed logs; stdout/stderr go to console log
$SIGNAL_CLI_BIN -vv -a "$SIGNAL_CLI_ACCOUNT" \
--log-file "$SIGNAL_CLI_LOG" \
daemon --socket "$SIGNAL_CLI_SOCKET" \
> "$DAEMON_CONSOLE_LOG" 2>&1 &
DAEMON_PID=$!
echo " Daemon PID: $DAEMON_PID"
echo " Log file: $SIGNAL_CLI_LOG"
# Wait for socket to appear
echo " Waiting for daemon socket..."
for i in $(seq 1 30); do
if [ -S "$SIGNAL_CLI_SOCKET" ]; then
break
fi
if ! kill -0 "$DAEMON_PID" 2>/dev/null; then
echo "ERROR: Daemon exited prematurely"
exit 1
fi
sleep 1
done
if [ ! -S "$SIGNAL_CLI_SOCKET" ]; then
echo "ERROR: Daemon socket did not appear within 30s"
exit 1
fi
echo " Daemon ready."
# --- Run tests ---
echo ""
echo "=== Running Tests ==="
SCENARIOS="${SCENARIOS:-A,B,C,D,E}"
TEST_PID=""
set +e
python3 -u "$SCRIPT_DIR/e2e_test.py" \
--socket "$SIGNAL_CLI_SOCKET" \
--log-dir "$LOG_DIR" \
--scenarios "$SCENARIOS" $RECORD_FLAG $NO_FAIL_FAST_FLAG &
TEST_PID=$!
wait "$TEST_PID"
TEST_EXIT=$?
TEST_PID=""
set -e
echo ""
if [ $TEST_EXIT -eq 0 ]; then
echo "=== ALL TESTS PASSED ==="
else
echo "=== SOME TESTS FAILED ==="
echo ""
echo "Log files for diagnosis:"
echo " signal-cli + tunnel : $SIGNAL_CLI_LOG"
echo " daemon console : $DAEMON_CONSOLE_LOG"
echo " Android logcat : $LOGCAT_LOG"
echo ""
echo "Per-scenario log excerpts are in: $LOG_DIR/scenario_*.log"
fi
exit $TEST_EXIT