Compare commits

..

28 Commits

Author SHA1 Message Date
Shaheen Gandhi
fd0bb1cbc4
Merge 9493381f57b28b56943ef174428023f09f29ae3c into 2885ffeee867203f5f21f65a9af2a080f92188ec 2026-03-06 00:16:28 +00:00
Shaheen Gandhi
9493381f57 Add call tunnel documentation
Add documentation about the architecture, protocol, and implementation of
signal-call-tunnel, the secure tunnel subprocess for voice calling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 16:16:07 -08:00
Shaheen Gandhi
32ada51c44 Add JSON-RPC commands for voice call control
Add startCall, acceptCall, hangupCall, rejectCall, and listCalls
commands for the JSON-RPC daemon interface. Register commands and
update GraalVM metadata for native image support.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 16:16:07 -08:00
Shaheen Gandhi
fafc5e3563 Add call state notification mechanism for JSON-RPC clients
Implement CallEventListener callback pattern that fires on every call
state transition (RINGING_INCOMING, RINGING_OUTGOING, CONNECTING,
CONNECTED, ENDED). The JSON-RPC layer auto-subscribes and pushes
callEvent notifications alongside receive notifications.

Changes:
- Manager.java: Add CallEventListener interface and methods
- ManagerImpl.java: Implement add/removeCallEventListener with cleanup
- DbusManagerImpl.java: Add stub implementation (not supported over DBus)
- JsonCallEvent.java: JSON notification record for call events
- SignalJsonRpcDispatcherHandler.java: Auto-subscribe call event listeners

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-05 16:16:07 -08:00
Shaheen Gandhi
fa010d03cf Implement call signaling state machine and message routing
Add CallSignalingHelper for x25519 key generation and HKDF-based SRTP
key derivation. Add CallManager for tracking active calls, spawning
call tunnel subprocesses, and handling call lifecycle (offer, answer,
ICE candidates, hangup, busy). Wire call message routing in
IncomingMessageHandler and implement Manager call methods in ManagerImpl.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 16:16:07 -08:00
Shaheen Gandhi
0ea4838e01 Add voice call API types, protobuf definitions, and build dependencies
Define call method interfaces in Manager, create API records (CallInfo,
CallOffer, TurnServer), and hand-coded protobuf parsers for RingRTC
signaling messages (ConnectionParametersV4, RtpDataMessage).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:34:53 -08:00
AsamK
2885ffeee8 Prepare next release 2026-03-03 17:50:00 +01:00
moppman
6071291f16
Expose a chat's isArchived status in JSON output (#1957)
Closes #1955
2026-03-03 12:46:34 +01:00
Zachary Johnson
37b8a4a996 enforce poll choices are between 2 and 10 2026-03-03 08:37:10 +01:00
AsamK
af56a28b94 Fix client folder name 2026-03-01 10:30:02 +01:00
AsamK
dfc7e3b495 Bump version to 0.14.0 2026-03-01 10:14:45 +01:00
AsamK
e9114ae8fc Add signal-cli-client to release and create container 2026-03-01 10:14:45 +01:00
AsamK
f77a74d93f Fix optional name parameter in client link command 2026-03-01 10:14:45 +01:00
AsamK
5cda87ee0e Fix native access warning in native build 2026-03-01 10:14:45 +01:00
AsamK
7384407823 Update man page 2026-03-01 09:50:22 +01:00
AsamK
7fa56a37fd Update CI actions 2026-03-01 09:29:19 +01:00
AsamK
8fcd953ece Always download long text attachments and use them as message body
Fixes #1901
2026-03-01 09:29:19 +01:00
AsamK
775236efc3 Update tests 2026-02-28 14:05:55 +01:00
AsamK
4b8dec26a9 Downgrade jackson library 2026-02-28 13:45:56 +01:00
AsamK
d94e05c38c Update test file 2026-02-28 13:45:56 +01:00
AsamK
1bbf98fac0 Update dependencies 2026-02-28 13:45:30 +01:00
AsamK
c70515035f Add missing flags to jsonrpc client 2026-02-28 13:45:30 +01:00
AsamK
a9d235b7f1 Add new commands to jsonrpc client 2026-02-28 13:45:30 +01:00
AsamK
92ded3fdf2 Fix incorrect cli definition 2026-02-28 13:45:30 +01:00
AsamK
aa1ed9e233 Add support for sending adminDelete messages 2026-02-28 11:59:31 +01:00
AsamK
3b6c199b1d Add pinMessage and unpinMessage commands
Closes #1923
2026-02-28 11:50:44 +01:00
AsamK
6d22ceef24 Support receiving admin delete messages 2026-02-28 11:30:33 +01:00
AsamK
54ff59737e Update libsignal-service-java
Fixes #1937
2026-02-28 11:14:53 +01:00
58 changed files with 1832 additions and 8161 deletions

View File

