From df69b0283eb671477693dd7fd315e3c7438e296b Mon Sep 17 00:00:00 2001 From: Shaheen Gandhi Date: Fri, 13 Feb 2026 21:02:10 -0800 Subject: [PATCH] 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. --- docs/TEST_HARNESS.md | 265 +++++++++ voice-test/README.md | 344 ++++++++++++ voice-test/__init__.py | 0 voice-test/config.sh | 22 + voice-test/e2e_test.py | 916 +++++++++++++++++++++++++++++++ voice-test/generate_proto.sh | 37 ++ voice-test/lib/__init__.py | 0 voice-test/lib/audio.py | 126 +++++ voice-test/lib/emulator.py | 474 ++++++++++++++++ voice-test/lib/grpc_audio.py | 189 +++++++ voice-test/lib/proto/__init__.py | 0 voice-test/lib/signal_rpc.py | 145 +++++ voice-test/requirements.txt | 2 + voice-test/run_e2e.sh | 404 ++++++++++++++ 14 files changed, 2924 insertions(+) create mode 100644 docs/TEST_HARNESS.md create mode 100644 voice-test/README.md create mode 100644 voice-test/__init__.py create mode 100644 voice-test/config.sh create mode 100644 voice-test/e2e_test.py create mode 100755 voice-test/generate_proto.sh create mode 100644 voice-test/lib/__init__.py create mode 100644 voice-test/lib/audio.py create mode 100644 voice-test/lib/emulator.py create mode 100644 voice-test/lib/grpc_audio.py create mode 100644 voice-test/lib/proto/__init__.py create mode 100644 voice-test/lib/signal_rpc.py create mode 100644 voice-test/requirements.txt create mode 100755 voice-test/run_e2e.sh diff --git a/docs/TEST_HARNESS.md b/docs/TEST_HARNESS.md new file mode 100644 index 00000000..3b5363da --- /dev/null +++ b/docs/TEST_HARNESS.md @@ -0,0 +1,265 @@ +# E2E Voice Call Test Harness + +Design document for the automated end-to-end voice call test infrastructure. + +## Architecture Overview + +The test harness has two layers: a shell orchestrator (`run_e2e.sh`) that manages +builds, processes, and cleanup, and a Python test runner (`e2e_test.py`) that +executes the five test scenarios. + +``` +run_e2e.sh (orchestrator) + +-- pre-flight checks (builds, adb, Signal installed) + +-- start signal-cli daemon on test socket + +-- start logcat collection + +-- launch e2e_test.py (test runner) + +-- SignalRPC -- JSON-RPC 2.0 client for signal-cli daemon + +-- EmulatorControl -- ADB-based Signal UI automation + +-- AudioProcessing -- tone generation + Goertzel detection + +-- VirtualAudioHelpers -- platform-aware play/record via sox/paplay + +-- GrpcAudio -- emulator gRPC audio HAL client +``` + +### File layout + +``` +test/ + run_e2e.sh # Shell orchestrator + config.sh # Shared environment config (paths, ports, accounts, frequencies) + requirements.txt # Python deps (grpcio, grpcio-tools) + generate_proto.sh # Compile emulator_controller.proto -> Python stubs + e2e_test.py # Test runner (scenarios A-E) + lib/ + signal_rpc.py # JSON-RPC 2.0 client for signal-cli + audio.py # Tone generation + Goertzel frequency detection + WAV I/O + emulator.py # ADB-based Signal UI automation + grpc_audio.py # Emulator gRPC audio injection/capture + proto/ # Generated protobuf stubs (created by generate_proto.sh) +``` + +--- + +## Component Descriptions + +### `run_e2e.sh` -- Shell Orchestrator + +Handles everything outside the test scenarios themselves: + +- **Pre-flight checks**: builds exist, adb reachable, Signal installed on emulator +- **Binary builds**: `gradlew installDist` + `cargo build --release` +- **Python setup**: virtualenv activation, `pip install` from `requirements.txt`, + proto stub generation +- **Daemon lifecycle**: starts signal-cli on a test socket, captures stdout to log file +- **Logcat collection**: background `adb logcat` filtered to Signal-related tags +- **Cleanup traps**: `trap cleanup EXIT INT TERM` ensures daemon, logcat, and orphaned + tunnel processes are killed on any exit path +- **Stale process detection**: pre-run `lsof`/`pkill` to kill leftover processes from + previous runs that might hold the test socket or tunnel ports + +### `config.sh` -- Shared Configuration + +Sourced by `run_e2e.sh`. Centralizes: + +- Account phone numbers (`SIGNAL_CLI_ACCOUNT`, `EMULATOR_ACCOUNT`) +- Android SDK paths (adb, emulator binary) +- Emulator AVD name, gRPC port +- Test socket path (`/tmp/signal-cli-test.sock`) +- Audio test frequencies (440 Hz outgoing, 1000 Hz incoming) +- Log directory paths + +### `e2e_test.py` -- Test Runner + +Runs scenarios A-E with per-scenario logging, screen recording, and result reporting. + +Key components: + +- **`LogCollector`**: tracks log file positions per scenario; on failure, extracts only + the relevant segment from each of the three log sources (signal-cli, daemon console, + logcat) with categorized line filtering (tunnel messages, call-related, errors) +- **`ScreenRecorder`**: optional `--record` flag captures emulator screen via + `adb screenrecord` for post-mortem debugging +- **Virtual audio helpers**: `play_to_device()` and `record_from_device()` provide + platform-aware audio I/O using `sox` (macOS) or `paplay`/`parecord` (Linux) +- **Fail-fast mode**: default behavior stops after the first failure; `--no-fail-fast` + runs the full suite +- **Clean state**: `wait_for_clean_state()` kills and relaunches Signal between + scenarios, then waits for the WebSocket to reconnect (~20 s) + +### `lib/signal_rpc.py` -- JSON-RPC Client + +JSON-RPC 2.0 client over Unix socket for the signal-cli daemon. + +- `start_call()`, `accept_call()`, `reject_call()`, `hangup_call()` -- call control +- `wait_for_state()` -- blocks until a `callEvent` notification with the target state +- `_read_lines()` -- generator yielding newline-delimited JSON; buffers partial reads +- `_wait_response()` -- waits for matching response ID, queues non-matching notifications + in `_pending_events` for later consumption by `read_event()` + +### `lib/audio.py` -- Audio Processing + +Pure-Python audio utilities (no numpy dependency): + +- `generate_tone(freq, duration)` -- sine wave PCM (48 kHz, 16-bit signed LE, mono) +- `goertzel_magnitude(samples, freq)` -- O(n) single-frequency energy detector +- `detect_tone(pcm, expected_freq)` -- scans a +/-100 Hz window around the target + frequency, compares peak to noise floor (SNR threshold = 3.0) +- `pcm_to_wav()` -- write raw PCM bytes to a WAV file +- `wav_to_pcm()` -- read a WAV file back as raw PCM bytes +- `rms_level()` -- RMS amplitude normalized to [0.0, 1.0] + +The +/-100 Hz scan window compensates for Opus codec frequency shifts (~30-50 Hz). +Noise is measured from bins 400-600 Hz away from the signal to avoid adjacent-band +leakage. + +### `lib/emulator.py` -- Emulator UI Automation + +ADB-based Signal UI automation using dynamic element lookup and logcat polling. + +- `_dump_ui()` -- runs `adb shell uiautomator dump /sdcard/window_dump.xml` then reads + the file back (piping to `/dev/stdout` is unreliable on many emulators). Requires + `adb root` on API 34+. Retries once on failure with a 5-second timeout. +- `_find_element()` / `_tap_element()` -- search the XML hierarchy for elements by + text, content-desc, resource-id, or class name, and tap their center coordinates +- `_dismiss_permission_dialogs()` -- dismiss camera/mic permission prompts on the + pre-join call screen by tapping "Not now" / "Deny" +- `launch_signal()` -- uses `monkey` launcher intent (not unexported `MainActivity`) +- `open_conversation()` -- kill, relaunch, find first conversation row via uiautomator +- `tap_call_button()` -- find call icon by content-desc, dismiss permission dialogs, + then tap "Start Call" on the pre-join screen +- `answer_incoming_call()` -- expand notification shade, find "Answer"/"Accept" button + via uiautomator (heads-up notification is invisible to uiautomator, but shade actions + are visible). Falls back to `HEADSETHOOK` keyevent and `cmd telecom` commands. +- `reject_incoming_call()` -- uses `ENDCALL` keyevent with `cmd telecom end-call` fallback +- `_wait_for_incoming_call()` -- poll logcat for `handleReceivedOffer` -> `LOCAL_RINGING` +- Foreground verification via `dumpsys window | grep mCurrentFocus` + +UI element coordinates are discovered dynamically via `uiautomator dump`, making the +harness portable across emulator display resolutions. + +### `lib/grpc_audio.py` -- gRPC Audio HAL Client + +Client for the Android emulator's gRPC audio streaming API. + +- `inject_audio(pcm)` -- client-streaming RPC to inject PCM into the virtual mic, + paced at ~8 ms per frame (slightly under 10 ms to avoid underruns) +- `capture_audio(seconds)` -- server-streaming RPC to capture speaker output +- **Auth discovery**: tries unauthenticated first (`-grpc` flag); on + `UNAUTHENTICATED` error, searches `~/.android/avd/running/` and `$TMPDIR/avd/running/` + for `grpc.token` or `.jwk` files +- 4-second settling delay after gRPC connection for HAL initialization + +--- + +## Test Scenarios + +| ID | Name | Flow | +|----|------|------| +| A | Outgoing call lifecycle | signal-cli places call -> emulator answers -> signal-cli hangs up | +| B | Incoming call lifecycle | Emulator places call -> signal-cli accepts -> signal-cli hangs up | +| C | Incoming call rejection | Emulator places call -> signal-cli rejects (verify hangup reason) | +| D | Ring timeout | signal-cli places call -> nobody answers -> verify timeout after ~60 s | +| E | Bidirectional audio | Connected call with tone generation via virtual audio devices + Goertzel detection via gRPC | + +Scenario E verifies both directions of the audio pipeline: + +1. **signal-cli -> emulator**: play 440 Hz tone WAV into the virtual input device + (via `sox`/`paplay`), capture from the emulator's speaker via gRPC, verify with + Goertzel +2. **emulator -> signal-cli**: record from the virtual output device (via + `sox`/`parecord`), verify playout data is non-empty and at the expected rate + +--- + +## Key Design Decisions & Lessons Learned + +### Emulator UI Automation + +- **Use `monkey` launcher intent, not unexported `MainActivity`**: the + `am start` command with `MainActivity` throws `SecurityException` because it is not + exported. `monkey -p org.thoughtcrime.securesms 1` uses the default launcher intent. +- **Dynamic element lookup via `uiautomator dump`**: dumps to + `/sdcard/window_dump.xml` then reads back with `cat` (piping to `/dev/stdout` is + unreliable). Requires `adb root` on API 34+ (run once in `run_e2e.sh`). Searches by + text, content-desc, or resource-id. Portable across display resolutions. The dump has + a 5-second timeout and retries once on failure. +- **Wait for `handleLocalRinging` logcat event, not `handleReceivedOffer`**: the offer + event fires before the UI is ready to accept taps. Waiting for `LOCAL_RINGING` ensures + the incoming call notification is visible. +- **Notification shade for call answer**: the heads-up notification is invisible to + `uiautomator dump` (it's rendered by SystemUI, not the app). Expanding the notification + shade with `cmd statusbar expand-notifications` makes the "Answer"/"Accept" action + buttons visible in the SystemUI hierarchy. Falls back to `HEADSETHOOK` keyevent. + Signal does NOT use Android's Telecom framework, so `cmd telecom accept-ringing-call` + is a no-op. +- **Dismiss permission dialogs on pre-join screen**: Signal may show camera/microphone + permission prompts when the pre-join call activity opens. `_dismiss_permission_dialogs()` + taps "Not now" to dismiss them before looking for the "Start Call" button. +- **Non-coordinate call reject**: uses `ENDCALL` keyevent with `cmd telecom end-call` + fallback. No screen coordinates needed. + +### State Management + +- **State-based polling replaces all `time.sleep()` calls**: every wait polls for a + specific state (RPC event, logcat message, or socket data) with a timeout, rather + than sleeping for a fixed duration. +- **`wait_for_clean_state()` uses time-based logcat filtering (`-T timestamp`), not + `logcat -c`**: clearing the logcat buffer races with the system logger writing new + entries. Filtering by timestamp is deterministic. +- **Kill + relaunch Signal between scenarios**: ensures WebSocket reconnection and a + clean call state. Without this, leftover state from a previous call causes the next + scenario to fail. +- **20 s WebSocket reconnect timeout**: after Signal restarts, signal-cli's WebSocket + needs time to reconnect before it can receive incoming calls. + +### Audio & Media Pipeline + +- **Virtual audio device enumeration timing**: cubeb needs time to detect newly created + virtual devices. The tunnel loops on `get_audio_playout_devices()` / + `get_audio_recording_devices()` at 100 ms intervals until devices appear. +- **Platform-specific audio tools**: `sox` on macOS (CoreAudio), `paplay`/`parecord` on + Linux (PulseAudio). The test harness auto-selects based on `platform.system()`. +- **Opus codec shifts frequencies +/-30-50 Hz**: the Goertzel detector scans a +/-100 Hz + window around the target frequency to tolerate codec artifacts. +- **gRPC audio HAL needs 4 s settling delay**: the emulator's audio subsystem needs + time to initialize after a gRPC connection. Without the delay, capture returns silence. +- **Retry logic for HAL flakiness**: scenario E retries once on failure to handle + transient emulator audio issues. +- **gRPC auth: `-grpc` flag for unauthenticated, JWT token auto-discovery as fallback**: + the emulator gRPC port may or may not require authentication depending on how it was + launched. The client tries unauthenticated first, then discovers tokens from the + emulator's runtime directories. + +### Test Infrastructure + +- **Trap `INT`/`TERM`/`EXIT` for cleanup; pre-run stale process detection via + `lsof`/`pkill`**: ensures no orphaned daemons, logcat processes, or tunnel subprocesses + survive across runs. +- **Export (not just set) environment variables for child processes**: `SIGNAL_CALL_TUNNEL_BIN` + must be exported so the signal-cli daemon's subprocess spawner can find the tunnel binary. +- **Buffered I/O must drain buffer before calling `recv()`**: the `_read_lines()` fix + (commit 6085ca0b) -- previously, buffered data from a prior `recv()` call was lost when + the generator was re-entered, causing missed JSON-RPC responses. +- **Fail-fast by default; `--no-fail-fast` for full suite runs**: most development + workflows want to stop at the first failure. CI or full validation runs use + `--no-fail-fast`. +- **`LogCollector`**: per-scenario extraction from three log sources (signal-cli output, + daemon console, logcat) with categorized diagnostics (tunnel lines, call-related, errors). +- **`--record` flag for emulator screen capture**: `adb screenrecord` during test + execution produces video for post-mortem debugging of UI automation failures. + +### Signal-cli Bugs Found via E2E Testing + +These bugs were found and fixed in separate commits after the test harness was complete: + +- **ICE credential mismatch**: RingRTC requires consistent peer ID across all API calls. + Using different IDs for `createOutgoingCall` and `proceed` caused ICE to fail silently. +- **SRTP key mismatch**: identity keys are 33 bytes with a `0x05` prefix in the Signal + protocol, but RingRTC expects 32-byte raw keys. Passing the prefixed key caused SRTP + decryption failure. +- **Multi-device hangup**: `sendHangup` is a protocol message to other devices, not a + local state change. The call manager was treating it as a local hangup. +- **Call ID overflow**: `BigInteger` -> `Long` cast truncated call IDs; unsigned + serialization was needed for Rust's `u64`. +- **Accept race**: calling `acceptCall` before the tunnel reports `Ringing` state causes + RingRTC to drop the accept. The fix defers `acceptCall` until ICE is connected. diff --git a/voice-test/README.md b/voice-test/README.md new file mode 100644 index 00000000..77f65348 --- /dev/null +++ b/voice-test/README.md @@ -0,0 +1,344 @@ +This is a test plan for voice calling. + +# Prerequisites + +- signal-cli is registered on this computer +- You have Signal on your phone (or emulator) with a separate account +- You know the phone number of both accounts +- `signal-call-tunnel` Rust binary is built (see Build section) +- **macOS**: BlackHole virtual audio drivers installed (one-time setup): + ```bash + cd third-party/ringrtc + sudo bin/virtual_audio.sh --setup --input-source signal_input --output-sink signal_output + ``` + Install `sox`: `brew install sox` +- **Linux**: PulseAudio running (virtual audio modules are created automatically) + +# Using the test harness + +This directory contains a fully automated test harness that runs +scenarios A-E against an Android emulator with Signal installed, requiring +no manual interaction. + +## Environment + +| Component | Value | +|-----------|-------| +| signal-cli account | Set via `--signal-cli-account` or `SIGNAL_CLI_ACCOUNT` env var | +| Emulator Signal account | Set via `--emulator-account` or `EMULATOR_ACCOUNT` env var | +| Emulator AVD | `signal-test` (emulator-5554) | +| Emulator gRPC | localhost:8554 | +| Python virtualenv | `signal-cli-dev` (pyenv, Python 3.9.6) | + +## Quick start + +```bash +# Run all five scenarios: +bash voice-test/run_e2e.sh --signal-cli-account +1... --emulator-account +1... + +# Run specific scenarios: +bash voice-test/run_e2e.sh --signal-cli-account +1... --emulator-account +1... --scenarios A,B,E +``` + +`run_e2e.sh` handles everything automatically: +1. Pre-flight checks (builds exist, adb reachable, Signal installed) +2. Installs Python deps (`grpcio`, `grpcio-tools`) +3. Compiles emulator gRPC proto stubs (if needed) +4. Starts the emulator if not already running +5. Launches Signal on the emulator +6. Starts a signal-cli daemon on a test socket +7. Runs the selected test scenarios +8. Cleans up (kills daemon, orphaned tunnel processes) + +## Scenarios + +| ID | Name | What it tests | +|----|------|---------------| +| A | Outgoing call lifecycle | signal-cli places call -> emulator answers -> signal-cli hangs up | +| B | Incoming call lifecycle | Emulator places call -> signal-cli accepts -> signal-cli hangs up | +| C | Incoming call rejection | Emulator places call -> signal-cli rejects | +| D | Ring timeout | signal-cli places call -> nobody answers -> timeout after ~60s | +| E | Bidirectional audio | Connected call with 440Hz tone via virtual audio devices, Goertzel detection via emulator gRPC audio API | + +## File structure + +``` +voice-test/ + run_e2e.sh # Master orchestrator + config.sh # Shared environment config + requirements.txt # Python deps (grpcio, grpcio-tools) + generate_proto.sh # Compile emulator_controller.proto -> Python stubs + e2e_test.py # Main test runner (scenarios A-E) + lib/ + signal_rpc.py # signal-cli JSON-RPC client + audio.py # Tone generation + Goertzel frequency detection + WAV I/O + emulator.py # ADB-based Signal UI automation + grpc_audio.py # Emulator gRPC audio injection/capture + proto/ # Generated protobuf stubs (created by generate_proto.sh) +``` + +## Debugging audio (Scenario E) + +Scenario E saves WAV files to `voice-test/output/` for manual inspection: + +```bash +ls voice-test/output/*.wav +afplay voice-test/output/e_tone_out_captured.wav # 440Hz captured from emulator speaker +afplay voice-test/output/e_playout_received.wav # Playout recorded from virtual output device +``` + +--- + +## Likely failure points + +1. **TURN credentials** -- `getTurnServerInfo()` must fetch credentials from Signal's server. Without TURN, ICE may fail behind NAT. +2. **ICE connectivity** -- Symmetric NAT on both sides with no TURN = ICE failure. On the same LAN, ICE should complete within ~100ms. +3. **signal-call-tunnel not found** -- Set `SIGNAL_CALL_TUNNEL_BIN` env var to the binary path, or ensure it is on `PATH` or in `/bin/`. +4. **Ring timeout** -- 60 seconds to accept before auto-hangup. +5. **Virtual audio devices not found** -- macOS: BlackHole drivers not installed (run `sudo virtual_audio.sh --setup` first). Linux: PulseAudio not running. + +# Manual Testing + +## Build + +```bash +# Build signal-cli +direnv exec . ./gradlew installDist + +# Build the Rust call tunnel binary +cd signal-call-tunnel && cargo build --release && cd .. +``` + +The first Rust build will automatically download the prebuilt WebRTC library +(~100 MB) from Signal's artifact server. This is cached for subsequent builds. + +## Start the daemon + +Terminal 1 -- start the daemon with a JSON-RPC socket and verbose logging: + +```bash +./build/install/signal-cli/bin/signal-cli -v daemon --socket +``` + +This binds a Unix domain socket at `$XDG_RUNTIME_DIR/signal-cli/socket` +(typically `~/.cache/signal-cli/socket` or `/run/user/$(id -u)/signal-cli/socket`). +Logs and received message notifications print to stdout. + +You can specify a custom path: `--socket /tmp/signal-cli.sock` + +Terminal 2 -- send JSON-RPC commands. Set the socket path to match: + +```bash +SOCKET="${XDG_RUNTIME_DIR:-$HOME/.cache}/signal-cli/socket" +``` + +To send a one-shot command and get the response: + +```bash +echo '{"jsonrpc":"2.0","method":"METHOD","id":1,"params":{}}' | socat - UNIX-CONNECT:$SOCKET +``` + +To open a persistent connection (needed for receiving notifications like incoming calls): + +```bash +socat STDIO UNIX-CONNECT:$SOCKET +``` + +Then type JSON-RPC requests directly. Notifications (incoming calls, state changes) +will appear interleaved with responses. + +--- + +## Test A: Outgoing call signaling and tunnel lifecycle + +### A1. Start the call + +```bash +echo '{"jsonrpc":"2.0","method":"startCall","id":1,"params":{"recipient":"+1YOURPHONENUMBER"}}' \ + | socat - UNIX-CONNECT:$SOCKET +``` + +**Expect in response:** +```json +{"jsonrpc":"2.0","result":{"callId":...,"state":"RINGING_OUTGOING","inputDeviceName":"signal_input_...","outputDeviceName":"signal_output_..."},"id":1} +``` + +**Expect in daemon logs (terminal 1):** +``` +Started outgoing call {callId} to {recipient} +Spawned media tunnel for call {callId} +Tunnel ready for call {callId} +``` + +**Expect on phone:** Incoming call notification from the signal-cli account. + +### A2. Answer on your phone + +Pick up the call. + +**Expect in daemon logs (key lines, in order):** +``` +Received answer for call {callId} +Control event: sendOffer (outgoing call offer generated by RingRTC) +Control event: sendIce (repeated, ICE candidates from RingRTC) +Control event: stateChange state=Connecting +Control event: stateChange state=Connected +``` + +### A3. Verify the media tunnel is running + +```bash +ps aux | grep signal-call-tunnel +``` + +Should show a `signal-call-tunnel` process. + +Check the socket directory (path from the `startCall` response): + +```bash +ls -la /tmp/sc-*/ +``` + +Should show `ctrl.sock` for the active call. + +### A4. Hang up + +From signal-cli (replace CALL_ID with the actual call ID): + +```bash +echo '{"jsonrpc":"2.0","method":"hangupCall","id":2,"params":{"callId":CALL_ID}}' \ + | socat - UNIX-CONNECT:$SOCKET +``` + +Or hang up on your phone. + +**Expect in daemon logs:** +``` +Call {callId} ended: local_hangup (if you hung up from signal-cli) +Call {callId} ended: remote_hangup (if you hung up from phone) +Media tunnel for call {callId} exited with code 0 +``` + +--- + +## Test B: Incoming call signaling, accept, and tunnel lifecycle + +### B1. Open a persistent connection + +You need to see incoming call notifications, so open a persistent connection: + +```bash +socat STDIO UNIX-CONNECT:$SOCKET +``` + +### B2. Call from your phone + +On your phone, start a Signal voice call to the signal-cli account. + +**Expect in daemon logs (terminal 1):** +``` +Incoming call {callId} from {yourPhoneNumber} +Spawned media tunnel for call {callId} +Tunnel ready for call {callId} +``` + +**Expect on the socat connection:** A `receive` notification containing a `callMessage` +with an `offerMessage`. The `callId` is in the offer's `id` field. + +### B3. List calls to get the call ID + +Type into the socat session: + +```json +{"jsonrpc":"2.0","method":"listCalls","id":3} +``` + +**Expect:** A response with a call in state `RINGING_INCOMING`. Note the `callId`. + +### B4. Accept the call + +Type into the socat session (replace CALL_ID): + +```json +{"jsonrpc":"2.0","method":"acceptCall","id":4,"params":{"callId":CALL_ID}} +``` + +**Expect in response:** +```json +{"jsonrpc":"2.0","result":{"callId":...,"state":"CONNECTING","inputDeviceName":"...","outputDeviceName":"..."},"id":4} +``` + +**Expect in daemon logs (key lines, in order):** +``` +Accepted incoming call {callId} +Control event: sendAnswer (RingRTC generated answer with DH key) +Control event: sendIce (repeated, ICE candidates from RingRTC) +Control event: stateChange state=Connecting +Control event: stateChange state=Connected +``` + +**Expect on phone:** Call shows as connected. + +### B5. Verify the media tunnel is running + +Same as A3. + +### B6. Hang up + +Same as A4. + +--- + +## Test C: Incoming call rejection + +### C1. Call from your phone (same as B1-B2) + +### C2. Reject it + +```json +{"jsonrpc":"2.0","method":"rejectCall","id":5,"params":{"callId":CALL_ID}} +``` + +**Expect in daemon logs:** +``` +Call {callId} ended: rejected +``` + +**Expect on phone:** Call ends, shown as declined/busy. + +--- + +## Test D: Unanswered call ring timeout + +### D1. Start the call (same as A1) + +Place an outgoing call from signal-cli. Do **not** answer on your phone. + +```bash +echo '{"jsonrpc":"2.0","method":"startCall","id":1,"params":{"recipient":"+1YOURPHONENUMBER"}}' \ + | socat - UNIX-CONNECT:$SOCKET +``` + +### D2. Wait 60 seconds without answering + +**Expect in daemon logs after ~60s:** +``` +Call {callId} ring timeout +Call {callId} ended: ring_timeout +``` + +**Expect on phone:** Incoming call stops ringing. + +--- + +## Success criteria + +| Stage | How to verify | +|---|---| +| Signaling (offer/answer) | `sendOffer`/`sendAnswer` control events in logs | +| Media tunnel spawn | `Spawned media tunnel` in logs, `ps aux \| grep signal-call-tunnel` shows process | +| ICE connectivity | `stateChange state=Connected` in logs | +| Key derivation | Handled internally by RingRTC (x25519 DH + HKDF); no errors in tunnel stderr | +| Virtual audio | Devices enumerated by cubeb (check tunnel logs for device selection) | +| Call connected | Phone shows connected call, tunnel process alive | +| Clean teardown | `ended` in logs, `exited with code 0`, socket dir cleaned up | diff --git a/voice-test/__init__.py b/voice-test/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/voice-test/config.sh b/voice-test/config.sh new file mode 100644 index 00000000..45da5c0e --- /dev/null +++ b/voice-test/config.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Shared environment configuration for E2E voice call tests. + +SIGNAL_CLI_ACCOUNT="${SIGNAL_CLI_ACCOUNT:?ERROR: SIGNAL_CLI_ACCOUNT not set (use --signal-cli-account or export SIGNAL_CLI_ACCOUNT)}" +EMULATOR_ACCOUNT="${EMULATOR_ACCOUNT:?ERROR: EMULATOR_ACCOUNT not set (use --emulator-account or export EMULATOR_ACCOUNT)}" +ANDROID_SDK="${ANDROID_SDK:-/opt/homebrew/share/android-commandlinetools}" +ADB="${ADB:-$(command -v adb || echo "$ANDROID_SDK/platform-tools/adb")}" +EMULATOR_BIN="${EMULATOR_BIN:-$(command -v emulator || echo "$ANDROID_SDK/emulator/emulator")}" +EMULATOR_AVD="${EMULATOR_AVD:-signal-test}" +EMULATOR_GRPC_PORT=8554 +SIGNAL_CLI_SOCKET="/tmp/signal-cli-test.sock" +SIGNAL_CLI_BIN="./build/install/signal-cli/bin/signal-cli" +SIGNAL_CALL_TUNNEL_BIN="./signal-call-tunnel/target/debug/signal-call-tunnel" +TEST_TONE_FREQ_OUT=440 # signal-cli -> emulator (Hz) +TEST_TONE_FREQ_IN=1000 # emulator -> signal-cli (Hz) +TEST_TONE_DURATION=3 # seconds + +# Log collection +LOG_DIR="voice-test/output/logs" +SIGNAL_CLI_LOG="$LOG_DIR/signal-cli.log" +DAEMON_CONSOLE_LOG="$LOG_DIR/daemon-console.log" +LOGCAT_LOG="$LOG_DIR/logcat.log" diff --git a/voice-test/e2e_test.py b/voice-test/e2e_test.py new file mode 100644 index 00000000..2cae58ed --- /dev/null +++ b/voice-test/e2e_test.py @@ -0,0 +1,916 @@ +#!/usr/bin/env python3 +"""E2E voice call test runner. + +Runs test scenarios A-E against a signal-cli daemon and an Android emulator +with Signal installed. + +Usage: + python3 voice-test/e2e_test.py --socket /tmp/signal-cli-test.sock --scenarios A,B,C,D,E +""" + +import argparse +import os +import platform +import shutil +import signal +import subprocess +import sys +import threading +import time +import traceback +from pathlib import Path + +# Add voice-test dir so we can import lib.* +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from lib.signal_rpc import SignalRPC +from lib.audio import generate_tone, detect_tone, pcm_to_wav, rms_level, wav_to_pcm +from lib.emulator import EmulatorControl + +# Optional: gRPC audio (only needed for scenario E) +try: + from lib.grpc_audio import EmulatorAudio + HAS_GRPC_AUDIO = True +except ImportError: + HAS_GRPC_AUDIO = False + + +# -- Configuration (accounts required via env, others overridable) -- +EMULATOR_ACCOUNT = os.environ["EMULATOR_ACCOUNT"] +SIGNAL_CLI_ACCOUNT = os.environ["SIGNAL_CLI_ACCOUNT"] +ADB_PATH = os.environ.get("ADB") or shutil.which("adb") +if not ADB_PATH: + sys.exit("ERROR: 'adb' not found. Set ADB env var or add adb to PATH.") +EMULATOR_GRPC_PORT = int(os.environ.get("EMULATOR_GRPC_PORT", "8554")) +TEST_TONE_FREQ_OUT = int(os.environ.get("TEST_TONE_FREQ_OUT", "440")) +TEST_TONE_FREQ_IN = int(os.environ.get("TEST_TONE_FREQ_IN", "1000")) +TEST_TONE_DURATION = int(os.environ.get("TEST_TONE_DURATION", "3")) +OUTPUT_DIR = Path(__file__).resolve().parent / "output" +IS_MACOS = platform.system() == "Darwin" + + +def setup_output_dir(): + OUTPUT_DIR.mkdir(exist_ok=True) + + +class TestResult: + def __init__(self, name, passed, message="", duration=0): + self.name = name + self.passed = passed + self.message = message + self.duration = duration + + def __str__(self): + status = "PASS" if self.passed else "FAIL" + return f"[{status}] {self.name} ({self.duration:.1f}s) {self.message}" + + +# --------------------------------------------------------------------------- +# Log collection +# --------------------------------------------------------------------------- +class LogCollector: + """Tracks log file positions per scenario and extracts relevant segments on failure. + + Monitors three log sources: + - signal-cli log file (includes tunnel output as [tunnel-{callId}] lines) + - daemon console log (stdout/stderr from the daemon process) + - Android logcat log (full device logcat) + """ + + # Lines to show in failure diagnostics (per log source) + TAIL_LINES = 80 + + # Logcat patterns relevant to call diagnostics + LOGCAT_FILTERS = [ + "WebRtcCallService", + "RingRTC", + "CallManager", + "org.thoughtcrime.securesms", + "signal", + "webrtc", + "AudioManager", + "AudioTrack", + "AudioRecord", + ] + + def __init__(self, log_dir): + self.log_dir = Path(log_dir) if log_dir else None + self.signal_cli_log = self.log_dir / "signal-cli.log" if self.log_dir else None + self.daemon_console_log = self.log_dir / "daemon-console.log" if self.log_dir else None + self.logcat_log = self.log_dir / "logcat.log" if self.log_dir else None + self._positions = {} # scenario_id -> {file: byte_offset} + + @property + def enabled(self): + return self.log_dir is not None and self.log_dir.is_dir() + + def _file_size(self, path): + try: + return path.stat().st_size if path and path.exists() else 0 + except OSError: + return 0 + + def mark_start(self, scenario_id): + """Record current end-of-file positions for all log files.""" + if not self.enabled: + return + self._positions[scenario_id] = { + "signal_cli": self._file_size(self.signal_cli_log), + "daemon_console": self._file_size(self.daemon_console_log), + "logcat": self._file_size(self.logcat_log), + } + + def extract_scenario_logs(self, scenario_id): + """Extract log segments written during this scenario. + + Returns dict of {source_name: text}. + """ + if not self.enabled or scenario_id not in self._positions: + return {} + + starts = self._positions[scenario_id] + segments = {} + + for name, log_path, start_pos in [ + ("signal-cli", self.signal_cli_log, starts["signal_cli"]), + ("daemon-console", self.daemon_console_log, starts["daemon_console"]), + ("logcat", self.logcat_log, starts["logcat"]), + ]: + if not log_path or not log_path.exists(): + continue + try: + end_pos = log_path.stat().st_size + if end_pos <= start_pos: + continue + with open(log_path, "r", errors="replace") as f: + f.seek(start_pos) + text = f.read(end_pos - start_pos) + if text.strip(): + segments[name] = text + except OSError: + continue + + return segments + + def save_scenario_logs(self, scenario_id, result): + """On failure, save per-scenario log excerpts and print diagnostics.""" + if not self.enabled: + return + + segments = self.extract_scenario_logs(scenario_id) + if not segments: + return + + # Save full per-scenario logs to files + for source, text in segments.items(): + out_path = self.log_dir / f"scenario_{scenario_id}_{source}.log" + with open(out_path, "w") as f: + f.write(text) + + if not result.passed: + self._print_diagnostics(scenario_id, segments) + + def _print_diagnostics(self, scenario_id, segments): + """Print relevant log excerpts for a failed scenario.""" + print(f"\n {'='*60}") + print(f" DIAGNOSTIC LOGS FOR SCENARIO {scenario_id}") + print(f" {'='*60}") + + # signal-cli log (includes [tunnel-*] lines) + if "signal-cli" in segments: + text = segments["signal-cli"] + lines = text.splitlines() + + # Separate tunnel lines from daemon lines + tunnel_lines = [l for l in lines if "[tunnel-" in l] + call_lines = [l for l in lines + if any(kw in l.lower() for kw in + ["call", "tunnel", "ice", "ring", "offer", + "answer", "hangup", "error", "exception", + "failed", "timeout", "media", "virtual", "audio"])] + + if tunnel_lines: + print(f"\n --- signal-call-tunnel ({len(tunnel_lines)} lines) ---") + for line in tunnel_lines[-self.TAIL_LINES:]: + print(f" | {line}") + + if call_lines: + # Deduplicate: skip lines already shown as tunnel lines + daemon_call_lines = [l for l in call_lines if "[tunnel-" not in l] + if daemon_call_lines: + print(f"\n --- signal-cli daemon (call-related, {len(daemon_call_lines)} lines) ---") + for line in daemon_call_lines[-self.TAIL_LINES:]: + print(f" | {line}") + + # Any ERROR/WARN lines not yet shown + error_lines = [l for l in lines + if any(lvl in l for lvl in [" ERROR ", " WARN "]) + and l not in call_lines and l not in tunnel_lines] + if error_lines: + print(f"\n --- signal-cli errors/warnings ({len(error_lines)} lines) ---") + for line in error_lines[-20:]: + print(f" | {line}") + + # Logcat (filtered for Signal/WebRTC) + if "logcat" in segments: + text = segments["logcat"] + lines = text.splitlines() + relevant = [l for l in lines + if any(f.lower() in l.lower() for f in self.LOGCAT_FILTERS)] + if relevant: + print(f"\n --- Android logcat (Signal/WebRTC, {len(relevant)} lines) ---") + for line in relevant[-self.TAIL_LINES:]: + print(f" | {line}") + elif lines: + # No filtered matches; show tail of raw logcat + print(f"\n --- Android logcat (tail, {len(lines)} total lines) ---") + for line in lines[-30:]: + print(f" | {line}") + + # Daemon console (startup errors, crashes) + if "daemon-console" in segments: + text = segments["daemon-console"].strip() + if text: + lines = text.splitlines() + print(f"\n --- daemon console output ({len(lines)} lines) ---") + for line in lines[-20:]: + print(f" | {line}") + + print(f"\n {'='*60}") + print(f" Full logs: {self.log_dir}/scenario_{scenario_id}_*.log") + print(f" {'='*60}\n") + + +# --------------------------------------------------------------------------- +# Screen recording +# --------------------------------------------------------------------------- +class ScreenRecorder: + """Records the emulator screen via `adb screenrecord` for a scenario.""" + + def __init__(self, adb_path, output_dir): + self.adb = adb_path + self.output_dir = Path(output_dir) + self.output_dir.mkdir(parents=True, exist_ok=True) + self._proc = None + self._device_path = "/sdcard/scenario_recording.mp4" + + def start(self, scenario_id): + """Start recording the emulator screen.""" + self.stop() # ensure no leftover recording + self._scenario_id = scenario_id + try: + self._proc = subprocess.Popen( + [self.adb, "shell", "screenrecord", "--time-limit", "120", + self._device_path], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + print(f" [rec] Screen recording started (PID {self._proc.pid})") + except Exception as e: + print(f" [rec] Failed to start screen recording: {e}") + self._proc = None + + def stop(self): + """Stop recording and pull the video to the output directory.""" + if self._proc is None: + return None + # Send SIGINT to gracefully stop screenrecord (finalizes the mp4) + try: + self._proc.send_signal(signal.SIGINT) + self._proc.wait(timeout=5) + except Exception: + self._proc.kill() + self._proc.wait(timeout=3) + self._proc = None + + # Give adb a moment to finalize the file + time.sleep(1) + + # Pull the recording from the device + local_path = self.output_dir / f"scenario_{self._scenario_id}_screen.mp4" + try: + subprocess.run( + [self.adb, "pull", self._device_path, str(local_path)], + capture_output=True, timeout=15, + ) + subprocess.run( + [self.adb, "shell", "rm", "-f", self._device_path], + capture_output=True, timeout=5, + ) + if local_path.exists() and local_path.stat().st_size > 0: + print(f" [rec] Screen recording saved: {local_path}") + return local_path + else: + print(f" [rec] Screen recording file is empty or missing") + except Exception as e: + print(f" [rec] Failed to pull screen recording: {e}") + return None + + +# --------------------------------------------------------------------------- +# Virtual audio device helpers +# --------------------------------------------------------------------------- +def play_to_device(device_name, wav_path, duration=None): + """Play a WAV file to a virtual audio input device (platform-aware). + + Returns the subprocess.Popen object for the background player process. + """ + if IS_MACOS: + # On macOS, use sox to play into the CoreAudio device + cmd = ["sox", str(wav_path), "-t", "coreaudio", device_name, "repeat", "-"] + return subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + else: + # On Linux, play to the PulseAudio sink associated with the input device + sink_name = f"sink_for_{device_name}" + cmd = ["paplay", f"--device={sink_name}", str(wav_path)] + return subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + +def record_from_device(device_name, wav_path, duration): + """Record audio from a virtual audio output device (platform-aware). + + Blocks for `duration` seconds, then returns. + """ + if IS_MACOS: + # On macOS, use sox to record from the CoreAudio device. + # Force mono 16-bit 48kHz output so Python's wave module can read it + # (BlackHole is 2ch, which makes sox emit WAVE_FORMAT_EXTENSIBLE). + cmd = ["sox", "-t", "coreaudio", device_name, + "-b", "16", "-c", "1", "-r", "48000", str(wav_path), + "trim", "0", str(duration)] + else: + # On Linux, record from the PulseAudio monitor source + monitor_name = f"{device_name}.monitor" + cmd = ["parecord", f"--device={monitor_name}", + f"--rate=48000", "--channels=1", "--format=s16le", + f"--file-format=wav", str(wav_path)] + + proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + if IS_MACOS: + # sox with trim will stop after duration + proc.wait(timeout=duration + 10) + else: + # parecord runs indefinitely; kill after duration + time.sleep(duration) + proc.terminate() + proc.wait(timeout=5) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- +def assert_call_stable(rpc, duration=2): + """Verify the call stays connected for at least duration seconds. + + Reads events from the RPC connection, raising AssertionError if an + unexpected ENDED event arrives. Replaces arbitrary time.sleep() pauses + with an actual state-based check. + """ + deadline = time.monotonic() + duration + while time.monotonic() < deadline: + remaining = deadline - time.monotonic() + if remaining <= 0: + break + try: + msg = rpc.read_event(timeout=remaining) + except TimeoutError: + break # No events — call is stable + if msg and msg.get("method") == "callEvent": + event = msg.get("params", {}).get("callEvent", {}) + state = event.get("state") + if state == "ENDED": + raise AssertionError( + f"Call dropped during stability check: reason={event.get('reason')}" + ) + print(f" [rpc] callEvent during stability check: {state}") + + +def wait_for_clean_state(): + """Ensure no lingering call state on the emulator between scenarios. + + Kills Signal, relaunches it, and waits for the WebSocket to reconnect + so the next scenario can receive calls. Raises RuntimeError on timeout. + """ + emu = EmulatorControl(ADB_PATH, output_dir=str(OUTPUT_DIR)) + emu.kill_signal() + # Record a timestamp before launch so we can filter logcat by time + start_ts = emu._shell("date '+%m-%d %H:%M:%S.000'") + emu.launch_signal() + # Wait for Signal's authenticated WebSocket to connect. + deadline = time.monotonic() + 20 + while time.monotonic() < deadline: + logcat = emu._shell( + f"logcat -d -T '{start_ts}' " + "-s SignalWebSocketHealthMo:V IncomingMessageObserver:D " + "2>/dev/null", + timeout=5, + ) + if "CONNECTED" in logcat: + return + time.sleep(1) + raise RuntimeError( + "Signal did not reconnect WebSocket within 20s after restart" + ) + + +# --------------------------------------------------------------------------- +# Scenario A: Outgoing call -- signaling and lifecycle +# --------------------------------------------------------------------------- +def scenario_a(socket_path): + """Outgoing call: signal-cli places call, emulator answers, signal-cli hangs up.""" + emu = EmulatorControl(ADB_PATH, output_dir=str(OUTPUT_DIR)) + rpc = SignalRPC(socket_path) + call_id = None + try: + rpc.subscribe_receive() + + # Place the call + print(" [A] Starting outgoing call to emulator...") + result = rpc.start_call(EMULATOR_ACCOUNT) + call_id = result.get("callId") + state = result.get("state") + input_dev = result.get("inputDeviceName") + output_dev = result.get("outputDeviceName") + print(f" [A] startCall => callId={call_id}, state={state}") + assert call_id, "No callId returned" + + # Wait for RINGING_OUTGOING + if state != "RINGING_OUTGOING": + rpc.wait_for_state("RINGING_OUTGOING", timeout=15) + + # Answer on emulator (polls internally for call to arrive) + print(" [A] Answering call on emulator...") + emu.answer_incoming_call() + + # Wait for CONNECTED + print(" [A] Waiting for CONNECTED...") + rpc.wait_for_state("CONNECTED", timeout=30) + print(" [A] Call connected! Waiting 5s before hanging up from signal-cli...") + assert_call_stable(rpc, duration=5) + + # Hang up + print(" [A] Hanging up from signal-cli...") + rpc.hangup_call(call_id) + call_id = None # Don't double-hangup in cleanup + + # Wait for ENDED + rpc.wait_for_state("ENDED", timeout=10) + print(" [A] Call ended normally.") + + return TestResult("A: Outgoing call lifecycle", True) + + except Exception as e: + return TestResult("A: Outgoing call lifecycle", False, str(e)) + finally: + if call_id: + try: + rpc.hangup_call(call_id) + except Exception: + pass + rpc.close() + + +# --------------------------------------------------------------------------- +# Scenario B: Incoming call -- emulator calls signal-cli +# --------------------------------------------------------------------------- +def scenario_b(socket_path): + """Incoming call: emulator places call, signal-cli accepts and hangs up.""" + emu = EmulatorControl(ADB_PATH, output_dir=str(OUTPUT_DIR)) + rpc = SignalRPC(socket_path) + call_id = None + try: + rpc.subscribe_receive() + + # Navigate emulator to conversation and place call + print(" [B] Opening conversation on emulator...") + emu.open_conversation(SIGNAL_CLI_ACCOUNT) + + print(" [B] Tapping call button on emulator...") + emu.tap_call_button() + + # Wait for incoming call event + print(" [B] Waiting for RINGING_INCOMING...") + params = rpc.wait_for_state("RINGING_INCOMING", timeout=30) + event = params.get("callEvent", {}) + call_id = event.get("callId") + print(f" [B] Incoming call: callId={call_id}") + assert call_id, "No callId in incoming call event" + + # Accept the call + print(" [B] Accepting call...") + rpc.accept_call(call_id) + + # Wait for CONNECTED + print(" [B] Waiting for CONNECTED...") + rpc.wait_for_state("CONNECTED", timeout=30) + print(" [B] Call connected!") + + # Verify call remains stable + print(" [B] Verifying call stability...") + assert_call_stable(rpc, duration=2) + + # Hang up from signal-cli + print(" [B] Hanging up...") + rpc.hangup_call(call_id) + call_id = None + + rpc.wait_for_state("ENDED", timeout=10) + print(" [B] Call ended normally.") + + return TestResult("B: Incoming call lifecycle", True) + + except Exception as e: + return TestResult("B: Incoming call lifecycle", False, str(e)) + finally: + if call_id: + try: + rpc.hangup_call(call_id) + except Exception: + pass + rpc.close() + + +# --------------------------------------------------------------------------- +# Scenario C: Incoming call rejection +# --------------------------------------------------------------------------- +def scenario_c(socket_path): + """Incoming call: emulator places call, signal-cli rejects it.""" + emu = EmulatorControl(ADB_PATH, output_dir=str(OUTPUT_DIR)) + rpc = SignalRPC(socket_path) + call_id = None + try: + rpc.subscribe_receive() + + # Emulator places call + print(" [C] Opening conversation on emulator...") + emu.open_conversation(SIGNAL_CLI_ACCOUNT) + + print(" [C] Tapping call button on emulator...") + emu.tap_call_button() + + # Wait for incoming ring + print(" [C] Waiting for RINGING_INCOMING...") + params = rpc.wait_for_state("RINGING_INCOMING", timeout=30) + event = params.get("callEvent", {}) + call_id = event.get("callId") + assert call_id, "No callId in incoming call event" + + # Reject the call + print(" [C] Rejecting call...") + rpc.reject_call(call_id) + call_id = None + + # Wait for ENDED and verify rejection reason + params = rpc.wait_for_state("ENDED", timeout=10) + event = params.get("callEvent", {}) + reason = event.get("reason", "") + print(f" [C] Call ended: reason={reason}") + assert any(kw in reason.lower() for kw in ("reject", "busy", "decline")), \ + f"Expected rejection reason, got: {reason}" + + return TestResult("C: Incoming call rejection", True, f"reason={reason}") + + except Exception as e: + return TestResult("C: Incoming call rejection", False, str(e)) + finally: + if call_id: + try: + rpc.hangup_call(call_id) + except Exception: + pass + rpc.close() + + +# --------------------------------------------------------------------------- +# Scenario D: Ring timeout +# --------------------------------------------------------------------------- +def scenario_d(socket_path): + """Outgoing call that is never answered -- should timeout.""" + emu = EmulatorControl(ADB_PATH, output_dir=str(OUTPUT_DIR)) + rpc = SignalRPC(socket_path) + call_id = None + try: + # Restart Signal to a clean main screen so it can receive the call + # (but nobody will tap Answer) + emu.ensure_signal_foreground() + + rpc.subscribe_receive() + + # Place call, don't answer on emulator + print(" [D] Starting call (will NOT answer)...") + result = rpc.start_call(EMULATOR_ACCOUNT) + call_id = result.get("callId") + state = result.get("state") + print(f" [D] startCall => callId={call_id}, state={state}") + + if state != "RINGING_OUTGOING": + rpc.wait_for_state("RINGING_OUTGOING", timeout=15) + + # Wait for timeout (Signal typically times out after ~60s) + print(" [D] Waiting for ring timeout (up to 90s)...") + params = rpc.wait_for_state("ENDED", timeout=90) + event = params.get("callEvent", {}) + reason = event.get("reason", "") + print(f" [D] Call ended: reason={reason}") + call_id = None + + assert "timeout" in reason.lower(), \ + f"Expected timeout reason, got: {reason}" + + return TestResult("D: Ring timeout", True, f"reason={reason}") + + except Exception as e: + return TestResult("D: Ring timeout", False, str(e)) + finally: + if call_id: + try: + rpc.hangup_call(call_id) + except Exception: + pass + rpc.close() + + +# --------------------------------------------------------------------------- +# Scenario E: Bidirectional audio verification +# --------------------------------------------------------------------------- +def scenario_e(socket_path): + """Connected call with bidirectional audio: tone generation and detection.""" + if not HAS_GRPC_AUDIO: + return TestResult("E: Bidirectional audio", False, + "grpc_audio not available (run generate_proto.sh first)") + + emu = EmulatorControl(ADB_PATH, output_dir=str(OUTPUT_DIR)) + rpc = SignalRPC(socket_path) + call_id = None + grpc_audio = None + player_proc = None + try: + # Bring Signal to foreground without killing it. + emu.launch_signal() + + rpc.subscribe_receive() + + # Place call and connect + print(" [E] Starting outgoing call...") + result = rpc.start_call(EMULATOR_ACCOUNT) + call_id = result.get("callId") + input_device = result.get("inputDeviceName") + output_device = result.get("outputDeviceName") + assert call_id, "Missing callId" + + state = result.get("state") + if state != "RINGING_OUTGOING": + rpc.wait_for_state("RINGING_OUTGOING", timeout=15) + + # Answer on emulator (polls internally for call to arrive) + print(" [E] Answering on emulator...") + emu.answer_incoming_call() + + print(" [E] Waiting for CONNECTED...") + event_params = rpc.wait_for_state("CONNECTED", timeout=30) + print(" [E] Call connected!") + + # Get device names from the CONNECTED event if not in startCall response + if not input_device or not output_device: + event = event_params.get("callEvent", {}) + input_device = input_device or event.get("inputDeviceName") + output_device = output_device or event.get("outputDeviceName") + + assert input_device, "No inputDeviceName available" + assert output_device, "No outputDeviceName available" + print(f" [E] Virtual audio: input={input_device}, output={output_device}") + + # Set in-call volume to max (default is often 3/15 on emulators) + emu.set_call_volume_max() + + # Settling delay: let WebRTC/Opus codec stabilize before audio tests + print(" [E] Waiting 2s for WebRTC/Opus to stabilize...") + assert_call_stable(rpc, duration=2) + + grpc_audio = EmulatorAudio(port=EMULATOR_GRPC_PORT) + + # --- Direction 1: signal-cli -> emulator (440 Hz) --- + # Generate test tone as WAV file, play it into the virtual input device + print(f" [E] Direction 1: Sending {TEST_TONE_FREQ_OUT}Hz tone via virtual audio device...") + play_duration = TEST_TONE_DURATION + 5 # extra time for settling + tone_pcm = generate_tone(TEST_TONE_FREQ_OUT, play_duration) + setup_output_dir() + + tone_wav_path = OUTPUT_DIR / "e_tone_out_source.wav" + pcm_to_wav(tone_pcm, tone_wav_path) + + # Start playing tone into the virtual input device + player_proc = play_to_device(input_device, tone_wav_path) + + # Wait for tone to flow through WebRTC (encode + network + decode) + time.sleep(4) + + # Capture from emulator speaker while tone is playing + capture_duration = TEST_TONE_DURATION + 1 + min_capture_bytes = 48000 * 2 * 2 # at least 2s of PCM + captured_pcm = grpc_audio.capture_audio(capture_duration) + + # Stop player + if player_proc and player_proc.poll() is None: + player_proc.terminate() + player_proc.wait(timeout=5) + player_proc = None + + print(f" [E] Captured {len(captured_pcm)} bytes from emulator speaker") + + if captured_pcm: + pcm_to_wav(captured_pcm, OUTPUT_DIR / "e_tone_out_captured.wav") + + dir1_ok = False + dir1_rms = 0.0 + if captured_pcm and len(captured_pcm) > 1920: + dir1_rms = rms_level(captured_pcm) + dir1_ok = detect_tone(captured_pcm, TEST_TONE_FREQ_OUT) + print(f" [E] Direction 1: detect={dir1_ok}, RMS={dir1_rms:.4f}") + assert dir1_rms > 0.001, \ + f"Direction 1: captured audio is silent (RMS={dir1_rms:.6f})" + else: + print(f" [E] Direction 1: insufficient captured audio") + + # --- Direction 2: emulator -> signal-cli (playout path) --- + # Record from the virtual output device to verify playout works + print(" [E] Direction 2: Recording from virtual output device...") + record_duration = 3 + recorded_wav_path = OUTPUT_DIR / "e_playout_received.wav" + record_from_device(output_device, recorded_wav_path, record_duration) + + recorded_pcm = b"" + if recorded_wav_path.exists() and recorded_wav_path.stat().st_size > 44: + recorded_pcm = wav_to_pcm(recorded_wav_path) + + recorded_bytes = len(recorded_pcm) + expected_bytes = 48000 * 2 * record_duration + min_bytes = expected_bytes // 2 + + print(f" [E] Direction 2: received {recorded_bytes} bytes " + f"(expected ~{expected_bytes})") + + dir2_ok = recorded_bytes >= min_bytes + dir2_rms = rms_level(recorded_pcm) if recorded_pcm else 0.0 + + # Hang up + print(" [E] Hanging up...") + rpc.hangup_call(call_id) + call_id = None + params = rpc.wait_for_state("ENDED", timeout=10) + + # Validate call ended normally (not a crash) + event = params.get("callEvent", {}) + reason = event.get("reason", "") + print(f" [E] Call ended: reason={reason}") + assert reason and "error" not in reason.lower(), \ + f"Call ended abnormally: reason={reason}" + + # Report results + msgs = [] + msgs.append(f"dir1(signal-cli->emu):{'OK' if dir1_ok else 'FAIL'} RMS={dir1_rms:.4f}") + msgs.append(f"dir2(playout-path):{'OK' if dir2_ok else 'FAIL'} " + f"{recorded_bytes}B/{expected_bytes}B") + + passed = dir1_ok and dir2_ok + return TestResult("E: Bidirectional audio", passed, ", ".join(msgs)) + + except Exception as e: + return TestResult("E: Bidirectional audio", False, str(e)) + finally: + if player_proc and player_proc.poll() is None: + player_proc.terminate() + if call_id: + try: + rpc.hangup_call(call_id) + except Exception: + pass + if grpc_audio: + grpc_audio.close() + rpc.close() + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- +SCENARIOS = { + "A": ("Outgoing call lifecycle", scenario_a), + "B": ("Incoming call lifecycle", scenario_b), + "C": ("Incoming call rejection", scenario_c), + "D": ("Ring timeout", scenario_d), + "E": ("Bidirectional audio", scenario_e), +} + + +def main(): + parser = argparse.ArgumentParser(description="E2E voice call test runner") + parser.add_argument("--socket", required=True, help="Path to signal-cli JSON-RPC socket") + parser.add_argument("--scenarios", default="A,B,C,D,E", + help="Comma-separated list of scenarios to run (default: A,B,C,D,E)") + parser.add_argument("--log-dir", default=None, + help="Directory containing log files for diagnostic collection") + parser.add_argument("--record", action="store_true", + help="Record emulator screen during each scenario (saved to output dir)") + parser.add_argument("--no-fail-fast", action="store_true", + help="Continue running scenarios after a failure (default: stop on first failure)") + args = parser.parse_args() + + selected = [s.strip().upper() for s in args.scenarios.split(",")] + for s in selected: + if s not in SCENARIOS: + print(f"Unknown scenario: {s}") + print(f"Available: {', '.join(SCENARIOS.keys())}") + sys.exit(1) + + logs = LogCollector(args.log_dir) + recorder = ScreenRecorder(ADB_PATH, OUTPUT_DIR) if args.record else None + + print(f"=== E2E Voice Call Tests ===") + print(f"Socket: {args.socket}") + print(f"Scenarios: {', '.join(selected)}") + print(f"Emulator account: {EMULATOR_ACCOUNT}") + print(f"signal-cli account: {SIGNAL_CLI_ACCOUNT}") + if logs.enabled: + print(f"Log collection: {args.log_dir}") + if recorder: + print(f"Screen recording: enabled") + print() + + results = [] + for s in selected: + name, func = SCENARIOS[s] + print(f"--- Scenario {s}: {name} ---") + logs.mark_start(s) + if recorder: + recorder.start(s) + t0 = time.monotonic() + try: + result = func(args.socket) + except Exception as e: + result = TestResult(f"{s}: {name}", False, f"Unhandled: {e}") + traceback.print_exc() + result.duration = time.monotonic() - t0 + + # Retry once on failure for scenarios with flaky external dependencies + if not result.passed and s == "E": + print(f" => {result}") + print(f" [E] Retrying scenario E (emulator audio HAL may need reset)...") + try: + wait_for_clean_state() + except RuntimeError as e: + print(f" [E] Cannot retry: {e}") + else: + logs.mark_start(s) + t0 = time.monotonic() + try: + result = func(args.socket) + except Exception as e: + result = TestResult(f"{s}: {name}", False, f"Unhandled: {e}") + traceback.print_exc() + result.duration = time.monotonic() - t0 + + if recorder: + recorder.stop() + results.append(result) + print(f" => {result}") + + # Collect and save logs for this scenario (prints diagnostics on failure) + logs.save_scenario_logs(s, result) + print() + + # Stop on first failure unless --no-fail-fast is set + if not result.passed and not args.no_fail_fast: + print(f"Stopping after scenario {s} failure (use --no-fail-fast to continue)") + break + + # Wait for clean state between scenarios + if s != selected[-1]: + try: + wait_for_clean_state() + except RuntimeError as e: + print(f" WARNING: {e}") + print(f" Continuing anyway — next scenario may fail.") + + # Summary + print("=" * 50) + print("RESULTS:") + passed = 0 + failed_ids = [] + for i, r in enumerate(results): + print(f" {r}") + if r.passed: + passed += 1 + else: + failed_ids.append(selected[i]) + total = len(results) + print(f"\n{passed}/{total} passed") + + if failed_ids and logs.enabled: + print(f"\nDiagnostic logs for failed scenarios:") + for fid in failed_ids: + print(f" Scenario {fid}: {args.log_dir}/scenario_{fid}_*.log") + + sys.exit(0 if passed == total else 1) + + +if __name__ == "__main__": + main() diff --git a/voice-test/generate_proto.sh b/voice-test/generate_proto.sh new file mode 100755 index 00000000..6a496444 --- /dev/null +++ b/voice-test/generate_proto.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Compile emulator_controller.proto into Python stubs. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ANDROID_SDK="${ANDROID_SDK:-/opt/homebrew/share/android-commandlinetools}" +PROTO_DIR="$ANDROID_SDK/emulator/lib" +PROTO_FILE="$PROTO_DIR/emulator_controller.proto" +OUT_DIR="$SCRIPT_DIR/lib/proto" + +if [ ! -f "$PROTO_FILE" ]; then + echo "ERROR: Proto file not found: $PROTO_FILE" + echo "Make sure Android emulator is installed via commandlinetools." + exit 1 +fi + +mkdir -p "$OUT_DIR" + +echo "Compiling $PROTO_FILE -> $OUT_DIR ..." +python3 -m grpc_tools.protoc \ + "-I$PROTO_DIR" \ + "--python_out=$OUT_DIR" \ + "--grpc_python_out=$OUT_DIR" \ + "$PROTO_FILE" + +# Fix the generated import path (grpc_tools generates absolute imports) +# The grpc stub file imports the pb2 module; ensure it works as a local import. +if [ -f "$OUT_DIR/emulator_controller_pb2_grpc.py" ]; then + # On macOS, sed -i requires '' argument + sed -i '' 's/^import emulator_controller_pb2/from . import emulator_controller_pb2/' \ + "$OUT_DIR/emulator_controller_pb2_grpc.py" 2>/dev/null || \ + sed -i 's/^import emulator_controller_pb2/from . import emulator_controller_pb2/' \ + "$OUT_DIR/emulator_controller_pb2_grpc.py" +fi + +echo "Proto stubs generated in $OUT_DIR:" +ls -la "$OUT_DIR"/emulator_controller_pb2*.py diff --git a/voice-test/lib/__init__.py b/voice-test/lib/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/voice-test/lib/audio.py b/voice-test/lib/audio.py new file mode 100644 index 00000000..b1ff0897 --- /dev/null +++ b/voice-test/lib/audio.py @@ -0,0 +1,126 @@ +"""Audio utilities: tone generation, frequency detection (Goertzel), WAV I/O. + +Pure Python, no numpy dependency. +""" + +import math +import struct +import wave + + +def generate_tone(freq_hz, duration_s, sample_rate=48000, amplitude=0.8): + """Generate PCM bytes (S16LE mono) for a sine wave at freq_hz.""" + n_samples = int(sample_rate * duration_s) + samples = [] + for i in range(n_samples): + t = i / sample_rate + value = amplitude * math.sin(2 * math.pi * freq_hz * t) + sample = int(value * 32767) + sample = max(-32768, min(32767, sample)) + samples.append(struct.pack("= sample_rate // 2: + continue + mag = goertzel_magnitude(pcm_bytes, f, sample_rate) + if mag > peak_mag: + peak_mag = mag + peak_freq = f + + # Measure noise at frequencies well outside the signal window. + noise_freqs = [] + for offset in [-600, -400, 400, 600]: + f = expected_freq + offset + if 50 < f < sample_rate // 2: + noise_freqs.append(f) + + if not noise_freqs: + return peak_mag > 0.001 + + avg_noise = sum( + goertzel_magnitude(pcm_bytes, f, sample_rate) for f in noise_freqs + ) / len(noise_freqs) + + if avg_noise < 1e-8: + return peak_mag > 0.001 + + ratio = peak_mag / avg_noise + print(f" [audio] detect_tone({expected_freq}Hz): peak={peak_mag:.6f}@{peak_freq}Hz, noise={avg_noise:.6f}, ratio={ratio:.1f} (threshold={threshold})") + return ratio >= threshold + + +def pcm_to_wav(pcm_bytes, path, sample_rate=48000): + """Write raw PCM (S16LE mono) to a WAV file.""" + wf = wave.open(str(path), "wb") + wf.setnchannels(1) + wf.setsampwidth(2) + wf.setframerate(sample_rate) + wf.writeframes(pcm_bytes) + wf.close() + + +def wav_to_pcm(path): + """Read a WAV file and return raw PCM bytes (S16LE mono).""" + wf = wave.open(str(path), "rb") + pcm = wf.readframes(wf.getnframes()) + wf.close() + return pcm + + +def rms_level(pcm_bytes): + """RMS amplitude of PCM data (0.0 = silence, 1.0 = full scale).""" + n_samples = len(pcm_bytes) // 2 + if n_samples == 0: + return 0.0 + sum_sq = 0.0 + for i in range(n_samples): + sample = struct.unpack_from("") + 1 + xml_text = xml_text[start:end] + if self._output_dir: + try: + dump_path = os.path.join(self._output_dir, "window_dump.xml") + with open(dump_path, "w") as f: + f.write(xml_text) + except OSError: + pass + return ET.fromstring(xml_text) + except subprocess.TimeoutExpired: + if attempt == 0: + print(" [emu] uiautomator dump timed out, retrying...") + continue + print(" [emu] uiautomator dump timed out twice") + return None + except (ET.ParseError, ValueError) as e: + if attempt == 0: + time.sleep(1) + continue + print(f" [emu] uiautomator dump parse error: {e}") + return None + return None + + def _parse_bounds(self, bounds_str): + """Parse a bounds attribute like '[0,0][1080,1920]' into (cx, cy).""" + m = re.match(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", bounds_str) + if not m: + return None + x1, y1, x2, y2 = int(m.group(1)), int(m.group(2)), int(m.group(3)), int(m.group(4)) + return ((x1 + x2) // 2, (y1 + y2) // 2) + + def _find_element(self, root=None, text=None, content_desc=None, + resource_id_contains=None, class_name=None): + """Find a UI element in the hierarchy and return its center (x, y). + + If root is None, calls _dump_ui() to get the current hierarchy. + Searches for a node matching ALL provided criteria. + Returns (center_x, center_y) or None if not found. + """ + if root is None: + root = self._dump_ui() + if root is None: + return None + + for node in root.iter("node"): + if text is not None and node.get("text", "") != text: + continue + if content_desc is not None and content_desc not in node.get("content-desc", ""): + continue + if resource_id_contains is not None and resource_id_contains not in node.get("resource-id", ""): + continue + if class_name is not None and node.get("class", "") != class_name: + continue + + bounds = node.get("bounds", "") + center = self._parse_bounds(bounds) + if center: + return center + return None + + def _tap_element(self, **kwargs): + """Find an element via _find_element() and tap its center. + + Returns True if the element was found and tapped, False otherwise. + Passes all kwargs through to _find_element(). + """ + center = self._find_element(**kwargs) + if center is None: + criteria = {k: v for k, v in kwargs.items() if v is not None and k != "root"} + print(f" [emu] Element not found: {criteria}") + return False + self.tap(*center) + return True + + # ------------------------------------------------------------------ + # Signal lifecycle + # ------------------------------------------------------------------ + + def kill_signal(self): + """Force-stop Signal. Clears any stuck dialogs, notifications, etc.""" + self._shell("am force-stop org.thoughtcrime.securesms") + time.sleep(1) + + def launch_signal(self): + """Launch Signal to its main chat list screen and wait for it.""" + self._shell( + "monkey -p org.thoughtcrime.securesms " + "-c android.intent.category.LAUNCHER 1" + ) + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + focus = self._shell("dumpsys window | grep mCurrentFocus") + if "org.thoughtcrime.securesms" in focus: + return True + time.sleep(0.5) + print(" [emu] Warning: Signal may not be in foreground") + return False + + def restart_signal(self): + """Kill and relaunch Signal to a clean state.""" + print(" [emu] Restarting Signal...") + self.kill_signal() + self.launch_signal() + + # ------------------------------------------------------------------ + # Navigation + # ------------------------------------------------------------------ + + def open_conversation(self, phone_number): + """Open conversation with the given phone number. + + Kills and relaunches Signal to start from a clean chat list, + then taps the first conversation row using uiautomator lookup. + """ + self.restart_signal() + time.sleep(2) # let chat list fully render + + # Try to find and tap the first conversation row + root = self._dump_ui() + tapped = False + if root is not None: + # Try by content-desc containing the contact name/number + tapped = self._tap_element(root=root, content_desc=phone_number) + if not tapped: + # Try finding a conversation item by resource-id + tapped = self._tap_element(root=root, resource_id_contains="conversation") + if not tapped: + # Find the first clickable row below the toolbar area: + # look for clickable FrameLayout/LinearLayout nodes + for node in root.iter("node"): + if node.get("clickable") != "true": + continue + cls = node.get("class", "") + if cls not in ("android.widget.FrameLayout", + "android.widget.LinearLayout", + "android.view.ViewGroup"): + continue + bounds = node.get("bounds", "") + center = self._parse_bounds(bounds) + if center and center[1] > 200: # below toolbar area + self.tap(*center) + tapped = True + break + + if not tapped: + print(" [emu] Warning: could not find conversation row via uiautomator") + + time.sleep(2) # let conversation load + + # Verify we're in a conversation by checking window focus + focus = self._shell("dumpsys window | grep mCurrentFocus") + if "org.thoughtcrime.securesms" in focus: + print(" [emu] Conversation opened") + else: + print(" [emu] Warning: may not be in conversation") + + def ensure_signal_foreground(self): + """Ensure Signal is in the foreground (for Scenario A where + we just need Signal running, not in a specific conversation).""" + self.restart_signal() + + # ------------------------------------------------------------------ + # Placing calls (from emulator) + # ------------------------------------------------------------------ + + def _dismiss_permission_dialogs(self): + """Dismiss any permission dialogs that appear on the call screen. + + Signal may show camera/microphone permission prompts before the + pre-join call screen. Tap "Not now" or "Deny" to dismiss them. + """ + for _ in range(3): # handle up to 3 stacked dialogs + root = self._dump_ui() + if root is None: + return + dismissed = False + for criteria in [ + {"root": root, "text": "Not now"}, + {"root": root, "text": "Deny"}, + {"root": root, "text": "Don\u2019t allow"}, + {"root": root, "text": "Don't allow"}, + ]: + if self._tap_element(**criteria): + print(f" [emu] Dismissed permission dialog") + dismissed = True + time.sleep(0.5) + break + if not dismissed: + return + + def tap_call_button(self): + """Tap the voice call button in the conversation header, + then confirm the 'Start voice call?' dialog.""" + # Tap the call icon in the header (try content-desc patterns) + tapped = self._tap_element(content_desc="Signal call") + if not tapped: + tapped = self._tap_element(content_desc="Voice call") + if not tapped: + tapped = self._tap_element(content_desc="call") + if not tapped: + print(" [emu] Warning: could not find call button via uiautomator") + + time.sleep(1.5) # wait for call screen / dialog + + # Dismiss any permission dialogs (camera, microphone) that may + # appear before the pre-join screen. + self._dismiss_permission_dialogs() + time.sleep(0.5) + + # On newer Signal versions, tapping the call icon opens a pre-join + # call activity instead of a confirmation dialog. Try both flows. + print(" [emu] Confirming call (dialog or pre-join screen)...") + root = self._dump_ui() + tapped = False + if root is not None: + # Try pre-join screen "Start Call" button first, then dialog "Call" + for criteria in [ + {"root": root, "text": "Start Call"}, + {"root": root, "text": "Start call"}, + {"root": root, "content_desc": "Start call"}, + {"root": root, "content_desc": "Start Call"}, + {"root": root, "text": "Call"}, + {"root": root, "text": "Voice call"}, + {"root": root, "content_desc": "Voice call"}, + ]: + if self._tap_element(**criteria): + tapped = True + break + + if not tapped: + print(" [emu] Warning: could not find call start button") + print(" [emu] Elements on screen:") + for node in root.iter("node"): + text = node.get("text", "") + desc = node.get("content-desc", "") + click = node.get("clickable", "") + if text or desc: + bounds = node.get("bounds", "") + print(f" [emu] text={text!r} desc={desc!r} " + f"click={click} bounds={bounds}") + + # ------------------------------------------------------------------ + # Answering / rejecting calls (on emulator) + # ------------------------------------------------------------------ + + def _wait_for_incoming_call(self, timeout=30): + """Poll logcat until the emulator is actually ringing. + + Waits for LocalRinging (the point where the heads-up notification + with answer/decline buttons appears). handleReceivedOffer fires + much earlier during ICE negotiation. + """ + start_ts = self._shell("date '+%m-%d %H:%M:%S.000'") + + deadline = time.monotonic() + timeout + offer_seen = False + while time.monotonic() < deadline: + logcat = self._shell( + f"logcat -d -T '{start_ts}' 2>/dev/null", timeout=5 + ) + + if not offer_seen and "handleReceivedOffer" in logcat: + print(" [emu] Offer received, waiting for ringing...") + offer_seen = True + + if "event: LOCAL_RINGING" in logcat or "handleLocalRinging" in logcat: + print(" [emu] Phone is ringing (LocalRinging)") + return True + + time.sleep(0.5) + print(" [emu] Warning: incoming call did not start ringing within timeout") + return False + + def _check_call_accepted(self, logcat_since): + """Check logcat for handleAcceptCall (call was answered).""" + logcat = self._shell( + f"logcat -d -T '{logcat_since}' 2>/dev/null", timeout=5 + ) + return "handleAcceptCall" in logcat + + def _wait_for_call_accepted(self, logcat_since, timeout=3): + """Poll logcat until handleAcceptCall appears or timeout.""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if self._check_call_accepted(logcat_since): + return True + time.sleep(0.3) + return False + + def answer_incoming_call(self): + """Answer an incoming call on the emulator. + + Waits for the phone to ring, then tries strategies in order: + 1. Expand notification shade, find and tap "Answer" via uiautomator + (the heads-up notification is invisible to uiautomator, but the + shade's notification actions ARE in the SystemUI hierarchy) + 2. KEYCODE_HEADSETHOOK (hardware button simulation) + 3. cmd telecom accept-ringing-call (only works if app uses Telecom) + + Each strategy is verified via logcat (handleAcceptCall). + """ + self._wait_for_incoming_call(timeout=30) + + logcat_since = self._shell("date '+%m-%d %H:%M:%S.000'") + time.sleep(1) # let notification fully render + + # Strategy 1: expand notification shade, find Answer button + print(" [emu] Expanding notification shade...") + self._shell("cmd statusbar expand-notifications") + time.sleep(1) # let shade animate open + + root = self._dump_ui() + tapped = False + if root is not None: + for criteria in [ + {"root": root, "text": "Answer"}, + {"root": root, "text": "Accept"}, + {"root": root, "content_desc": "Answer"}, + {"root": root, "content_desc": "Accept"}, + ]: + if self._tap_element(**criteria): + tapped = True + break + + # Collapse the shade regardless of whether we found the button + self._shell("cmd statusbar collapse") + + if tapped: + if self._wait_for_call_accepted(logcat_since, timeout=3): + print(" [emu] Call answered via notification tap") + return + + # Strategy 2: HEADSETHOOK keyevent + print(" [emu] Fallback: KEYCODE_HEADSETHOOK") + self._shell("input keyevent 79") + if self._wait_for_call_accepted(logcat_since, timeout=3): + print(" [emu] Call answered via HEADSETHOOK") + return + + # Strategy 3: telecom command (works if app registers with Telecom) + print(" [emu] Fallback: cmd telecom accept-ringing-call") + self._shell("cmd telecom accept-ringing-call") + if self._wait_for_call_accepted(logcat_since, timeout=3): + print(" [emu] Call answered via telecom command") + return + + print(" [emu] Warning: could not confirm call was answered") + + def reject_incoming_call(self): + """Reject an incoming call on the emulator. + + Waits for ringing, then uses the ENDCALL keyevent or telecom command. + Verifies via logcat that the call ended. + """ + self._wait_for_incoming_call(timeout=30) + + logcat_since = self._shell("date '+%m-%d %H:%M:%S.000'") + time.sleep(1) + + # Use ENDCALL keyevent (keycode 6) — works without coordinates + print(" [emu] Rejecting call via ENDCALL keyevent...") + self._shell("input keyevent ENDCALL") + + # Verify call ended + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + logcat = self._shell( + f"logcat -d -T '{logcat_since}' 2>/dev/null", timeout=5 + ) + if "call_concluded" in logcat or "onCallConcluded" in logcat: + print(" [emu] Call declined") + return + time.sleep(0.3) + + # Fallback: try telecom end-call + print(" [emu] Fallback: cmd telecom end-call") + self._shell("cmd telecom end-call") + print(" [emu] Warning: could not confirm call was declined") + + # ------------------------------------------------------------------ + # Utilities + # ------------------------------------------------------------------ + + def set_call_volume_max(self): + """Set in-call volume to maximum by simulating volume-up key presses. + + The voice call volume can only be changed during an active call. + We press KEYCODE_VOLUME_UP enough times to reach max (15 steps). + """ + for _ in range(15): + self._shell("input keyevent 24") # KEYCODE_VOLUME_UP + + def tap(self, x, y): + """Tap at screen coordinates.""" + self._shell(f"input tap {x} {y}") + + def is_device_online(self): + """Check if the emulator is reachable via adb.""" + output = self._run("devices") + return "emulator" in output and "device" in output diff --git a/voice-test/lib/grpc_audio.py b/voice-test/lib/grpc_audio.py new file mode 100644 index 00000000..edd025b2 --- /dev/null +++ b/voice-test/lib/grpc_audio.py @@ -0,0 +1,189 @@ +"""Emulator gRPC audio injection and capture client. + +Uses compiled proto stubs from the Android emulator's emulator_controller.proto. +Supports 48kHz mono S16LE audio matching the media socket format. + +Handles both unauthenticated (-grpc flag) and JWT-authenticated emulator gRPC. +""" + +import json +import os +import time +from pathlib import Path + + +def _get_stubs(): + """Lazy-import the generated proto stubs.""" + from lib.proto import emulator_controller_pb2 as pb + from lib.proto import emulator_controller_pb2_grpc as pb_grpc + return pb, pb_grpc + + +class EmulatorAudio: + """Client for the emulator's gRPC audio streaming API.""" + + def __init__(self, host="localhost", port=8554, token=None): + import grpc + self._grpc = grpc + self._token = token + self._host = host + self._port = port + + self.channel = grpc.insecure_channel(f"{host}:{port}") + pb, pb_grpc = _get_stubs() + self.stub = pb_grpc.EmulatorControllerStub(self.channel) + self.pb = pb + + # If no token provided, probe connectivity and auto-discover if needed + if self._token is None: + self._try_connect_or_discover() + + def _metadata(self): + """Return gRPC call metadata with auth header if token is set.""" + if self._token: + return [('authorization', f'Bearer {self._token}')] + return [] + + def _try_connect_or_discover(self): + """Test unauthenticated connectivity; fall back to token discovery.""" + import grpc + try: + grpc.channel_ready_future(self.channel).result(timeout=3) + # Channel connected — try a trivial call to check auth + self.stub.getStatus(self.pb.Empty(), timeout=3) + except grpc.RpcError as e: + if e.code() == grpc.StatusCode.UNAUTHENTICATED: + print(" [grpc] Unauthenticated — attempting token discovery...") + token = self.discover_token() + if token: + self._token = token + print(" [grpc] Token discovered, retrying with auth...") + else: + print(" [grpc] No token found. Restart emulator with: -grpc ") + else: + # Non-auth error (e.g. connection refused) — let caller handle + pass + except Exception: + pass + + @classmethod + def discover_token(cls): + """Search emulator discovery directories for a gRPC auth token. + + The emulator writes discovery files to: + - ~/.android/avd/running/ (Linux/macOS default) + - $TMPDIR/avd/running/ (macOS alternate) + + Each running emulator creates a pid_NNNNN.ini with grpc.token or + a corresponding .jwk file. + """ + search_dirs = [] + + # ~/.android/avd/running/ + android_home = Path.home() / ".android" / "avd" / "running" + if android_home.is_dir(): + search_dirs.append(android_home) + + # $TMPDIR/avd/running/ + tmpdir = os.environ.get("TMPDIR", "/tmp") + tmpdir_running = Path(tmpdir) / "avd" / "running" + if tmpdir_running.is_dir(): + search_dirs.append(tmpdir_running) + + for d in search_dirs: + # Look for pid_*.ini files that contain grpc.token + for ini_file in sorted(d.glob("pid_*.ini"), reverse=True): + try: + text = ini_file.read_text() + for line in text.splitlines(): + if line.startswith("grpc.token="): + token = line.split("=", 1)[1].strip() + if token: + return token + except OSError: + continue + + # Look for .jwk files (JSON Web Key — contains the token) + for jwk_file in sorted(d.glob("*.jwk"), reverse=True): + try: + data = json.loads(jwk_file.read_text()) + # The emulator JWK file format varies; look for common keys + if isinstance(data, dict): + token = data.get("token") or data.get("grpc_token") + if token: + return token + except (OSError, json.JSONDecodeError): + continue + + return None + + def close(self): + self.channel.close() + + def inject_audio(self, pcm_bytes, sample_rate=48000): + """Inject PCM audio into the emulator's virtual microphone. + + Client-streaming RPC: first packet includes AudioFormat, all include audio data. + """ + pb = self.pb + metadata = self._metadata() + audio_format = pb.AudioFormat( + samplingRate=sample_rate, + channels=pb.AudioFormat.Mono, + format=pb.AudioFormat.AUD_FMT_S16, + mode=pb.AudioFormat.MODE_UNSPECIFIED, + ) + + def _packet_generator(): + # Send in chunks matching 10ms frames (960 bytes at 48kHz mono S16LE) + frame_size = sample_rate * 2 // 100 # 10ms worth of bytes + offset = 0 + first = True + while offset < len(pcm_bytes): + chunk = pcm_bytes[offset:offset + frame_size] + if first: + yield pb.AudioPacket(format=audio_format, audio=chunk) + first = False + else: + yield pb.AudioPacket(audio=chunk) + offset += frame_size + # Pace the injection to approximate real-time + time.sleep(0.008) # slightly less than 10ms to avoid underruns + + self.stub.injectAudio(_packet_generator(), metadata=metadata) + + def capture_audio(self, duration_s, sample_rate=48000): + """Capture audio from the emulator's speaker output. + + Server-streaming RPC: returns concatenated PCM bytes. + """ + pb = self.pb + metadata = self._metadata() + audio_format = pb.AudioFormat( + samplingRate=sample_rate, + channels=pb.AudioFormat.Mono, + format=pb.AudioFormat.AUD_FMT_S16, + ) + + deadline = time.monotonic() + duration_s + 1.0 + chunks = [] + total_bytes = 0 + target_bytes = int(sample_rate * 2 * duration_s) # S16 mono + + try: + for packet in self.stub.streamAudio( + audio_format, + timeout=duration_s + 5, + metadata=metadata, + ): + if packet.audio: + chunks.append(packet.audio) + total_bytes += len(packet.audio) + if total_bytes >= target_bytes: + break + if time.monotonic() > deadline: + break + except Exception as e: + print(f" [grpc] streamAudio ended: {e}") + + return b"".join(chunks) diff --git a/voice-test/lib/proto/__init__.py b/voice-test/lib/proto/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/voice-test/lib/signal_rpc.py b/voice-test/lib/signal_rpc.py new file mode 100644 index 00000000..bdd784c9 --- /dev/null +++ b/voice-test/lib/signal_rpc.py @@ -0,0 +1,145 @@ +"""JSON-RPC client for signal-cli daemon over Unix socket.""" + +import json +import socket +import time + + +class SignalRPC: + """Connects to signal-cli's JSON-RPC Unix socket and provides call control methods.""" + + SAMPLE_RATE = 48000 + CHANNELS = 1 + PTIME_MS = 10 + SAMPLES_PER_FRAME = SAMPLE_RATE * PTIME_MS // 1000 # 480 + BYTES_PER_SAMPLE = 2 # 16-bit signed LE + PCM_FRAME_SIZE = SAMPLES_PER_FRAME * BYTES_PER_SAMPLE # 960 + + def __init__(self, socket_path): + self.socket_path = socket_path + self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.sock.connect(socket_path) + self._next_id = 0 + self._buf = b"" + + def close(self): + try: + self.sock.close() + except OSError: + pass + + def _send(self, method, params=None): + self._next_id += 1 + req = {"jsonrpc": "2.0", "method": method, "id": self._next_id} + if params: + req["params"] = params + line = json.dumps(req) + "\n" + self.sock.sendall(line.encode("utf-8")) + return self._next_id + + def _read_lines(self): + """Yield complete newline-delimited JSON strings from socket.""" + # Drain any complete lines already in the buffer from a previous recv + while b"\n" in self._buf: + line, self._buf = self._buf.split(b"\n", 1) + line = line.strip() + if line: + yield line.decode("utf-8") + while True: + data = self.sock.recv(4096) + if not data: + return + self._buf += data + while b"\n" in self._buf: + line, self._buf = self._buf.split(b"\n", 1) + line = line.strip() + if line: + yield line.decode("utf-8") + + def _wait_response(self, req_id, timeout=10): + """Wait for a JSON-RPC response matching req_id, buffering notifications.""" + self.sock.settimeout(timeout) + try: + for line in self._read_lines(): + msg = json.loads(line) + if "id" in msg and msg["id"] == req_id: + if "error" in msg: + raise RuntimeError(f"RPC error: {msg['error']}") + return msg.get("result") + # Buffer notification for later consumption + self._pending_events.append(msg) + except socket.timeout: + raise TimeoutError(f"Timed out waiting for response to request {req_id}") + finally: + self.sock.settimeout(None) + + def subscribe_receive(self): + self._pending_events = getattr(self, "_pending_events", []) + req_id = self._send("subscribeReceive") + return self._wait_response(req_id) + + def start_call(self, recipient): + self._pending_events = getattr(self, "_pending_events", []) + req_id = self._send("startCall", {"recipient": [recipient]}) + return self._wait_response(req_id, timeout=30) + + def accept_call(self, call_id): + self._pending_events = getattr(self, "_pending_events", []) + req_id = self._send("acceptCall", {"call-id": call_id}) + return self._wait_response(req_id, timeout=30) + + def reject_call(self, call_id): + self._pending_events = getattr(self, "_pending_events", []) + req_id = self._send("rejectCall", {"call-id": call_id}) + return self._wait_response(req_id, timeout=10) + + def hangup_call(self, call_id): + self._pending_events = getattr(self, "_pending_events", []) + req_id = self._send("hangupCall", {"call-id": call_id}) + return self._wait_response(req_id, timeout=10) + + def list_calls(self): + self._pending_events = getattr(self, "_pending_events", []) + req_id = self._send("listCalls") + return self._wait_response(req_id, timeout=10) + + def read_event(self, timeout=30): + """Read the next JSON-RPC notification (callEvent, receive, etc.).""" + self._pending_events = getattr(self, "_pending_events", []) + # Return buffered events first + if self._pending_events: + return self._pending_events.pop(0) + self.sock.settimeout(timeout) + try: + for line in self._read_lines(): + msg = json.loads(line) + if "id" in msg and "method" not in msg: + # This is a response, buffer it (shouldn't normally happen here) + self._pending_events.append(msg) + continue + return msg + except socket.timeout: + raise TimeoutError(f"No event received within {timeout}s") + finally: + self.sock.settimeout(None) + return None + + def wait_for_state(self, target_state, timeout=60): + """Block until a callEvent with the given state arrives. Returns the event params.""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + remaining = deadline - time.monotonic() + if remaining <= 0: + break + try: + msg = self.read_event(timeout=remaining) + except TimeoutError: + break + if msg and msg.get("method") == "callEvent": + params = msg.get("params", {}) + event = params.get("callEvent", {}) + state = event.get("state") + print(f" [rpc] callEvent: {state} (reason={event.get('reason')})") + if state == target_state: + return params + raise TimeoutError(f"Did not reach state {target_state} within {timeout}s") diff --git a/voice-test/requirements.txt b/voice-test/requirements.txt new file mode 100644 index 00000000..cc47cf34 --- /dev/null +++ b/voice-test/requirements.txt @@ -0,0 +1,2 @@ +grpcio>=1.60.0 +grpcio-tools>=1.60.0 diff --git a/voice-test/run_e2e.sh b/voice-test/run_e2e.sh new file mode 100755 index 00000000..b34a700e --- /dev/null +++ b/voice-test/run_e2e.sh @@ -0,0 +1,404 @@ +#!/usr/bin/env bash +# Master orchestrator for E2E voice call tests. +# +# Starts signal-cli daemon, ensures emulator is ready, runs test scenarios, +# and cleans up afterwards. +set -euo pipefail + +usage() { + cat </dev/null; then + echo "Stopping test runner (PID $TEST_PID)..." + kill "$TEST_PID" 2>/dev/null || true + wait "$TEST_PID" 2>/dev/null || true + fi + + if [ -n "$LOGCAT_PID" ] && kill -0 "$LOGCAT_PID" 2>/dev/null; then + echo "Stopping logcat collector (PID $LOGCAT_PID)..." + kill "$LOGCAT_PID" 2>/dev/null || true + wait "$LOGCAT_PID" 2>/dev/null || true + fi + + if [ -n "$DAEMON_PID" ] && kill -0 "$DAEMON_PID" 2>/dev/null; then + echo "Stopping signal-cli daemon (PID $DAEMON_PID)..." + kill "$DAEMON_PID" 2>/dev/null || true + wait "$DAEMON_PID" 2>/dev/null || true + fi + + rm -f "$SIGNAL_CLI_SOCKET" + + # Kill any orphaned signal-call-tunnel processes + pkill -f "signal-call-tunnel" 2>/dev/null || true + + echo "Cleanup done." + echo "" + echo "=== Log files ===" + echo " signal-cli + tunnel : $SIGNAL_CLI_LOG" + echo " daemon console : $DAEMON_CONSOLE_LOG" + echo " Android logcat : $LOGCAT_LOG" +} + +# Trap EXIT (normal exit, set -e failures) plus INT/TERM (Ctrl+C, kill) +trap cleanup EXIT INT TERM + +# --- Kill stale processes from previous interrupted runs --- +echo "=== Pre-run Cleanup ===" +STALE=false + +# Kill leftover signal-cli daemon using our test socket +if [ -S "$SIGNAL_CLI_SOCKET" ]; then + echo " Removing stale socket: $SIGNAL_CLI_SOCKET" + # Find the daemon that owns it (if still alive) + STALE_PID=$(lsof -t "$SIGNAL_CLI_SOCKET" 2>/dev/null | head -1 || true) + if [ -n "$STALE_PID" ]; then + echo " Killing stale daemon (PID $STALE_PID)..." + kill "$STALE_PID" 2>/dev/null || true + sleep 1 + fi + rm -f "$SIGNAL_CLI_SOCKET" + STALE=true +fi + +# Kill leftover signal-call-tunnel processes +if pgrep -f "signal-call-tunnel" >/dev/null 2>&1; then + echo " Killing orphaned signal-call-tunnel processes..." + pkill -f "signal-call-tunnel" 2>/dev/null || true + STALE=true +fi + +# Kill leftover logcat collectors from our log file +if pgrep -f "logcat.*threadtime" >/dev/null 2>&1; then + echo " Killing stale logcat collectors..." + pkill -f "logcat.*threadtime" 2>/dev/null || true + STALE=true +fi + +if $STALE; then + echo " Stale processes cleaned up. Waiting 2s..." + sleep 2 +else + echo " No stale processes found." +fi + +# --- Build binaries --- +echo "=== Building Binaries ===" + +echo " Building signal-cli (./gradlew installDist)..." +if ! ./gradlew -q installDist; then + echo "ERROR: signal-cli build failed" + exit 1 +fi +echo " signal-cli: OK" + +echo " Building signal-call-tunnel (cargo build)..." +if ! (cd signal-call-tunnel && cargo build --quiet); then + echo "ERROR: signal-call-tunnel build failed" + exit 1 +fi +echo " signal-call-tunnel: OK" + +# Check for BlackHole virtual audio drivers (macOS only) +if [ "$(uname)" = "Darwin" ]; then + AUDIO_HAL="/Library/Audio/Plug-Ins/HAL" + MISSING_DRIVERS=false + if [ ! -d "$AUDIO_HAL/signal_input.driver" ]; then + MISSING_DRIVERS=true + fi + if [ ! -d "$AUDIO_HAL/signal_output.driver" ]; then + MISSING_DRIVERS=true + fi + if $MISSING_DRIVERS; then + RINGRTC_DIR="$PROJECT_DIR/third-party/ringrtc" + echo "" + echo "ERROR: BlackHole virtual audio drivers are not installed." + echo " signal-call-tunnel requires pre-installed audio drivers on macOS." + echo "" + echo " Run the following command once (requires root):" + echo "" + echo " sudo bash $RINGRTC_DIR/bin/virtual_audio.sh \\" + echo " --setup --input-source signal_input --output-sink signal_output" + echo "" + echo " To remove them later:" + echo "" + echo " sudo bash $RINGRTC_DIR/bin/virtual_audio.sh \\" + echo " --teardown --input-source signal_input --output-sink signal_output" + echo "" + exit 1 + fi + echo " BlackHole audio drivers: OK" +fi + +if ! command -v python3 &>/dev/null; then + echo "ERROR: python3 not found" + exit 1 +fi +echo " python3: $(python3 --version)" + +if ! "$ADB" devices 2>/dev/null | grep -q "emulator"; then + echo "WARNING: No emulator detected via adb. Attempting to start..." + # The headed (non-headless) emulator binary is required for gRPC audio + # streaming (scenario E). The headless binary strips audio output support, + # causing streamAudio to block forever. + "$EMULATOR_BIN" -avd "$EMULATOR_AVD" -no-snapshot-load \ + -grpc "$EMULATOR_GRPC_PORT" & + EMU_PID=$! + echo " Waiting for emulator boot (PID $EMU_PID)..." + "$ADB" wait-for-device + # Wait for boot to complete + for i in $(seq 1 60); do + BOOT=$("$ADB" shell getprop sys.boot_completed 2>/dev/null || echo "") + if [ "$BOOT" = "1" ]; then + break + fi + sleep 2 + done + echo " Emulator booted." +else + echo " Emulator: already running" +fi + +# Ensure adbd runs as root (required for uiautomator dump on API 34+) +"$ADB" root 2>/dev/null || true +"$ADB" wait-for-device 2>/dev/null +echo " adb root: $(${ADB} shell id -u 2>/dev/null || echo 'unknown')" + +# Check gRPC connectivity (scenario E needs unauthenticated gRPC) +if echo "$SCENARIOS" | grep -q "E"; then + echo " Checking emulator gRPC on port $EMULATOR_GRPC_PORT..." + if python3 -c " +import grpc, sys +ch = grpc.insecure_channel('localhost:$EMULATOR_GRPC_PORT') +try: + grpc.channel_ready_future(ch).result(timeout=3) +except Exception: + sys.exit(1) +finally: + ch.close() +" 2>/dev/null; then + echo " gRPC: reachable" + else + echo " WARNING: Emulator gRPC on port $EMULATOR_GRPC_PORT is not reachable." + echo " If scenario E fails with UNAUTHENTICATED, restart the emulator with:" + echo " $EMULATOR_BIN -avd $EMULATOR_AVD -no-snapshot-load -no-window -grpc $EMULATOR_GRPC_PORT" + fi +fi + +# Verify Signal is installed +if ! "$ADB" shell pm list packages 2>/dev/null | grep -q "org.thoughtcrime.securesms"; then + echo "ERROR: Signal is not installed on the emulator" + exit 1 +fi +echo " Signal app: installed" + +# --- Install Python deps --- +echo "" +echo "=== Python Dependencies ===" +pip install -q -r "$SCRIPT_DIR/requirements.txt" +echo " grpcio: OK" + +# --- Generate proto stubs --- +echo "" +echo "=== Proto Stubs ===" +if [ ! -f "$SCRIPT_DIR/lib/proto/emulator_controller_pb2.py" ]; then + bash "$SCRIPT_DIR/generate_proto.sh" +else + echo " Proto stubs already generated (use 'bash voice-test/generate_proto.sh' to regenerate)" +fi + +# --- Ensure Signal is on main screen --- +echo "" +echo "=== Preparing Signal App ===" +"$ADB" shell monkey -p org.thoughtcrime.securesms -c android.intent.category.LAUNCHER 1 +sleep 2 +echo " Signal launched" + +# Set media and ring volumes to max (voice call volume is set during the +# active call in scenario E via KEYCODE_VOLUME_UP key events). +"$ADB" shell cmd media_session volume --stream 2 --set 15 >/dev/null 2>&1 # ring +"$ADB" shell cmd media_session volume --stream 3 --set 15 >/dev/null 2>&1 # music +echo " Audio volumes: ring/media set to max" + +# --- Set up log collection --- +echo "" +echo "=== Log Collection ===" +mkdir -p "$LOG_DIR" +# Truncate logs from previous runs +: > "$SIGNAL_CLI_LOG" +: > "$DAEMON_CONSOLE_LOG" +: > "$LOGCAT_LOG" + +# Start logcat collector (Signal app + WebRTC/RingRTC tags) +"$ADB" logcat -c 2>/dev/null || true # Clear old logcat buffer +"$ADB" logcat -v threadtime > "$LOGCAT_LOG" 2>&1 & +LOGCAT_PID=$! +echo " logcat collector PID: $LOGCAT_PID -> $LOGCAT_LOG" + +# --- Start signal-cli daemon --- +echo "" +echo "=== Starting signal-cli Daemon ===" + +# Export tunnel binary path so the daemon subprocess can find it +export SIGNAL_CALL_TUNNEL_BIN + +# -vv for DEBUG+TRACE on org.asamk (includes [tunnel-{callId}] lines) +# --log-file captures detailed logs; stdout/stderr go to console log +$SIGNAL_CLI_BIN -vv -a "$SIGNAL_CLI_ACCOUNT" \ + --log-file "$SIGNAL_CLI_LOG" \ + daemon --socket "$SIGNAL_CLI_SOCKET" \ + > "$DAEMON_CONSOLE_LOG" 2>&1 & +DAEMON_PID=$! +echo " Daemon PID: $DAEMON_PID" +echo " Log file: $SIGNAL_CLI_LOG" + +# Wait for socket to appear +echo " Waiting for daemon socket..." +for i in $(seq 1 30); do + if [ -S "$SIGNAL_CLI_SOCKET" ]; then + break + fi + if ! kill -0 "$DAEMON_PID" 2>/dev/null; then + echo "ERROR: Daemon exited prematurely" + exit 1 + fi + sleep 1 +done + +if [ ! -S "$SIGNAL_CLI_SOCKET" ]; then + echo "ERROR: Daemon socket did not appear within 30s" + exit 1 +fi +echo " Daemon ready." + +# --- Run tests --- +echo "" +echo "=== Running Tests ===" +SCENARIOS="${SCENARIOS:-A,B,C,D,E}" + +TEST_PID="" +set +e +python3 -u "$SCRIPT_DIR/e2e_test.py" \ + --socket "$SIGNAL_CLI_SOCKET" \ + --log-dir "$LOG_DIR" \ + --scenarios "$SCENARIOS" $RECORD_FLAG $NO_FAIL_FAST_FLAG & +TEST_PID=$! +wait "$TEST_PID" +TEST_EXIT=$? +TEST_PID="" +set -e + +echo "" +if [ $TEST_EXIT -eq 0 ]; then + echo "=== ALL TESTS PASSED ===" +else + echo "=== SOME TESTS FAILED ===" + echo "" + echo "Log files for diagnosis:" + echo " signal-cli + tunnel : $SIGNAL_CLI_LOG" + echo " daemon console : $DAEMON_CONSOLE_LOG" + echo " Android logcat : $LOGCAT_LOG" + echo "" + echo "Per-scenario log excerpts are in: $LOG_DIR/scenario_*.log" +fi + +exit $TEST_EXIT