@ -19,14 +19,14 @@ jobs:
java: [ '25' ]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Set up JDK
uses: actions/setup-java@v3
uses: actions/setup-java@v5
with:
distribution: 'zulu'
java-version: ${{ matrix.java }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
uses: gradle/actions/setup-gradle@v5
with:
dependency-graph: generate-and-submit
- name: Install asciidoc
@ -45,7 +45,7 @@ jobs:
- name: Compress archive
run: gzip -n -9 build/distributions/signal-cli-*.tar
- name: Archive production artifacts
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: signal-cli-archive-${{ matrix.java }}
path: build/distributions/signal-cli-*.tar.gz
@ -55,7 +55,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: graalvm/setup-graalvm@v1
with:
distribution: 'graalvm'
@ -65,7 +65,7 @@ jobs:
- name: Build with Gradle
run: ./gradlew --no-daemon nativeCompile
- name: Archive production artifacts
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: signal-cli-native
path: build/native/nativeCompile/signal-cli
@ -82,13 +82,13 @@ jobs:
run:
working-directory: ./client
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Install rust
run: rustup default stable
- name: Build client
run: cargo build --release --verbose
- name: Archive production artifacts
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: signal-cli-client-${{ matrix.os }}
path: |

View File

@ -21,13 +21,13 @@ jobs:
steps:
- name: Setup Java JDK
uses: actions/setup-java@v3
uses: actions/setup-java@v5
with:
distribution: 'zulu'
java-version: 25
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
# We must fetch at least the immediate parents so that if this is
# a pull request then we can checkout the head.
@ -35,7 +35,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
uses: github/codeql-action/init@v4
# Override language selection by uncommenting this and choosing your languages
# with:
# languages: go, javascript, csharp, python, cpp, java
@ -43,7 +43,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v3
uses: github/codeql-action/autobuild@v4
# Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@ -57,4 +57,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
uses: github/codeql-action/analyze@v4

View File

@ -35,7 +35,7 @@ jobs:
steps:
- name: Download signal-cli build from CI workflow
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
- name: Get signal-cli version
id: cli_ver
@ -79,6 +79,14 @@ jobs:
tar -czf signal-cli-${SIGNAL_CLI_VER}-Linux-native.tar.gz -C signal-cli-native signal-cli
rm -rf signal-cli-native/
- name: Compress client app
env:
SIGNAL_CLI_VER: ${{ steps.cli_ver.outputs.version }}
run: |
chmod +x signal-cli-client-ubuntu/signal-cli-client
tar -czf signal-cli-${SIGNAL_CLI_VER}-Linux-client.tar.gz -C signal-cli-client-ubuntu signal-cli-client
rm -rf signal-cli-client-ubuntu/
# - name: Replace Windows lib
# env:
# SIGNAL_CLI_VER: ${{ steps.cli_ver.outputs.version }}
@ -138,6 +146,16 @@ jobs:
asset_name: signal-cli-${{ steps.cli_ver.outputs.version }}-Linux-native.tar.gz
asset_content_type: application/x-compressed-tar # .tar.gz
- name: Upload Linux client archive
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: signal-cli-${{ steps.cli_ver.outputs.version }}-Linux-client.tar.gz
asset_name: signal-cli-${{ steps.cli_ver.outputs.version }}-Linux-client.tar.gz
asset_content_type: application/x-compressed-tar # .tar.gz
# - name: Upload windows archive
# uses: actions/upload-release-asset@v1
# env:
@ -166,9 +184,9 @@ jobs:
packages: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Download signal-cli build from CI workflow
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
- name: Get signal-cli version
id: cli_ver
@ -216,9 +234,9 @@ jobs:
packages: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Download signal-cli build from CI workflow
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
- name: Get signal-cli version
id: cli_ver
@ -255,3 +273,51 @@ jobs:
- name: Echo outputs
run: |
echo "${{ toJSON(steps.push.outputs) }}"
build-container-client:
needs: ci_wf
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v6
- name: Download signal-cli build from CI workflow
uses: actions/download-artifact@v8
- name: Get signal-cli version
id: cli_ver
run: |
ver="${GITHUB_REF_NAME#v}"
echo "version=${ver}" >> $GITHUB_OUTPUT
- name: Move archive file
run: |
mkdir -p client/target/release/
chmod +x ./signal-cli-client-ubuntu/signal-cli-client
mv ./signal-cli-client-ubuntu/signal-cli-client client/target/release/
- name: Build Image
id: build_image
uses: redhat-actions/buildah-build@v2
with:
image: ${{ env.IMAGE_NAME }}
tags: latest-client ${{ github.sha }}-client ${{ steps.cli_ver.outputs.version }}-client
containerfiles:
./client.Containerfile
oci: true
- name: Push To GHCR
uses: redhat-actions/push-to-registry@v2
id: push
with:
image: ${{ steps.build_image.outputs.image }}
tags: ${{ steps.build_image.outputs.tags }}
registry: ${{ env.IMAGE_REGISTRY }}
username: ${{ env.REGISTRY_USER }}
password: ${{ env.REGISTRY_PASSWORD }}
- name: Echo outputs
run: |
echo "${{ toJSON(steps.push.outputs) }}"

3
.gitmodules vendored
View File

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

View File

@ -2,8 +2,12 @@
## [Unreleased]
## [0.14.0] - 2026-03-01
**Attention**: Now requires Java 25
Requires libsignal-client version 0.87.4.
### Breaking changes
- Remove isRegistered method without parameters from Signal dbus interface, which always returned `true`
@ -13,8 +17,22 @@
### Added
- The `link` command now prints a QR code in the terminal. (Thanks @karel1980)
- Add --ignore-avatars flag to prevent downloading avatars
- Add --ignore-stickers flag to prevent downloading sticker packs
- Add `--no-urgent` flag to `send` command to send messages that don't trigger a push notification. (Thanks @kaikozlov)
- Add `sendPinMessage`/`sendUnpinMessage` commands for pinning messages
### Improved
- Improved behavior for unregistered contacts
- Profiles are refreshed when using the listContacts command
- For long text messages the text attachment is used instead of the truncated body
### Fixed
- Adapted to new binary aci/pni formats in the Signal protocol
- Group invites should now work when the user was invited via phone number (PNI only)
## [0.13.24] - 2026-02-05

View File

@ -8,7 +8,7 @@ plugins {
allprojects {
group = "org.asamk"
version = "0.14.0-SNAPSHOT"
version = "0.14.1-SNAPSHOT"
}
java {
@ -33,6 +33,7 @@ graalvmNative {
buildArgs.add("-Dfile.encoding=UTF-8")
buildArgs.add("-J-Dfile.encoding=UTF-8")
buildArgs.add("-march=compatibility")
buildArgs.add("--enable-native-access=ALL-UNNAMED")
resources.autodetect()
if (System.getenv("GRAALVM_HOME") == null) {
toolchainDetection.set(true)

11
client.Containerfile Normal file
View File

@ -0,0 +1,11 @@
FROM docker.io/debian:testing-slim
LABEL org.opencontainers.image.source=https://github.com/AsamK/signal-cli
LABEL org.opencontainers.image.description="signal-cli provides an unofficial commandline, dbus and JSON-RPC interface for the Signal messenger."
LABEL org.opencontainers.image.licenses=GPL-3.0-only
RUN useradd signal-cli --system
ADD client/target/release/signal-cli-client /usr/bin/signal-cli-client
USER signal-cli
ENTRYPOINT ["/usr/bin/signal-cli-client"]

572
client/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -10,6 +10,9 @@ pub struct Cli {
#[arg(short = 'a', long)]
pub account: Option<String>,
#[arg(long)]
pub output: Option<String>,
/// TCP host and port of signal-cli daemon
#[arg(long, conflicts_with = "json_rpc_http")]
pub json_rpc_tcp: Option<Option<SocketAddr>>,
@ -94,7 +97,7 @@ pub enum CliCommands {
},
Link {
#[arg(short = 'n', long)]
name: String,
name: Option<String>,
},
ListAccounts,
ListContacts {
@ -105,6 +108,10 @@ pub enum CliCommands {
blocked: Option<bool>,
#[arg(long)]
name: Option<String>,
#[arg(long)]
detailed: bool,
#[arg(long)]
internal: bool,
},
ListDevices,
ListGroups {
@ -135,6 +142,8 @@ pub enum CliCommands {
voice: bool,
#[arg(long)]
captcha: Option<String>,
#[arg(long)]
reregister: bool,
},
RemoveContact {
recipient: String,
@ -167,15 +176,24 @@ pub enum CliCommands {
#[arg(short = 'g', long)]
group_id: Vec<String>,
#[arg(short = 'u', long = "username")]
username: Vec<String>,
#[arg(long)]
note_to_self: bool,
#[arg(long)]
notify_self: bool,
#[arg(short = 'e', long)]
end_session: bool,
#[arg(short = 'm', long)]
message: Option<String>,
#[arg(long)]
message_from_stdin: bool,
#[arg(short = 'a', long)]
attachment: Vec<String>,
@ -229,6 +247,25 @@ pub enum CliCommands {
#[arg(long)]
edit_timestamp: Option<u64>,
#[arg(long = "no-urgent")]
no_urgent: bool,
},
SendAdminDelete {
#[arg(short = 'g', long = "group-id")]
group_id: Vec<String>,
#[arg(short = 'a', long = "target-author")]
target_author: String,
#[arg(short = 't', long = "target-timestamp")]
target_timestamp: u64,
#[arg(long)]
story: bool,
#[arg(long)]
notify_self: bool,
},
SendContacts,
SendPaymentNotification {
@ -240,15 +277,117 @@ pub enum CliCommands {
#[arg(long)]
note: String,
},
SendPinMessage {
recipient: Vec<String>,
#[arg(short = 'g', long = "group-id")]
group_id: Vec<String>,
#[arg(short = 'u', long = "username")]
username: Vec<String>,
#[arg(short = 'a', long = "target-author")]
target_author: String,
#[arg(short = 't', long = "target-timestamp")]
target_timestamp: u64,
#[arg(short = 'd', long = "pin-duration")]
pin_duration: Option<i32>,
#[arg(long = "note-to-self")]
note_to_self: bool,
#[arg(long)]
notify_self: bool,
#[arg(long)]
story: bool,
},
SendPollCreate {
recipient: Vec<String>,
#[arg(short = 'g', long = "group-id")]
group_id: Vec<String>,
#[arg(short = 'u', long = "username")]
username: Vec<String>,
#[arg(short = 'q', long = "question")]
question: String,
#[arg(short = 'o', long = "option")]
option: Vec<String>,
#[arg(long = "no-multi")]
no_multi: bool,
#[arg(long = "note-to-self")]
note_to_self: bool,
#[arg(long)]
notify_self: bool,
},
SendPollTerminate {
recipient: Vec<String>,
#[arg(short = 'g', long = "group-id")]
group_id: Vec<String>,
#[arg(short = 'u', long = "username")]
username: Vec<String>,
#[arg(long = "poll-timestamp")]
poll_timestamp: u64,
#[arg(long = "note-to-self")]
note_to_self: bool,
#[arg(long)]
notify_self: bool,
},
SendPollVote {
recipient: Vec<String>,
#[arg(short = 'g', long = "group-id")]
group_id: Vec<String>,
#[arg(short = 'u', long = "username")]
username: Vec<String>,
#[arg(long = "poll-author")]
poll_author: Option<String>,
#[arg(long = "poll-timestamp")]
poll_timestamp: u64,
#[arg(short = 'o', long = "option")]
option: Vec<i32>,
#[arg(long = "vote-count")]
vote_count: i32,
#[arg(long = "note-to-self")]
note_to_self: bool,
#[arg(long)]
notify_self: bool,
},
SendReaction {
recipient: Vec<String>,
#[arg(short = 'g', long = "group-id")]
group_id: Vec<String>,
#[arg(short = 'u', long = "username")]
username: Vec<String>,
#[arg(long = "note-to-self")]
note_to_self: bool,
#[arg(long)]
notify_self: bool,
#[arg(short = 'e', long)]
emoji: String,
@ -267,6 +406,9 @@ pub enum CliCommands {
SendReceipt {
recipient: String,
#[arg(short = 'u', long = "username")]
username: Vec<String>,
#[arg(short = 't', long = "target-timestamp")]
target_timestamp: Vec<u64>,
@ -283,12 +425,37 @@ pub enum CliCommands {
#[arg(short = 's', long)]
stop: bool,
},
SendUnpinMessage {
recipient: Vec<String>,
#[arg(short = 'g', long = "group-id")]
group_id: Vec<String>,
#[arg(short = 'u', long = "username")]
username: Vec<String>,
#[arg(short = 'a', long = "target-author")]
target_author: String,
#[arg(short = 't', long = "target-timestamp")]
target_timestamp: u64,
#[arg(long = "note-to-self")]
note_to_self: bool,
#[arg(long)]
notify_self: bool,
#[arg(long)]
story: bool,
},
SendMessageRequestResponse {
recipient: Vec<String>,
#[arg(short = 'g', long = "group-id")]
group_id: Vec<String>,
#[arg(long)]
r#type: MessageRequestResponseType,
},
SetPin {
@ -334,6 +501,10 @@ pub enum CliCommands {
discoverable_by_number: Option<bool>,
#[arg(long = "number-sharing")]
number_sharing: Option<bool>,
#[arg(short = 'u', long = "username")]
username: Option<String>,
#[arg(long = "delete-username")]
delete_username: bool,
},
UpdateConfiguration {
#[arg(long = "read-receipts")]
@ -356,6 +527,28 @@ pub enum CliCommands {
#[arg(short = 'n', long)]
name: Option<String>,
#[arg(long = "given-name")]
given_name: Option<String>,
#[arg(long = "family-name")]
family_name: Option<String>,
#[arg(long = "nick-given-name")]
nick_given_name: Option<String>,
#[arg(long = "nick-family-name")]
nick_family_name: Option<String>,
#[arg(long)]
note: Option<String>,
},
UpdateDevice {
#[arg(short = 'd', long = "device-id")]
device_id: u32,
#[arg(short = 'n', long = "device-name")]
device_name: String,
},
UpdateGroup {
#[arg(short = 'g', long = "group-id")]

View File

@ -90,7 +90,7 @@ pub trait Rpc {
fn finish_link(
&self,
#[allow(non_snake_case)] deviceLinkUri: String,
#[allow(non_snake_case)] deviceName: String,
#[allow(non_snake_case)] deviceName: Option<String>,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "listAccounts", param_kind = map)]
@ -104,6 +104,8 @@ pub trait Rpc {
#[allow(non_snake_case)] allRecipients: bool,
blocked: Option<bool>,
name: Option<String>,
detailed: bool,
internal: bool,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "listDevices", param_kind = map)]
@ -141,6 +143,7 @@ pub trait Rpc {
account: Option<String>,
voice: bool,
captcha: Option<String>,
reregister: bool,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "removeContact", param_kind = map)]
@ -179,32 +182,116 @@ pub trait Rpc {
account: Option<String>,
recipients: Vec<String>,
groupIds: Vec<String>,
noteToSelf: bool,
endSession: bool,
usernames: Vec<String>,
#[allow(non_snake_case)] notifySelf: bool,
#[allow(non_snake_case)] noteToSelf: bool,
#[allow(non_snake_case)] endSession: bool,
message: String,
attachments: Vec<String>,
viewOnce: bool,
#[allow(non_snake_case)] viewOnce: bool,
mentions: Vec<String>,
textStyle: Vec<String>,
quoteTimestamp: Option<u64>,
quoteAuthor: Option<String>,
quoteMessage: Option<String>,
quoteMention: Vec<String>,
quoteTextStyle: Vec<String>,
quoteAttachment: Vec<String>,
previewUrl: Option<String>,
previewTitle: Option<String>,
previewDescription: Option<String>,
previewImage: Option<String>,
#[allow(non_snake_case)] textStyle: Vec<String>,
#[allow(non_snake_case)] quoteTimestamp: Option<u64>,
#[allow(non_snake_case)] quoteAuthor: Option<String>,
#[allow(non_snake_case)] quoteMessage: Option<String>,
#[allow(non_snake_case)] quoteMention: Vec<String>,
#[allow(non_snake_case)] quoteTextStyle: Vec<String>,
#[allow(non_snake_case)] quoteAttachment: Vec<String>,
#[allow(non_snake_case)] previewUrl: Option<String>,
#[allow(non_snake_case)] previewTitle: Option<String>,
#[allow(non_snake_case)] previewDescription: Option<String>,
#[allow(non_snake_case)] previewImage: Option<String>,
sticker: Option<String>,
storyTimestamp: Option<u64>,
storyAuthor: Option<String>,
editTimestamp: Option<u64>,
#[allow(non_snake_case)] storyTimestamp: Option<u64>,
#[allow(non_snake_case)] storyAuthor: Option<String>,
#[allow(non_snake_case)] editTimestamp: Option<u64>,
#[allow(non_snake_case)] noUrgent: bool,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "sendContacts", param_kind = map)]
fn send_contacts(&self, account: Option<String>) -> Result<Value, ErrorObjectOwned>;
#[method(name = "sendAdminDelete", param_kind = map)]
fn send_admin_delete(
&self,
account: Option<String>,
#[allow(non_snake_case)] groupIds: Vec<String>,
#[allow(non_snake_case)] targetAuthor: String,
#[allow(non_snake_case)] targetTimestamp: u64,
story: bool,
#[allow(non_snake_case)] notifySelf: bool,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "sendPinMessage", param_kind = map)]
fn send_pin_message(
&self,
account: Option<String>,
recipients: Vec<String>,
#[allow(non_snake_case)] groupIds: Vec<String>,
usernames: Vec<String>,
#[allow(non_snake_case)] targetAuthor: String,
#[allow(non_snake_case)] targetTimestamp: u64,
#[allow(non_snake_case)] pinDuration: Option<i32>,
#[allow(non_snake_case)] noteToSelf: bool,
#[allow(non_snake_case)] notifySelf: bool,
story: bool,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "sendPollCreate", param_kind = map)]
fn send_poll_create(
&self,
account: Option<String>,
recipients: Vec<String>,
#[allow(non_snake_case)] groupIds: Vec<String>,
usernames: Vec<String>,
question: String,
option: Vec<String>,
#[allow(non_snake_case)] noMulti: bool,
#[allow(non_snake_case)] noteToSelf: bool,
#[allow(non_snake_case)] notifySelf: bool,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "sendPollVote", param_kind = map)]
fn send_poll_vote(
&self,
account: Option<String>,
recipients: Vec<String>,
#[allow(non_snake_case)] groupIds: Vec<String>,
usernames: Vec<String>,
#[allow(non_snake_case)] pollAuthor: Option<String>,
#[allow(non_snake_case)] pollTimestamp: u64,
option: Vec<i32>,
#[allow(non_snake_case)] voteCount: i32,
#[allow(non_snake_case)] noteToSelf: bool,
#[allow(non_snake_case)] notifySelf: bool,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "sendPollTerminate", param_kind = map)]
fn send_poll_terminate(
&self,
account: Option<String>,
recipients: Vec<String>,
#[allow(non_snake_case)] groupIds: Vec<String>,
usernames: Vec<String>,
#[allow(non_snake_case)] pollTimestamp: u64,
#[allow(non_snake_case)] noteToSelf: bool,
#[allow(non_snake_case)] notifySelf: bool,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "sendUnpinMessage", param_kind = map)]
fn send_unpin_message(
&self,
account: Option<String>,
recipients: Vec<String>,
#[allow(non_snake_case)] groupIds: Vec<String>,
usernames: Vec<String>,
#[allow(non_snake_case)] targetAuthor: String,
#[allow(non_snake_case)] targetTimestamp: u64,
#[allow(non_snake_case)] noteToSelf: bool,
#[allow(non_snake_case)] notifySelf: bool,
story: bool,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "sendPaymentNotification", param_kind = map)]
fn send_payment_notification(
&self,
@ -220,7 +307,9 @@ pub trait Rpc {
account: Option<String>,
recipients: Vec<String>,
#[allow(non_snake_case)] groupIds: Vec<String>,
usernames: Vec<String>,
#[allow(non_snake_case)] noteToSelf: bool,
#[allow(non_snake_case)] notifySelf: bool,
emoji: String,
#[allow(non_snake_case)] targetAuthor: String,
#[allow(non_snake_case)] targetTimestamp: u64,
@ -233,6 +322,7 @@ pub trait Rpc {
&self,
account: Option<String>,
recipient: String,
usernames: Vec<String>,
#[allow(non_snake_case)] targetTimestamps: Vec<u64>,
r#type: String,
) -> Result<Value, ErrorObjectOwned>;
@ -314,6 +404,8 @@ pub trait Rpc {
unrestrictedUnidentifiedSender: Option<bool>,
discoverableByNumber: Option<bool>,
numberSharing: Option<bool>,
username: Option<String>,
deleteUsername: bool,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "updateConfiguration", param_kind = map)]
@ -333,6 +425,19 @@ pub trait Rpc {
recipient: String,
name: Option<String>,
expiration: Option<u32>,
#[allow(non_snake_case)] givenName: Option<String>,
#[allow(non_snake_case)] familyName: Option<String>,
#[allow(non_snake_case)] nickGivenName: Option<String>,
#[allow(non_snake_case)] nickFamilyName: Option<String>,
note: Option<String>,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "updateDevice", param_kind = map)]
fn update_device(
&self,
account: Option<String>,
#[allow(non_snake_case)] deviceId: u32,
#[allow(non_snake_case)] deviceName: String,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "updateGroup", param_kind = map)]

View File

@ -84,9 +84,19 @@ async fn handle_command(
all_recipients,
blocked,
name,
detailed,
internal,
} => {
client
.list_contacts(cli.account, recipient, all_recipients, blocked, name)
.list_contacts(
cli.account,
recipient,
all_recipients,
blocked,
name,
detailed,
internal,
)
.await
}
CliCommands::ListDevices => client.list_devices(cli.account).await,
@ -105,8 +115,14 @@ async fn handle_command(
.quit_group(cli.account, group_id, delete, admin)
.await
}
CliCommands::Register { voice, captcha } => {
client.register(cli.account, voice, captcha).await
CliCommands::Register {
voice,
captcha,
reregister,
} => {
client
.register(cli.account, voice, captcha, reregister)
.await
}
CliCommands::RemoveContact {
recipient,
@ -140,9 +156,12 @@ async fn handle_command(
CliCommands::Send {
recipient,
group_id,
username,
notify_self,
note_to_self,
end_session,
message,
message_from_stdin,
attachment,
view_once,
mention,
@ -161,15 +180,22 @@ async fn handle_command(
story_timestamp,
story_author,
edit_timestamp,
no_urgent,
} => {
client
.send(
cli.account,
recipient,
group_id,
username,
notify_self,
note_to_self,
end_session,
message.unwrap_or_default(),
if message_from_stdin {
std::io::read_to_string(std::io::stdin()).unwrap()
} else {
message.unwrap_or_default()
},
attachment,
view_once,
mention,
@ -188,10 +214,29 @@ async fn handle_command(
story_timestamp,
story_author,
edit_timestamp,
no_urgent,
)
.await
}
CliCommands::SendContacts => client.send_contacts(cli.account).await,
CliCommands::SendAdminDelete {
group_id,
target_author,
target_timestamp,
story,
notify_self,
} => {
client
.send_admin_delete(
cli.account,
group_id,
target_author,
target_timestamp,
story,
notify_self,
)
.await
}
CliCommands::SendPaymentNotification {
recipient,
receipt,
@ -201,10 +246,108 @@ async fn handle_command(
.send_payment_notification(cli.account, recipient, receipt, note)
.await
}
CliCommands::SendPinMessage {
recipient,
group_id,
username,
target_author,
target_timestamp,
pin_duration,
note_to_self,
notify_self,
story,
} => {
client
.send_pin_message(
cli.account,
recipient,
group_id,
username,
target_author,
target_timestamp,
pin_duration,
note_to_self,
notify_self,
story,
)
.await
}
CliCommands::SendPollCreate {
recipient,
group_id,
username,
question,
option,
no_multi,
note_to_self,
notify_self,
} => {
client
.send_poll_create(
cli.account,
recipient,
group_id,
username,
question,
option,
no_multi,
note_to_self,
notify_self,
)
.await
}
CliCommands::SendPollTerminate {
recipient,
group_id,
username,
poll_timestamp,
note_to_self,
notify_self,
} => {
client
.send_poll_terminate(
cli.account,
recipient,
group_id,
username,
poll_timestamp,
note_to_self,
notify_self,
)
.await
}
CliCommands::SendPollVote {
recipient,
group_id,
username,
poll_author,
poll_timestamp,
option,
vote_count,
note_to_self,
notify_self,
} => {
client
.send_poll_vote(
cli.account,
recipient,
group_id,
username,
poll_author,
poll_timestamp,
option,
vote_count,
note_to_self,
notify_self,
)
.await
}
CliCommands::SendReaction {
recipient,
group_id,
username,
note_to_self,
notify_self,
emoji,
target_author,
target_timestamp,
@ -216,7 +359,9 @@ async fn handle_command(
cli.account,
recipient,
group_id,
username,
note_to_self,
notify_self,
emoji,
target_author,
target_timestamp,
@ -227,6 +372,7 @@ async fn handle_command(
}
CliCommands::SendReceipt {
recipient,
username,
target_timestamp,
r#type,
} => {
@ -234,6 +380,7 @@ async fn handle_command(
.send_receipt(
cli.account,
recipient,
username,
target_timestamp,
match r#type {
cli::ReceiptType::Read => "read".to_owned(),
@ -252,6 +399,30 @@ async fn handle_command(
.send_typing(cli.account, recipient, group_id, stop)
.await
}
CliCommands::SendUnpinMessage {
recipient,
group_id,
username,
target_author,
target_timestamp,
note_to_self,
notify_self,
story,
} => {
client
.send_unpin_message(
cli.account,
recipient,
group_id,
username,
target_author,
target_timestamp,
note_to_self,
notify_self,
story,
)
.await
}
CliCommands::SetPin { pin } => client.set_pin(cli.account, pin).await,
CliCommands::SubmitRateLimitChallenge { challenge, captcha } => {
client
@ -284,6 +455,8 @@ async fn handle_command(
unrestricted_unidentified_sender,
discoverable_by_number,
number_sharing,
username,
delete_username,
} => {
client
.update_account(
@ -292,6 +465,8 @@ async fn handle_command(
unrestricted_unidentified_sender,
discoverable_by_number,
number_sharing,
username,
delete_username,
)
.await
}
@ -315,9 +490,32 @@ async fn handle_command(
recipient,
expiration,
name,
given_name,
family_name,
nick_given_name,
nick_family_name,
note,
} => {
client
.update_contact(cli.account, recipient, name, expiration)
.update_contact(
cli.account,
recipient,
name,
expiration,
given_name,
family_name,
nick_given_name,
nick_family_name,
note,
)
.await
}
CliCommands::UpdateDevice {
device_id,
device_name,
} => {
client
.update_device(cli.account, device_id, device_name)
.await
}
CliCommands::UpdateGroup {

View File

@ -45,6 +45,9 @@
<content_attribute id="social-chat">intense</content_attribute>
</content_rating>
<releases>
<release version="0.14.0" date="2026-03-01">
<url type="details">https://github.com/AsamK/signal-cli/releases/tag/v0.14.0</url>
</release>
<release version="0.13.24" date="2026-02-05">
<url type="details">https://github.com/AsamK/signal-cli/releases/tag/v0.13.24</url>
</release>

View File

@ -1,123 +1,53 @@
# Media Tunnel Architecture
# Voice Call Support
## Overview
signal-cli uses a Rust subprocess called `signal-call-tunnel` to handle voice
calls. The tunnel wraps RingRTC (Signal's WebRTC layer) and exposes a **control
socket** for call signaling. Audio flows through **virtual audio devices**
created by ringrtc's `VirtualAudioDevicePair`, which the tunnel selects via
cubeb. External processes connect using platform audio APIs (PulseAudio on
Linux, CoreAudio on macOS).
signal-cli supports voice calls by spawning a subprocess called
`signal-call-tunnel` for each call. The tunnel handles WebRTC negotiation and
audio transport. signal-cli communicates with it over a Unix domain socket using
newline-delimited JSON messages, relaying signaling between the tunnel and the
Signal protocol.
```
signal-cli (Java)
|
spawn per call, config via stdin
|
v
signal-call-tunnel (Rust)
/ \
ctrl.sock VirtualAudioDevicePair
(JSON control) / \
| [virtual input] [virtual output]
signal-cli connects (signal_input_XXX) (signal_output_XXX)
(signaling relay) | |
client writes audio client reads audio
(PulseAudio/CoreAudio) (PulseAudio/CoreAudio)
signal-cli signal-call-tunnel
| |
|-- spawn (config on stdin) --------->|
| |
|<======= ctrl.sock (JSON) ==========>|
| signaling relay | WebRTC
| | audio I/O
| |
```
Each call gets its own tunnel process and control socket inside a temporary
directory (`/tmp/sc-<random>/`). Virtual audio devices are created per call on
Linux (PulseAudio modules) or use pre-installed BlackHole drivers on macOS.
When the call ends, everything is cleaned up.
directory (`/tmp/sc-<random>/`). When the call ends, signal-cli kills the
process and deletes the directory.
Audio device names (`inputDeviceName`, `outputDeviceName`) are opaque strings
returned by the tunnel in its `ready` message. signal-cli passes them through
to JSON-RPC clients, which use them to connect audio via platform APIs.
---
## Components
## Spawning the Tunnel
### signal-call-tunnel (Rust binary)
For each call, signal-cli:
The subprocess that runs one call. Source: `signal-call-tunnel/src/`.
1. Creates a temporary directory `/tmp/sc-<random>/` (mode `0700`)
2. Generates a random 32-byte auth token
3. Spawns `signal-call-tunnel` with config JSON on stdin
4. Connects to the control socket (retries up to 50x at 200 ms intervals)
5. Authenticates with the auth token
| File | Role |
|------|------|
| `main.rs` | Entry point, RingRTC initialization, virtual audio setup, event loop |
| `config.rs` | Deserializes startup config from stdin |
| `control.rs` | Control socket server, JSON message parsing/serialization |
| `platform.rs` | RingRTC trait impls (SignalingSender, CallStateHandler) |
The `signal-call-tunnel` binary is located by searching (in order):
Audio flows through cubeb with virtual audio devices selected by name. The
tunnel creates a `VirtualAudioDevicePair` from ringrtc's `virtual_audio` module,
waits for cubeb to enumerate the devices, then selects them as the recording
and playout devices.
1. `SIGNAL_CALL_TUNNEL_BIN` environment variable
2. `<signal-cli install dir>/bin/signal-call-tunnel`
3. `signal-call-tunnel` on `PATH`
### CallManager.java (Java parent)
### Config JSON
`lib/src/main/java/org/asamk/signal/manager/helper/CallManager.java`
Manages the call lifecycle from the Java side:
1. Creates a temp directory and generates a random auth token
2. Spawns `signal-call-tunnel` with config JSON on stdin
3. Connects to the control socket (retries up to 50x at 200 ms intervals),
authenticates, and relays signaling between the tunnel and the Signal protocol
4. Parses `inputDeviceName` and `outputDeviceName` from the tunnel's `ready`
message and includes them in `CallInfo`
5. Translates tunnel state changes into `CallInfo.State` values and fires
`callEvent` JSON-RPC notifications to connected clients
6. Defers the `accept` message for incoming calls until the tunnel reports
`Ringing` state (sending earlier causes RingRTC to drop it)
7. Schedules a 60-second ring timeout for both incoming and outgoing calls
8. On hangup: sends hangup message, kills the process, deletes the control socket
### Audio client (external process)
Any process that sends/receives audio via the virtual audio devices using
platform audio APIs.
1. Receives `inputDeviceName` and `outputDeviceName` from a `startCall`/
`acceptCall` JSON-RPC response or a `callEvent` notification
2. Waits for the call to reach `CONNECTED` state
3. **To send audio** (mic input to WebRTC): write to the virtual input device
- Linux: `paplay --device=sink_for_<inputDeviceName> audio.wav`
- macOS: `sox audio.wav -t coreaudio <inputDeviceName>`
4. **To receive audio** (WebRTC playout): read from the virtual output device
- Linux: `parecord --device=<outputDeviceName>.monitor output.wav`
- macOS: `sox -t coreaudio <outputDeviceName> output.wav`
5. Disconnects when the call ends
---
## Startup Sequence
```
signal-cli signal-call-tunnel
| |
|-- spawn process ------------------> |
| (config JSON on stdin) |
| | parse config
| | create VirtualAudioDevicePair
| | init RingRTC CallManager
| | start control channel
| | bind ctrl.sock
| | queue "ready" message
| | init cubeb AudioDeviceModule
| | wait for device enumeration
| | select virtual devices by name
| |
|-- connect to ctrl.sock ------------->|
| (retries: 50x @ 200ms) |
|<-------- ready -----------------------|
| {"type":"ready", |
| "inputDeviceName":"...", |
| "outputDeviceName":"..."} |
|-- auth ------------------------------>|
| {"type":"auth","token":"<b64>"} |
| | constant-time token verify
| |
```
Config JSON written to stdin before the process starts:
Written to the tunnel's stdin before it starts:
```json
{
@ -131,9 +61,20 @@ Config JSON written to stdin before the process starts:
}
```
The `input_device_name` and `output_device_name` fields are optional. If
omitted, the tunnel generates per-call names like `signal_input_<call_id>`.
On macOS, these should match the installed BlackHole driver names.
| Field | Type | Description |
|-------|------|-------------|
| `call_id` | unsigned 64-bit integer | Call identifier (use unsigned representation) |
| `is_outgoing` | boolean | Whether this is an outgoing call |
| `control_socket_path` | string | Path where the tunnel creates its control socket |
| `control_token` | string | Base64-encoded 32-byte auth token |
| `local_device_id` | integer | Signal device ID |
| `input_device_name` | string (optional) | Requested input audio device name |
| `output_device_name` | string (optional) | Requested output audio device name |
If `input_device_name` or `output_device_name` are omitted, the tunnel
chooses default names. On Linux, these are per-call unique names (e.g.,
`signal_input_<call_id>`). On macOS, these are the fixed names `signal_input`
and `signal_output`, which must match the pre-installed BlackHole drivers.
---
@ -143,14 +84,15 @@ Unix SOCK_STREAM at `ctrl.sock`. Newline-delimited JSON messages.
### Authentication
The first message from the parent **must** be an auth message. The token is
The first message from signal-cli **must** be an auth message. The token is
a random 32-byte value generated per call and passed in the startup config.
The tunnel performs constant-time comparison.
```json
{"type":"auth","token":"<base64-encoded token>"}
```
### Parent -> Tunnel
### signal-cli -> Tunnel
| Type | When | Fields |
|------|------|--------|
@ -163,15 +105,15 @@ a random 32-byte value generated per call and passed in the startup config.
| `accept` | User accepts incoming call | *(none)* |
| `hangup` | End the call | *(none)* |
### Tunnel -> Parent
### Tunnel -> signal-cli
| Type | When | Fields |
|------|------|--------|
| `ready` | Control socket bound, virtual devices created | `inputDeviceName`, `outputDeviceName` |
| `sendOffer` | RingRTC generated an offer | `callId`, `opaque`, `callMediaType` |
| `sendAnswer` | RingRTC generated an answer | `callId`, `opaque` |
| `ready` | Control socket bound, audio devices created | `inputDeviceName`, `outputDeviceName` |
| `sendOffer` | Tunnel generated an offer | `callId`, `opaque`, `callMediaType` |
| `sendAnswer` | Tunnel generated an answer | `callId`, `opaque` |
| `sendIce` | ICE candidates gathered | `callId`, `candidates` (array of `{"opaque":"..."}`) |
| `sendHangup` | RingRTC wants to hang up | `callId`, `hangupType` |
| `sendHangup` | Tunnel wants to hang up | `callId`, `hangupType` |
| `sendBusy` | Line is busy | `callId` |
| `stateChange` | Call state transition | `state`, `reason` (optional) |
| `error` | Something went wrong | `message` |
@ -184,59 +126,26 @@ Opaque blobs and identity keys are base64-encoded. ICE servers use the format:
---
## Virtual Audio Devices
## Startup Sequence
The tunnel uses `VirtualAudioDevicePair` from ringrtc's `virtual_audio` module
to create platform-specific virtual audio devices.
### PCM parameters
| Parameter | Value |
|-----------|-------|
| Sample rate | 48,000 Hz |
| Channels | 1 (mono) |
| Sample format | 16-bit signed integer, little-endian |
### Linux (PulseAudio)
Virtual devices are PulseAudio null sinks/sources created automatically per
call and torn down on drop. No setup required.
- **Input device** (client -> WebRTC): write to PulseAudio sink `sink_for_<inputDeviceName>`
- **Output device** (WebRTC -> client): read from PulseAudio monitor `<outputDeviceName>.monitor`
Example:
```bash
# Send audio to WebRTC
paplay --device=sink_for_signal_input_12345 tone.wav
# Record from WebRTC
parecord --device=signal_output_12345.monitor --rate=48000 --channels=1 --format=s16le captured.wav
```
### macOS (BlackHole)
Requires one-time root setup to install BlackHole audio drivers:
```bash
cd third-party/ringrtc
sudo bin/virtual_audio.sh --setup --input-source signal_input --output-sink signal_output
```
This installs drivers in `/Library/Audio/Plug-Ins/HAL/` that persist across
reboots. No root is needed after setup. On macOS, use fixed device names
matching the installed drivers via `input_device_name`/`output_device_name`
in the config.
Example:
```bash
# Send audio to WebRTC
sox tone.wav -t coreaudio signal_input repeat -
# Record from WebRTC
sox -t coreaudio signal_output captured.wav trim 0 5
signal-cli signal-call-tunnel
| |
|-- spawn process ------------------> |
| (config JSON on stdin) |
| | initialize
| | bind ctrl.sock
| |
|-- connect to ctrl.sock -------------->|
| (retries: 50x @ 200ms) |
|<-------- ready -----------------------|
| {"type":"ready", |
| "inputDeviceName":"...", |
| "outputDeviceName":"..."} |
|-- auth ------------------------------>|
| {"type":"auth","token":"<b64>"} |
| | constant-time token verify
| |
```
---
@ -253,22 +162,17 @@ signal-cli signal-call-tunnel Remote Phone
|-- auth ----------------->| |
|-- createOutgoingCall --->| |
|-- proceed (TURN) ------->| |
| | RingRTC creates offer |
| | create offer |
|<-- sendOffer ------------| |
|-- offer via Signal -------------------------------->|
|<-- answer via Signal --------------------------------|
|<-- answer via Signal -------------------------------|
|-- receivedAnswer ------->| (+ identity keys) |
| | x25519 DH + HKDF |
|<-- sendIce --------------| |
|-- ICE via Signal -------------------------------> |
|<-- ICE via Signal -------------------------------- |
|-- receivedIce ---------->| |
| | ICE connects, SRTP up |
| | ICE connects |
|<-- stateChange:Connected | |
| | |
| audio client connects to virtual audio devices |
| |<-- audio --- client |
| |--- audio --> client |
```
### Incoming call
@ -282,8 +186,7 @@ signal-cli signal-call-tunnel Remote Phone
|-- auth ----------------->| |
|-- receivedOffer -------->| (+ identity keys) |
|-- proceed (TURN) ------->| |
| | RingRTC processes offer |
| | x25519 DH + HKDF |
| | process offer |
|<-- sendAnswer -----------| |
|-- answer via Signal -------------------------------->|
|<-- sendIce --------------| |
@ -297,12 +200,8 @@ signal-cli signal-call-tunnel Remote Phone
| | |
|<-- stateChange:Ringing --| (tunnel ready to accept)|
|-- accept --------------->| (deferred accept sent) |
| | RingRTC accepts |
| | accept |
|<-- stateChange:Connected | |
| | |
| audio client connects to virtual audio devices |
| |<-- audio --- client |
| |--- audio --> client |
```
### JSON-RPC client perspective
@ -322,8 +221,8 @@ JSON-RPC Client signal-cli daemon
| ... remote answers ... |
|<-- callEvent: CONNECTED -------------|
| |
| connect to virtual audio devices |
| (via PulseAudio/CoreAudio) |
| connect to audio devices |
| (via platform audio APIs) |
| |
|-- hangupCall(callId) --------------->| (or: receive callEvent ENDED)
|<-- callEvent: ENDED -----------------|
@ -345,121 +244,15 @@ JSON-RPC Client signal-cli daemon
|<-- callEvent: CONNECTING ------------|
|<-- callEvent: CONNECTED -------------|
| |
| connect to virtual audio devices |
| (via PulseAudio/CoreAudio) |
| connect to audio devices |
| (via platform audio APIs) |
```
---
## Audio Client Integration Guide
### When to connect
Connect to the virtual audio devices **after** the call reaches `CONNECTED`
state. Device names are returned in the `startCall`/`acceptCall` response and
in `callEvent` notifications.
You can connect earlier (the devices exist from tunnel startup), but no
meaningful audio will flow until ICE completes and the call is connected.
### Sending audio (recording path)
Write audio to the virtual input device using platform APIs. The tunnel's
cubeb recording captures from this device and feeds it to WebRTC.
- **Linux**: `paplay --device=sink_for_<inputDeviceName> audio.wav`
- **macOS**: `sox audio.wav -t coreaudio <inputDeviceName>`
If you have nothing to send (muted), simply don't write. WebRTC's Opus DTX
will detect silence and send minimal comfort noise packets.
### Receiving audio (playout path)
Read audio from the virtual output device using platform APIs. WebRTC receives
and Opus-decodes remote audio, cubeb plays it to the virtual output device,
and you capture it from the monitor/device.
- **Linux**: `parecord --device=<outputDeviceName>.monitor output.wav`
- **macOS**: `sox -t coreaudio <outputDeviceName> output.wav`
---
## Encryption and Key Derivation
The tunnel subprocess handles all encryption. Neither the Java parent nor the
audio client ever sees SRTP keys or encrypted media.
RingRTC uses a custom key derivation scheme (not DTLS-SRTP):
1. Each side generates an ephemeral x25519 keypair
2. Public keys are embedded in the opaque offer/answer blobs
3. x25519 DH produces a shared secret
4. HKDF-SHA256 derives SRTP keys with info string:
`Signal_Calling_20200807_SignallingDH_SRTPKey_KDF || caller_identity || callee_identity`
5. Keys are injected into WebRTC with DTLS disabled
The identity keys are **not** inside the opaque blobs. They are passed
separately via the control protocol (`senderIdentityKey`, `receiverIdentityKey`)
and come from the Signal protocol message envelope.
Identity keys in `senderIdentityKey` and `receiverIdentityKey` must be **raw
32-byte Curve25519 public keys** (without the 0x05 DJB type prefix). Signal
Android strips this prefix via `WebRtcUtil.getPublicKeyBytes()`. If the 33-byte
serialized form is used instead, SRTP key derivation produces different keys on
each side, causing `srtp_err_status_auth_fail`.
---
## Implementation Notes
### Peer ID consistency
The `peerId` field in `createOutgoingCall` and `receivedOffer` must be the actual
remote peer UUID (e.g., `senderAddress.toString()`). RingRTC's
`compare_remotes()` rejects ICE candidates if the peer ID doesn't match across
calls, causing "Ignoring peer-reflexive ICE candidate because the ufrag is
unknown."
### sendHangup semantics
`sendHangup` from the tunnel is a request to send a hangup message via Signal
protocol. It is **not** a local state change -- local state transitions come
exclusively from `stateChange` events. For single-device clients, ignore
`AcceptedOnAnotherDevice`, `DeclinedOnAnotherDevice`, and
`BusyOnAnotherDevice` hangup types in the `hangupType` field -- sending these to
the remote peer causes it to terminate the call prematurely.
### Call ID serialization
Call IDs can exceed `Long.MAX_VALUE` in Java. Use `Long.toUnsignedString()` when
serializing to JSON for the tunnel (which expects `u64`). In the config JSON,
`call_id` should also use unsigned representation.
### Incoming hangup filtering
When receiving hangup messages via Signal protocol, only honor `NORMAL` type
hangups. `ACCEPTED`, `DECLINED`, and `BUSY` types are multi-device coordination
messages and should be ignored by single-device clients.
### JSON-RPC call ID types
JSON-RPC clients may send call IDs as various numeric types (Long, BigInteger,
Integer). Use `Number.longValue()` rather than direct casting when extracting
call IDs from JSON-RPC parameters.
### VirtualAudioDevicePair lifecycle
The `VirtualAudioDevicePair` is kept alive for the duration of the tunnel
process. On drop:
- **Linux**: calls `pactl unload-module` to clean up PulseAudio modules
- **macOS**: logs a warning (can't teardown without root) but BlackHole drivers
persist for the next call, which is the intended behavior
---
## State Machine
Call states as seen by JSON-RPC clients (mapped from RingRTC internal states):
Call states as seen by JSON-RPC clients:
```
startCall()
@ -497,8 +290,74 @@ Reconnection (ICE restart):
ENDED (ICE restart failed)
```
`RECONNECTING` maps from the tunnel's `Connecting` state, which RingRTC
emits during ICE restarts (not during initial connection).
`RECONNECTING` maps from the tunnel's `Connecting` state, which is emitted
during ICE restarts (not during initial connection).
---
## CallManager.java
`lib/src/main/java/org/asamk/signal/manager/helper/CallManager.java`
Manages the call lifecycle from the Java side:
1. Creates a temp directory and generates a random auth token
2. Spawns `signal-call-tunnel` with config JSON on stdin
3. Connects to the control socket (retries up to 50x at 200 ms intervals),
authenticates, and relays signaling between the tunnel and the Signal protocol
4. Parses `inputDeviceName` and `outputDeviceName` from the tunnel's `ready`
message and includes them in `CallInfo`
5. Translates tunnel state changes into `CallInfo.State` values and fires
`callEvent` JSON-RPC notifications to connected clients
6. Defers the `accept` message for incoming calls until the tunnel reports
`Ringing` state (sending earlier causes the tunnel to drop it)
7. Schedules a 60-second ring timeout for both incoming and outgoing calls
8. On hangup: sends hangup message, kills the process, deletes the control socket
---
## Implementation Notes
### Peer ID consistency
The `peerId` field in `createOutgoingCall` and `receivedOffer` must be the actual
remote peer UUID (e.g., `senderAddress.toString()`). The tunnel rejects ICE
candidates if the peer ID doesn't match across calls, causing "Ignoring
peer-reflexive ICE candidate because the ufrag is unknown."
### sendHangup semantics
`sendHangup` from the tunnel is a request to send a hangup message via Signal
protocol. It is **not** a local state change -- local state transitions come
exclusively from `stateChange` events. For single-device clients, ignore
`AcceptedOnAnotherDevice`, `DeclinedOnAnotherDevice`, and
`BusyOnAnotherDevice` hangup types in the `hangupType` field -- sending these to
the remote peer causes it to terminate the call prematurely.
### Call ID serialization
Call IDs can exceed `Long.MAX_VALUE` in Java. Use `Long.toUnsignedString()` when
serializing to JSON for the tunnel (which expects unsigned 64-bit integers). In
the config JSON, `call_id` should also use unsigned representation.
### Incoming hangup filtering
When receiving hangup messages via Signal protocol, only honor `NORMAL` type
hangups. `ACCEPTED`, `DECLINED`, and `BUSY` types are multi-device coordination
messages and should be ignored by single-device clients.
### JSON-RPC call ID types
JSON-RPC clients may send call IDs as various numeric types (Long, BigInteger,
Integer). Use `Number.longValue()` rather than direct casting when extracting
call IDs from JSON-RPC parameters.
### Identity key format
Identity keys in `senderIdentityKey` and `receiverIdentityKey` must be **raw
32-byte Curve25519 public keys** (without the 0x05 DJB type prefix). If the
33-byte serialized form is used instead, SRTP key derivation produces different
keys on each side, causing authentication failures.
---
@ -511,36 +370,3 @@ emits during ICE restarts (not during initial connection).
The control socket is created with mode `0700` on the parent directory. The
directory and its contents are deleted when the call ends.
The `signal-call-tunnel` binary is located by searching (in order):
1. `SIGNAL_CALL_TUNNEL_BIN` environment variable
2. `<signal-cli install dir>/bin/signal-call-tunnel`
3. `signal-call-tunnel` on `PATH`
---
## Building
```bash
# Build signal-cli
./gradlew installDist
# Build the Rust call tunnel
cd signal-call-tunnel && cargo build --release && cd ..
```
The first Rust build downloads a prebuilt WebRTC library (~100 MB) from
Signal's artifact server. Subsequent builds use the cached copy.
### Prerequisites
- **macOS**: Install BlackHole virtual audio drivers (one-time, requires root):
```bash
cd third-party/ringrtc
sudo bin/virtual_audio.sh --setup --input-source signal_input --output-sink signal_output
```
Install `sox` for audio playback/recording in tests: `brew install sox`
- **Linux**: PulseAudio must be running. Virtual audio modules are created
automatically per call.

View File

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

View File

@ -10,9 +10,9 @@ dbusjava = "com.github.hypfvieh:dbus-java-transport-native-unixsocket:5.0.0"
zxing = "com.google.zxing:core:3.5.4"
slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" }
slf4j-jul = { module = "org.slf4j:jul-to-slf4j", version.ref = "slf4j" }
logback = "ch.qos.logback:logback-classic:1.5.29"
logback = "ch.qos.logback:logback-classic:1.5.32"
signalservice = "com.github.turasa:signal-service-java:2.15.3_unofficial_138"
signalservice = "com.github.turasa:signal-service-java:2.15.3_unofficial_140"
sqlite = "org.xerial:sqlite-jdbc:3.51.2.0"
hikari = "com.zaxxer:HikariCP:7.0.2"
junit-jupiter-bom = { module = "org.junit:junit-bom", version.ref = "junit" }

View File

@ -228,6 +228,31 @@ public interface Manager extends Closeable {
final boolean isStory
) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException, UnregisteredRecipientException;
SendMessageResults sendAdminDelete(
RecipientIdentifier.Single targetAuthor,
long targetSentTimestamp,
Set<RecipientIdentifier.Group> recipients,
boolean notifySelf,
boolean isStory
) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException, UnregisteredRecipientException;
SendMessageResults sendPinMessage(
int pinDuration,
RecipientIdentifier.Single targetAuthor,
long targetSentTimestamp,
Set<RecipientIdentifier> recipients,
boolean notifySelf,
boolean isStory
) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException, UnregisteredRecipientException;
SendMessageResults sendUnpinMessage(
RecipientIdentifier.Single targetAuthor,
long targetSentTimestamp,
Set<RecipientIdentifier> recipients,
boolean notifySelf,
boolean isStory
) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException, UnregisteredRecipientException;
SendMessageResults sendPaymentNotificationMessage(
byte[] receipt,
String note,

View File

@ -3,6 +3,7 @@ package org.asamk.signal.manager.api;
import org.asamk.signal.manager.groups.GroupUtils;
import org.asamk.signal.manager.helper.RecipientAddressResolver;
import org.asamk.signal.manager.storage.recipients.RecipientResolver;
import org.asamk.signal.manager.util.MimeUtils;
import org.signal.core.models.ServiceId;
import org.signal.libsignal.metadata.ProtocolException;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
@ -37,6 +38,7 @@ import org.whispersystems.signalservice.api.messages.multidevice.ViewedMessage;
import java.io.File;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
@ -122,15 +124,28 @@ public record MessageEnvelope(
List<Preview> previews,
List<TextStyle> textStyles,
Optional<PinMessage> pinMessage,
Optional<UnpinMessage> unpinMessage
Optional<UnpinMessage> unpinMessage,
Optional<AdminDelete> adminDelete
) {
static Data from(
final SignalServiceDataMessage dataMessage,
Map<String, String> longTexts,
RecipientResolver recipientResolver,
RecipientAddressResolver addressResolver,
final AttachmentFileProvider fileProvider
) {
var body = dataMessage.getBody();
if (dataMessage.getAttachments().isPresent()) {
for (final var attachment : dataMessage.getAttachments().get()) {
if (MimeUtils.LONG_TEXT.equals(attachment.getContentType()) && attachment.isPointer()) {
final var longBody = longTexts.get(attachment.asPointer().getRemoteId().toString());
if (longBody != null) {
body = Optional.of(longBody);
}
}
}
}
return new Data(dataMessage.getTimestamp(),
dataMessage.getGroupContext().map(GroupContext::from),
dataMessage.getStoryContext()
@ -138,7 +153,7 @@ public record MessageEnvelope(
recipientResolver,
addressResolver)),
dataMessage.getGroupCallUpdate().map(GroupCallUpdate::from),
dataMessage.getBody(),
body,
dataMessage.getExpiresInSeconds(),
dataMessage.isExpirationUpdate(),
dataMessage.isViewOnce(),
@ -173,8 +188,8 @@ public record MessageEnvelope(
.map(a -> a.stream().filter(r -> r.style != null).map(TextStyle::from).toList())
.orElse(List.of()),
dataMessage.getPinnedMessage().map(p -> PinMessage.from(p, recipientResolver, addressResolver)),
dataMessage.getUnpinnedMessage()
.map(p -> UnpinMessage.from(p, recipientResolver, addressResolver)));
dataMessage.getUnpinnedMessage().map(p -> UnpinMessage.from(p, recipientResolver, addressResolver)),
dataMessage.getAdminDelete().map(p -> AdminDelete.from(p, recipientResolver, addressResolver)));
}
public record GroupContext(GroupId groupId, boolean isGroupUpdate, int revision) {
@ -602,18 +617,35 @@ public record MessageEnvelope(
}
}
public record AdminDelete(RecipientAddress targetAuthor, long targetSentTimestamp) {
static AdminDelete from(
SignalServiceDataMessage.AdminDelete adminDelete,
RecipientResolver recipientResolver,
RecipientAddressResolver addressResolver
) {
return new AdminDelete(addressResolver.resolveRecipientAddress(recipientResolver.resolveRecipient(
adminDelete.getTargetAuthor())).toApiRecipientAddress(), adminDelete.getTargetSentTimestamp());
}
}
}
public record Edit(long targetSentTimestamp, Data dataMessage) {
public static Edit from(
final SignalServiceEditMessage editMessage,
Map<String, String> longTexts,
RecipientResolver recipientResolver,
RecipientAddressResolver addressResolver,
final AttachmentFileProvider fileProvider
) {
return new Edit(editMessage.getTargetSentTimestamp(),
Data.from(editMessage.getDataMessage(), recipientResolver, addressResolver, fileProvider));
Data.from(editMessage.getDataMessage(),
longTexts,
recipientResolver,
addressResolver,
fileProvider));
}
}
@ -630,12 +662,13 @@ public record MessageEnvelope(
public static Sync from(
final SignalServiceSyncMessage syncMessage,
Map<String, String> longTexts,
RecipientResolver recipientResolver,
RecipientAddressResolver addressResolver,
final AttachmentFileProvider fileProvider
) {
return new Sync(syncMessage.getSent()
.map(s -> Sent.from(s, recipientResolver, addressResolver, fileProvider)),
.map(s -> Sent.from(s, longTexts, recipientResolver, addressResolver, fileProvider)),
syncMessage.getBlockedList().map(b -> Blocked.from(b, recipientResolver, addressResolver)),
syncMessage.getRead()
.map(r -> r.stream().map(rm -> Read.from(rm, recipientResolver, addressResolver)).toList())
@ -664,6 +697,7 @@ public record MessageEnvelope(
static Sent from(
SentTranscriptMessage sentMessage,
Map<String, String> longTexts,
RecipientResolver recipientResolver,
RecipientAddressResolver addressResolver,
final AttachmentFileProvider fileProvider
@ -679,9 +713,17 @@ public record MessageEnvelope(
.toApiRecipientAddress())
.collect(Collectors.toSet()),
sentMessage.getDataMessage()
.map(message -> Data.from(message, recipientResolver, addressResolver, fileProvider)),
.map(message -> Data.from(message,
longTexts,
recipientResolver,
addressResolver,
fileProvider)),
sentMessage.getEditMessage()
.map(message -> Edit.from(message, recipientResolver, addressResolver, fileProvider)),
.map(message -> Edit.from(message,
longTexts,
recipientResolver,
addressResolver,
fileProvider)),
sentMessage.getStoryMessage().map(s -> Story.from(s, fileProvider)));
}
}
@ -980,6 +1022,7 @@ public record MessageEnvelope(
public static MessageEnvelope from(
SignalServiceEnvelope envelope,
SignalServiceContent content,
Map<String, String> longTexts,
RecipientResolver recipientResolver,
RecipientAddressResolver addressResolver,
final AttachmentFileProvider fileProvider,
@ -1010,9 +1053,15 @@ public record MessageEnvelope(
receipt = content.getReceiptMessage().map(Receipt::from);
typing = content.getTypingMessage().map(Typing::from);
data = content.getDataMessage()
.map(dataMessage -> Data.from(dataMessage, recipientResolver, addressResolver, fileProvider));
edit = content.getEditMessage().map(s -> Edit.from(s, recipientResolver, addressResolver, fileProvider));
sync = content.getSyncMessage().map(s -> Sync.from(s, recipientResolver, addressResolver, fileProvider));
.map(dataMessage -> Data.from(dataMessage,
longTexts,
recipientResolver,
addressResolver,
fileProvider));
edit = content.getEditMessage()
.map(s -> Edit.from(s, longTexts, recipientResolver, addressResolver, fileProvider));
sync = content.getSyncMessage()
.map(s -> Sync.from(s, longTexts, recipientResolver, addressResolver, fileProvider));
call = content.getCallMessage().map(Call::from);
story = content.getStoryMessage().map(s -> Story.from(s, fileProvider));
} else {

View File

@ -38,7 +38,7 @@ import java.util.concurrent.TimeUnit;
/**
* Manages active voice calls: tracks state, spawns/monitors the signal-call-tunnel
* Rust subprocess (RingRTC-based), routes incoming call messages, and handles timeouts.
* subprocess, routes incoming call messages, and handles timeouts.
*/
public class CallManager implements AutoCloseable {
@ -109,7 +109,7 @@ public class CallManager implements AutoCloseable {
activeCalls.put(callId, state);
fireCallEvent(state, null);
// Spawn Rust binary and connect control channel
// Spawn call tunnel binary and connect control channel
spawnMediaTunnel(state);
// Fetch TURN servers
@ -240,7 +240,7 @@ public class CallManager implements AutoCloseable {
state.rawOfferOpaque = opaque;
activeCalls.put(callId, state);
// Spawn Rust binary immediately
// Spawn call tunnel binary immediately
spawnMediaTunnel(state);
// Get identity keys for the receivedOffer message
@ -366,7 +366,7 @@ public class CallManager implements AutoCloseable {
private void spawnMediaTunnel(CallState state) {
try {
var command = new ArrayList<>(List.of(findRustBinary()));
var command = new ArrayList<>(List.of(findTunnelBinary()));
// Config is sent via stdin; no --host-audio by default
var processBuilder = new ProcessBuilder(command);
@ -414,7 +414,7 @@ public class CallManager implements AutoCloseable {
}
}
private String findRustBinary() {
private String findTunnelBinary() {
// Check environment variable first
var envPath = System.getenv("SIGNAL_CALL_TUNNEL_BIN");
if (envPath != null && !envPath.isEmpty()) {
@ -716,7 +716,7 @@ public class CallManager implements AutoCloseable {
return serializedKey;
}
/** Format call ID as unsigned for JSON (Rust tunnel expects u64). */
/** Format call ID as unsigned for JSON (tunnel binary expects u64). */
private static String callIdJson(long callId) {
return Long.toUnsignedString(callId);
}

View File

@ -35,6 +35,7 @@ import org.asamk.signal.manager.storage.groups.GroupInfoV1;
import org.asamk.signal.manager.storage.recipients.RecipientAddress;
import org.asamk.signal.manager.storage.recipients.RecipientId;
import org.asamk.signal.manager.storage.stickers.StickerPack;
import org.asamk.signal.manager.util.MimeUtils;
import org.signal.core.models.ServiceId;
import org.signal.core.models.ServiceId.ACI;
import org.signal.libsignal.metadata.ProtocolInvalidKeyException;
@ -70,8 +71,13 @@ import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.internal.push.Envelope;
import org.whispersystems.signalservice.internal.push.UnsupportedDataMessageException;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
@ -273,13 +279,18 @@ public final class IncomingMessageHandler {
return List.of();
} else {
List<HandleAction> actions;
Map<String, String> longTexts;
if (content != null) {
actions = handleMessage(envelope, content, receiveConfig);
final var results = handleMessage(envelope, content, receiveConfig);
actions = results.first();
longTexts = results.second();
} else {
actions = List.of();
longTexts = Map.of();
}
handler.handleMessage(MessageEnvelope.from(envelope,
content,
longTexts,
account.getRecipientResolver(),
account.getRecipientAddressResolver(),
context.getAttachmentHelper()::getAttachmentFile,
@ -288,12 +299,13 @@ public final class IncomingMessageHandler {
}
}
public List<HandleAction> handleMessage(
public Pair<List<HandleAction>, Map<String, String>> handleMessage(
SignalServiceEnvelope envelope,
SignalServiceContent content,
ReceiveConfig receiveConfig
) {
var actions = new ArrayList<HandleAction>();
final var actions = new ArrayList<HandleAction>();
final var longTexts = new HashMap<String, String>();
final var senderDeviceAddress = getSender(envelope, content);
final var sender = senderDeviceAddress.recipientId();
final var senderServiceId = senderDeviceAddress.serviceId();
@ -368,11 +380,13 @@ public final class IncomingMessageHandler {
message.getTimestamp()));
}
actions.addAll(handleSignalServiceDataMessage(message,
final var dataResults = handleSignalServiceDataMessage(message,
false,
senderDeviceAddress,
destination,
receiveConfig));
receiveConfig);
actions.addAll(dataResults.first());
longTexts.putAll(dataResults.second());
}
if (content.getStoryMessage().isPresent()) {
@ -382,14 +396,16 @@ public final class IncomingMessageHandler {
if (content.getSyncMessage().isPresent()) {
var syncMessage = content.getSyncMessage().get();
actions.addAll(handleSyncMessage(envelope, syncMessage, senderDeviceAddress, receiveConfig));
final var syncResults = handleSyncMessage(envelope, syncMessage, senderDeviceAddress, receiveConfig);
actions.addAll(syncResults.first());
longTexts.putAll(syncResults.second());
}
if (content.getCallMessage().isPresent()) {
handleCallMessage(content.getCallMessage().get(), sender);
}
return actions;
return new Pair<>(actions, longTexts);
}
private void handleCallMessage(
@ -511,19 +527,20 @@ public final class IncomingMessageHandler {
}
}
private List<HandleAction> handleSyncMessage(
private Pair<List<HandleAction>, Map<String, String>> handleSyncMessage(
final SignalServiceEnvelope envelope,
final SignalServiceSyncMessage syncMessage,
final DeviceAddress sender,
final ReceiveConfig receiveConfig
) {
var actions = new ArrayList<HandleAction>();
final var actions = new ArrayList<HandleAction>();
final var longTexts = new HashMap<String, String>();
account.setMultiDevice(true);
if (syncMessage.getSent().isPresent()) {
var message = syncMessage.getSent().get();
final var destination = message.getDestination().orElse(null);
if (message.getDataMessage().isPresent()) {
actions.addAll(handleSignalServiceDataMessage(message.getDataMessage().get(),
final var dataResults = handleSignalServiceDataMessage(message.getDataMessage().get(),
true,
sender,
destination == null
@ -531,7 +548,9 @@ public final class IncomingMessageHandler {
: new DeviceAddress(account.getRecipientResolver().resolveRecipient(destination),
destination.getServiceId(),
0),
receiveConfig));
receiveConfig);
actions.addAll(dataResults.first());
longTexts.putAll(dataResults.second());
}
if (message.getStoryMessage().isPresent()) {
actions.addAll(handleSignalServiceStoryMessage(message.getStoryMessage().get(),
@ -683,7 +702,7 @@ public final class IncomingMessageHandler {
actions.add(RetrieveDeviceNameAction.create());
}
}
return actions;
return new Pair<>(actions, longTexts);
}
private SignalServiceGroupContext getGroupContext(SignalServiceContent content) {
@ -749,15 +768,21 @@ public final class IncomingMessageHandler {
}
}
var groupId = GroupUtils.getGroupId(groupContext);
var group = context.getGroupHelper().getGroup(groupId);
final var message = content.getDataMessage().orElse(null);
final var recipientId = account.getRecipientResolver().resolveRecipient(source);
final var groupId = GroupUtils.getGroupId(groupContext);
final var group = context.getGroupHelper().getGroup(groupId);
if (message != null && message.getAdminDelete().isPresent() && (group == null || !group.isAdmin(recipientId))) {
return true;
}
if (group == null) {
return false;
}
final var message = content.getDataMessage().orElse(null);
final var recipientId = account.getRecipientResolver().resolveRecipient(source);
if (!group.isMember(recipientId) && !(
group.isPendingMember(recipientId) && message != null && message.isGroupV2Update()
)) {
@ -776,13 +801,14 @@ public final class IncomingMessageHandler {
return false;
}
private List<HandleAction> handleSignalServiceDataMessage(
private Pair<List<HandleAction>, Map<String, String>> handleSignalServiceDataMessage(
SignalServiceDataMessage message,
boolean isSync,
DeviceAddress source,
DeviceAddress destination,
ReceiveConfig receiveConfig
) {
final var longTexts = new HashMap<String, String>();
var actions = new ArrayList<HandleAction>();
if (message.getGroupContext().isPresent()) {
final var groupContext = message.getGroupContext().get();
@ -877,6 +903,17 @@ public final class IncomingMessageHandler {
if (message.getAttachments().isPresent()) {
for (var attachment : message.getAttachments().get()) {
context.getAttachmentHelper().downloadAttachment(attachment);
if (attachment.isPointer()) {
final var file = context.getAttachmentHelper().getAttachmentFile(attachment.asPointer());
if (MimeUtils.LONG_TEXT.equals(attachment.getContentType()) && attachment.isPointer()) {
try {
final var longText = Files.readString(file.toPath());
longTexts.put(attachment.asPointer().getRemoteId().toString(), longText);
} catch (IOException e) {
logger.warn("Failed to read long text attachment, ignoring", e);
}
}
}
}
}
if (message.getSharedContacts().isPresent()) {
@ -906,6 +943,21 @@ public final class IncomingMessageHandler {
}
}
}
} else {
if (message.getAttachments().isPresent()) {
for (var attachment : message.getAttachments().get()) {
if (MimeUtils.LONG_TEXT.equals(attachment.getContentType()) && attachment.isPointer()) {
try {
context.getAttachmentHelper().retrieveAttachment(attachment, in -> {
final var longText = new String(in.readAllBytes(), StandardCharsets.UTF_8);
longTexts.put(attachment.asPointer().getRemoteId().toString(), longText);
});
} catch (IOException e) {
logger.warn("Failed to download long text attachment, ignoring", e);
}
}
}
}
}
if (message.getGiftBadge().isPresent()) {
handleIncomingGiftBadge(message.getGiftBadge().get());
@ -926,7 +978,7 @@ public final class IncomingMessageHandler {
.enqueueJob(new RetrieveStickerPackJob(stickerPackId, messageSticker.getPackKey()));
}
}
return actions;
return new Pair<>(actions, longTexts);
}
private void handleIncomingGiftBadge(final SignalServiceDataMessage.GiftBadge giftBadge) {

View File

@ -145,11 +145,8 @@ public class RecipientHelper {
try {
final var usernameLinkUrl = UsernameLinkUrl.fromUri(username);
final var components = usernameLinkUrl.getComponents();
final var encryptedUsername = handleResponseException(dependencies.getUsernameApi()
.getEncryptedUsernameFromLinkServerId(components.getServerId()));
final var link = new Username.UsernameLink(components.getEntropy(), encryptedUsername);
return Username.fromLink(link);
return handleResponseException(dependencies.getUsernameApi()
.getDecryptedUsernameFromLinkServerIdAndEntropy(components.getServerId(), components.getEntropy()));
} catch (UsernameLinkUrl.InvalidUsernameLinkException e) {
return new Username(username);
}

View File

@ -1003,6 +1003,77 @@ public class ManagerImpl implements Manager {
return sendMessage(messageBuilder, recipients, notifySelf);
}
@Override
public SendMessageResults sendAdminDelete(
RecipientIdentifier.Single targetAuthor,
long targetSentTimestamp,
Set<RecipientIdentifier.Group> recipients,
final boolean notifySelf,
final boolean isStory
) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException, UnregisteredRecipientException {
final var targetAuthorRecipientId = context.getRecipientHelper().resolveRecipient(targetAuthor);
final var authorServiceId = context.getRecipientHelper()
.resolveSignalServiceAddress(targetAuthorRecipientId)
.getServiceId();
final var adminDelete = new SignalServiceDataMessage.AdminDelete(authorServiceId, targetSentTimestamp);
final var messageBuilder = SignalServiceDataMessage.newBuilder().withAdminDelete(adminDelete);
if (isStory) {
messageBuilder.withStoryContext(new SignalServiceDataMessage.StoryContext(authorServiceId,
targetSentTimestamp));
}
return sendMessage(messageBuilder,
recipients.stream().map(r -> (RecipientIdentifier) r).collect(Collectors.toSet()),
notifySelf);
}
@Override
public SendMessageResults sendPinMessage(
int pinDuration,
RecipientIdentifier.Single targetAuthor,
long targetSentTimestamp,
Set<RecipientIdentifier> recipients,
final boolean notifySelf,
final boolean isStory
) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException, UnregisteredRecipientException {
final var targetAuthorRecipientId = context.getRecipientHelper().resolveRecipient(targetAuthor);
final var authorServiceId = context.getRecipientHelper()
.resolveSignalServiceAddress(targetAuthorRecipientId)
.getServiceId();
final var duration = pinDuration >= 0 ? pinDuration : null;
final var forever = pinDuration < 0;
final var pinnedMessage = new SignalServiceDataMessage.PinnedMessage(authorServiceId,
targetSentTimestamp,
duration,
forever);
final var messageBuilder = SignalServiceDataMessage.newBuilder().withPinnedMessage(pinnedMessage);
if (isStory) {
messageBuilder.withStoryContext(new SignalServiceDataMessage.StoryContext(authorServiceId,
targetSentTimestamp));
}
return sendMessage(messageBuilder, recipients, notifySelf);
}
@Override
public SendMessageResults sendUnpinMessage(
RecipientIdentifier.Single targetAuthor,
long targetSentTimestamp,
Set<RecipientIdentifier> recipients,
final boolean notifySelf,
final boolean isStory
) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException, UnregisteredRecipientException {
final var targetAuthorRecipientId = context.getRecipientHelper().resolveRecipient(targetAuthor);
final var authorServiceId = context.getRecipientHelper()
.resolveSignalServiceAddress(targetAuthorRecipientId)
.getServiceId();
final var unpinnedMessage = new SignalServiceDataMessage.UnpinnedMessage(authorServiceId, targetSentTimestamp);
final var messageBuilder = SignalServiceDataMessage.newBuilder().withUnpinnedMessage(unpinnedMessage);
if (isStory) {
messageBuilder.withStoryContext(new SignalServiceDataMessage.StoryContext(authorServiceId,
targetSentTimestamp));
}
return sendMessage(messageBuilder, recipients, notifySelf);
}
@Override
public SendMessageResults sendPaymentNotificationMessage(
byte[] receipt,

View File

@ -262,6 +262,10 @@ Only works, if this is the primary device.
Specify the uri contained in the QR code shown by the new device.
You will need the full URI such as "sgnl://linkdevice?uuid=...&pub_key=..." (formerly "tsdevice:/?uuid=...") Make sure to enclose it in quotation marks for shells.
=== listAccounts
Show a list of registered accounts.
=== listDevices
Show a list of linked devices.
@ -287,6 +291,27 @@ One or more numbers to check.
[--username [USERNAME ...]]::
One or more usernames or username links to check.
=== sendAdminDelete
Send admin delete message for a previously received or sent message.
Admin delete is used by group admins to remove messages from all group members.
Only works with group recipients.
*-g* GROUP, *--group-id* GROUP::
Specify the recipient group ID in base64 encoding (required).
*--notify-self*::
If self is part of recipients/groups send a normal message, not a sync message.
*-a* RECIPIENT, *--target-author* RECIPIENT::
Specify the number of the author of the message to admin delete.
*-t* TIMESTAMP, *--target-timestamp* TIMESTAMP::
Specify the timestamp of the message to admin delete.
*--story*::
Admin delete a story instead of a normal message.
=== send
Send a message to another user or group.
@ -385,6 +410,10 @@ Clear session state and send end session message.
*--edit-timestamp*::
Specify the timestamp of a previous message with the recipient or group to send an edited message.
*--no-urgent*::
Send the message without the urgent flag, so no push notification is triggered for the recipient.
The message will still be delivered in real-time if the recipient's app is active.
=== sendPollCreate
Send a poll create message to another user or group.
@ -413,6 +442,7 @@ By default, recipients can select multiple options.
*-o* OPTION [OPTION ...], *--option* OPTION [OPTION ...]*::
The options for the poll.
Between 2 and 10 options must be specified.
=== sendPollVote
@ -497,6 +527,60 @@ The base64 encoded receipt blob.
*--note* NOTE::
Specify a note for the payment notification.
=== sendPinMessage
Send pin message for a previously received or sent message.
RECIPIENT::
Specify the recipients.
*-g* GROUP, *--group-id* GROUP::
Specify the recipient group ID in base64 encoding.
*-u* USERNAME, *--username* USERNAME::
Specify the recipient username or username link.
*--note-to-self*::
Send the pin message to self.
*--notify-self*::
If self is part of recipients/groups send a normal message, not a sync message.
*-d* DURATION, *--pin-duration* DURATION::
Specify the pin duration in seconds.
Use -1 for forever (default: -1).
*-a* RECIPIENT, *--target-author* RECIPIENT::
Specify the number of the author of the message to pin.
*-t* TIMESTAMP, *--target-timestamp* TIMESTAMP::
Specify the timestamp of the message to pin.
=== sendUnpinMessage
Send unpin message for a previously received or sent message.
RECIPIENT::
Specify the recipients.
*-g* GROUP, *--group-id* GROUP::
Specify the recipient group ID in base64 encoding.
*-u* USERNAME, *--username* USERNAME::
Specify the recipient username or username link.
*--note-to-self*::
Send the unpin message to self.
*--notify-self*::
If self is part of recipients/groups send a normal message, not a sync message.
*-a* RECIPIENT, *--target-author* RECIPIENT::
Specify the number of the author of the message to unpin.
*-t* TIMESTAMP, *--target-timestamp* TIMESTAMP::
Specify the timestamp of the message to unpin.
=== sendReaction
Send reaction to a previously received or sent message.
@ -510,6 +594,12 @@ Specify the recipient group ID in base64 encoding.
*-u* USERNAME, *--username* USERNAME::
Specify the recipient username or username link.
*--note-to-self*::
Send the reaction to self.
*--notify-self*::
If self is part of recipients/groups send a normal message, not a sync message.
*-e* EMOJI, *--emoji* EMOJI::
Specify the emoji, should be a single unicode grapheme cluster.
@ -532,6 +622,9 @@ Send a read or viewed receipt to a previously received message.
RECIPIENT::
Specify the sender.
*-u* USERNAME, *--username* USERNAME::
Specify the recipient username or username link.
*-t* TIMESTAMP, *--target-timestamp* TIMESTAMP::
Specify the timestamp of the message to which to react.
@ -578,7 +671,7 @@ In json mode this is outputted as one json object per line.
Number of seconds to wait for new messages (negative values disable timeout).
Default is 5 seconds.
*--max-messages*::
*--max-messages* MAX_MESSAGES::
Maximum number of messages to receive, before returning.
*--ignore-attachments*::
@ -702,10 +795,10 @@ Find contacts with the given contact or profile name.
*--detailed*::
List the contacts with more details.
If output=json, then this is always set
If output=json, then this is always set.
*--internal*::
Include internal information that's normally not user visible
Include internal information that's normally not user visible.
=== listIdentities
@ -786,6 +879,18 @@ New note.
Set expiration time of messages (seconds).
To disable expiration set expiration time to 0.
=== updateDevice
Update a linked device.
Only works, if this is the primary device.
*-d* DEVICE_ID, *--device-id* DEVICE_ID::
Specify the device you want to update.
Use listDevices to see the deviceIds.
*-n* NAME, *--device-name* NAME::
Specify a name to describe the given device.
=== removeContact
Remove the info of a given contact
@ -1007,6 +1112,10 @@ The challenge token from the failed send attempt.
*--captcha* CAPTCHA::
The captcha result, starting with signalcaptcha://
=== version
Show version information.
== Examples
Register a number (with SMS verification)::

View File

@ -19,12 +19,19 @@ PATH_MAIN="$PATH_TEST_CONFIG/main"
PATH_LINK="$PATH_TEST_CONFIG/link"
if [ "$NATIVE" -eq 1 ]; then
./gradlew nativeCompile
SIGNAL_CLI="$PWD/build/native/nativeCompile/signal-cli"
elif [ "$JSON_RPC" -eq 1 ]; then
export RUST_BACKTRACE=1
(cd client && cargo build)
"$PWD/build/install/signal-cli/bin/signal-cli" --verbose --verbose --trust-new-identities=always --config="$PATH_MAIN" --service-environment="staging" --log-file="$PATH_MAIN/log" daemon --socket --receive-mode=manual&
./gradlew installDist
"$PWD/build/install/signal-cli/bin/signal-cli" --verbose --verbose --trust-new-identities=always --config="$PATH_LINK" --service-environment="staging" --log-file="$PATH_LINK/log" daemon --tcp --receive-mode=manual&
sleep 5
if [ ! -z "$GRAALVM_HOME" ]; then
export JAVA_HOME=$GRAALVM_HOME
export SIGNAL_CLI_OPTS="-agentlib:native-image-agent=config-merge-dir=graalvm-config-dir-main/"
fi
"$PWD/build/install/signal-cli/bin/signal-cli" --verbose --verbose --trust-new-identities=always --config="$PATH_MAIN" --service-environment="staging" --log-file="$PATH_MAIN/log" daemon --socket --receive-mode=manual&
sleep 15
SIGNAL_CLI="$PWD/client/target/debug/signal-cli-client"
else
./gradlew installDist
@ -113,10 +120,10 @@ fi
sleep 5
run_main listAccounts
run_main --output=json listAccounts
run_main --scrub-log listAccounts
if [ "$JSON_RPC" -eq 0 ]; then
run_main --output=json listAccounts
run_main --scrub-log listAccounts
## DBus
#run_main -a "$NUMBER_1" --dbus send "$NUMBER_2" -m daemon_not_running || true
#run_main daemon &

File diff suppressed because it is too large Load Diff

View File

@ -1,19 +0,0 @@
[package]
name = "signal-call-tunnel"
version = "0.1.0"
edition = "2024"
[dependencies]
ringrtc = { path = "../third-party/ringrtc/src/rust", features = ["prebuilt_webrtc", "virtual_audio"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
log = "0.4"
env_logger = "0.11"
base64 = "0.22"
anyhow = "1"
subtle = "2"
[patch.crates-io]
# Use Signal's fork of curve25519-dalek for zkgroup compatibility (matches ringrtc workspace).
curve25519-dalek = { git = 'https://github.com/signalapp/curve25519-dalek', tag = 'signal-curve25519-4.1.3' }

View File

@ -1,66 +0,0 @@
use std::path::Path;
use std::process::Command;
fn main() {
// Apply the VPIO-disable patch to ringrtc if it hasn't been applied yet.
// This is a build-time patch: cargo re-runs build.rs when the patch file
// or the target source file changes.
let ringrtc_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../third-party/ringrtc");
let patch_file = Path::new(env!("CARGO_MANIFEST_DIR")).join("patches/ringrtc-disable-vpio.patch");
let adm_file = ringrtc_dir.join("src/rust/src/webrtc/audio_device_module.rs");
println!("cargo::rerun-if-changed={}", patch_file.display());
println!("cargo::rerun-if-changed={}", adm_file.display());
if !patch_file.exists() {
return;
}
// Check if the patch is already applied by looking for the marker function.
if let Ok(content) = std::fs::read_to_string(&adm_file) {
if content.contains("RINGRTC_NO_VOICE_PROCESSING") {
// Already applied
return;
}
}
// Canonicalize paths so git apply works regardless of how cargo sets cwd.
// Run from within the ringrtc directory to avoid parent-repo submodule issues.
let ringrtc_canonical = match ringrtc_dir.canonicalize() {
Ok(p) => p,
Err(e) => {
eprintln!("cargo:warning=Cannot resolve ringrtc path: {e}");
return;
}
};
let patch_canonical = match patch_file.canonicalize() {
Ok(p) => p,
Err(e) => {
eprintln!("cargo:warning=Cannot resolve patch path: {e}");
return;
}
};
let status = Command::new("git")
.arg("apply")
.arg(&patch_canonical)
.current_dir(&ringrtc_canonical)
.status();
match status {
Ok(s) if s.success() => {
eprintln!("cargo:warning=Applied ringrtc VPIO-disable patch for virtual audio support");
}
Ok(s) => {
eprintln!(
"cargo:warning=Failed to apply ringrtc patch (exit {}); \
VPIO may hang with virtual audio devices",
s
);
}
Err(e) => {
eprintln!("cargo:warning=Could not run git apply: {e}");
}
}
}

View File

@ -1,41 +0,0 @@
diff --git a/src/rust/src/webrtc/audio_device_module.rs b/src/rust/src/webrtc/audio_device_module.rs
index 5e3a6ecf..a9b76c12 100644
--- a/src/rust/src/webrtc/audio_device_module.rs
+++ b/src/rust/src/webrtc/audio_device_module.rs
@@ -265,6 +265,18 @@ impl Worker {
}
}
+ /// Returns the stream preferences for cubeb audio streams.
+ ///
+ /// When `RINGRTC_NO_VOICE_PROCESSING` is set, returns `StreamPrefs::NONE`
+ /// to skip macOS VoiceProcessingIO which hangs with virtual audio drivers.
+ fn stream_prefs() -> StreamPrefs {
+ if std::env::var("RINGRTC_NO_VOICE_PROCESSING").is_ok() {
+ StreamPrefs::NONE
+ } else {
+ StreamPrefs::VOICE
+ }
+ }
+
fn init_playout(&mut self) -> anyhow::Result<()> {
let out_device = if let Some(device) = self.playout_device {
device
@@ -276,7 +288,7 @@ impl Worker {
.rate(SAMPLE_FREQUENCY)
.channels(2)
.layout(cubeb::ChannelLayout::STEREO)
- .prefs(StreamPrefs::VOICE)
+ .prefs(Self::stream_prefs())
.take();
let mut builder = cubeb::StreamBuilder::<OutFrame>::new();
let transport = Arc::clone(&self.audio_transport);
@@ -411,7 +423,7 @@ impl Worker {
.rate(SAMPLE_FREQUENCY)
.channels(NUM_CHANNELS)
.layout(cubeb::ChannelLayout::MONO)
- .prefs(StreamPrefs::VOICE)
+ .prefs(Self::stream_prefs())
.take();
let mut builder = cubeb::StreamBuilder::<Frame>::new();

View File

@ -1,92 +0,0 @@
use serde::Deserialize;
#[derive(Debug, Deserialize)]
pub struct Config {
pub call_id: u64,
pub is_outgoing: bool,
pub control_socket_path: String,
pub control_token: String,
pub local_device_id: u32,
pub input_device_name: Option<String>,
pub output_device_name: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deserialize_valid_config() {
let json = r#"{
"call_id": 12345678,
"is_outgoing": true,
"control_socket_path": "/tmp/sc-abc/ctrl.sock",
"control_token": "dG9rZW4=",
"local_device_id": 1
}"#;
let config: Config = serde_json::from_str(json).unwrap();
assert_eq!(config.call_id, 12345678);
assert!(config.is_outgoing);
assert_eq!(config.control_socket_path, "/tmp/sc-abc/ctrl.sock");
assert_eq!(config.control_token, "dG9rZW4=");
assert_eq!(config.local_device_id, 1);
assert!(config.input_device_name.is_none());
assert!(config.output_device_name.is_none());
}
#[test]
fn deserialize_with_device_names() {
let json = r#"{
"call_id": 99,
"is_outgoing": false,
"control_socket_path": "/tmp/ctrl.sock",
"control_token": "tok",
"local_device_id": 2,
"input_device_name": "signal_input",
"output_device_name": "signal_output"
}"#;
let config: Config = serde_json::from_str(json).unwrap();
assert_eq!(config.call_id, 99);
assert!(!config.is_outgoing);
assert_eq!(config.local_device_id, 2);
assert_eq!(config.input_device_name.as_deref(), Some("signal_input"));
assert_eq!(config.output_device_name.as_deref(), Some("signal_output"));
}
#[test]
fn deserialize_missing_field_fails() {
let json = r#"{
"call_id": 1,
"is_outgoing": true
}"#;
let result: Result<Config, _> = serde_json::from_str(json);
assert!(result.is_err());
}
#[test]
fn deserialize_wrong_type_fails() {
let json = r#"{
"call_id": "not_a_number",
"is_outgoing": true,
"control_socket_path": "/tmp/ctrl.sock",
"control_token": "tok",
"local_device_id": 1
}"#;
let result: Result<Config, _> = serde_json::from_str(json);
assert!(result.is_err());
}
#[test]
fn deserialize_extra_fields_ok() {
let json = r#"{
"call_id": 1,
"is_outgoing": true,
"control_socket_path": "/tmp/ctrl.sock",
"control_token": "tok",
"local_device_id": 1,
"extra_field": "ignored"
}"#;
let config: Config = serde_json::from_str(json).unwrap();
assert_eq!(config.call_id, 1);
}
}

View File

@ -1,521 +0,0 @@
use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::UnixListener;
use std::sync::mpsc;
use anyhow::{Context, Result, bail};
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64;
use log::{error, info, warn};
use serde_json::Value;
use subtle::ConstantTimeEq;
use crate::platform::PlatformEvent;
/// Messages parsed from the parent process.
#[derive(Debug)]
pub enum ControlMessage {
Auth { token: String },
CreateOutgoingCall { call_id: u64, peer_id: String },
Proceed { call_id: u64, ice_servers: Vec<IceServerConfig>, hide_ip: bool },
ReceivedOffer {
call_id: u64,
peer_id: String,
sender_device_id: u32,
opaque: Vec<u8>,
age_ms: u64,
sender_identity_key: Vec<u8>,
receiver_identity_key: Vec<u8>,
},
ReceivedAnswer {
opaque: Vec<u8>,
sender_device_id: u32,
sender_identity_key: Vec<u8>,
receiver_identity_key: Vec<u8>,
},
ReceivedIce { candidates: Vec<Vec<u8>> },
Accept,
Hangup,
}
#[derive(Debug, Clone)]
pub struct IceServerConfig {
pub username: String,
pub password: String,
pub urls: Vec<String>,
}
/// Parse a JSON line into a ControlMessage.
pub fn parse_message(line: &str) -> Result<ControlMessage> {
let v: Value = serde_json::from_str(line).context("invalid JSON")?;
let msg_type = v["type"].as_str().unwrap_or("");
match msg_type {
"auth" => Ok(ControlMessage::Auth {
token: v["token"].as_str().unwrap_or("").to_string(),
}),
"createOutgoingCall" => Ok(ControlMessage::CreateOutgoingCall {
call_id: v["callId"].as_u64().unwrap_or(0),
peer_id: v["peerId"].as_str().unwrap_or("").to_string(),
}),
"proceed" => {
let ice_servers = if let Some(servers) = v["iceServers"].as_array() {
servers
.iter()
.map(|s| IceServerConfig {
username: s["username"].as_str().unwrap_or("").to_string(),
password: s["password"].as_str().unwrap_or("").to_string(),
urls: s["urls"]
.as_array()
.map(|urls| {
urls.iter()
.filter_map(|u| u.as_str().map(String::from))
.collect()
})
.unwrap_or_default(),
})
.collect()
} else {
Vec::new()
};
Ok(ControlMessage::Proceed {
call_id: v["callId"].as_u64().unwrap_or(0),
ice_servers,
hide_ip: v["hideIp"].as_bool().unwrap_or(false),
})
}
"receivedOffer" => Ok(ControlMessage::ReceivedOffer {
call_id: v["callId"].as_u64().unwrap_or(0),
peer_id: v["peerId"].as_str().unwrap_or("remote").to_string(),
sender_device_id: v["senderDeviceId"].as_u64().unwrap_or(1) as u32,
opaque: BASE64
.decode(v["opaque"].as_str().unwrap_or(""))
.unwrap_or_default(),
age_ms: v["age"].as_u64().unwrap_or(0),
sender_identity_key: BASE64
.decode(v["senderIdentityKey"].as_str().unwrap_or(""))
.unwrap_or_default(),
receiver_identity_key: BASE64
.decode(v["receiverIdentityKey"].as_str().unwrap_or(""))
.unwrap_or_default(),
}),
"receivedAnswer" => Ok(ControlMessage::ReceivedAnswer {
opaque: BASE64
.decode(v["opaque"].as_str().unwrap_or(""))
.unwrap_or_default(),
sender_device_id: v["senderDeviceId"].as_u64().unwrap_or(1) as u32,
sender_identity_key: BASE64
.decode(v["senderIdentityKey"].as_str().unwrap_or(""))
.unwrap_or_default(),
receiver_identity_key: BASE64
.decode(v["receiverIdentityKey"].as_str().unwrap_or(""))
.unwrap_or_default(),
}),
"receivedIce" => {
let candidates = if let Some(arr) = v["candidates"].as_array() {
arr.iter()
.filter_map(|c| {
let b64 = c.as_str()?;
BASE64.decode(b64).ok()
})
.collect()
} else {
Vec::new()
};
Ok(ControlMessage::ReceivedIce { candidates })
}
"accept" => Ok(ControlMessage::Accept),
"hangup" => Ok(ControlMessage::Hangup),
_ => bail!("unknown message type: {}", msg_type),
}
}
/// Validate the auth token using constant-time comparison.
pub fn validate_token(received: &str, expected: &str) -> bool {
let received_bytes = received.as_bytes();
let expected_bytes = expected.as_bytes();
if received_bytes.len() != expected_bytes.len() {
return false;
}
received_bytes.ct_eq(expected_bytes).into()
}
/// Runs the control channel server. Binds a Unix socket, accepts one connection,
/// validates auth, then reads messages and sends events.
///
/// Returns a channel receiver for incoming control messages and a writer for
/// sending events to the parent.
pub struct ControlChannel {
pub msg_receiver: mpsc::Receiver<ControlMessage>,
pub writer: ControlWriter,
}
#[derive(Clone)]
pub struct ControlWriter {
sender: mpsc::Sender<String>,
}
impl ControlWriter {
pub fn send_line(&self, line: &str) {
if let Err(e) = self.sender.send(line.to_string()) {
error!("Failed to send to control writer: {}", e);
}
}
pub fn send_event(&self, event: &PlatformEvent) {
self.send_line(&event.to_json());
}
}
#[cfg(test)]
mod tests {
use super::*;
// --- parse_message tests ---
#[test]
fn parse_auth() {
let msg = parse_message(r#"{"type":"auth","token":"secret123"}"#).unwrap();
match msg {
ControlMessage::Auth { token } => assert_eq!(token, "secret123"),
_ => panic!("expected Auth, got {:?}", msg),
}
}
#[test]
fn parse_create_outgoing_call() {
let msg = parse_message(
r#"{"type":"createOutgoingCall","callId":42,"peerId":"abc-def"}"#,
)
.unwrap();
match msg {
ControlMessage::CreateOutgoingCall { call_id, peer_id } => {
assert_eq!(call_id, 42);
assert_eq!(peer_id, "abc-def");
}
_ => panic!("expected CreateOutgoingCall, got {:?}", msg),
}
}
#[test]
fn parse_proceed_with_ice_servers() {
let json = r#"{
"type": "proceed",
"callId": 99,
"hideIp": true,
"iceServers": [
{
"username": "user1",
"password": "pass1",
"urls": ["turn:example.com:3478", "stun:example.com:3478"]
},
{
"username": "user2",
"password": "pass2",
"urls": ["turn:other.com:443"]
}
]
}"#;
let msg = parse_message(json).unwrap();
match msg {
ControlMessage::Proceed {
call_id,
ice_servers,
hide_ip,
} => {
assert_eq!(call_id, 99);
assert!(hide_ip);
assert_eq!(ice_servers.len(), 2);
assert_eq!(ice_servers[0].username, "user1");
assert_eq!(ice_servers[0].password, "pass1");
assert_eq!(ice_servers[0].urls.len(), 2);
assert_eq!(ice_servers[0].urls[0], "turn:example.com:3478");
assert_eq!(ice_servers[1].username, "user2");
assert_eq!(ice_servers[1].urls.len(), 1);
}
_ => panic!("expected Proceed, got {:?}", msg),
}
}
#[test]
fn parse_proceed_no_ice_servers() {
let msg = parse_message(r#"{"type":"proceed","callId":1}"#).unwrap();
match msg {
ControlMessage::Proceed {
call_id,
ice_servers,
hide_ip,
} => {
assert_eq!(call_id, 1);
assert!(!hide_ip);
assert!(ice_servers.is_empty());
}
_ => panic!("expected Proceed, got {:?}", msg),
}
}
#[test]
fn parse_received_offer() {
// "aGVsbG8=" is base64 for "hello"
let json = r#"{
"type": "receivedOffer",
"callId": 100,
"peerId": "0b949a17-dc53-41b1-9ebc-dea99cb93920",
"senderDeviceId": 3,
"opaque": "aGVsbG8=",
"age": 500,
"senderIdentityKey": "AQID",
"receiverIdentityKey": "BAUG"
}"#;
let msg = parse_message(json).unwrap();
match msg {
ControlMessage::ReceivedOffer {
call_id,
peer_id,
sender_device_id,
opaque,
age_ms,
sender_identity_key,
receiver_identity_key,
} => {
assert_eq!(call_id, 100);
assert_eq!(peer_id, "0b949a17-dc53-41b1-9ebc-dea99cb93920");
assert_eq!(sender_device_id, 3);
assert_eq!(opaque, b"hello");
assert_eq!(age_ms, 500);
assert_eq!(sender_identity_key, vec![1, 2, 3]);
assert_eq!(receiver_identity_key, vec![4, 5, 6]);
}
_ => panic!("expected ReceivedOffer, got {:?}", msg),
}
}
#[test]
fn parse_received_answer() {
let json = r#"{
"type": "receivedAnswer",
"opaque": "AQID",
"senderDeviceId": 2,
"senderIdentityKey": "BAUG",
"receiverIdentityKey": "BwgJ"
}"#;
let msg = parse_message(json).unwrap();
match msg {
ControlMessage::ReceivedAnswer {
opaque,
sender_device_id,
sender_identity_key,
receiver_identity_key,
} => {
assert_eq!(opaque, vec![1, 2, 3]);
assert_eq!(sender_device_id, 2);
assert_eq!(sender_identity_key, vec![4, 5, 6]);
assert_eq!(receiver_identity_key, vec![7, 8, 9]);
}
_ => panic!("expected ReceivedAnswer, got {:?}", msg),
}
}
#[test]
fn parse_received_ice() {
// Two base64-encoded candidates
let json = r#"{"type":"receivedIce","candidates":["AQID","BAUG"]}"#;
let msg = parse_message(json).unwrap();
match msg {
ControlMessage::ReceivedIce { candidates } => {
assert_eq!(candidates.len(), 2);
assert_eq!(candidates[0], vec![1, 2, 3]);
assert_eq!(candidates[1], vec![4, 5, 6]);
}
_ => panic!("expected ReceivedIce, got {:?}", msg),
}
}
#[test]
fn parse_received_ice_empty() {
let msg = parse_message(r#"{"type":"receivedIce","candidates":[]}"#).unwrap();
match msg {
ControlMessage::ReceivedIce { candidates } => {
assert!(candidates.is_empty());
}
_ => panic!("expected ReceivedIce, got {:?}", msg),
}
}
#[test]
fn parse_accept() {
let msg = parse_message(r#"{"type":"accept"}"#).unwrap();
assert!(matches!(msg, ControlMessage::Accept));
}
#[test]
fn parse_hangup() {
let msg = parse_message(r#"{"type":"hangup"}"#).unwrap();
assert!(matches!(msg, ControlMessage::Hangup));
}
#[test]
fn parse_unknown_type_fails() {
let result = parse_message(r#"{"type":"foobar"}"#);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("unknown message type"));
}
#[test]
fn parse_invalid_json_fails() {
let result = parse_message("not json at all");
assert!(result.is_err());
}
#[test]
fn parse_missing_type_fails() {
let result = parse_message(r#"{"callId":1}"#);
assert!(result.is_err());
}
// --- validate_token tests ---
#[test]
fn validate_token_matching() {
assert!(validate_token("my-secret-token", "my-secret-token"));
}
#[test]
fn validate_token_mismatch() {
assert!(!validate_token("wrong-token", "my-secret-token"));
}
#[test]
fn validate_token_different_lengths() {
assert!(!validate_token("short", "a-much-longer-token"));
}
#[test]
fn validate_token_empty() {
assert!(validate_token("", ""));
}
#[test]
fn validate_token_one_empty() {
assert!(!validate_token("", "notempty"));
assert!(!validate_token("notempty", ""));
}
}
pub fn start_control_channel(
control_socket_path: &str,
expected_token: &str,
input_device_name: &str,
output_device_name: &str,
) -> Result<ControlChannel> {
// Remove stale socket file
let _ = std::fs::remove_file(control_socket_path);
let listener = UnixListener::bind(control_socket_path)
.with_context(|| format!("failed to bind control socket at {}", control_socket_path))?;
info!("Control channel listening on {}", control_socket_path);
let (msg_sender, msg_receiver) = mpsc::channel::<ControlMessage>();
let (write_sender, write_receiver) = mpsc::channel::<String>();
let writer = ControlWriter {
sender: write_sender,
};
// Send ready message immediately (parent can connect after this)
let ready_msg = format!(
r#"{{"type":"ready","inputDeviceName":"{}","outputDeviceName":"{}"}}"#,
input_device_name, output_device_name
);
let expected_token = expected_token.to_string();
// Spawn reader thread
std::thread::spawn(move || {
// Accept one connection
let (stream, _) = match listener.accept() {
Ok(s) => s,
Err(e) => {
error!("Failed to accept control connection: {}", e);
return;
}
};
info!("Control channel: parent connected");
let mut writer_stream = match stream.try_clone() {
Ok(s) => s,
Err(e) => {
error!("Failed to clone control stream: {}", e);
return;
}
};
// Spawn writer thread
std::thread::spawn(move || {
for line in write_receiver {
if let Err(e) = writeln!(writer_stream, "{}", line) {
error!("Failed to write to control channel: {}", e);
break;
}
if let Err(e) = writer_stream.flush() {
error!("Failed to flush control channel: {}", e);
break;
}
}
});
let reader = BufReader::new(stream);
let mut authenticated = false;
for line in reader.lines() {
let line = match line {
Ok(l) => l,
Err(e) => {
info!("Control channel read ended: {}", e);
break;
}
};
if line.trim().is_empty() {
continue;
}
let msg = match parse_message(&line) {
Ok(m) => m,
Err(e) => {
warn!("Failed to parse control message: {} (line: {})", e, line);
continue;
}
};
// First message must be auth
if !authenticated {
if let ControlMessage::Auth { ref token } = msg {
if validate_token(token, &expected_token) {
authenticated = true;
info!("Control channel: authenticated");
continue;
} else {
error!("Control channel: auth failed");
break;
}
} else {
error!("Control channel: first message must be auth");
break;
}
}
if let Err(e) = msg_sender.send(msg) {
info!("Control message receiver dropped: {}", e);
break;
}
}
});
// Send the ready message through the writer channel
// (it will be sent once the writer thread starts)
writer.send_line(&ready_msg);
Ok(ControlChannel {
msg_receiver,
writer,
})
}

View File

@ -1,410 +0,0 @@
mod config;
mod control;
mod platform;
use std::io::Read;
use std::sync::mpsc;
use std::time::Duration;
use anyhow::{Context, Result};
use log::{debug, error, info};
use ringrtc::common::{CallConfig, CallId, CallMediaType, DataMode, DeviceId};
use ringrtc::core::{call_manager::CallManager, signaling};
use ringrtc::lite::http;
use ringrtc::native::{NativeCallContext, NativePlatform, PeerId};
use ringrtc::virtual_audio::VirtualAudioDevicePair;
use ringrtc::webrtc::{
media::{VideoFrame, VideoSink},
peer_connection_factory::{AudioConfig, IceServer, PeerConnectionFactory},
};
use crate::config::Config;
use crate::control::{ControlMessage, start_control_channel};
use crate::platform::{
PlatformEvent, TunnelGroupHandler, TunnelSignalingSender, TunnelStateHandler,
};
/// Dummy video sink that discards all frames.
#[derive(Debug)]
struct NullVideoSink;
impl VideoSink for NullVideoSink {
fn on_video_frame(&self, _track_id: u32, _frame: VideoFrame) {}
fn box_clone(&self) -> Box<dyn VideoSink> {
Box::new(NullVideoSink)
}
}
/// Dummy HTTP client for CallManager (no SFU needed for 1:1 calls).
#[derive(Clone)]
struct NullHttpClient;
impl http::Delegate for NullHttpClient {
fn send_request(&self, _request_id: u32, _request: http::Request) {
// No-op -- no group call SFU requests
}
}
fn main() -> Result<()> {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info"))
.format_timestamp_millis()
.init();
// Read config from stdin
let mut config_str = String::new();
std::io::stdin()
.read_to_string(&mut config_str)
.context("failed to read config from stdin")?;
let config: Config =
serde_json::from_str(&config_str).context("failed to parse config JSON")?;
info!(
"signal-call-tunnel starting: call_id={}, is_outgoing={}",
config.call_id, config.is_outgoing
);
// Create virtual audio devices (signal-call-tunnel owns their lifecycle).
// On macOS, BlackHole drivers must be pre-installed with matching names (requires
// root), so we default to fixed names. On Linux, PulseAudio virtual sinks are
// created dynamically, so per-call unique names avoid collisions.
let input_name = config.input_device_name.clone().unwrap_or_else(|| {
if cfg!(target_os = "macos") {
"signal_input".to_string()
} else {
format!("signal_input_{}", config.call_id)
}
});
let output_name = config.output_device_name.clone().unwrap_or_else(|| {
if cfg!(target_os = "macos") {
"signal_output".to_string()
} else {
format!("signal_output_{}", config.call_id)
}
});
let virtual_audio = VirtualAudioDevicePair::new(&input_name, &output_name)?;
info!(
"Virtual audio devices: input={}, output={}",
virtual_audio.input_source(),
virtual_audio.output_sink()
);
// Channel for platform events (signaling + state changes)
let (event_sender, event_receiver) = mpsc::channel::<PlatformEvent>();
// Start control channel
let control = start_control_channel(
&config.control_socket_path,
&config.control_token,
virtual_audio.input_source(),
virtual_audio.output_sink(),
)?;
// Show WebRTC logs while debugging
#[cfg(debug_assertions)]
ringrtc::webrtc::logging::set_logger(log::LevelFilter::Debug);
#[cfg(not(debug_assertions))]
ringrtc::webrtc::logging::set_logger(log::LevelFilter::Warn);
// Disable macOS VPIO (VoiceProcessingIO) for cubeb audio streams.
// VPIO creates an aggregate device that hangs with BlackHole virtual audio
// drivers. Voice processing (AEC/AGC/NS) is unnecessary for virtual audio.
// Safety: called before any threads are spawned, single-threaded at this point.
unsafe { std::env::set_var("RINGRTC_NO_VOICE_PROCESSING", "1") };
let audio_config = AudioConfig::default();
let mut pcf = PeerConnectionFactory::new(&audio_config, false, "", None)?;
// Wait for cubeb to enumerate the virtual devices
loop {
std::thread::sleep(Duration::from_millis(100));
if pcf
.get_audio_playout_devices()
.is_ok_and(|d| !d.is_empty())
&& pcf
.get_audio_recording_devices()
.is_ok_and(|d| !d.is_empty())
{
break;
}
}
// Select virtual devices by name.
//
// We can't use set_audio_*_device_by_id() because the ADM matches on the
// cubeb unique_id (e.g. "signal_input2ch_UID"), not the friendly name we
// know ("signal_input"). Instead, enumerate and find the index by name.
let input_name = virtual_audio.input_source();
let recording_devices = pcf.get_audio_recording_devices()?;
let recording_index = recording_devices
.iter()
.position(|d| d.name == input_name)
.ok_or_else(|| anyhow::anyhow!("recording device '{}' not found", input_name))?
as u16;
pcf.set_audio_recording_device(recording_index)?;
info!("Selected recording device: index={}, name={}", recording_index, input_name);
let output_name = virtual_audio.output_sink();
let playout_devices = pcf.get_audio_playout_devices()?;
let playout_index = playout_devices
.iter()
.position(|d| d.name == output_name)
.ok_or_else(|| anyhow::anyhow!("playout device '{}' not found", output_name))?
as u16;
pcf.set_audio_playout_device(playout_index)?;
info!("Selected playout device: index={}, name={}", playout_index, output_name);
// Create platform with our trait implementations
let signaling_sender = Box::new(TunnelSignalingSender {
event_sender: event_sender.clone(),
});
let state_handler = Box::new(TunnelStateHandler {
event_sender: event_sender.clone(),
});
let group_handler = Box::new(TunnelGroupHandler);
let platform = NativePlatform::new(
pcf.clone(),
signaling_sender,
true, // should_assume_messages_sent
state_handler,
group_handler,
);
let http_client = http::DelegatingClient::new(NullHttpClient);
let mut call_manager = CallManager::new(platform, http_client)?;
// Peer ID is set by the first createOutgoingCall or receivedOffer message.
let mut active_peer_id = PeerId::from("remote");
let call_id = CallId::from(config.call_id);
let local_device_id = config.local_device_id as DeviceId;
info!("CallManager initialized, entering event loop");
// Spawn a thread to forward platform events to control channel
let control_writer = control.writer.clone();
std::thread::spawn(move || {
for event in event_receiver {
control_writer.send_event(&event);
}
});
// Main event loop: process control messages
loop {
let msg = match control.msg_receiver.recv_timeout(Duration::from_millis(100)) {
Ok(msg) => msg,
Err(mpsc::RecvTimeoutError::Timeout) => continue,
Err(mpsc::RecvTimeoutError::Disconnected) => {
info!("Control channel disconnected, exiting");
break;
}
};
match msg {
ControlMessage::Auth { .. } => {
// Already handled by control channel
}
ControlMessage::CreateOutgoingCall {
call_id: cid,
peer_id: pid,
} => {
let call_id = CallId::from(cid);
let peer_id = PeerId::from(pid.as_str());
active_peer_id = peer_id.clone();
info!("Creating outgoing call: call_id={}, peer_id={}", cid, pid);
if let Err(e) = call_manager.create_outgoing_call(
peer_id,
call_id,
CallMediaType::Audio,
local_device_id,
) {
error!("Failed to create outgoing call: {}", e);
control.writer.send_line(&format!(
r#"{{"type":"error","message":"failed to create outgoing call: {}"}}"#,
e
));
}
}
ControlMessage::Proceed {
call_id: cid,
ice_servers,
hide_ip,
} => {
let call_id = CallId::from(cid);
info!("Proceeding with call: call_id={}", cid);
let ice_server_list: Vec<IceServer> = ice_servers
.iter()
.map(|s| IceServer::new(
s.username.clone(),
s.password.clone(),
String::new(),
s.urls.clone(),
))
.collect();
let outgoing_audio_track = match pcf.create_outgoing_audio_track() {
Ok(t) => t,
Err(e) => {
error!("Failed to create audio track: {}", e);
continue;
}
};
let outgoing_video_source = match pcf.create_outgoing_video_source() {
Ok(s) => s,
Err(e) => {
error!("Failed to create video source: {}", e);
continue;
}
};
let outgoing_video_track =
match pcf.create_outgoing_video_track(&outgoing_video_source) {
Ok(t) => t,
Err(e) => {
error!("Failed to create video track: {}", e);
continue;
}
};
let call_context = NativeCallContext::new(
hide_ip,
ice_server_list,
outgoing_audio_track,
outgoing_video_track,
Box::new(NullVideoSink),
);
let call_config = CallConfig {
data_mode: DataMode::Low,
..Default::default()
};
if let Err(e) =
call_manager.proceed(call_id, call_context, call_config, None)
{
error!("Failed to proceed: {}", e);
control.writer.send_line(&format!(
r#"{{"type":"error","message":"failed to proceed: {}"}}"#,
e
));
}
}
ControlMessage::ReceivedOffer {
call_id: cid,
peer_id: pid,
sender_device_id,
opaque,
age_ms,
sender_identity_key,
receiver_identity_key,
} => {
let call_id = CallId::from(cid);
let peer_id = PeerId::from(pid.as_str());
active_peer_id = peer_id.clone();
info!(
"Received offer: call_id={}, peer_id={}, sender_device={}",
cid, pid, sender_device_id
);
let offer = match signaling::Offer::new(CallMediaType::Audio, opaque) {
Ok(o) => o,
Err(e) => {
error!("Failed to parse offer: {}", e);
continue;
}
};
let received = signaling::ReceivedOffer {
offer,
age: Duration::from_millis(age_ms),
sender_device_id: sender_device_id as DeviceId,
receiver_device_id: local_device_id,
sender_identity_key,
receiver_identity_key,
};
if let Err(e) = call_manager.received_offer(
peer_id,
call_id,
received,
) {
error!("Failed to process received offer: {}", e);
}
}
ControlMessage::ReceivedAnswer {
opaque,
sender_device_id,
sender_identity_key,
receiver_identity_key,
} => {
info!("Received answer from device {}", sender_device_id);
let answer = match signaling::Answer::new(opaque) {
Ok(a) => a,
Err(e) => {
error!("Failed to parse answer: {}", e);
continue;
}
};
let received = signaling::ReceivedAnswer {
answer,
sender_device_id: sender_device_id as DeviceId,
sender_identity_key,
receiver_identity_key,
};
if let Err(e) = call_manager.received_answer(
active_peer_id.clone(),
call_id,
received,
) {
error!("Failed to process received answer: {}", e);
}
}
ControlMessage::ReceivedIce { candidates } => {
debug!("Received {} ICE candidates", candidates.len());
let ice_candidates: Vec<signaling::IceCandidate> = candidates
.into_iter()
.map(signaling::IceCandidate::new)
.collect();
let received = signaling::ReceivedIce {
ice: signaling::Ice {
candidates: ice_candidates,
},
sender_device_id: 1 as DeviceId,
};
if let Err(e) = call_manager.received_ice(
active_peer_id.clone(),
call_id,
received,
) {
error!("Failed to process received ICE: {}", e);
}
}
ControlMessage::Accept => {
info!("Accepting call");
if let Err(e) = call_manager.accept_call(call_id) {
error!("Failed to accept call: {}", e);
}
}
ControlMessage::Hangup => {
info!("Hanging up");
if let Err(e) = call_manager.hangup() {
error!("Failed to hangup: {}", e);
}
// Give time for hangup to be sent
std::thread::sleep(Duration::from_millis(500));
break;
}
}
}
info!("signal-call-tunnel exiting");
Ok(())
}

View File

@ -1,481 +0,0 @@
use std::collections::{HashMap, HashSet};
use std::sync::mpsc;
use anyhow::Result;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64;
use log::{debug, error, info, warn};
use ringrtc::common::{CallId, CallMediaType, DeviceId};
use ringrtc::core::{group_call, signaling};
use ringrtc::lite::sfu::UserId;
use ringrtc::native::{
CallState, CallStateHandler, GroupUpdate, GroupUpdateHandler, SignalingSender,
};
use ringrtc::webrtc::peer_connection::AudioLevel;
use ringrtc::webrtc::peer_connection_observer::NetworkRoute;
/// Events sent from the platform callbacks to the control channel writer.
#[derive(Debug)]
pub enum PlatformEvent {
/// A signaling message to send to the parent process.
SendSignaling {
call_id: CallId,
message: SignalingEvent,
},
/// A call state change.
StateChange {
state: String,
reason: Option<String>,
},
}
#[derive(Debug)]
pub enum SignalingEvent {
SendOffer {
opaque: Vec<u8>,
call_media_type: CallMediaType,
},
SendAnswer {
opaque: Vec<u8>,
},
SendIce {
candidates: Vec<Vec<u8>>,
},
SendHangup {
hangup_type: String,
},
SendBusy,
}
impl PlatformEvent {
pub fn to_json(&self) -> String {
match self {
PlatformEvent::SendSignaling { call_id, message } => match message {
SignalingEvent::SendOffer {
opaque,
call_media_type,
} => {
let media_type = match call_media_type {
CallMediaType::Audio => "audio",
CallMediaType::Video => "video",
};
format!(
r#"{{"type":"sendOffer","callId":{},"opaque":"{}","callMediaType":"{}"}}"#,
u64::from(*call_id),
BASE64.encode(opaque),
media_type,
)
}
SignalingEvent::SendAnswer { opaque } => {
format!(
r#"{{"type":"sendAnswer","callId":{},"opaque":"{}"}}"#,
u64::from(*call_id),
BASE64.encode(opaque),
)
}
SignalingEvent::SendIce { candidates } => {
let candidates_json: Vec<String> = candidates
.iter()
.map(|c| format!(r#"{{"opaque":"{}"}}"#, BASE64.encode(c)))
.collect();
format!(
r#"{{"type":"sendIce","callId":{},"candidates":[{}]}}"#,
u64::from(*call_id),
candidates_json.join(","),
)
}
SignalingEvent::SendHangup { hangup_type } => {
format!(
r#"{{"type":"sendHangup","callId":{},"hangupType":"{}"}}"#,
u64::from(*call_id),
hangup_type,
)
}
SignalingEvent::SendBusy => {
format!(
r#"{{"type":"sendBusy","callId":{}}}"#,
u64::from(*call_id),
)
}
},
PlatformEvent::StateChange { state, reason } => {
if let Some(reason) = reason {
format!(
r#"{{"type":"stateChange","state":"{}","reason":"{}"}}"#,
state, reason,
)
} else {
format!(r#"{{"type":"stateChange","state":"{}"}}"#, state)
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::Value;
fn parse_event_json(event: &PlatformEvent) -> Value {
serde_json::from_str(&event.to_json()).expect("event JSON should be valid")
}
#[test]
fn send_offer_json() {
let event = PlatformEvent::SendSignaling {
call_id: CallId::from(42u64),
message: SignalingEvent::SendOffer {
opaque: vec![1, 2, 3],
call_media_type: CallMediaType::Audio,
},
};
let json = parse_event_json(&event);
assert_eq!(json["type"], "sendOffer");
assert_eq!(json["callId"], 42);
assert_eq!(json["callMediaType"], "audio");
// Verify opaque is valid base64 that decodes back
let opaque_b64 = json["opaque"].as_str().unwrap();
let decoded = BASE64.decode(opaque_b64).unwrap();
assert_eq!(decoded, vec![1, 2, 3]);
}
#[test]
fn send_offer_video_json() {
let event = PlatformEvent::SendSignaling {
call_id: CallId::from(1u64),
message: SignalingEvent::SendOffer {
opaque: vec![],
call_media_type: CallMediaType::Video,
},
};
let json = parse_event_json(&event);
assert_eq!(json["callMediaType"], "video");
}
#[test]
fn send_answer_json() {
let event = PlatformEvent::SendSignaling {
call_id: CallId::from(99u64),
message: SignalingEvent::SendAnswer {
opaque: vec![4, 5, 6],
},
};
let json = parse_event_json(&event);
assert_eq!(json["type"], "sendAnswer");
assert_eq!(json["callId"], 99);
let decoded = BASE64.decode(json["opaque"].as_str().unwrap()).unwrap();
assert_eq!(decoded, vec![4, 5, 6]);
}
#[test]
fn send_ice_json() {
let event = PlatformEvent::SendSignaling {
call_id: CallId::from(7u64),
message: SignalingEvent::SendIce {
candidates: vec![vec![10, 20], vec![30, 40]],
},
};
let json = parse_event_json(&event);
assert_eq!(json["type"], "sendIce");
assert_eq!(json["callId"], 7);
let candidates = json["candidates"].as_array().unwrap();
assert_eq!(candidates.len(), 2);
let c0 = BASE64
.decode(candidates[0]["opaque"].as_str().unwrap())
.unwrap();
assert_eq!(c0, vec![10, 20]);
let c1 = BASE64
.decode(candidates[1]["opaque"].as_str().unwrap())
.unwrap();
assert_eq!(c1, vec![30, 40]);
}
#[test]
fn send_ice_empty_candidates_json() {
let event = PlatformEvent::SendSignaling {
call_id: CallId::from(1u64),
message: SignalingEvent::SendIce {
candidates: vec![],
},
};
let json = parse_event_json(&event);
assert_eq!(json["candidates"].as_array().unwrap().len(), 0);
}
#[test]
fn send_hangup_json() {
let event = PlatformEvent::SendSignaling {
call_id: CallId::from(50u64),
message: SignalingEvent::SendHangup {
hangup_type: "normal".to_string(),
},
};
let json = parse_event_json(&event);
assert_eq!(json["type"], "sendHangup");
assert_eq!(json["callId"], 50);
assert_eq!(json["hangupType"], "normal");
}
#[test]
fn send_busy_json() {
let event = PlatformEvent::SendSignaling {
call_id: CallId::from(8u64),
message: SignalingEvent::SendBusy,
};
let json = parse_event_json(&event);
assert_eq!(json["type"], "sendBusy");
assert_eq!(json["callId"], 8);
}
#[test]
fn state_change_with_reason_json() {
let event = PlatformEvent::StateChange {
state: "Ended".to_string(),
reason: Some("Timeout".to_string()),
};
let json = parse_event_json(&event);
assert_eq!(json["type"], "stateChange");
assert_eq!(json["state"], "Ended");
assert_eq!(json["reason"], "Timeout");
}
#[test]
fn state_change_without_reason_json() {
let event = PlatformEvent::StateChange {
state: "Connected".to_string(),
reason: None,
};
let json = parse_event_json(&event);
assert_eq!(json["type"], "stateChange");
assert_eq!(json["state"], "Connected");
assert!(json.get("reason").is_none());
}
// --- Round-trip tests: platform event -> JSON -> parse as control message ---
#[test]
fn round_trip_offer() {
let opaque = vec![0xDE, 0xAD, 0xBE, 0xEF];
let event = PlatformEvent::SendSignaling {
call_id: CallId::from(123u64),
message: SignalingEvent::SendOffer {
opaque: opaque.clone(),
call_media_type: CallMediaType::Audio,
},
};
let json_str = event.to_json();
// The parent would receive this JSON and could extract the opaque
let parsed: Value = serde_json::from_str(&json_str).unwrap();
let decoded = BASE64
.decode(parsed["opaque"].as_str().unwrap())
.unwrap();
assert_eq!(decoded, opaque);
}
#[test]
fn round_trip_ice() {
let candidates = vec![vec![1, 2, 3], vec![4, 5, 6, 7, 8]];
let event = PlatformEvent::SendSignaling {
call_id: CallId::from(456u64),
message: SignalingEvent::SendIce {
candidates: candidates.clone(),
},
};
let json_str = event.to_json();
let parsed: Value = serde_json::from_str(&json_str).unwrap();
let arr = parsed["candidates"].as_array().unwrap();
assert_eq!(arr.len(), 2);
for (i, c) in arr.iter().enumerate() {
let decoded = BASE64.decode(c["opaque"].as_str().unwrap()).unwrap();
assert_eq!(decoded, candidates[i]);
}
}
}
/// Implements SignalingSender -- relays signaling messages to the control channel.
pub struct TunnelSignalingSender {
pub event_sender: mpsc::Sender<PlatformEvent>,
}
impl SignalingSender for TunnelSignalingSender {
fn send_signaling(
&self,
_recipient_id: &str,
call_id: CallId,
_receiver_device_id: Option<DeviceId>,
message: signaling::Message,
) -> Result<()> {
let event = match message {
signaling::Message::Offer(offer) => SignalingEvent::SendOffer {
opaque: offer.opaque,
call_media_type: offer.call_media_type,
},
signaling::Message::Answer(answer) => SignalingEvent::SendAnswer {
opaque: answer.opaque,
},
signaling::Message::Ice(ice) => SignalingEvent::SendIce {
candidates: ice.candidates.into_iter().map(|c| c.opaque).collect(),
},
signaling::Message::Hangup(hangup) => {
let (hangup_type, _device_id) = hangup.to_type_and_device_id();
SignalingEvent::SendHangup {
hangup_type: format!("{:?}", hangup_type).to_lowercase(),
}
}
signaling::Message::Busy => SignalingEvent::SendBusy,
};
if let Err(e) = self.event_sender.send(PlatformEvent::SendSignaling {
call_id,
message: event,
}) {
error!("Failed to send signaling event: {}", e);
}
Ok(())
}
fn send_call_message(
&self,
_recipient_id: UserId,
_message: Vec<u8>,
_urgency: group_call::SignalingMessageUrgency,
) -> Result<()> {
// No-op for 1:1 calls
Ok(())
}
fn send_call_message_to_group(
&self,
_group_id: group_call::GroupId,
_message: Vec<u8>,
_urgency: group_call::SignalingMessageUrgency,
_recipients_override: HashSet<UserId>,
) -> Result<()> {
// No-op for 1:1 calls
Ok(())
}
fn send_call_message_to_adhoc_group(
&self,
_message: Vec<u8>,
_urgency: group_call::SignalingMessageUrgency,
_expiration: u64,
_recipients_to_endorsements: HashMap<UserId, Vec<u8>>,
) -> Result<()> {
// No-op for 1:1 calls
Ok(())
}
}
/// Implements CallStateHandler -- relays state changes to the control channel.
pub struct TunnelStateHandler {
pub event_sender: mpsc::Sender<PlatformEvent>,
}
impl CallStateHandler for TunnelStateHandler {
fn handle_call_state(
&self,
_remote_peer_id: &str,
_call_id: CallId,
call_state: CallState,
) -> Result<()> {
let (state, reason) = match call_state {
CallState::Incoming(media_type) => {
(format!("Incoming({:?})", media_type), None)
}
CallState::Outgoing(media_type) => {
(format!("Outgoing({:?})", media_type), None)
}
CallState::Ringing => ("Ringing".to_string(), None),
CallState::Connected => ("Connected".to_string(), None),
CallState::Connecting => ("Connecting".to_string(), None),
CallState::Ended(reason, _summary) => {
("Ended".to_string(), Some(format!("{:?}", reason)))
}
CallState::Rejected(reason) => {
("Rejected".to_string(), Some(format!("{:?}", reason)))
}
CallState::Concluded => ("Concluded".to_string(), None),
};
info!("Call state: {} (reason: {:?})", state, reason);
if let Err(e) = self
.event_sender
.send(PlatformEvent::StateChange { state, reason })
{
error!("Failed to send state change event: {}", e);
}
Ok(())
}
fn handle_remote_audio_state(
&self,
_remote_peer_id: &str,
enabled: bool,
) -> Result<()> {
debug!("Remote audio state: {}", enabled);
Ok(())
}
fn handle_remote_video_state(
&self,
_remote_peer_id: &str,
enabled: bool,
) -> Result<()> {
debug!("Remote video state: {}", enabled);
Ok(())
}
fn handle_remote_sharing_screen(
&self,
_remote_peer_id: &str,
enabled: bool,
) -> Result<()> {
debug!("Remote sharing screen: {}", enabled);
Ok(())
}
fn handle_network_route(
&self,
_remote_peer_id: &str,
network_route: NetworkRoute,
) -> Result<()> {
info!("Network route: {:?}", network_route);
Ok(())
}
fn handle_audio_levels(
&self,
_remote_peer_id: &str,
_captured_level: AudioLevel,
_received_level: AudioLevel,
) -> Result<()> {
// Don't log -- too noisy
Ok(())
}
fn handle_low_bandwidth_for_video(
&self,
_remote_peer_id: &str,
recovered: bool,
) -> Result<()> {
if recovered {
info!("Low bandwidth for video: recovered");
} else {
warn!("Low bandwidth for video");
}
Ok(())
}
}
/// Implements GroupUpdateHandler -- all no-ops for 1:1 calls.
pub struct TunnelGroupHandler;
impl GroupUpdateHandler for TunnelGroupHandler {
fn handle_group_update(&self, _update: GroupUpdate) -> Result<()> {
Ok(())
}
}

View File

@ -8,7 +8,7 @@ public class BaseConfig {
public static final String PROJECT_VERSION = BaseConfig.class.getPackage().getImplementationVersion();
static final String USER_AGENT_SIGNAL_ANDROID = Optional.ofNullable(System.getenv("SIGNAL_CLI_USER_AGENT"))
.orElse("Signal-Android/8.0.3");
.orElse("Signal-Android/8.1.2");
static final String USER_AGENT_SIGNAL_CLI = PROJECT_NAME == null
? "signal-cli"
: PROJECT_NAME + "/" + PROJECT_VERSION;

View File

@ -224,6 +224,11 @@ public class ReceiveMessageHandler implements Manager.ReceiveMessageHandler {
final var unpinMessage = message.unpinMessage().get();
printUnpinMessage(writer.indentedWriter(), unpinMessage);
}
if (message.adminDelete().isPresent()) {
writer.println("Admin Delete:");
final var adminDelete = message.adminDelete().get();
printAdminDelete(writer.indentedWriter(), adminDelete);
}
}
private void printEditMessage(PlainTextWriter writer, MessageEnvelope.Edit message) {
@ -648,6 +653,11 @@ public class ReceiveMessageHandler implements Manager.ReceiveMessageHandler {
writer.println("Target timestamp: {}", DateUtils.formatTimestamp(unpinMessage.targetSentTimestamp()));
}
private void printAdminDelete(final PlainTextWriter writer, final MessageEnvelope.Data.AdminDelete adminDelete) {
writer.println("Target author: {}", formatContact(adminDelete.targetAuthor()));
writer.println("Target timestamp: {}", DateUtils.formatTimestamp(adminDelete.targetSentTimestamp()));
}
private String formatContact(RecipientAddress address) {
final var number = address.getLegacyIdentifier();
final var name = m.getContactOrProfileName(RecipientIdentifier.Single.fromAddress(address));

View File

@ -41,10 +41,12 @@ public class Commands {
addCommand(new RemoveDeviceCommand());
addCommand(new RemovePinCommand());
addCommand(new RemoteDeleteCommand());
addCommand(new SendAdminDeleteCommand());
addCommand(new SendCommand());
addCommand(new SendContactsCommand());
addCommand(new SendMessageRequestResponseCommand());
addCommand(new SendPaymentNotificationCommand());
addCommand(new SendPinMessageCommand());
addCommand(new SendPollCreateCommand());
addCommand(new SendPollVoteCommand());
addCommand(new SendPollTerminateCommand());
@ -52,6 +54,7 @@ public class Commands {
addCommand(new SendReceiptCommand());
addCommand(new SendSyncRequestCommand());
addCommand(new SendTypingCommand());
addCommand(new SendUnpinMessageCommand());
addCommand(new SetPinCommand());
addCommand(new StartCallCommand());
addCommand(new SubmitRateLimitChallengeCommand());

View File

@ -144,6 +144,7 @@ public class ListContactsCommand implements JsonRpcLocalCommand {
contact.nickNameFamilyName(),
contact.note(),
contact.color(),
contact.isArchived(),
contact.isBlocked(),
contact.isHidden(),
contact.messageExpirationTime(),

View File

@ -0,0 +1,90 @@
package org.asamk.signal.commands;
import net.sourceforge.argparse4j.impl.Arguments;
import net.sourceforge.argparse4j.inf.Namespace;
import net.sourceforge.argparse4j.inf.Subparser;
import org.asamk.signal.commands.exceptions.CommandException;
import org.asamk.signal.commands.exceptions.UnexpectedErrorException;
import org.asamk.signal.commands.exceptions.UserErrorException;
import org.asamk.signal.manager.Manager;
import org.asamk.signal.manager.api.GroupNotFoundException;
import org.asamk.signal.manager.api.GroupSendingNotAllowedException;
import org.asamk.signal.manager.api.NotAGroupMemberException;
import org.asamk.signal.manager.api.RecipientIdentifier;
import org.asamk.signal.manager.api.UnregisteredRecipientException;
import org.asamk.signal.output.OutputWriter;
import org.asamk.signal.util.CommandUtil;
import java.io.IOException;
import java.util.Set;
import static org.asamk.signal.util.SendMessageResultUtils.outputResult;
public class SendAdminDeleteCommand implements JsonRpcLocalCommand {
@Override
public String getName() {
return "sendAdminDelete";
}
@Override
public void attachToSubparser(final Subparser subparser) {
subparser.help("Send admin delete message for a previously received or sent message.");
subparser.addArgument("-g", "--group-id", "--group").help("Specify the recipient group ID.").nargs("+");
subparser.addArgument("--notify-self")
.help("If self is part of recipients/groups send a normal message, not a sync message.")
.action(Arguments.storeTrue());
subparser.addArgument("-a", "--target-author")
.required(true)
.help("Specify the number of the author of the message to admin delete.");
subparser.addArgument("-t", "--target-timestamp")
.required(true)
.type(long.class)
.help("Specify the timestamp of the message to admin delete.");
subparser.addArgument("--story")
.help("Admin delete a story instead of a normal message")
.action(Arguments.storeTrue());
}
@Override
public void handleCommand(
final Namespace ns,
final Manager m,
final OutputWriter outputWriter
) throws CommandException {
final var notifySelf = Boolean.TRUE.equals(ns.getBoolean("notify-self"));
final var groupIdStrings = ns.<String>getList("group-id");
Set<RecipientIdentifier.Group> groupIdentifiers = CommandUtil.getGroupIdentifiers(groupIdStrings);
if (groupIdentifiers.isEmpty()) {
throw new UserErrorException("Admin delete requires group IDs");
}
final var targetAuthor = ns.getString("target-author");
final var targetTimestamp = ns.getLong("target-timestamp");
final var isStory = Boolean.TRUE.equals(ns.getBoolean("story"));
final RecipientIdentifier.Single targetAuthorIdentifier = CommandUtil.getSingleRecipientIdentifier(targetAuthor,
m.getSelfNumber());
try {
final var results = m.sendAdminDelete(targetAuthorIdentifier,
targetTimestamp,
groupIdentifiers,
notifySelf,
isStory);
outputResult(outputWriter, results);
} catch (GroupNotFoundException | NotAGroupMemberException | GroupSendingNotAllowedException e) {
throw new UserErrorException(e.getMessage());
} catch (IOException e) {
throw new UnexpectedErrorException("Failed to send message: " + e.getMessage() + " (" + e.getClass()
.getSimpleName() + ")", e);
} catch (UnregisteredRecipientException e) {
throw new UserErrorException("The user " + e.getSender().getIdentifier() + " is not registered.");
}
}
}

View File

@ -0,0 +1,104 @@
package org.asamk.signal.commands;
import net.sourceforge.argparse4j.impl.Arguments;
import net.sourceforge.argparse4j.inf.Namespace;
import net.sourceforge.argparse4j.inf.Subparser;
import org.asamk.signal.commands.exceptions.CommandException;
import org.asamk.signal.commands.exceptions.UnexpectedErrorException;
import org.asamk.signal.commands.exceptions.UserErrorException;
import org.asamk.signal.manager.Manager;
import org.asamk.signal.manager.api.GroupNotFoundException;
import org.asamk.signal.manager.api.GroupSendingNotAllowedException;
import org.asamk.signal.manager.api.NotAGroupMemberException;
import org.asamk.signal.manager.api.RecipientIdentifier;
import org.asamk.signal.manager.api.UnregisteredRecipientException;
import org.asamk.signal.output.OutputWriter;
import org.asamk.signal.util.CommandUtil;
import java.io.IOException;
import static org.asamk.signal.util.SendMessageResultUtils.outputResult;
public class SendPinMessageCommand implements JsonRpcLocalCommand {
@Override
public String getName() {
return "sendPinMessage";
}
@Override
public void attachToSubparser(final Subparser subparser) {
subparser.help("Send pin message for a previously received or sent message.");
subparser.addArgument("-g", "--group-id", "--group").help("Specify the recipient group ID.").nargs("*");
subparser.addArgument("recipient").help("Specify the recipients' phone number.").nargs("*");
subparser.addArgument("-u", "--username").help("Specify the recipient username or username link.").nargs("*");
subparser.addArgument("--note-to-self").help("Send the pin message to self.").action(Arguments.storeTrue());
subparser.addArgument("--notify-self")
.help("If self is part of recipients/groups send a normal message, not a sync message.")
.action(Arguments.storeTrue());
subparser.addArgument("-d", "--pin-duration")
.type(int.class)
.setDefault(-1)
.help("Specify the pin duration in seconds. Use -1 for forever.");
subparser.addArgument("-a", "--target-author")
.required(true)
.help("Specify the number of the author of the message to pin.");
subparser.addArgument("-t", "--target-timestamp")
.required(true)
.type(long.class)
.help("Specify the timestamp of the message to pin.");
subparser.addArgument("--story").help("Pin a story instead of a normal message").action(Arguments.storeTrue());
}
@Override
public void handleCommand(
final Namespace ns,
final Manager m,
final OutputWriter outputWriter
) throws CommandException {
final var notifySelf = Boolean.TRUE.equals(ns.getBoolean("notify-self"));
final var isNoteToSelf = Boolean.TRUE.equals(ns.getBoolean("note-to-self"));
final var recipientStrings = ns.<String>getList("recipient");
final var groupIdStrings = ns.<String>getList("group-id");
final var usernameStrings = ns.<String>getList("username");
final var recipientIdentifiers = CommandUtil.getRecipientIdentifiers(m,
isNoteToSelf,
recipientStrings,
groupIdStrings,
usernameStrings);
final var pinDuration = ns.getInt("pin-duration");
final var targetAuthor = ns.getString("target-author");
final var targetTimestamp = ns.getLong("target-timestamp");
final var isStory = Boolean.TRUE.equals(ns.getBoolean("story"));
final RecipientIdentifier.Single targetAuthorIdentifier;
if (targetAuthor == null && recipientIdentifiers.size() == 1 && recipientIdentifiers.stream()
.findFirst()
.get() instanceof RecipientIdentifier.Single single) {
targetAuthorIdentifier = single;
} else {
targetAuthorIdentifier = CommandUtil.getSingleRecipientIdentifier(targetAuthor, m.getSelfNumber());
}
try {
final var results = m.sendPinMessage(pinDuration,
targetAuthorIdentifier,
targetTimestamp,
recipientIdentifiers,
notifySelf,
isStory);
outputResult(outputWriter, results);
} catch (GroupNotFoundException | NotAGroupMemberException | GroupSendingNotAllowedException e) {
throw new UserErrorException(e.getMessage());
} catch (IOException e) {
throw new UnexpectedErrorException("Failed to send message: " + e.getMessage() + " (" + e.getClass()
.getSimpleName() + ")", e);
} catch (UnregisteredRecipientException e) {
throw new UserErrorException("The user " + e.getSender().getIdentifier() + " is not registered.");
}
}
}

View File

@ -24,6 +24,7 @@ import static org.asamk.signal.util.SendMessageResultUtils.outputResult;
public class SendPollCreateCommand implements JsonRpcLocalCommand {
private static final Logger logger = LoggerFactory.getLogger(SendPollCreateCommand.class);
private static final int MAX_POLL_OPTIONS = 10;
@Override
public String getName() {
@ -72,6 +73,9 @@ public class SendPollCreateCommand implements JsonRpcLocalCommand {
if (options.size() < 2) {
throw new UserErrorException("Poll needs at least two options");
}
if (options.size() > MAX_POLL_OPTIONS) {
throw new UserErrorException("Poll cannot have more than " + MAX_POLL_OPTIONS + " options");
}
try {
var results = m.sendPollCreateMessage(question, !noMulti, options, recipientIdentifiers, notifySelf);

View File

@ -0,0 +1,100 @@
package org.asamk.signal.commands;
import net.sourceforge.argparse4j.impl.Arguments;
import net.sourceforge.argparse4j.inf.Namespace;
import net.sourceforge.argparse4j.inf.Subparser;
import org.asamk.signal.commands.exceptions.CommandException;
import org.asamk.signal.commands.exceptions.UnexpectedErrorException;
import org.asamk.signal.commands.exceptions.UserErrorException;
import org.asamk.signal.manager.Manager;
import org.asamk.signal.manager.api.GroupNotFoundException;
import org.asamk.signal.manager.api.GroupSendingNotAllowedException;
import org.asamk.signal.manager.api.NotAGroupMemberException;
import org.asamk.signal.manager.api.RecipientIdentifier;
import org.asamk.signal.manager.api.UnregisteredRecipientException;
import org.asamk.signal.output.OutputWriter;
import org.asamk.signal.util.CommandUtil;
import java.io.IOException;
import static org.asamk.signal.util.SendMessageResultUtils.outputResult;
public class SendUnpinMessageCommand implements JsonRpcLocalCommand {
@Override
public String getName() {
return "sendUnpinMessage";
}
@Override
public void attachToSubparser(final Subparser subparser) {
subparser.help("Send unpin message for a previously received or sent message.");
subparser.addArgument("-g", "--group-id", "--group").help("Specify the recipient group ID.").nargs("*");
subparser.addArgument("recipient").help("Specify the recipients' phone number.").nargs("*");
subparser.addArgument("-u", "--username").help("Specify the recipient username or username link.").nargs("*");
subparser.addArgument("--note-to-self").help("Send the unpin message to self.").action(Arguments.storeTrue());
subparser.addArgument("--notify-self")
.help("If self is part of recipients/groups send a normal message, not a sync message.")
.action(Arguments.storeTrue());
subparser.addArgument("-a", "--target-author")
.required(true)
.help("Specify the number of the author of the message to unpin.");
subparser.addArgument("-t", "--target-timestamp")
.required(true)
.type(long.class)
.help("Specify the timestamp of the message to unpin.");
subparser.addArgument("--story")
.help("Unpin a story instead of a normal message")
.action(Arguments.storeTrue());
}
@Override
public void handleCommand(
final Namespace ns,
final Manager m,
final OutputWriter outputWriter
) throws CommandException {
final var notifySelf = Boolean.TRUE.equals(ns.getBoolean("notify-self"));
final var isNoteToSelf = Boolean.TRUE.equals(ns.getBoolean("note-to-self"));
final var recipientStrings = ns.<String>getList("recipient");
final var groupIdStrings = ns.<String>getList("group-id");
final var usernameStrings = ns.<String>getList("username");
final var recipientIdentifiers = CommandUtil.getRecipientIdentifiers(m,
isNoteToSelf,
recipientStrings,
groupIdStrings,
usernameStrings);
final var targetAuthor = ns.getString("target-author");
final var targetTimestamp = ns.getLong("target-timestamp");
final var isStory = Boolean.TRUE.equals(ns.getBoolean("story"));
final RecipientIdentifier.Single targetAuthorIdentifier;
if (targetAuthor == null && recipientIdentifiers.size() == 1 && recipientIdentifiers.stream()
.findFirst()
.get() instanceof RecipientIdentifier.Single single) {
targetAuthorIdentifier = single;
} else {
targetAuthorIdentifier = CommandUtil.getSingleRecipientIdentifier(targetAuthor, m.getSelfNumber());
}
try {
final var results = m.sendUnpinMessage(targetAuthorIdentifier,
targetTimestamp,
recipientIdentifiers,
notifySelf,
isStory);
outputResult(outputWriter, results);
} catch (GroupNotFoundException | NotAGroupMemberException | GroupSendingNotAllowedException e) {
throw new UserErrorException(e.getMessage());
} catch (IOException e) {
throw new UnexpectedErrorException("Failed to send message: " + e.getMessage() + " (" + e.getClass()
.getSimpleName() + ")", e);
} catch (UnregisteredRecipientException e) {
throw new UserErrorException("The user " + e.getSender().getIdentifier() + " is not registered.");
}
}
}

View File

@ -493,6 +493,40 @@ public class DbusManagerImpl implements Manager {
groupId));
}
@Override
public SendMessageResults sendAdminDelete(
final RecipientIdentifier.Single targetAuthor,
final long targetSentTimestamp,
final Set<RecipientIdentifier.Group> recipients,
final boolean notifySelf,
final boolean isStory
) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException, UnregisteredRecipientException {
throw new UnsupportedOperationException();
}
@Override
public SendMessageResults sendPinMessage(
final int pinDuration,
final RecipientIdentifier.Single targetAuthor,
final long targetSentTimestamp,
final Set<RecipientIdentifier> recipients,
final boolean notifySelf,
final boolean isStory
) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException, UnregisteredRecipientException {
throw new UnsupportedOperationException();
}
@Override
public SendMessageResults sendUnpinMessage(
final RecipientIdentifier.Single targetAuthor,
final long targetSentTimestamp,
final Set<RecipientIdentifier> recipients,
final boolean notifySelf,
final boolean isStory
) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException, UnregisteredRecipientException {
throw new UnsupportedOperationException();
}
@Override
public SendMessageResults sendPaymentNotificationMessage(
final byte[] receipt,
@ -1046,6 +1080,7 @@ public class DbusManagerImpl implements Manager {
List.of(),
List.of(),
Optional.empty(),
Optional.empty(),
Optional.empty())),
Optional.empty(),
Optional.empty(),
@ -1094,6 +1129,7 @@ public class DbusManagerImpl implements Manager {
List.of(),
List.of(),
Optional.empty(),
Optional.empty(),
Optional.empty()))),
Optional.empty(),
Optional.empty(),
@ -1174,6 +1210,7 @@ public class DbusManagerImpl implements Manager {
List.of(),
List.of(),
Optional.empty(),
Optional.empty(),
Optional.empty())),
Optional.empty(),
Optional.empty())),

View File

@ -0,0 +1,21 @@
package org.asamk.signal.json;
import org.asamk.signal.manager.api.MessageEnvelope;
import java.util.UUID;
public record JsonAdminDelete(
@Deprecated String targetAuthor, String targetAuthorNumber, String targetAuthorUuid, long targetSentTimestamp
) {
static JsonAdminDelete from(MessageEnvelope.Data.AdminDelete adminDelete) {
final var address = adminDelete.targetAuthor();
final var targetAuthor = address.getLegacyIdentifier();
final var targetAuthorNumber = address.number().orElse(null);
final var targetAuthorUuid = address.uuid().map(UUID::toString).orElse(null);
final var targetSentTimestamp = adminDelete.targetSentTimestamp();
return new JsonAdminDelete(targetAuthor, targetAuthorNumber, targetAuthorUuid, targetSentTimestamp);
}
}

View File

@ -16,6 +16,7 @@ public record JsonContact(
String nickFamilyName,
String note,
String color,
boolean isArchived,
boolean isBlocked,
boolean isHidden,
int messageExpirationTime,

View File

@ -29,7 +29,8 @@ record JsonDataMessage(
@JsonInclude(JsonInclude.Include.NON_NULL) JsonGroupInfo groupInfo,
@JsonInclude(JsonInclude.Include.NON_NULL) JsonStoryContext storyContext,
@JsonInclude(JsonInclude.Include.NON_NULL) JsonPinMessage pinMessage,
@JsonInclude(JsonInclude.Include.NON_NULL) JsonUnpinMessage unpinMessage
@JsonInclude(JsonInclude.Include.NON_NULL) JsonUnpinMessage unpinMessage,
@JsonInclude(JsonInclude.Include.NON_NULL) JsonAdminDelete adminDelete
) {
static JsonDataMessage from(MessageEnvelope.Data dataMessage, Manager m) {
@ -75,6 +76,7 @@ record JsonDataMessage(
.toList() : null;
final var pinMessage = dataMessage.pinMessage().map(JsonPinMessage::from).orElse(null);
final var unpinMessage = dataMessage.unpinMessage().map(JsonUnpinMessage::from).orElse(null);
final var adminDelete = dataMessage.adminDelete().map(JsonAdminDelete::from).orElse(null);
return new JsonDataMessage(timestamp,
message,
@ -97,6 +99,7 @@ record JsonDataMessage(
groupInfo,
storyContext,
pinMessage,
unpinMessage);
unpinMessage,
adminDelete);
}
}

View File

@ -2354,6 +2354,27 @@
{
"type": "org.asamk.signal.dbus.DbusSignalImpl$DbusSignalIdentityImpl"
},
{
"type": "org.asamk.signal.json.JsonAdminDelete",
"methods": [
{
"name": "targetAuthor",
"parameterTypes": []
},
{
"name": "targetAuthorNumber",
"parameterTypes": []
},
{
"name": "targetAuthorUuid",
"parameterTypes": []
},
{
"name": "targetSentTimestamp",
"parameterTypes": []
}
]
},
{
"type": "org.asamk.signal.json.JsonAttachment",
"allDeclaredFields": true,
@ -5799,6 +5820,18 @@
}
]
},
{
"type": "org.signal.libsignal.protocol.SessionCipher$2",
"jniAccessible": true,
"methods": [
{
"name": "loadSignedPreKey",
"parameterTypes": [
"int"
]
}
]
},
{
"type": "org.signal.libsignal.protocol.SignalProtocolAddress",
"jniAccessible": true,
@ -6049,6 +6082,10 @@
"type": "org.signal.libsignal.protocol.state.internal.PreKeyStore",
"jniAccessible": true
},
{
"type": "org.signal.libsignal.protocol.state.internal.SignedPreKeyStore",
"jniAccessible": true
},
{
"type": "org.signal.libsignal.usernames.BadDiscriminatorCharacterException",
"jniAccessible": true,
@ -9783,6 +9820,27 @@
{
"glob": "com/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_*"
},
{
"glob": "com/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_AG"
},
{
"glob": "com/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_AI"
},
{
"glob": "com/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_AS"
},
{
"glob": "com/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_BB"
},
{
"glob": "com/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_BM"
},
{
"glob": "com/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_BS"
},
{
"glob": "com/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_CA"
},
{
"glob": "com/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_DE"
},

1
third-party/ringrtc vendored

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

View File

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

View File

View File

@ -1,22 +0,0 @@
#!/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"

View File

@ -1,916 +0,0 @@
#!/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()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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