Compare commits

...

166 Commits

Author SHA1 Message Date
AsamK
29e65a5c8e Add missing graalvm metadata
Fixes #2050
2026-05-25 11:51:08 +02:00
AsamK
60779c91c6 Add missing graalvm metadata
Fixes #2051
2026-05-25 11:40:46 +02:00
AsamK
bda4e7fc0f Prepare next release 2026-05-24 01:20:23 +02:00
AsamK
12ffc34967 Bump version to 0.14.4.1 2026-05-24 00:51:21 +02:00
AsamK
9e3585dcce Fix cli default values 2026-05-24 00:51:12 +02:00
AsamK
46c61c5aac Bump version to 0.14.4 2026-05-23 23:01:29 +02:00
AsamK
8d6264e02e Update dependencies 2026-05-23 22:56:09 +02:00
AsamK
f057c5031c Fix use of deprecated API 2026-05-23 22:55:54 +02:00
AsamK
40b1928844 Fix removal of local only unregistered accounts in storage sync 2026-05-23 22:39:59 +02:00
AsamK
2a827f1285 Don't log empty alerts 2026-05-23 21:34:33 +02:00
AsamK
ced9560040 Update libsignal-service 2026-05-23 20:54:42 +02:00
AsamK
44d54b3215 Add support for a global configuration file 2026-05-23 17:56:35 +02:00
AsamK
393e1efcd1 Create temp file with limited permissions 2026-05-23 14:30:33 +02:00
AsamK
f34b552054 Validate host header for http daemon 2026-05-23 14:21:25 +02:00
AsamK
46ce552589 Normalize attachment ids 2026-05-23 14:17:28 +02:00
AsamK
6da5c37504 Prevent attaching files from the signal-cli data directory 2026-05-23 13:50:39 +02:00
AsamK
4601e60118 Adapt containerfile to older apt versions 2026-05-16 11:14:17 +02:00
AsamK
fcf82b9318 Adapt containerfile to older apt versions 2026-05-16 10:48:12 +02:00
AsamK
9c8137fafa Update dependencies 2026-05-15 17:06:21 +02:00
AsamK
0a1531dcce Improve destination/source checks for incoming messages 2026-05-13 18:29:26 +02:00
AsamK
c10f618a3e Update gradle 2026-05-13 18:26:12 +02:00
AsamK
4a3d9d90a6 Update libsignal-service 2026-05-13 18:25:35 +02:00
AsamK
b4275414e1 Pass correct serviceId to SignalServiceCipher
Fixes #2036
2026-05-13 17:39:44 +02:00
Connor Lanigan
5f94b7b6d1
fix: Attempted immutable list modification causes runtime exception (#2038) 2026-05-05 10:15:21 +02:00
legacycode
dc43e44020
fix: flush SSE response headers immediately on connect (#2034)
Without an initial flush(), the JVM HttpServer buffers all
output until the first flush() in the 15-second keep-alive loop.
Clients with shorter timeouts (e.g. 10 s) abort before receiving
any data.

Add a flush() call directly after creating ServerSentEventSender,
before the wait loop, so the HTTP 200 response and headers reach the
client immediately upon connection.

Adds regression test SseInitialFlushTest that verifies at least one
byte arrives within 2 seconds of connecting to GET /api/v1/events.
2026-04-29 22:52:34 +02:00
AsamK
251bd2d87a Refactor profile key extraction 2026-04-27 16:36:22 +02:00
AsamK
a3fcda7598 Update kotlin jvm version for buildSrc 2026-04-27 16:27:15 +02:00
Patrick Dattilio
c9e2504349
Store profile keys from group requesting members (#2031)
When filling or updating a V2 group, profile keys were copied from
DecryptedGroup.members into the local profile store but not from
requestingMembers. Admins who never had a prior session with a user in
the join queue then lacked profile keys and could not decrypt profiles
(e.g. for listContacts).

Also process DecryptedRequestingMember entries the same way as full
members, using DecryptedMember / DecryptedRequestingMember types so the
lib module does not require a direct protobuf dependency.

Made-with: Cursor
2026-04-27 16:25:47 +02:00
dependabot[bot]
9b09df5f17
Bump rustls-webpki from 0.103.12 to 0.103.13 in /client (#2030)
Bumps [rustls-webpki](https://github.com/rustls/webpki) from 0.103.12 to 0.103.13.
- [Release notes](https://github.com/rustls/webpki/releases)
- [Commits](https://github.com/rustls/webpki/compare/v/0.103.12...v/0.103.13)

---
updated-dependencies:
- dependency-name: rustls-webpki
  dependency-version: 0.103.13
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-24 19:55:35 +02:00
AsamK
5fe94ff44a Temporarily disable 26 build due to container issue 2026-04-23 21:58:10 +02:00
AsamK
6286a054eb Update libsignal-service 2026-04-23 20:37:46 +02:00
AsamK
e6635d1bb0 Prepare next release 2026-04-23 20:37:46 +02:00
AsamK
056878fad7 Bump version to 0.14.3 2026-04-22 23:16:58 +02:00
AsamK
da214817be Reduce number of installed packages 2026-04-22 22:43:13 +02:00
AsamK
d6edaf3be2 Update reproducible build container images 2026-04-22 22:42:49 +02:00
AsamK
47e50988b5 Make SOURCE_DATE_EPOCH configurable 2026-04-22 22:24:26 +02:00
AsamK
aa446619f2 Add script to update pinned container versions 2026-04-22 22:08:10 +02:00
AsamK
6405655127 Only build native/client containers if required 2026-04-22 21:43:26 +02:00
AsamK
b8d990b0f9 Adapt workflows to use previous naming scheme 2026-04-22 21:36:08 +02:00
AsamK
417d2ce971 Keep websocket connection alive during call 2026-04-16 21:04:20 +02:00
AsamK
33b2b563b3 Don't send busy call response to allow linked devices to accept call 2026-04-16 20:54:19 +02:00
AsamK
740cd6f89b Update dependencies 2026-04-16 20:53:58 +02:00
AsamK
7887ed408d Update libsignal-service 2026-04-16 20:47:39 +02:00
tonycpsu
ddfad2c4ce
Add distinct JSON-RPC error code for captcha rejection (#2021)
* Add distinct JSON-RPC error code for captcha rejection

Previously submitRateLimitChallenge mapped CaptchaRejectedException to
the generic USER_ERROR code (-1), making it indistinguishable from any
other user error (bad params, unknown command, etc.).

Introduce CaptchaRejectedErrorException and wire it to a new error code
(-6 / CAPTCHA_REJECTED_ERROR) throughout the JSON-RPC layer. Callers can
now reliably distinguish a rejected captcha token (user must obtain a
fresh token) from a network failure (transient, worth retrying) or a
generic argument error.

The CLI exit code for this path becomes 6, consistent with the existing
per-error-type exit code convention.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Add exit code 6 to man page

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 08:00:39 +02:00
AsamK
7e95ea7403 Log identifier of failed profile download
Fixes #2003
2026-04-15 21:18:26 +02:00
BarbossHack
2991cdafe7
Add reproducible builds (#1954) 2026-04-15 21:10:42 +02:00
AsamK
561dfc373f Refactor retry after handling 2026-04-15 21:01:29 +02:00
Gara Dorta
5bfb044245
JSON Schema for JSON-RPC (#1952)
* Add OpenAPIDocs

* Remove the json prefix from the names

* Format file

* Rename models to schemas

* Add required = true to all the required attributes

* Add missing required = true schemas

* Deprecated fields are not required

* switch to micronout json generation

* Fix generator for JsonUnwrapped files

* Fix layout of manual schemas

* Pretty print the json files

* Remove @JsonProperty(required = true)

* Make references local

* Updated the readme

* Removed uneeded import

* Remove extra empty lines

* Clean readme

* Add docs depedency only when needed

* Revert uneeded changes

* Revert more changes

* Better formatting

* Simplified name

* fix: remove jsonunwrapped workaround by upgrading to micronaut-json-schema version 2.0.0-M6

* Simplified jsonSchemas task definition

* Updated readme with the new schemas path

* typo fixing

* Remove empty space from merge
2026-04-15 20:57:36 +02:00
tonycpsu
e1b17bf863
Surface server Retry-After for rate-limit send failures (#2016)
* Surface retry-after seconds for plain rate-limit failures

libsignal-service's RateLimitException exposes retryAfterMilliseconds
for HTTP 413 responses, but signal-cli only forwarded retry-after for
ProofRequired (428) failures. Clients had no signal for when it was
safe to retry plain rate-limited sends, so every failed retry
potentially extended the server-side window.

SendMessageResult now carries an optional rateLimitRetryAfterSeconds,
populated from the upstream Optional<Long>. JsonSendMessageResult
exposes it for RATE_LIMIT_FAILURE type. Text output includes the
window when known. Aggregate RateLimitErrorException now carries the
real nextAttemptTimestamp (was hardcoded to 0).

Closes #1996.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Address review: include proof-required retry-after and ceiling-round millis

Codex adversarial review flagged two issues in the phase 1 retry-after
plumbing:

* Aggregate retry-after ignored proof-required failures. Because
  isRateLimitFailure is true for proof-required cases but
  rateLimitRetryAfterSeconds was only populated from plain 413s, an
  all-proof-required batch (or a mixed batch where the proof-required
  delay was longer) could flow into outputResult() and produce a
  RateLimitException(0), telling callers to retry immediately.

* Millisecond Retry-After values were truncated by integer division,
  so 1..999ms became 0 and non-second-aligned values lost up to 999ms.
  A retry suggested from the floored value can land before the
  server's real deadline and re-trigger the limit.

SendMessageResult.from(...) now populates rateLimitRetryAfterSeconds
from either the proof-required seconds or the plain rate-limit ms
(converted via ceiling division), giving maxRateLimitRetryAfterSeconds
a single source of truth. JsonSendMessageResult.from(...) reads the
unified field. New millisToCeilingSeconds helper plus boundary test.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Preserve source compat and document retry-after field change

Add a non-canonical 8-arg SendMessageResult constructor that delegates
to the canonical form with null retry-after. This keeps source
compatibility for any downstream code that constructs the record
directly (tests, mocks) without changing the canonical shape. Records
permit additional constructors alongside the canonical one.

Document the retryAfterSeconds meaning change in the CHANGELOG. The
field was previously populated only for proof-required failures; it
is now populated whenever the server sends a Retry-After header. The
canonical proof-required discriminator is still token != null.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 20:17:54 +02:00
AsamK
aafb40fd94 Increase connection disconnect duration to match android app
Related #2018
2026-04-15 00:17:44 +02:00
Stefan Meinecke
7dc55eba81
Fix sender key re-distribution on every group message (#2019)
* Fix sender key re-distribution on every group message in daemon mode

sendGroupMessageInternalWithSenderKey() calls sender.send() which handles
distribution and delivery, but never calls markSenderKeySharedWith() on
success. SenderKeySharedStore therefore has no record that the distribution
was sent, causing it to re-distribute to all recipients on every subsequent
sendGroupMessage call.

This results in a fresh unidentified TLS connection being opened for each
group message (~6s delay per send), even for back-to-back sends to the
same group. All send modes are affected: DBus daemon, JSON-RPC socket/http,
and CLI send command all share the same code path.

The fix mirrors the existing pattern in resendMessage() (line 307): after
a successful send, record each successful recipient's address+device in
the sender key shared store.

* Fix sender key re-distribution on every group message

SenderKeySharedStore.markSenderKeysSharedWith() stored the address using
entry.toString() instead of entry.address(). Since SenderKeySharedEntry is
a Java record, toString() returns the full record representation:

  SenderKeySharedEntry[address=<uuid>, deviceId=1]

instead of just the UUID. When signal-service-java later calls
getSenderKeySharedWith() and compares the retrieved addresses against the
current group member UUIDs, the comparison always fails — causing the
distribution message to be re-sent to all recipients on every
sendGroupMessage call.

This results in a fresh unidentified TLS connection being opened for each
group message (~6s delay per send), even for immediate consecutive sends
to the same group. All send modes are affected: DBus daemon, JSON-RPC
socket/http, and the CLI send command all share the same code path.

The fix is a one-character change: entry.address() instead of
entry.toString().
2026-04-15 00:06:19 +02:00
AsamK
a03d17a9e4 Use padded and encrypted attachment size for upload spec
Fixes #2014
2026-04-12 22:34:59 +02:00
AsamK
364f89f1d0 Prepare next release 2026-04-12 22:09:36 +02:00
AsamK
d0ee90dbbc Reformat files 2026-04-11 12:29:16 +02:00
AsamK
398faa50b0 Make address cache synchronized 2026-04-11 12:26:41 +02:00
AsamK
e9eabbeeb5 Add commits in early returns 2026-04-11 12:26:24 +02:00
tonycpsu
132dfb95dc
Fix SQLiteException in resolveRecipient by checking cache before opening connection (#2011) 2026-04-11 12:23:15 +02:00
AsamK
2651823d4d Fix add group member handling for already members 2026-04-11 11:56:50 +02:00
AsamK
4709cfacc7 Update multiple member roles in one change
Fixes #2009
2026-04-11 11:55:59 +02:00
AsamK
9bc4c0ecd8 Update libsignal-service 2026-04-10 18:24:57 +02:00
AsamK
763ddf85e6 Bump version to 0.14.2 2026-04-04 15:05:24 +02:00
AsamK
b2bab0d0dc Add libsignal-version file
Fixes #1964
2026-04-03 11:40:08 +02:00
AsamK
62fc96c4c9 Handle MustRequestNewCodeException
Fixes #1968
2026-04-03 11:20:46 +02:00
AsamK
2667688139 Update gradle wrapper 2026-04-03 11:20:01 +02:00
AsamK
990d1eab58 Add java 26 to ci build 2026-04-03 10:11:04 +02:00
AsamK
e6b33b8da7 Check for missing attachment id
Closes #1989
2026-04-02 22:06:10 +02:00
AsamK
d40f62ec21 Adapt exception handling to libsignal-service changes 2026-04-02 21:32:19 +02:00
AsamK
265369e353 Update libsignal-service 2026-04-01 22:47:54 +02:00
AsamK
d1106299fe Pass sender device id to ice handler 2026-04-01 22:47:54 +02:00
AsamK
7919a0f4aa Change subscribeCallEvents command to match subscribeReceive 2026-04-01 22:47:54 +02:00
AsamK
7a8a34f45e Some call refactoring 2026-04-01 22:47:54 +02:00
AsamK
0a777ea7df Some minor code improvements 2026-04-01 22:08:08 +02:00
AsamK
103a0807ca Update graalvm buildtools 2026-04-01 22:07:48 +02:00
Shaheen Gandhi
135d3a1677
Add voice calling support (#1932)
* 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>

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

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

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

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

* Remove unused integration test tag from lib/build.gradle.kts

The excludeTags("integration") block was added but no tests use the
@Tag("integration") annotation. Revert to upstream's simple
useJUnitPlatform() call.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Derive install dir from jar location instead of nonexistent property

The signal.cli.install.dir system property was never set by the Gradle
start script or anywhere else. Replace it with code source detection:
resolve the jar's parent directory to find the install root, then look
for bin/signal-call-tunnel relative to that.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Remove explicit success responses from hangup and reject commands

Successful commands with no additional information should not return
a response, matching the pattern used by other signal-cli commands
like SendSyncRequestCommand and UpdateConfigurationCommand.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Use instanceof pattern matching for call ID extraction

Replace explicit null check and Number cast with instanceof pattern
matching in AcceptCallCommand, HangupCallCommand, and
RejectCallCommand.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Guard handleIncoming* methods against missing call event listeners

Skip processing incoming call offers when no call event listeners are
registered, since there is nobody to notify about the call. For hangup
and busy, also guard when there are no listeners AND no active call
(the tunnel may still need cleanup if already spawned).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Use Jackson JSON serialization in CallManager

Replace all manual JSON string concatenation with Jackson ObjectNode
construction and ObjectMapper serialization. Use BigInteger for call
IDs to properly represent unsigned 64-bit values in JSON.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Add subscribeCallEvents command for opt-in call event notifications

Call events are no longer subscribed by default. JSON-RPC clients must
explicitly call subscribeCallEvents to receive callEvent notifications
and enable incoming call handling. This avoids sending unwanted call
events to clients that don't use voice calling.

Also adds unsubscribeCallEvents for cleanup, idempotent subscription
guard, and updates CALL_TUNNEL.md to document the subscription step.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Replace Unix socket with stdin/stdout for tunnel communication

Use the tunnel subprocess' stdin for sending control messages and
stdout for receiving control events, instead of a separate Unix
domain socket. This eliminates:
- Temporary directory creation (/tmp/sc-<random>/)
- Socket path and auth token in config JSON
- Connection retry loop (50x at 200ms)
- Auth message handshake
- Socket cleanup on call end

The tunnel's stderr is captured separately for logging. Config JSON
is written as the first line on stdin, followed by control messages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 22:07:16 +02:00
Kevin Kickartz-Grabowsky
59a0bd87cd
Add --voice-note flag for send command (#1973)
Add support for marking audio attachments as voice notes when sending
messages. Voice notes are displayed inline with a play button in Signal
clients, rather than as file attachments.

This addresses a longstanding TODO in AttachmentUtils.java and resolves
the feature request in #1601.

Changes:
- Add 'voiceNote' field to Message record
- Pass voiceNote flag through AttachmentHelper to AttachmentUtils
- Add .withVoiceNote() to SignalServiceAttachmentStream builder
- Add --voice-note CLI argument to SendCommand
- Support voiceNote parameter in JSON-RPC mode

Usage:
  signal-cli send -a audio.m4a --voice-note +1234567890

JSON-RPC:
  {"method":"send","params":{"attachment":"audio.m4a","voiceNote":true,...}}

Closes #1601
2026-03-28 12:28:13 +01:00
AsamK
c94da00212 Update libsignal-service 2026-03-21 22:32:38 +01:00
AsamK
36649a8526 Update CONTRIBUTING.md 2026-03-21 22:04:48 +01:00
AsamK
3297acd3f4 Update gradle 2026-03-21 22:04:48 +01:00
AsamK
3e5caf9284 Prepare next release 2026-03-21 22:04:48 +01:00
dependabot[bot]
53ae9e4266
Bump rustls-webpki from 0.103.9 to 0.103.10 in /client (#1982)
Bumps [rustls-webpki](https://github.com/rustls/webpki) from 0.103.9 to 0.103.10.
- [Release notes](https://github.com/rustls/webpki/releases)
- [Commits](https://github.com/rustls/webpki/compare/v/0.103.9...v/0.103.10)

---
updated-dependencies:
- dependency-name: rustls-webpki
  dependency-version: 0.103.10
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-21 16:37:53 +01:00
joeykrim
313f5392ef
Fix updateGroup --admin and --remove-member silent failures (#1970)
Fix type mismatch in retainAll() calls that cause updateGroup --admin
and --remove-member to silently do nothing.

retainAll() compared Set<RecipientId> against Collection<GroupMemberInfo>,
which always evaluates to false (different types), emptying the set.

Replace group.getMembers() with group.getMemberRecipientIds() in all
three affected locations.

Fixes: updateGroup --admin silently failing to promote members
Fixes: updateGroup --remove-member silently failing to remove members

Co-authored-by: joey <joey@Mac-Studio.local>
2026-03-13 08:08:02 +01:00
moppman
7d89375d3a
Update GraalVM metadata (#1966)
Add isArchived from #1957
2026-03-09 19:10:36 +01:00
Zachary Johnson
db0f660d08
enforce poll options between 1 and 100 characters; update documentation (#1965)
pre-PR cleanup
2026-03-09 19:09:58 +01:00
AsamK
b498d2050a Bump version to 0.14.1 2026-03-08 12:55:39 +01:00
AsamK
27a722dc75 Add flags to update group member labels 2026-03-08 12:23:26 +01:00
AsamK
7014f629fe Show member labels in listGroups command 2026-03-08 11:55:19 +01:00
AsamK
30b57bdb3d Update graalvm build tools 2026-03-08 08:41:44 +01:00
AsamK
b94162afbc Enable spqr capability 2026-03-07 15:58:01 +01: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
AsamK
516a37ba69 Fix accepting group invite using PNI
Fixes #1841
2026-02-28 10:15:05 +01:00
AsamK
6a6bebd503 Add support for receiving pin/unpin messages
Related #1923
2026-02-28 09:37:29 +01:00
AsamK
f9cbfa6d6c Invert urgent boolean in Message to be consistent 2026-02-25 21:43:52 +01:00
Kai Kozlov
d4b3816c5d
Add --no-urgent flag to send command (#1933)
* Add --no-push flag to send command

Expose the server's `urgent` parameter so callers can skip sending a
push notification (FCM/APNs) to the recipient. The message is still
delivered in real-time over WebSocket if the recipient's app is active.

The flag is added to the Message record (following the same pattern as
viewOnce) and threaded through ManagerImpl and SendHelper, keeping the
Manager interface unchanged.

* Rename --no-push flag to --no-urgent

Align with the protocol naming as suggested by the maintainer.
The flag controls the 'urgent' parameter on the server request.
2026-02-25 21:42:51 +01:00
AsamK
4a35d47515 Set explicit console colors for qr code 2026-02-25 21:37:41 +01:00
AsamK
52d4d61e2b Improve aci/pni handling in storage contact sync 2026-02-25 21:36:10 +01:00
AsamK
5bff902394 Configure signal service logger 2026-02-25 21:02:22 +01:00
AsamK
f33eb86335 Fix remote updates of unregistered contacts 2026-02-25 20:23:44 +01:00
AsamK
2ea26b9d1b Load recipient profiles in listContacts command if required 2026-02-25 20:08:23 +01:00
AsamK
10fa3e1619 Load unregistered_timestamp for recipient 2026-02-25 20:08:01 +01:00
AsamK
956e17c81c Improve aci/pni comparison in contact record processor 2026-02-25 19:49:29 +01:00
AsamK
6f749352d8 Split unregistered recipients when loading profile fails with 404 2026-02-25 19:48:58 +01:00
AsamK
5f3f6c071b Update libsignal-service-java
Fixes #1931
2026-02-25 00:50:20 +01:00
Karel Vervaeke
5795d43d0d print qr code when producing a device link uri, but only if System.console is not null 2026-02-19 18:35:04 +01:00
Denys Filonenko
de42f55e37 Fix typo in SendPollCreateCommand.java
Fix typo in error message, when options < 2
2026-02-16 18:22:38 +01:00
AsamK
7e93a15204 Add missing dbus reachability metadata
Fixes #1925
2026-02-15 14:29:13 +01:00
AsamK
715f819c3e Move graalvm reachability-metadata to resources 2026-02-15 14:07:47 +01:00
AsamK
345de8fb5d Update reachability metadata 2026-02-14 15:20:12 +01:00
AsamK
667a47c03a Update dependencies 2026-02-14 13:15:37 +01:00
AsamK
5dd5e304bd Update gradle wrapper 2026-02-14 12:54:45 +01:00
AsamK
60a1589616 Use new graalvm metadata format 2026-02-14 12:54:29 +01:00
AsamK
4d1d28672d Refactor message receive 2026-02-14 12:15:27 +01:00
Brian (bex) Exelbierd
fefca7d837 Add --ignore-avatars and --ignore-stickers CLI flags
Implement two new CLI flags to disable downloading avatars and sticker
packs during message reception, following the existing pattern of
--ignore-attachments and --ignore-stories flags.

Changes:
- Add --ignore-avatars and --ignore-stickers flags to ReceiveCommand,
  DaemonCommand, and JsonRpcDispatcherCommand
- Extend ReceiveConfig record with ignoreAvatars and ignoreStickers
  fields
- Pass ignoreAvatars as explicit boolean parameter to ProfileHelper,
  SyncHelper, and GroupHelper methods (per maintainer feedback)
- Gate avatar downloads in ProfileHelper (profile avatars), SyncHelper
  (contact avatars), and GroupHelper (group avatars for V1 and V2)
- Gate sticker pack downloads in IncomingMessageHandler for both
  direct sticker messages and sync sticker pack operations
- Update handleSignalServiceDataMessage and handleSyncMessage to pass
  full ReceiveConfig instead of individual boolean flags
- Update man page (signal-cli.1.adoc) with flag documentation
- Add entries to CHANGELOG.md

When these flags are set, the respective content is not downloaded
during message reception. Metadata (avatar paths, sticker pack IDs)
is still stored, and existing FileNotFoundException handling will
surface if content is later requested but wasn't downloaded.

Fixes #1903
Closes #1904
2026-02-14 12:15:13 +01:00
AsamK
d9f5a573cd Update graalvm jni config
Fixes #1919
2026-02-06 19:26:07 +01:00
AsamK
4c77cde9da Bump version to 0.13.24 2026-02-05 21:35:06 +01:00
AsamK
658b098c3a Update graalvm config 2026-02-05 17:57:41 +01:00
AsamK
70644ba31b Update libsignal-service 2026-02-05 17:52:11 +01:00
dependabot[bot]
06a706d070 Bump bytes from 1.10.1 to 1.11.1 in /client
Bumps [bytes](https://github.com/tokio-rs/bytes) from 1.10.1 to 1.11.1.
- [Release notes](https://github.com/tokio-rs/bytes/releases)
- [Changelog](https://github.com/tokio-rs/bytes/blob/master/CHANGELOG.md)
- [Commits](https://github.com/tokio-rs/bytes/compare/v1.10.1...v1.11.1)

---
updated-dependencies:
- dependency-name: bytes
  dependency-version: 1.11.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-04 10:56:09 +01:00
AsamK
14297986f2 Use SequencedCollection instead of List 2026-01-24 17:24:27 +01:00
AsamK
0bd4d554d8 Use virtual threads 2026-01-24 17:24:27 +01:00
AsamK
32c8d4f801 Update to java 25 2026-01-24 15:38:02 +01:00
AsamK
82abc20871 Remove deprecated functionality 2026-01-24 15:37:00 +01:00
AsamK
e8ab01f665 Fix use of deprecated API in plugin 2026-01-24 15:20:55 +01:00
AsamK
dee557a9ad Prepare next release 2026-01-24 15:09:23 +01:00
AsamK
f1fa2eba1d Bump version to 0.13.23 2026-01-24 14:21:40 +01:00
AsamK
ccd58bbf23 Use graalvm-community 2026-01-24 14:21:40 +01:00
AsamK
54700d9cd0 Update libsignal-service 2026-01-24 12:36:29 +01:00
AsamK
984ea47f9d Update gradle 2026-01-24 12:36:29 +01:00
AsamK
9458972d15 Update dependencies 2026-01-24 12:36:29 +01:00
AsamK
8eb9662694 Implement new updateDevice command to update device names
Fixes #1906
2026-01-23 20:00:25 +01:00
Benjamin Loison
a9be1aa608 Remove an unnecessary space in README.md 2026-01-23 19:37:26 +01:00
AsamK
3af9dff0ed Update libsignal-service 2025-12-10 19:49:57 +01:00
AsamK
c5e4b250b8 Parse binary aci/pni in storage records 2025-12-10 18:07:25 +01:00
AsamK
5fafa24974 Create correct nickname record if nickname is empty 2025-12-08 19:09:57 +01:00
AsamK
b26c521930 Extend username documentation
Fixes #1886
2025-12-08 17:44:06 +01:00
AsamK
eb52380ecf Remove now unnecessary check for primary device from updateContact
Fixes #1880
2025-12-08 17:08:38 +01:00
AsamK
f1de69d7ff Set same toolchain in lib module as in main module 2025-12-07 19:58:25 +01:00
AsamK
ba2214d8c7 Silence unchecked error 2025-12-07 19:47:53 +01:00
AsamK
fca4d7459c Clear verification sessionId after registration/changeNumber
Fixes #1882
2025-12-05 21:28:36 +01:00
AsamK
ad2338b898 Add documentation for polls
Closes #1885
2025-12-05 21:20:53 +01:00
AsamK
c237f98044 Mark legacy accounts without ACI as unregistered 2025-12-05 20:08:07 +01:00
AsamK
87945ac506 Ignore authorization failed errors in multi account mode
Fixes #1884
2025-12-05 20:08:07 +01:00
Artemii Bigdan
11d96c894d Print user number in UserAlreadyExistsException DBus error
Currently, the error looks like "UserAlreadyExistsException null", which does not give enough information to handle this situation in the multi-account daemon mode.

This change adds a phone number to the error message resolving my issue and achieving functional parity with CLI interface.
2025-12-03 18:39:48 +01:00
AsamK
8dcc16d640 Update README.md 2025-11-19 20:31:54 +01:00
Ankur Heramb Joshi
9a1ddd0d41 Revise installation commands in README.md
Updated installation instructions for signal-cli to use the latest version dynamically.

1] Uncompressed signal-cli-"${VERSION}".tar.gz directly produces executable named signal-cli and NOT named signal-cli-"${VERSION}" , thus was getting error in installation. 
This change should rectify the issue.

2] Also finding latest release version is simplified
2025-11-19 20:31:54 +01:00
AsamK
b8bb58b083 Prepare next release 2025-11-14 20:38:10 +01:00
248 changed files with 19181 additions and 5575 deletions

57
.github/workflows/build.yml vendored Normal file
View File

@ -0,0 +1,57 @@
name: build
on:
push:
branches:
- "**"
pull_request:
workflow_call:
permissions: {}
jobs:
build:
strategy:
matrix:
# java="25" is the LTS Java version used in reproducible builds script (default in Containerfile).
# More Java versions can be added to test compatibility, eg. "26".
java: ["25", "26"]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Build
run: |
if [ "${{ matrix.java }}" != "25" ]; then
export OVERRIDE_JAVA_VERSION="${{ matrix.java }}"
fi
./reproducible-builds/build.sh
- name: Upload build artifacts
uses: actions/upload-artifact@v7
with:
name: signal-cli-archive-${{ matrix.java }}
path: dist/*
build-client:
strategy:
matrix:
os:
- ubuntu
- macos
- windows
runs-on: ${{ matrix.os }}-latest
defaults:
run:
working-directory: ./client
steps:
- 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@v7
with:
name: signal-cli-client-${{ matrix.os }}
path: |
client/target/release/signal-cli-client
client/target/release/signal-cli-client.exe

View File

@ -1,96 +0,0 @@
name: signal-cli CI
on:
push:
branches:
- '**'
pull_request:
workflow_call:
permissions:
contents: write # to fetch code (actions/checkout) and submit dependency graph (gradle/gradle-build-action)
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
java: [ '21', '25' ]
steps:
- uses: actions/checkout@v4
- name: Set up JDK
uses: actions/setup-java@v3
with:
distribution: 'zulu'
java-version: ${{ matrix.java }}
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
with:
dependency-graph: generate-and-submit
- name: Install asciidoc
run: sudo apt update && sudo apt --no-install-recommends install -y asciidoc-base
- name: Build with Gradle
run: ./gradlew --no-daemon build
- name: Build man page
run: |
cd man
make install
- name: Add man page to archive
run: |
version=$(tar tf build/distributions/signal-cli-*.tar | head -n1 | sed 's|signal-cli-\([^/]*\)/.*|\1|')
echo $version
tar --transform="flags=r;s|man|signal-cli-${version}/man|" -rf build/distributions/signal-cli-${version}.tar man/man{1,5}
- name: Compress archive
run: gzip -n -9 build/distributions/signal-cli-*.tar
- name: Archive production artifacts
uses: actions/upload-artifact@v4
with:
name: signal-cli-archive-${{ matrix.java }}
path: build/distributions/signal-cli-*.tar.gz
build-graalvm:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: graalvm/setup-graalvm@v1
with:
version: 'latest'
java-version: '21'
cache: 'gradle'
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Build with Gradle
run: ./gradlew --no-daemon nativeCompile
- name: Archive production artifacts
uses: actions/upload-artifact@v4
with:
name: signal-cli-native
path: build/native/nativeCompile/signal-cli
build-client:
strategy:
matrix:
os:
- ubuntu
- macos
- windows
runs-on: ${{ matrix.os }}-latest
defaults:
run:
working-directory: ./client
steps:
- uses: actions/checkout@v4
- name: Install rust
run: rustup default stable
- name: Build client
run: cargo build --release --verbose
- name: Archive production artifacts
uses: actions/upload-artifact@v4
with:
name: signal-cli-client-${{ matrix.os }}
path: |
client/target/release/signal-cli-client
client/target/release/signal-cli-client.exe

View File

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

View File

@ -5,98 +5,35 @@ on:
tags: tags:
- v* - v*
permissions: permissions: {}
contents: write # to fetch code (actions/checkout) and create release
env: env:
IMAGE_NAME: signal-cli IMAGE_NAME: signal-cli
IMAGE_REGISTRY: ghcr.io/asamk IMAGE_REGISTRY: ghcr.io/asamk
REGISTRY_USER: ${{ github.actor }} REGISTRY_USER: ${{ github.actor }}
REGISTRY_PASSWORD: ${{ github.token }} REGISTRY_PASSWORD: ${{ github.token }}
ARCHIVE_JAVA_VERSION: 25
jobs: jobs:
build:
uses: ./.github/workflows/build.yml
ci_wf: release:
permissions: needs: build
contents: write
uses: AsamK/signal-cli/.github/workflows/ci.yml@master
# ${{ github.repository }} not accepted here
lib_to_jar:
needs: ci_wf
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: write contents: write
outputs: outputs:
signal_cli_version: ${{ steps.cli_ver.outputs.version }} version: ${{ steps.version.outputs.version }}
release_id: ${{ steps.create_release.outputs.id }}
steps: steps:
- name: Download signal-cli build from CI workflow - name: Download signal-cli build from CI workflow
uses: actions/download-artifact@v4 uses: actions/download-artifact@v8
- name: Get signal-cli version - name: Get signal-cli version
id: cli_ver id: version
run: | run: |
ver="${GITHUB_REF_NAME#v}" mv ./signal-cli-archive-${{ env.ARCHIVE_JAVA_VERSION }}/* .
echo "version=${ver}" >> $GITHUB_OUTPUT echo "version=$(cat VERSION)" >> $GITHUB_OUTPUT
- name: Extract archive
run: |
tree .
ARCHIVE_DIR=$(ls signal-cli-archive-*/ -d | tail -n1)
tar -xzf ./"${ARCHIVE_DIR}"/*.tar.gz
mv ./"${ARCHIVE_DIR}"/*.tar.gz signal-cli-${{ steps.cli_ver.outputs.version }}.tar.gz
rm -rf signal-cli-archive-*/
# - name: Get signal-client jar version
# id: lib_ver
# run: |
# JAR_PREFIX=libsignal-client-
# jar_file=$(find ./signal-cli-*/lib/ -name "$JAR_PREFIX*.jar")
# jar_version=$(echo "$jar_file" | xargs basename | sed "s/$JAR_PREFIX//; s/.jar//")
# echo "$jar_version"
# echo "signal_client_version=${jar_version}" >> $GITHUB_OUTPUT
#
# - name: Download signal-client builds
# env:
# RELEASES_URL: https://github.com/signalapp/libsignal/releases/download/
# FILE_NAMES: signal_jni.dll libsignal_jni.dylib
# SIGNAL_CLIENT_VER: ${{ steps.lib_ver.outputs.signal_client_version }}
# run: |
# for file_name in $FILE_NAMES; do
# curl -sOL "${RELEASES_URL}/v${SIGNAL_CLIENT_VER}/${file_name}" # note: added v
# done
# tree .
- name: Compress native app
env:
SIGNAL_CLI_VER: ${{ steps.cli_ver.outputs.version }}
run: |
chmod +x signal-cli-native/signal-cli
tar -czf signal-cli-${SIGNAL_CLI_VER}-Linux-native.tar.gz -C signal-cli-native signal-cli
rm -rf signal-cli-native/
# - name: Replace Windows lib
# env:
# SIGNAL_CLI_VER: ${{ steps.cli_ver.outputs.version }}
# SIGNAL_CLIENT_VER: ${{ steps.lib_ver.outputs.signal_client_version }}
# run: |
# mv signal_jni.dll libsignal_jni.so
# zip -u ./signal-cli-*/lib/libsignal-client-${SIGNAL_CLIENT_VER}.jar ./libsignal_jni.so
# tar -czf signal-cli-${SIGNAL_CLI_VER}-Windows.tar.gz signal-cli-*/
#
# - name: Replace macOS lib
# env:
# SIGNAL_CLI_VER: ${{ steps.cli_ver.outputs.version }}
# SIGNAL_CLIENT_VER: ${{ steps.lib_ver.outputs.signal_client_version }}
# run: |
# jar_file=./signal-cli-*/lib/libsignal-client-${SIGNAL_CLIENT_VER}.jar
# zip -d $jar_file libsignal_jni.so
# zip $jar_file libsignal_jni.dylib
# tar -czf signal-cli-${SIGNAL_CLI_VER}-macOS.tar.gz signal-cli-*/
- name: Create release - name: Create release
id: create_release id: create_release
@ -104,8 +41,8 @@ jobs:
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: with:
tag_name: v${{ steps.cli_ver.outputs.version }} # note: added `v` tag_name: v${{ steps.version.outputs.version }} # note: added `v`
release_name: v${{ steps.cli_ver.outputs.version }} # note: added `v` release_name: v${{ steps.version.outputs.version }} # note: added `v`
draft: true draft: true
- name: Upload archive - name: Upload archive
@ -114,19 +51,9 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: with:
upload_url: ${{ steps.create_release.outputs.upload_url }} upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: signal-cli-${{ steps.cli_ver.outputs.version }}.tar.gz asset_path: signal-cli-${{ steps.version.outputs.version }}.tar.gz
asset_name: signal-cli-${{ steps.cli_ver.outputs.version }}.tar.gz asset_name: signal-cli-${{ steps.version.outputs.version }}.tar.gz
asset_content_type: application/x-compressed-tar # .tar.gz asset_content_type: application/x-compressed-tar # .tar.gz
# - name: Upload Linux 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.tar.gz
# asset_name: signal-cli-${{ steps.cli_ver.outputs.version }}-Linux.tar.gz
# asset_content_type: application/x-compressed-tar # .tar.gz
- name: Upload Linux native archive - name: Upload Linux native archive
uses: actions/upload-release-asset@v1 uses: actions/upload-release-asset@v1
@ -134,64 +61,44 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: with:
upload_url: ${{ steps.create_release.outputs.upload_url }} upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: signal-cli-${{ steps.cli_ver.outputs.version }}-Linux-native.tar.gz asset_path: signal-cli-${{ steps.version.outputs.version }}-Linux-native.tar.gz
asset_name: signal-cli-${{ steps.cli_ver.outputs.version }}-Linux-native.tar.gz asset_name: signal-cli-${{ steps.version.outputs.version }}-Linux-native.tar.gz
asset_content_type: application/x-compressed-tar # .tar.gz asset_content_type: application/x-compressed-tar # .tar.gz
# - name: Upload windows archive - name: Upload Linux client archive
# uses: actions/upload-release-asset@v1 uses: actions/upload-release-asset@v1
# env: env:
# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# with: with:
# upload_url: ${{ steps.create_release.outputs.upload_url }} upload_url: ${{ steps.create_release.outputs.upload_url }}
# asset_path: signal-cli-${{ steps.cli_ver.outputs.version }}-Windows.tar.gz asset_path: signal-cli-${{ steps.version.outputs.version }}-Linux-client.tar.gz
# asset_name: signal-cli-${{ steps.cli_ver.outputs.version }}-Windows.tar.gz asset_name: signal-cli-${{ steps.version.outputs.version }}-Linux-client.tar.gz
# asset_content_type: application/x-compressed-tar # .tar.gz asset_content_type: application/x-compressed-tar # .tar.gz
#
# - name: Upload macos 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 }}-macOS.tar.gz
# asset_name: signal-cli-${{ steps.cli_ver.outputs.version }}-macOS.tar.gz
# asset_content_type: application/x-compressed-tar # .tar.gz
build-container: build-container:
needs: ci_wf needs: release
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read
packages: write packages: write
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
- name: Download signal-cli build from CI workflow - 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
run: |
ver="${GITHUB_REF_NAME#v}"
echo "version=${ver}" >> $GITHUB_OUTPUT
- name: Move archive file - name: Move archive file
run: | run: |
ARCHIVE_DIR=$(ls signal-cli-archive-*/ -d | tail -n1) tar xf signal-cli-archive-${{ env.ARCHIVE_JAVA_VERSION }}/signal-cli-${{ needs.release.outputs.version }}.tar.gz
tar xf ./"${ARCHIVE_DIR}"/*.tar.gz
rm -r signal-cli-archive-* signal-cli-native
mkdir -p build/install/ mkdir -p build/install/
mv ./signal-cli-"${GITHUB_REF_NAME#v}"/ build/install/signal-cli mv ./signal-cli-"${{ needs.release.outputs.version }}"/ build/install/signal-cli
- name: Build Image - name: Build Image
id: build_image id: build_image
uses: redhat-actions/buildah-build@v2 uses: redhat-actions/buildah-build@v2
with: with:
image: ${{ env.IMAGE_NAME }} image: ${{ env.IMAGE_NAME }}
tags: latest ${{ github.sha }} ${{ steps.cli_ver.outputs.version }} tags: latest ${{ github.sha }} ${{ needs.release.outputs.version }}
containerfiles: containerfiles: ./Containerfile
./Containerfile
oci: true oci: true
- name: Push To GHCR - name: Push To GHCR
@ -209,37 +116,71 @@ jobs:
echo "${{ toJSON(steps.push.outputs) }}" echo "${{ toJSON(steps.push.outputs) }}"
build-container-native: build-container-native:
needs: ci_wf needs: release
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read
packages: write packages: write
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
- name: Download signal-cli build from CI workflow - 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
run: |
ver="${GITHUB_REF_NAME#v}"
echo "version=${ver}" >> $GITHUB_OUTPUT
- name: Move archive file - name: Move archive file
run: | run: |
tar xf signal-cli-archive-${{ env.ARCHIVE_JAVA_VERSION }}/signal-cli-${{ needs.release.outputs.version }}-Linux-native.tar.gz
mkdir -p build/native/nativeCompile/ mkdir -p build/native/nativeCompile/
chmod +x ./signal-cli-native/signal-cli mv signal-cli build/native/nativeCompile/
mv ./signal-cli-native/signal-cli build/native/nativeCompile/ chmod +x build/native/nativeCompile/signal-cli
- name: Build Image - name: Build Image
id: build_image id: build_image
uses: redhat-actions/buildah-build@v2 uses: redhat-actions/buildah-build@v2
with: with:
image: ${{ env.IMAGE_NAME }} image: ${{ env.IMAGE_NAME }}
tags: latest-native ${{ github.sha }}-native ${{ steps.cli_ver.outputs.version }}-native tags: latest-native ${{ github.sha }}-native ${{ needs.release.outputs.version }}-native
containerfiles: containerfiles: ./native.Containerfile
./native.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) }}"
build-container-client:
needs: release
runs-on: ubuntu-latest
permissions:
packages: write
steps:
- uses: actions/checkout@v6
- name: Download signal-cli build from CI workflow
uses: actions/download-artifact@v8
- name: Move archive file
run: |
tar xf signal-cli-archive-${{ env.ARCHIVE_JAVA_VERSION }}/signal-cli-${{ needs.release.outputs.version }}-Linux-client.tar.gz
mkdir -p client/target/release/
mv signal-cli-client client/target/release/
chmod +x client/target/release/signal-cli-client
- name: Build Image
id: build_image
uses: redhat-actions/buildah-build@v2
with:
image: ${{ env.IMAGE_NAME }}
tags: latest-client ${{ github.sha }}-client ${{ needs.release.outputs.version }}-client
containerfiles: ./client.Containerfile
oci: true oci: true
- name: Push To GHCR - name: Push To GHCR

7
.gitignore vendored
View File

@ -1,4 +1,5 @@
.gradle/ .gradle/
.kotlin/
.idea/* .idea/*
!.idea/codeStyles/ !.idea/codeStyles/
build/ build/
@ -13,3 +14,9 @@ out/
.DS_Store .DS_Store
/bin/ /bin/
/test-config/ /test-config/
/dist/
/github/
man/*.1
man/*.5
man/man1
man/man5

View File

@ -1,5 +1,121 @@
# Changelog # Changelog
## [Unreleased]
## [0.14.4] - 2026-05-23
### Added
- Support for a global configuration file to set system-wide defaults
### Fixed
- Group admins can now see profile information for users requesting to join groups.
- Storage sync with unregistered contacts fixed
- Incoming messages are validated more accurately, fixing receiving messages from new contacts
### Improved
- Some security and stability improvements, including HTTP HOST header validation and safer temporary file handling.
## [0.14.3] - 2026-04-22
### Fixed
- Fix sender key re-distribution on every group message (Thanks @meinecke)
### Improved
- Performance improvement when assigning admin role to multiple group members
- Increase disconnect timeout for websocket connections
- Release builds are now reproducible
### Changed
- Send message results now surface server-advised retry time for plain rate-limit (HTTP 413) failures, not only for proof-required challenges. The `retryAfterSeconds` field in JSON-RPC `SendMessageResult` is populated whenever the server sends a `Retry-After` header. The canonical way to distinguish proof-required failures remains `token != null`. Text output includes "retry after N seconds" when known.
- Add distinct JSON-RPC error code (6) for captcha rejection (Thanks @tonycpsu)
- No longer sends busy call response to allow linked devices to accept call
## [0.14.2] - 2026-04-04
### Added
- Add `--voice-note` parameter to `send` command (Thanks @Kevin)
- Add experimental support for voice calling (Thanks @visigoth)
### Fixed
- Fix `updateGroup` command for adding admins and removing members (Thanks @joeykrim)
## [0.14.1] - 2026-03-08
### Added
- Added isArchived to contact json output (Thanks @moppman)
- Added support for group member labels
### Fixed
- Adapt registration to signal server changes
## [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`
- Remove `sandbox` value for --service-environment parameter, use `staging` instead
- The `daemon` command now requires at least one channel parameter (`--socket`, `--dbus`, ...) and no longer defaults to
dbus
### 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
Requires libsignal-client version 0.87.0.
### Improved
- Improve performance of first send to large group
- Improve envelope validation
## [0.13.23] - 2026-01-24
Requires libsignal-client version 0.86.12.
### Added
- Add sendPollCreate, sendPollVote, sendPollTerminate commands for polls
- Add updateDevice command to set device name of linked devices
### Changed
- Allow updating contact names from linked devices
### Fixed
- Start multi account mode even if some accounts have authorization failures
## [0.13.22] - 2025-11-14 ## [0.13.22] - 2025-11-14
Requires libsignal-client version 0.86.1. Requires libsignal-client version 0.86.1.
@ -120,9 +236,11 @@ Requires libsignal-client version 0.68.1.
Requires libsignal-client version 0.66.2. Requires libsignal-client version 0.66.2.
### Added ### Added
- Allow setting nickname and note with `updateContact` command - Allow setting nickname and note with `updateContact` command
### Fixed ### Fixed
- Fix syncing nickname, note and expiration timer - Fix syncing nickname, note and expiration timer
- Fix check for registered users with a proxy - Fix check for registered users with a proxy
- Improve handling of storage records not yet supported by signal-cli - Improve handling of storage records not yet supported by signal-cli
@ -148,6 +266,7 @@ Requires libsignal-client version 0.65.2.
Requires libsignal-client version 0.64.0. Requires libsignal-client version 0.64.0.
### Fixed ### Fixed
- Fix issue with receiving messages that have an invalid destination - Fix issue with receiving messages that have an invalid destination
## [0.13.10] - 2024-11-30 ## [0.13.10] - 2024-11-30
@ -160,6 +279,7 @@ Requires libsignal-client version 0.62.0.
- Fix receiving expiration timer updates - Fix receiving expiration timer updates
### Improved ### Improved
- Add support for new storage encryption scheme - Add support for new storage encryption scheme
## [0.13.9] - 2024-10-28 ## [0.13.9] - 2024-10-28

View File

@ -10,7 +10,7 @@ If you have a question you can ask it in the [GitHub discussions page](https://g
- Be sure to include a **title and clear description**, as much relevant information as possible. - Be sure to include a **title and clear description**, as much relevant information as possible.
- Specify the versions of signal-cli, libsignal-client (if self-compiled), JDK and OS you're using - Specify the versions of signal-cli, libsignal-client (if self-compiled), JDK and OS you're using
- Specify if it's the normal java or the graalvm native version. - Specify if it's the normal java or the graalvm native version.
- Run the failing command with `--verbose` flag to get a more detailed log output and include that in the bug report - Run the failing command with `-vv --scrub-log` flags to get a more detailed log output and include that in the bug report
# Pull request # Pull request

View File

@ -1,4 +1,4 @@
FROM docker.io/azul/zulu-openjdk:21-jre-headless FROM docker.io/azul/zulu-openjdk:25-jre-headless
LABEL org.opencontainers.image.source=https://github.com/AsamK/signal-cli 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.description="signal-cli provides an unofficial commandline, dbus and JSON-RPC interface for the Signal messenger."

View File

@ -8,7 +8,7 @@ For registering you need a phone number where you can receive SMS or incoming ca
signal-cli is primarily intended to be used on servers to notify admins of important events. signal-cli is primarily intended to be used on servers to notify admins of important events.
For this use-case, it has a daemon mode with JSON-RPC interface ([man page](https://github.com/AsamK/signal-cli/blob/master/man/signal-cli-jsonrpc.5.adoc)) For this use-case, it has a daemon mode with JSON-RPC interface ([man page](https://github.com/AsamK/signal-cli/blob/master/man/signal-cli-jsonrpc.5.adoc))
and D-BUS interface ([man page](https://github.com/AsamK/signal-cli/blob/master/man/signal-cli-dbus.5.adoc)) . and D-BUS interface ([man page](https://github.com/AsamK/signal-cli/blob/master/man/signal-cli-dbus.5.adoc)).
For the JSON-RPC interface there's also a simple [example client](https://github.com/AsamK/signal-cli/tree/master/client), written in Rust. For the JSON-RPC interface there's also a simple [example client](https://github.com/AsamK/signal-cli/tree/master/client), written in Rust.
signal-cli needs to be kept up-to-date to keep up with Signal-Server changes. signal-cli needs to be kept up-to-date to keep up with Signal-Server changes.
@ -23,24 +23,33 @@ Windows. There's also a [docker image and some Linux packages](https://github.co
System requirements: System requirements:
- at least Java Runtime Environment (JRE) 21 - at least Java Runtime Environment (JRE) 25
- native library: libsignal-client - native library: libsignal-client
The native libs are bundled for x86_64 Linux (with recent enough glibc), Windows and MacOS. For other The native libs are bundled for x86_64 Linux (with recent enough glibc), Windows and MacOS. For other
systems/architectures systems/architectures
see: [Provide native lib for libsignal](https://github.com/AsamK/signal-cli/wiki/Provide-native-lib-for-libsignal) see: [Provide native lib for libsignal](https://github.com/AsamK/signal-cli/wiki/Provide-native-lib-for-libsignal)
### Install system-wide on Linux ### Install system-wide on Linux [ JVM build ]
See [latest version](https://github.com/AsamK/signal-cli/releases). See [latest version](https://github.com/AsamK/signal-cli/releases).
```sh ```sh
export VERSION=<latest version, format "x.y.z"> VERSION=$(curl -Ls -o /dev/null -w %{url_effective} https://github.com/AsamK/signal-cli/releases/latest | sed -e 's/^.*\/v//')
wget https://github.com/AsamK/signal-cli/releases/download/v"${VERSION}"/signal-cli-"${VERSION}".tar.gz curl -L -O https://github.com/AsamK/signal-cli/releases/download/v"${VERSION}"/signal-cli-"${VERSION}".tar.gz
sudo tar xf signal-cli-"${VERSION}".tar.gz -C /opt sudo tar xf signal-cli-"${VERSION}".tar.gz -C /opt
sudo ln -sf /opt/signal-cli-"${VERSION}"/bin/signal-cli /usr/local/bin/ sudo ln -sf /opt/signal-cli-"${VERSION}"/bin/signal-cli /usr/local/bin/
``` ```
### Install system-wide on Linux [ GraalVM native build ]
```sh
VERSION=$(curl -Ls -o /dev/null -w %{url_effective} https://github.com/AsamK/signal-cli/releases/latest | sed -e 's/^.*\/v//')
curl -L -O https://github.com/AsamK/signal-cli/releases/download/v"${VERSION}"/signal-cli-"${VERSION}"-Linux-native.tar.gz
sudo tar xf signal-cli-"${VERSION}"-Linux-native.tar.gz -C /opt
sudo ln -sf /opt/signal-cli /usr/local/bin/
```
You can find further instructions on the Wiki: You can find further instructions on the Wiki:
- [Quickstart](https://github.com/AsamK/signal-cli/wiki/Quickstart) - [Quickstart](https://github.com/AsamK/signal-cli/wiki/Quickstart)
@ -139,6 +148,16 @@ version installed, you can replace `./gradlew` with `gradle` in the following st
./gradlew run --args="--help" ./gradlew run --args="--help"
``` ```
### JSON Schemas for the JSON-RPC mode
1. Generate [JSON Schema](https://json-schema.org/) files for all the JSON-RPC data classes (`src/main/java/org/asamk/signal/json`):
```sh
./gradlew jsonSchemas
```
2. The generated files can be found in the `build/generated/META-INF/schemas` folder.
### Building a native binary with GraalVM (EXPERIMENTAL) ### Building a native binary with GraalVM (EXPERIMENTAL)
It is possible to build a native binary with [GraalVM](https://www.graalvm.org). This is still experimental and will not It is possible to build a native binary with [GraalVM](https://www.graalvm.org). This is still experimental and will not

View File

@ -1,19 +1,21 @@
import groovy.json.JsonOutput
plugins { plugins {
java java
application application
eclipse eclipse
`check-lib-versions` `check-lib-versions`
id("org.graalvm.buildtools.native") version "0.11.3" id("org.graalvm.buildtools.native") version "1.0.0"
} }
allprojects { allprojects {
group = "org.asamk" group = "org.asamk"
version = "0.13.22" version = "0.14.5-SNAPSHOT"
} }
java { java {
sourceCompatibility = JavaVersion.VERSION_21 sourceCompatibility = JavaVersion.VERSION_25
targetCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_25
if (!JavaVersion.current().isCompatibleWith(targetCompatibility)) { if (!JavaVersion.current().isCompatibleWith(targetCompatibility)) {
toolchain { toolchain {
@ -30,16 +32,15 @@ application {
graalvmNative { graalvmNative {
binaries { binaries {
this["main"].run { this["main"].run {
buildArgs.add("--install-exit-handlers")
buildArgs.add("-Dfile.encoding=UTF-8") buildArgs.add("-Dfile.encoding=UTF-8")
buildArgs.add("-J-Dfile.encoding=UTF-8") buildArgs.add("-J-Dfile.encoding=UTF-8")
buildArgs.add("-march=compatibility") buildArgs.add("-march=compatibility")
buildArgs.add("--enable-native-access=ALL-UNNAMED")
resources.autodetect() resources.autodetect()
configurationFileDirectories.from(file("graalvm-config-dir"))
if (System.getenv("GRAALVM_HOME") == null) { if (System.getenv("GRAALVM_HOME") == null) {
toolchainDetection.set(true) toolchainDetection.set(true)
javaLauncher.set(javaToolchains.launcherFor { javaLauncher.set(javaToolchains.launcherFor {
languageVersion.set(JavaLanguageVersion.of(21)) languageVersion.set(JavaLanguageVersion.of(25))
}) })
} else { } else {
toolchainDetection.set(false) toolchainDetection.set(false)
@ -73,6 +74,11 @@ val excludePatterns = mapOf(
) )
) )
val schemaAnnotationProcessor by configurations.creating {
isCanBeConsumed = false
isCanBeResolved = true
}
dependencies { dependencies {
registerTransform(JarFileExcluder::class) { registerTransform(JarFileExcluder::class) {
from.attribute(minified, false).attribute(artifactType, "jar") from.attribute(minified, false).attribute(artifactType, "jar")
@ -83,6 +89,8 @@ dependencies {
} }
} }
schemaAnnotationProcessor(libs.micronaut.json.schema.processor)
schemaAnnotationProcessor(libs.micronaut.inject.java)
implementation(libs.bouncycastle) implementation(libs.bouncycastle)
implementation(libs.jackson.databind) implementation(libs.jackson.databind)
implementation(libs.argparse4j) implementation(libs.argparse4j)
@ -90,7 +98,20 @@ dependencies {
implementation(libs.slf4j.api) implementation(libs.slf4j.api)
implementation(libs.slf4j.jul) implementation(libs.slf4j.jul)
implementation(libs.logback) implementation(libs.logback)
implementation(libs.zxing)
implementation(libs.micronaut.json.schema.annotations)
if (gradle.startParameter.taskNames.any { it.contains("jsonSchemas") }) {
implementation(libs.micronaut.json.schema.generator)
}
implementation(project(":libsignal-cli")) implementation(project(":libsignal-cli"))
testImplementation(libs.junit.jupiter)
testImplementation(platform(libs.junit.jupiter.bom))
testRuntimeOnly(libs.junit.launcher)
}
tasks.named<Test>("test") {
useJUnitPlatform()
} }
configurations { configurations {
@ -124,6 +145,7 @@ tasks.register("fatJar", type = Jar::class) {
archiveBaseName.set("${project.name}-fat") archiveBaseName.set("${project.name}-fat")
exclude( exclude(
"META-INF/*.SF", "META-INF/*.SF",
"META-INF/**/*.MF",
"META-INF/*.DSA", "META-INF/*.DSA",
"META-INF/*.RSA", "META-INF/*.RSA",
"META-INF/NOTICE*", "META-INF/NOTICE*",
@ -137,3 +159,44 @@ tasks.register("fatJar", type = Jar::class) {
} }
with(tasks.jar.get()) with(tasks.jar.get())
} }
tasks.register("writeLibsignalVersion") {
doLast {
val resolutionResult = configurations.runtimeClasspath.get().incoming.resolutionResult
val libsignalDep =
resolutionResult.allDependencies.find { dep -> dep.requested is ModuleComponentSelector && (dep.requested as ModuleComponentSelector).group == "org.signal" && (dep.requested as ModuleComponentSelector).moduleIdentifier.name == "libsignal-client" }
if (libsignalDep != null) {
val version = (libsignalDep.requested as ModuleComponentSelector).version
file("libsignal-version").writeText(version + "\n")
} else {
throw GradleException("Could not find libsignal-client dependency")
}
}
}
tasks.register<JavaCompile>("jsonSchemas") {
dependsOn(tasks.compileJava)
val schemaBaseUri = "http://localhost:8080/schemas/"
source = sourceSets.main.get().java
include("org/asamk/signal/json/**/*.java")
classpath = sourceSets.main.get().compileClasspath + files(sourceSets.main.get().java.destinationDirectory)
destinationDirectory.set(layout.buildDirectory.dir("generated"))
options.annotationProcessorPath = schemaAnnotationProcessor
options.compilerArgs.addAll(
listOf(
"-Amicronaut.processing.group=org.asamk",
"-Amicronaut.processing.module=signal-cli",
"-Amicronaut.processing.annotations=org.asamk.signal.json.*",
"-Amicronaut.jsonschema.baseUri=$schemaBaseUri",
)
)
doLast {
fileTree(destinationDirectory.get().dir("META-INF/schemas").asFile) {
include("*.schema.json")
}.forEach { schemaFile ->
val normalized = schemaFile.readText().replace("\"$schemaBaseUri/", "\"")
val prettyJson = JsonOutput.prettyPrint(normalized)
schemaFile.writeText("$prettyJson\n")
}
}
}

View File

@ -7,11 +7,11 @@ plugins {
} }
tasks.named<KotlinCompilationTask<KotlinJvmCompilerOptions>>("compileKotlin").configure { tasks.named<KotlinCompilationTask<KotlinJvmCompilerOptions>>("compileKotlin").configure {
compilerOptions.jvmTarget.set(JvmTarget.JVM_17) compilerOptions.jvmTarget.set(JvmTarget.JVM_25)
} }
java { java {
targetCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_25
} }
repositories { repositories {

View File

@ -1,5 +1,3 @@
@file:Suppress("DEPRECATION")
import org.gradle.api.Plugin import org.gradle.api.Plugin
import org.gradle.api.Project import org.gradle.api.Project
import org.gradle.api.Task import org.gradle.api.Task
@ -8,7 +6,7 @@ import javax.xml.parsers.DocumentBuilderFactory
class CheckLibVersionsPlugin : Plugin<Project> { class CheckLibVersionsPlugin : Plugin<Project> {
override fun apply(project: Project) { override fun apply(project: Project) {
project.task("checkLibVersions") { project.tasks.register("checkLibVersions") {
description = description =
"Find any 3rd party libraries which have released new versions to the central Maven repo since we last upgraded." "Find any 3rd party libraries which have released new versions to the central Maven repo since we last upgraded."
doLast { doLast {
@ -28,7 +26,7 @@ class CheckLibVersionsPlugin : Plugin<Project> {
try { try {
val dbf = DocumentBuilderFactory.newInstance() val dbf = DocumentBuilderFactory.newInstance()
val db = dbf.newDocumentBuilder() val db = dbf.newDocumentBuilder()
val doc = db.parse(metaDataUrl); val doc = db.parse(metaDataUrl)
val newest = doc.getElementsByTagName("latest").item(0).textContent val newest = doc.getElementsByTagName("latest").item(0).textContent
if (version != newest.toString()) { if (version != newest.toString()) {
println("UPGRADE {\"group\": \"$group\", \"name\": \"$name\", \"current\": \"$version\", \"latest\": \"$newest\"}") println("UPGRADE {\"group\": \"$group\", \"name\": \"$name\", \"current\": \"$version\", \"latest\": \"$newest\"}")

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

671
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)] #[arg(short = 'a', long)]
pub account: Option<String>, pub account: Option<String>,
#[arg(long)]
pub output: Option<String>,
/// TCP host and port of signal-cli daemon /// TCP host and port of signal-cli daemon
#[arg(long, conflicts_with = "json_rpc_http")] #[arg(long, conflicts_with = "json_rpc_http")]
pub json_rpc_tcp: Option<Option<SocketAddr>>, pub json_rpc_tcp: Option<Option<SocketAddr>>,
@ -94,7 +97,7 @@ pub enum CliCommands {
}, },
Link { Link {
#[arg(short = 'n', long)] #[arg(short = 'n', long)]
name: String, name: Option<String>,
}, },
ListAccounts, ListAccounts,
ListContacts { ListContacts {
@ -105,6 +108,10 @@ pub enum CliCommands {
blocked: Option<bool>, blocked: Option<bool>,
#[arg(long)] #[arg(long)]
name: Option<String>, name: Option<String>,
#[arg(long)]
detailed: bool,
#[arg(long)]
internal: bool,
}, },
ListDevices, ListDevices,
ListGroups { ListGroups {
@ -135,6 +142,8 @@ pub enum CliCommands {
voice: bool, voice: bool,
#[arg(long)] #[arg(long)]
captcha: Option<String>, captcha: Option<String>,
#[arg(long)]
reregister: bool,
}, },
RemoveContact { RemoveContact {
recipient: String, recipient: String,
@ -167,15 +176,24 @@ pub enum CliCommands {
#[arg(short = 'g', long)] #[arg(short = 'g', long)]
group_id: Vec<String>, group_id: Vec<String>,
#[arg(short = 'u', long = "username")]
username: Vec<String>,
#[arg(long)] #[arg(long)]
note_to_self: bool, note_to_self: bool,
#[arg(long)]
notify_self: bool,
#[arg(short = 'e', long)] #[arg(short = 'e', long)]
end_session: bool, end_session: bool,
#[arg(short = 'm', long)] #[arg(short = 'm', long)]
message: Option<String>, message: Option<String>,
#[arg(long)]
message_from_stdin: bool,
#[arg(short = 'a', long)] #[arg(short = 'a', long)]
attachment: Vec<String>, attachment: Vec<String>,
@ -229,6 +247,25 @@ pub enum CliCommands {
#[arg(long)] #[arg(long)]
edit_timestamp: Option<u64>, 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, SendContacts,
SendPaymentNotification { SendPaymentNotification {
@ -240,15 +277,117 @@ pub enum CliCommands {
#[arg(long)] #[arg(long)]
note: String, 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 { SendReaction {
recipient: Vec<String>, recipient: Vec<String>,
#[arg(short = 'g', long = "group-id")] #[arg(short = 'g', long = "group-id")]
group_id: Vec<String>, group_id: Vec<String>,
#[arg(short = 'u', long = "username")]
username: Vec<String>,
#[arg(long = "note-to-self")] #[arg(long = "note-to-self")]
note_to_self: bool, note_to_self: bool,
#[arg(long)]
notify_self: bool,
#[arg(short = 'e', long)] #[arg(short = 'e', long)]
emoji: String, emoji: String,
@ -267,6 +406,9 @@ pub enum CliCommands {
SendReceipt { SendReceipt {
recipient: String, recipient: String,
#[arg(short = 'u', long = "username")]
username: Vec<String>,
#[arg(short = 't', long = "target-timestamp")] #[arg(short = 't', long = "target-timestamp")]
target_timestamp: Vec<u64>, target_timestamp: Vec<u64>,
@ -283,12 +425,37 @@ pub enum CliCommands {
#[arg(short = 's', long)] #[arg(short = 's', long)]
stop: bool, 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 { SendMessageRequestResponse {
recipient: Vec<String>, recipient: Vec<String>,
#[arg(short = 'g', long = "group-id")] #[arg(short = 'g', long = "group-id")]
group_id: Vec<String>, group_id: Vec<String>,
#[arg(long)]
r#type: MessageRequestResponseType, r#type: MessageRequestResponseType,
}, },
SetPin { SetPin {
@ -334,6 +501,10 @@ pub enum CliCommands {
discoverable_by_number: Option<bool>, discoverable_by_number: Option<bool>,
#[arg(long = "number-sharing")] #[arg(long = "number-sharing")]
number_sharing: Option<bool>, number_sharing: Option<bool>,
#[arg(short = 'u', long = "username")]
username: Option<String>,
#[arg(long = "delete-username")]
delete_username: bool,
}, },
UpdateConfiguration { UpdateConfiguration {
#[arg(long = "read-receipts")] #[arg(long = "read-receipts")]
@ -356,6 +527,28 @@ pub enum CliCommands {
#[arg(short = 'n', long)] #[arg(short = 'n', long)]
name: Option<String>, 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 { UpdateGroup {
#[arg(short = 'g', long = "group-id")] #[arg(short = 'g', long = "group-id")]
@ -405,6 +598,12 @@ pub enum CliCommands {
#[arg(short = 'e', long)] #[arg(short = 'e', long)]
expiration: Option<u32>, expiration: Option<u32>,
#[arg(long = "member-label-emoji")]
member_label_emoji: Option<String>,
#[arg(long = "member-label")]
member_label: Option<String>,
}, },
UpdateProfile { UpdateProfile {
#[arg(long = "given-name")] #[arg(long = "given-name")]

View File

@ -90,7 +90,7 @@ pub trait Rpc {
fn finish_link( fn finish_link(
&self, &self,
#[allow(non_snake_case)] deviceLinkUri: String, #[allow(non_snake_case)] deviceLinkUri: String,
#[allow(non_snake_case)] deviceName: String, #[allow(non_snake_case)] deviceName: Option<String>,
) -> Result<Value, ErrorObjectOwned>; ) -> Result<Value, ErrorObjectOwned>;
#[method(name = "listAccounts", param_kind = map)] #[method(name = "listAccounts", param_kind = map)]
@ -104,6 +104,8 @@ pub trait Rpc {
#[allow(non_snake_case)] allRecipients: bool, #[allow(non_snake_case)] allRecipients: bool,
blocked: Option<bool>, blocked: Option<bool>,
name: Option<String>, name: Option<String>,
detailed: bool,
internal: bool,
) -> Result<Value, ErrorObjectOwned>; ) -> Result<Value, ErrorObjectOwned>;
#[method(name = "listDevices", param_kind = map)] #[method(name = "listDevices", param_kind = map)]
@ -141,6 +143,7 @@ pub trait Rpc {
account: Option<String>, account: Option<String>,
voice: bool, voice: bool,
captcha: Option<String>, captcha: Option<String>,
reregister: bool,
) -> Result<Value, ErrorObjectOwned>; ) -> Result<Value, ErrorObjectOwned>;
#[method(name = "removeContact", param_kind = map)] #[method(name = "removeContact", param_kind = map)]
@ -179,32 +182,116 @@ pub trait Rpc {
account: Option<String>, account: Option<String>,
recipients: Vec<String>, recipients: Vec<String>,
groupIds: Vec<String>, groupIds: Vec<String>,
noteToSelf: bool, usernames: Vec<String>,
endSession: bool, #[allow(non_snake_case)] notifySelf: bool,
#[allow(non_snake_case)] noteToSelf: bool,
#[allow(non_snake_case)] endSession: bool,
message: String, message: String,
attachments: Vec<String>, attachments: Vec<String>,
viewOnce: bool, #[allow(non_snake_case)] viewOnce: bool,
mentions: Vec<String>, mentions: Vec<String>,
textStyle: Vec<String>, #[allow(non_snake_case)] textStyle: Vec<String>,
quoteTimestamp: Option<u64>, #[allow(non_snake_case)] quoteTimestamp: Option<u64>,
quoteAuthor: Option<String>, #[allow(non_snake_case)] quoteAuthor: Option<String>,
quoteMessage: Option<String>, #[allow(non_snake_case)] quoteMessage: Option<String>,
quoteMention: Vec<String>, #[allow(non_snake_case)] quoteMention: Vec<String>,
quoteTextStyle: Vec<String>, #[allow(non_snake_case)] quoteTextStyle: Vec<String>,
quoteAttachment: Vec<String>, #[allow(non_snake_case)] quoteAttachment: Vec<String>,
previewUrl: Option<String>, #[allow(non_snake_case)] previewUrl: Option<String>,
previewTitle: Option<String>, #[allow(non_snake_case)] previewTitle: Option<String>,
previewDescription: Option<String>, #[allow(non_snake_case)] previewDescription: Option<String>,
previewImage: Option<String>, #[allow(non_snake_case)] previewImage: Option<String>,
sticker: Option<String>, sticker: Option<String>,
storyTimestamp: Option<u64>, #[allow(non_snake_case)] storyTimestamp: Option<u64>,
storyAuthor: Option<String>, #[allow(non_snake_case)] storyAuthor: Option<String>,
editTimestamp: Option<u64>, #[allow(non_snake_case)] editTimestamp: Option<u64>,
#[allow(non_snake_case)] noUrgent: bool,
) -> Result<Value, ErrorObjectOwned>; ) -> Result<Value, ErrorObjectOwned>;
#[method(name = "sendContacts", param_kind = map)] #[method(name = "sendContacts", param_kind = map)]
fn send_contacts(&self, account: Option<String>) -> Result<Value, ErrorObjectOwned>; 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)] #[method(name = "sendPaymentNotification", param_kind = map)]
fn send_payment_notification( fn send_payment_notification(
&self, &self,
@ -220,7 +307,9 @@ pub trait Rpc {
account: Option<String>, account: Option<String>,
recipients: Vec<String>, recipients: Vec<String>,
#[allow(non_snake_case)] groupIds: Vec<String>, #[allow(non_snake_case)] groupIds: Vec<String>,
usernames: Vec<String>,
#[allow(non_snake_case)] noteToSelf: bool, #[allow(non_snake_case)] noteToSelf: bool,
#[allow(non_snake_case)] notifySelf: bool,
emoji: String, emoji: String,
#[allow(non_snake_case)] targetAuthor: String, #[allow(non_snake_case)] targetAuthor: String,
#[allow(non_snake_case)] targetTimestamp: u64, #[allow(non_snake_case)] targetTimestamp: u64,
@ -233,6 +322,7 @@ pub trait Rpc {
&self, &self,
account: Option<String>, account: Option<String>,
recipient: String, recipient: String,
usernames: Vec<String>,
#[allow(non_snake_case)] targetTimestamps: Vec<u64>, #[allow(non_snake_case)] targetTimestamps: Vec<u64>,
r#type: String, r#type: String,
) -> Result<Value, ErrorObjectOwned>; ) -> Result<Value, ErrorObjectOwned>;
@ -314,6 +404,8 @@ pub trait Rpc {
unrestrictedUnidentifiedSender: Option<bool>, unrestrictedUnidentifiedSender: Option<bool>,
discoverableByNumber: Option<bool>, discoverableByNumber: Option<bool>,
numberSharing: Option<bool>, numberSharing: Option<bool>,
username: Option<String>,
deleteUsername: bool,
) -> Result<Value, ErrorObjectOwned>; ) -> Result<Value, ErrorObjectOwned>;
#[method(name = "updateConfiguration", param_kind = map)] #[method(name = "updateConfiguration", param_kind = map)]
@ -333,6 +425,19 @@ pub trait Rpc {
recipient: String, recipient: String,
name: Option<String>, name: Option<String>,
expiration: Option<u32>, 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>; ) -> Result<Value, ErrorObjectOwned>;
#[method(name = "updateGroup", param_kind = map)] #[method(name = "updateGroup", param_kind = map)]
@ -355,6 +460,8 @@ pub trait Rpc {
#[allow(non_snake_case)] setPermissionEditDetails: Option<String>, #[allow(non_snake_case)] setPermissionEditDetails: Option<String>,
#[allow(non_snake_case)] setPermissionSendMessages: Option<String>, #[allow(non_snake_case)] setPermissionSendMessages: Option<String>,
expiration: Option<u32>, expiration: Option<u32>,
#[allow(non_snake_case)] memberLabelEmoji: Option<String>,
#[allow(non_snake_case)] memberLabel: Option<String>,
) -> Result<Value, ErrorObjectOwned>; ) -> Result<Value, ErrorObjectOwned>;
#[method(name = "updateProfile", param_kind = map)] #[method(name = "updateProfile", param_kind = map)]

View File

@ -84,9 +84,19 @@ async fn handle_command(
all_recipients, all_recipients,
blocked, blocked,
name, name,
detailed,
internal,
} => { } => {
client client
.list_contacts(cli.account, recipient, all_recipients, blocked, name) .list_contacts(
cli.account,
recipient,
all_recipients,
blocked,
name,
detailed,
internal,
)
.await .await
} }
CliCommands::ListDevices => client.list_devices(cli.account).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) .quit_group(cli.account, group_id, delete, admin)
.await .await
} }
CliCommands::Register { voice, captcha } => { CliCommands::Register {
client.register(cli.account, voice, captcha).await voice,
captcha,
reregister,
} => {
client
.register(cli.account, voice, captcha, reregister)
.await
} }
CliCommands::RemoveContact { CliCommands::RemoveContact {
recipient, recipient,
@ -140,9 +156,12 @@ async fn handle_command(
CliCommands::Send { CliCommands::Send {
recipient, recipient,
group_id, group_id,
username,
notify_self,
note_to_self, note_to_self,
end_session, end_session,
message, message,
message_from_stdin,
attachment, attachment,
view_once, view_once,
mention, mention,
@ -161,15 +180,22 @@ async fn handle_command(
story_timestamp, story_timestamp,
story_author, story_author,
edit_timestamp, edit_timestamp,
no_urgent,
} => { } => {
client client
.send( .send(
cli.account, cli.account,
recipient, recipient,
group_id, group_id,
username,
notify_self,
note_to_self, note_to_self,
end_session, 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, attachment,
view_once, view_once,
mention, mention,
@ -188,10 +214,29 @@ async fn handle_command(
story_timestamp, story_timestamp,
story_author, story_author,
edit_timestamp, edit_timestamp,
no_urgent,
) )
.await .await
} }
CliCommands::SendContacts => client.send_contacts(cli.account).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 { CliCommands::SendPaymentNotification {
recipient, recipient,
receipt, receipt,
@ -201,10 +246,108 @@ async fn handle_command(
.send_payment_notification(cli.account, recipient, receipt, note) .send_payment_notification(cli.account, recipient, receipt, note)
.await .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 { CliCommands::SendReaction {
recipient, recipient,
group_id, group_id,
username,
note_to_self, note_to_self,
notify_self,
emoji, emoji,
target_author, target_author,
target_timestamp, target_timestamp,
@ -216,7 +359,9 @@ async fn handle_command(
cli.account, cli.account,
recipient, recipient,
group_id, group_id,
username,
note_to_self, note_to_self,
notify_self,
emoji, emoji,
target_author, target_author,
target_timestamp, target_timestamp,
@ -227,6 +372,7 @@ async fn handle_command(
} }
CliCommands::SendReceipt { CliCommands::SendReceipt {
recipient, recipient,
username,
target_timestamp, target_timestamp,
r#type, r#type,
} => { } => {
@ -234,6 +380,7 @@ async fn handle_command(
.send_receipt( .send_receipt(
cli.account, cli.account,
recipient, recipient,
username,
target_timestamp, target_timestamp,
match r#type { match r#type {
cli::ReceiptType::Read => "read".to_owned(), cli::ReceiptType::Read => "read".to_owned(),
@ -252,6 +399,30 @@ async fn handle_command(
.send_typing(cli.account, recipient, group_id, stop) .send_typing(cli.account, recipient, group_id, stop)
.await .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::SetPin { pin } => client.set_pin(cli.account, pin).await,
CliCommands::SubmitRateLimitChallenge { challenge, captcha } => { CliCommands::SubmitRateLimitChallenge { challenge, captcha } => {
client client
@ -284,6 +455,8 @@ async fn handle_command(
unrestricted_unidentified_sender, unrestricted_unidentified_sender,
discoverable_by_number, discoverable_by_number,
number_sharing, number_sharing,
username,
delete_username,
} => { } => {
client client
.update_account( .update_account(
@ -292,6 +465,8 @@ async fn handle_command(
unrestricted_unidentified_sender, unrestricted_unidentified_sender,
discoverable_by_number, discoverable_by_number,
number_sharing, number_sharing,
username,
delete_username,
) )
.await .await
} }
@ -315,9 +490,32 @@ async fn handle_command(
recipient, recipient,
expiration, expiration,
name, name,
given_name,
family_name,
nick_given_name,
nick_family_name,
note,
} => { } => {
client 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 .await
} }
CliCommands::UpdateGroup { CliCommands::UpdateGroup {
@ -337,6 +535,8 @@ async fn handle_command(
set_permission_edit_details, set_permission_edit_details,
set_permission_send_messages, set_permission_send_messages,
expiration, expiration,
member_label_emoji,
member_label,
} => { } => {
client client
.update_group( .update_group(
@ -370,6 +570,8 @@ async fn handle_command(
GroupPermission::OnlyAdmins => "onlyAdmins".to_owned(), GroupPermission::OnlyAdmins => "onlyAdmins".to_owned(),
}), }),
expiration, expiration,
member_label_emoji,
member_label,
) )
.await .await
} }

View File

@ -45,6 +45,27 @@
<content_attribute id="social-chat">intense</content_attribute> <content_attribute id="social-chat">intense</content_attribute>
</content_rating> </content_rating>
<releases> <releases>
<release version="0.14.4" date="2026-05-23">
<url type="details">https://github.com/AsamK/signal-cli/releases/tag/v0.14.4</url>
</release>
<release version="0.14.3" date="2026-04-22">
<url type="details">https://github.com/AsamK/signal-cli/releases/tag/v0.14.3</url>
</release>
<release version="0.14.2" date="2026-04-04">
<url type="details">https://github.com/AsamK/signal-cli/releases/tag/v0.14.2</url>
</release>
<release version="0.14.1" date="2026-03-08">
<url type="details">https://github.com/AsamK/signal-cli/releases/tag/v0.14.1</url>
</release>
<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>
<release version="0.13.23" date="2026-01-24">
<url type="details">https://github.com/AsamK/signal-cli/releases/tag/v0.13.23</url>
</release>
<release version="0.13.22" date="2025-11-14"> <release version="0.13.22" date="2025-11-14">
<url type="details">https://github.com/AsamK/signal-cli/releases/tag/v0.13.22</url> <url type="details">https://github.com/AsamK/signal-cli/releases/tag/v0.13.22</url>
</release> </release>

359
docs/CALL_TUNNEL.md Normal file
View File

@ -0,0 +1,359 @@
# Voice Call Support
## Overview
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 the tunnel over its stdin/stdout
using newline-delimited JSON messages, relaying signaling between the tunnel
and the Signal protocol.
```
signal-cli signal-call-tunnel
| |
|-- spawn --------------------------->|
|-- config JSON on stdin ------------>|
| |
|-- commands on stdin --------------->|
|<-- events on stdout ----------------|
| | WebRTC
| signaling relay | audio I/O
| |
| (stderr: tunnel logging) -------->| (captured by signal-cli)
```
Each call gets its own tunnel process. When the call ends, signal-cli closes
stdin and destroys the process.
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.
---
## Spawning the Tunnel
For each call, signal-cli:
1. Spawns `signal-call-tunnel`
2. Writes config JSON followed by a newline to stdin
3. Keeps stdin open for subsequent control messages
4. Reads control events from stdout
5. Captures stderr for logging
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` (detected from jar location)
3. `signal-call-tunnel` on `PATH`
### Config JSON
The first line written to the tunnel's stdin:
```json
{
"call_id": 12345,
"is_outgoing": true,
"local_device_id": 1,
"input_device_name": "signal_input",
"output_device_name": "signal_output"
}
```
| Field | Type | Description |
|----------------------|-------------------------|-----------------------------------------------|
| `call_id` | unsigned 64-bit integer | Call identifier (use unsigned representation) |
| `is_outgoing` | boolean | Whether this is an outgoing call |
| `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.
---
## Control Protocol
Newline-delimited JSON messages over stdin (signal-cli to tunnel) and stdout
(tunnel to signal-cli). The first line on stdin is the config JSON. Subsequent
lines are control messages.
### signal-cli -> Tunnel (stdin)
| Type | When | Fields |
|----------------------|----------------------------|---------------------------------------------------------------------------------------------------|
| `createOutgoingCall` | Outgoing call setup | `callId`, `peerId` |
| `proceed` | After offer/receivedOffer | `callId`, `hideIp`, `iceServers` |
| `receivedOffer` | Incoming call | `callId`, `peerId`, `opaque`, `age`, `senderDeviceId`, `senderIdentityKey`, `receiverIdentityKey` |
| `receivedAnswer` | Outgoing call answered | `opaque`, `senderDeviceId`, `senderIdentityKey`, `receiverIdentityKey` |
| `receivedIce` | ICE candidates arrive | `candidates` (array of base64 opaque blobs) |
| `accept` | User accepts incoming call | *(none)* |
| `hangup` | End the call | *(none)* |
### Tunnel -> signal-cli (stdout)
| Type | When | Fields |
|---------------|---------------------------------------------|------------------------------------------------------|
| `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` | Tunnel wants to hang up | `callId`, `hangupType` |
| `sendBusy` | Line is busy | `callId` |
| `stateChange` | Call state transition | `state`, `reason` (optional) |
| `error` | Something went wrong | `message` |
Opaque blobs and identity keys are base64-encoded. ICE servers use the format:
```json
{
"urls": [
"turn:example.com"
],
"username": "u",
"password": "p"
}
```
---
## Startup Sequence
```
signal-cli signal-call-tunnel
| |
|-- spawn process ------------------> |
|-- config JSON + newline on stdin ---->|
| | parse config
| | initialize audio
| |
|<-------- ready (on stdout) -----------|
| {"type":"ready", |
| "inputDeviceName":"...", |
| "outputDeviceName":"..."} |
| |
|-- control messages on stdin --------->|
|<-- control events on stdout ----------|
```
---
## Call Flows
### Outgoing call
```
signal-cli signal-call-tunnel Remote Phone
| | |
|-- spawn + config ------->| |
|<-- ready ----------------| |
|-- createOutgoingCall --->| |
|-- proceed (TURN) ------->| |
| | create offer |
|<-- sendOffer ------------| |
|-- offer via Signal -------------------------------->|
|<-- answer via Signal -------------------------------|
|-- receivedAnswer ------->| (+ identity keys) |
|<-- sendIce --------------| |
|-- ICE via Signal -------------------------------> |
|<-- ICE via Signal -------------------------------- |
|-- receivedIce ---------->| |
| | ICE connects |
|<-- stateChange:Connected | |
```
### Incoming call
```
signal-cli signal-call-tunnel Remote Phone
| | |
|<-- offer via Signal --------------------------------|
|-- spawn + config ------->| |
|<-- ready ----------------| |
|-- receivedOffer -------->| (+ identity keys) |
|-- proceed (TURN) ------->| |
| | process offer |
|<-- sendAnswer -----------| |
|-- answer via Signal -------------------------------->|
|<-- sendIce --------------| |
|-- ICE via Signal ------------------------------> |
|<-- ICE via Signal -------------------------------- |
|-- receivedIce ---------->| |
| | ICE connecting... |
| | |
| (user accepts call) | |
| Java defers accept | |
| | |
|<-- stateChange:Ringing --| (tunnel ready to accept)|
|-- accept --------------->| (deferred accept sent) |
| | accept |
|<-- stateChange:Connected | |
```
### JSON-RPC client perspective
An external application (bot, UI, test script) interacts via JSON-RPC only.
**Important:** Call event notifications are not sent by default. Clients must
call `subscribeCallEvents` before initiating or receiving calls. Without this,
incoming calls are silently ignored (no tunnel is spawned).
```
JSON-RPC Client signal-cli daemon
| |
|-- subscribeCallEvents() ------------>| (required: enables call support)
| |
|-- startCall(recipient) ------------->|
|<-- {callId, state, -|
| inputDeviceName, |
| outputDeviceName} |
| |
|<-- callEvent: RINGING_OUTGOING ------|
| ... remote answers ... |
|<-- callEvent: CONNECTED -------------|
| |
| connect to audio devices |
| (via platform audio APIs) |
| |
|-- hangupCall(callId) --------------->| (or: receive callEvent ENDED)
|<-- callEvent: ENDED -----------------|
| disconnect from audio devices |
```
For incoming calls:
```
JSON-RPC Client signal-cli daemon
| |
|-- subscribeCallEvents() ------------>| (if not already subscribed)
| |
|<-- callEvent: RINGING_INCOMING ------| (includes callId, device names)
| |
|-- acceptCall(callId) --------------->|
|<-- {callId, state, -|
| inputDeviceName, |
| outputDeviceName} |
| |
|<-- callEvent: CONNECTING ------------|
|<-- callEvent: CONNECTED -------------|
| |
| connect to audio devices |
| (via platform audio APIs) |
```
To stop receiving call events, call `unsubscribeCallEvents`.
---
## State Machine
Call states as seen by JSON-RPC clients:
```
startCall()
|
v
+----- RINGING_OUTGOING ----+ RINGING_INCOMING -----+
| | | | |
| (timeout | (answered) | (rejected) | acceptCall() | (timeout
| ~60s) | | | | ~60s)
v v v v v
ENDED CONNECTED ENDED CONNECTING ENDED
| |
| v
| CONNECTED
| |
| (hangup/error) | (hangup/error)
v v
ENDED ENDED
```
For outgoing calls, `CONNECTED` fires directly when the tunnel reports
`Connected` state -- there is no intermediate `CONNECTING` event.
For incoming calls, `CONNECTING` is set by Java when the user calls
`acceptCall()`, before the tunnel completes ICE negotiation.
Both directions have a 60-second ring timeout.
Reconnection (ICE restart):
```
CONNECTED --> RECONNECTING --> CONNECTED (ICE restart succeeded)
|
v
ENDED (ICE restart failed)
```
`RECONNECTING` maps from the tunnel's `Connecting` state, which 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. Spawns `signal-call-tunnel` and writes config JSON to stdin
2. Keeps stdin open as the control write channel; reads stdout for control events
3. Captures stderr for tunnel logging
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, closes stdin, and destroys the process
---
## 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.

View File

@ -1,343 +0,0 @@
[
{
"name":"[B"
},
{
"name":"[Ljava.lang.String;"
},
{
"name":"[Z"
},
{
"name":"[[B"
},
{
"name":"com.sun.security.auth.module.UnixSystem",
"fields":[{"name":"gid"}, {"name":"groups"}, {"name":"uid"}, {"name":"username"}]
},
{
"name":"java.lang.Boolean",
"methods":[{"name":"getBoolean","parameterTypes":["java.lang.String"] }]
},
{
"name":"java.lang.Class",
"methods":[{"name":"getCanonicalName","parameterTypes":[] }, {"name":"getClassLoader","parameterTypes":[] }]
},
{
"name":"java.lang.ClassLoader",
"methods":[{"name":"getPlatformClassLoader","parameterTypes":[] }, {"name":"loadClass","parameterTypes":["java.lang.String"] }]
},
{
"name":"java.lang.ClassNotFoundException"
},
{
"name":"java.lang.Enum",
"methods":[{"name":"ordinal","parameterTypes":[] }]
},
{
"name":"java.lang.IllegalArgumentException",
"methods":[{"name":"<init>","parameterTypes":["java.lang.String"] }]
},
{
"name":"java.lang.IllegalStateException",
"methods":[{"name":"<init>","parameterTypes":["java.lang.String"] }]
},
{
"name":"java.lang.Long",
"methods":[{"name":"<init>","parameterTypes":["long"] }]
},
{
"name":"java.lang.NoClassDefFoundError"
},
{
"name":"java.lang.NoSuchMethodError"
},
{
"name":"java.lang.String"
},
{
"name":"java.lang.Thread",
"methods":[{"name":"currentThread","parameterTypes":[] }, {"name":"getStackTrace","parameterTypes":[] }]
},
{
"name":"java.lang.Throwable",
"methods":[{"name":"getMessage","parameterTypes":[] }, {"name":"setStackTrace","parameterTypes":["java.lang.StackTraceElement[]"] }, {"name":"toString","parameterTypes":[] }]
},
{
"name":"java.lang.UnsatisfiedLinkError",
"methods":[{"name":"<init>","parameterTypes":["java.lang.String"] }]
},
{
"name":"java.util.HashMap",
"methods":[{"name":"<init>","parameterTypes":[] }]
},
{
"name":"java.util.Map",
"methods":[{"name":"get","parameterTypes":["java.lang.Object"] }, {"name":"put","parameterTypes":["java.lang.Object","java.lang.Object"] }, {"name":"remove","parameterTypes":["java.lang.Object"] }]
},
{
"name":"java.util.UUID",
"methods":[{"name":"<init>","parameterTypes":["long","long"] }, {"name":"getLeastSignificantBits","parameterTypes":[] }, {"name":"getMostSignificantBits","parameterTypes":[] }]
},
{
"name":"java.util.concurrent.ForkJoinWorkerThread"
},
{
"name":"jdk.internal.loader.ClassLoaders$AppClassLoader"
},
{
"name":"jdk.internal.loader.ClassLoaders$PlatformClassLoader"
},
{
"name":"org.asamk.signal.manager.internal.SignalWebSocketHealthMonitor$KeepAliveSender"
},
{
"name":"org.asamk.signal.manager.storage.protocol.SignalProtocolStore",
"methods":[{"name":"getIdentity","parameterTypes":["org.signal.libsignal.protocol.SignalProtocolAddress"] }, {"name":"getIdentityKeyPair","parameterTypes":[] }, {"name":"getLocalRegistrationId","parameterTypes":[] }, {"name":"isTrustedIdentity","parameterTypes":["org.signal.libsignal.protocol.SignalProtocolAddress","org.signal.libsignal.protocol.IdentityKey","org.signal.libsignal.protocol.state.IdentityKeyStore$Direction"] }, {"name":"loadKyberPreKey","parameterTypes":["int"] }, {"name":"loadPreKey","parameterTypes":["int"] }, {"name":"loadSenderKey","parameterTypes":["org.signal.libsignal.protocol.SignalProtocolAddress","java.util.UUID"] }, {"name":"loadSession","parameterTypes":["org.signal.libsignal.protocol.SignalProtocolAddress"] }, {"name":"loadSignedPreKey","parameterTypes":["int"] }, {"name":"markKyberPreKeyUsed","parameterTypes":["int"] }, {"name":"markKyberPreKeyUsed","parameterTypes":["int","int","org.signal.libsignal.protocol.ecc.ECPublicKey"] }, {"name":"removePreKey","parameterTypes":["int"] }, {"name":"saveIdentity","parameterTypes":["org.signal.libsignal.protocol.SignalProtocolAddress","org.signal.libsignal.protocol.IdentityKey"] }, {"name":"storeSenderKey","parameterTypes":["org.signal.libsignal.protocol.SignalProtocolAddress","java.util.UUID","org.signal.libsignal.protocol.groups.state.SenderKeyRecord"] }, {"name":"storeSession","parameterTypes":["org.signal.libsignal.protocol.SignalProtocolAddress","org.signal.libsignal.protocol.state.SessionRecord"] }]
},
{
"name":"org.asamk.signal.manager.storage.senderKeys.SenderKeyStore",
"methods":[{"name":"loadSenderKey","parameterTypes":["org.signal.libsignal.protocol.SignalProtocolAddress","java.util.UUID"] }, {"name":"storeSenderKey","parameterTypes":["org.signal.libsignal.protocol.SignalProtocolAddress","java.util.UUID","org.signal.libsignal.protocol.groups.state.SenderKeyRecord"] }]
},
{
"name":"org.graalvm.jniutils.JNIExceptionWrapperEntryPoints",
"methods":[{"name":"getClassName","parameterTypes":["java.lang.Class"] }]
},
{
"name":"org.signal.libsignal.internal.CompletableFuture",
"methods":[{"name":"<init>","parameterTypes":[] }, {"name":"complete","parameterTypes":["java.lang.Object"] }, {"name":"completeExceptionally","parameterTypes":["java.lang.Throwable"] }, {"name":"setCancellationId","parameterTypes":["long"] }]
},
{
"name":"org.signal.libsignal.internal.NativeHandleGuard$SimpleOwner",
"methods":[{"name":"unsafeNativeHandleWithoutGuard","parameterTypes":[] }]
},
{
"name":"org.signal.libsignal.net.CdsiLookupResponse",
"methods":[{"name":"<init>","parameterTypes":["java.util.Map","int"] }]
},
{
"name":"org.signal.libsignal.net.CdsiLookupResponse$Entry",
"methods":[{"name":"<init>","parameterTypes":["byte[]","byte[]"] }]
},
{
"name":"org.signal.libsignal.net.ChatConnection$ListenerBridge",
"methods":[{"name":"onConnectionInterrupted","parameterTypes":["java.lang.Throwable"] }, {"name":"onIncomingMessage","parameterTypes":["byte[]","long","long"] }, {"name":"onQueueEmpty","parameterTypes":[] }, {"name":"onReceivedAlerts","parameterTypes":["java.lang.String[]"] }]
},
{
"name":"org.signal.libsignal.net.ChatConnection$Response",
"methods":[{"name":"<init>","parameterTypes":["int","java.lang.String","java.util.Map","byte[]"] }]
},
{
"name":"org.signal.libsignal.net.ChatService"
},
{
"name":"org.signal.libsignal.net.ChatService$DebugInfo"
},
{
"name":"org.signal.libsignal.net.ChatService$Response"
},
{
"name":"org.signal.libsignal.net.ChatService$ResponseAndDebugInfo"
},
{
"name":"org.signal.libsignal.net.DeviceDeregisteredException",
"methods":[{"name":"<init>","parameterTypes":["java.lang.String"] }]
},
{
"name":"org.signal.libsignal.net.NetworkException",
"methods":[{"name":"<init>","parameterTypes":["java.lang.String"] }]
},
{
"name":"org.signal.libsignal.net.RetryLaterException",
"methods":[{"name":"<init>","parameterTypes":["long"] }]
},
{
"name":"org.signal.libsignal.net.internal.BridgeChatListener"
},
{
"name":"org.signal.libsignal.protocol.DuplicateMessageException",
"methods":[{"name":"<init>","parameterTypes":["java.lang.String"] }]
},
{
"name":"org.signal.libsignal.protocol.IdentityKey",
"methods":[{"name":"<init>","parameterTypes":["long"] }, {"name":"<init>","parameterTypes":["byte[]"] }, {"name":"serialize","parameterTypes":[] }]
},
{
"name":"org.signal.libsignal.protocol.IdentityKeyPair",
"methods":[{"name":"serialize","parameterTypes":[] }]
},
{
"name":"org.signal.libsignal.protocol.InvalidKeyException",
"methods":[{"name":"<init>","parameterTypes":["java.lang.String"] }]
},
{
"name":"org.signal.libsignal.protocol.InvalidKeyIdException"
},
{
"name":"org.signal.libsignal.protocol.InvalidMessageException",
"methods":[{"name":"<init>","parameterTypes":["java.lang.String"] }]
},
{
"name":"org.signal.libsignal.protocol.NoSessionException",
"methods":[{"name":"<init>","parameterTypes":["java.lang.String"] }]
},
{
"name":"org.signal.libsignal.protocol.SignalProtocolAddress",
"methods":[{"name":"<init>","parameterTypes":["long"] }, {"name":"<init>","parameterTypes":["java.lang.String","int"] }]
},
{
"name":"org.signal.libsignal.protocol.UntrustedIdentityException",
"methods":[{"name":"<init>","parameterTypes":["java.lang.String"] }]
},
{
"name":"org.signal.libsignal.protocol.ecc.ECPublicKey",
"methods":[{"name":"<init>","parameterTypes":["long"] }]
},
{
"name":"org.signal.libsignal.protocol.fingerprint.FingerprintParsingException",
"methods":[{"name":"<init>","parameterTypes":["java.lang.String"] }]
},
{
"name":"org.signal.libsignal.protocol.groups.state.SenderKeyRecord",
"fields":[{"name":"unsafeHandle"}],
"methods":[{"name":"<init>","parameterTypes":["long"] }]
},
{
"name":"org.signal.libsignal.protocol.groups.state.SenderKeyStore"
},
{
"name":"org.signal.libsignal.protocol.logging.Log",
"methods":[{"name":"log","parameterTypes":["int","java.lang.String","java.lang.String"] }]
},
{
"name":"org.signal.libsignal.protocol.message.PlaintextContent",
"fields":[{"name":"unsafeHandle"}]
},
{
"name":"org.signal.libsignal.protocol.message.PreKeySignalMessage",
"fields":[{"name":"unsafeHandle"}],
"methods":[{"name":"<init>","parameterTypes":["long"] }]
},
{
"name":"org.signal.libsignal.protocol.message.SenderKeyMessage",
"fields":[{"name":"unsafeHandle"}],
"methods":[{"name":"<init>","parameterTypes":["long"] }]
},
{
"name":"org.signal.libsignal.protocol.message.SignalMessage",
"fields":[{"name":"unsafeHandle"}],
"methods":[{"name":"<init>","parameterTypes":["long"] }]
},
{
"name":"org.signal.libsignal.protocol.state.IdentityKeyStore"
},
{
"name":"org.signal.libsignal.protocol.state.IdentityKeyStore$Direction",
"fields":[{"name":"RECEIVING"}, {"name":"SENDING"}]
},
{
"name":"org.signal.libsignal.protocol.state.IdentityKeyStore$IdentityChange"
},
{
"name":"org.signal.libsignal.protocol.state.KyberPreKeyRecord",
"fields":[{"name":"unsafeHandle"}]
},
{
"name":"org.signal.libsignal.protocol.state.KyberPreKeyStore"
},
{
"name":"org.signal.libsignal.protocol.state.PreKeyRecord",
"fields":[{"name":"unsafeHandle"}]
},
{
"name":"org.signal.libsignal.protocol.state.PreKeyStore"
},
{
"name":"org.signal.libsignal.protocol.state.SessionRecord",
"fields":[{"name":"unsafeHandle"}],
"methods":[{"name":"<init>","parameterTypes":["long"] }, {"name":"<init>","parameterTypes":["byte[]"] }]
},
{
"name":"org.signal.libsignal.protocol.state.SessionStore"
},
{
"name":"org.signal.libsignal.protocol.state.SignedPreKeyRecord",
"fields":[{"name":"unsafeHandle"}]
},
{
"name":"org.signal.libsignal.protocol.state.SignedPreKeyStore"
},
{
"name":"org.signal.libsignal.usernames.BadDiscriminatorCharacterException",
"methods":[{"name":"<init>","parameterTypes":["java.lang.String"] }]
},
{
"name":"org.signal.libsignal.usernames.BadNicknameCharacterException",
"methods":[{"name":"<init>","parameterTypes":["java.lang.String"] }]
},
{
"name":"org.signal.libsignal.usernames.CannotBeEmptyException",
"methods":[{"name":"<init>","parameterTypes":["java.lang.String"] }]
},
{
"name":"org.signal.libsignal.usernames.DiscriminatorCannotBeZeroException",
"methods":[{"name":"<init>","parameterTypes":["java.lang.String"] }]
},
{
"name":"org.signal.libsignal.usernames.MissingSeparatorException",
"methods":[{"name":"<init>","parameterTypes":["java.lang.String"] }]
},
{
"name":"org.signal.libsignal.usernames.NicknameTooLongException",
"methods":[{"name":"<init>","parameterTypes":["java.lang.String"] }]
},
{
"name":"org.signal.libsignal.usernames.NicknameTooShortException",
"methods":[{"name":"<init>","parameterTypes":["java.lang.String"] }]
},
{
"name":"org.signal.libsignal.zkgroup.InvalidInputException",
"methods":[{"name":"<init>","parameterTypes":["java.lang.String"] }]
},
{
"name":"org.sqlite.BusyHandler",
"methods":[{"name":"callback","parameterTypes":["int"] }]
},
{
"name":"org.sqlite.Collation",
"methods":[{"name":"xCompare","parameterTypes":["java.lang.String","java.lang.String"] }]
},
{
"name":"org.sqlite.Function",
"fields":[{"name":"args"}, {"name":"context"}, {"name":"value"}],
"methods":[{"name":"xFunc","parameterTypes":[] }]
},
{
"name":"org.sqlite.Function$Aggregate",
"methods":[{"name":"clone","parameterTypes":[] }, {"name":"xFinal","parameterTypes":[] }, {"name":"xStep","parameterTypes":[] }]
},
{
"name":"org.sqlite.Function$Window",
"methods":[{"name":"xInverse","parameterTypes":[] }, {"name":"xValue","parameterTypes":[] }]
},
{
"name":"org.sqlite.ProgressHandler",
"methods":[{"name":"progress","parameterTypes":[] }]
},
{
"name":"org.sqlite.core.DB",
"methods":[{"name":"onCommit","parameterTypes":["boolean"] }, {"name":"onUpdate","parameterTypes":["int","java.lang.String","java.lang.String","long"] }, {"name":"throwex","parameterTypes":[] }, {"name":"throwex","parameterTypes":["int"] }]
},
{
"name":"org.sqlite.core.DB$ProgressObserver",
"methods":[{"name":"progress","parameterTypes":["int","int"] }]
},
{
"name":"org.sqlite.core.NativeDB",
"fields":[{"name":"busyHandler"}, {"name":"commitListener"}, {"name":"pointer"}, {"name":"progressHandler"}, {"name":"updateListener"}],
"methods":[{"name":"stringToUtf8ByteArray","parameterTypes":["java.lang.String"] }, {"name":"throwex","parameterTypes":["java.lang.String"] }]
},
{
"name":"org.whispersystems.signalservice.api.websocket.SignalWebSocket$DelayedDisconnectThread"
}
]

View File

@ -1,8 +0,0 @@
[
{
"type":"agent-extracted",
"classes":[
]
}
]

View File

@ -1,26 +0,0 @@
[
{
"interfaces":["java.sql.Connection"]
},
{
"interfaces":["org.asamk.Signal"]
},
{
"interfaces":["org.asamk.Signal$Configuration"]
},
{
"interfaces":["org.asamk.Signal$Device"]
},
{
"interfaces":["org.asamk.Signal$Group"]
},
{
"interfaces":["org.asamk.Signal$Identity"]
},
{
"interfaces":["org.asamk.SignalControl"]
},
{
"interfaces":["org.freedesktop.dbus.interfaces.DBus"]
}
]

File diff suppressed because it is too large Load Diff

View File

@ -1,226 +0,0 @@
{
"resources":{
"includes":[{
"pattern":"\\QMETA-INF/maven/org.xerial/sqlite-jdbc/pom.properties\\E"
}, {
"pattern":"\\QMETA-INF/services/ch.qos.logback.classic.spi.Configurator\\E"
}, {
"pattern":"\\QMETA-INF/services/com.sun.net.httpserver.spi.HttpServerProvider\\E"
}, {
"pattern":"\\QMETA-INF/services/java.lang.System$LoggerFinder\\E"
}, {
"pattern":"\\QMETA-INF/services/java.net.spi.InetAddressResolverProvider\\E"
}, {
"pattern":"\\QMETA-INF/services/java.net.spi.URLStreamHandlerProvider\\E"
}, {
"pattern":"\\QMETA-INF/services/java.nio.channels.spi.SelectorProvider\\E"
}, {
"pattern":"\\QMETA-INF/services/java.nio.file.spi.FileTypeDetector\\E"
}, {
"pattern":"\\QMETA-INF/services/java.sql.Driver\\E"
}, {
"pattern":"\\QMETA-INF/services/java.time.zone.ZoneRulesProvider\\E"
}, {
"pattern":"\\QMETA-INF/services/java.util.spi.ResourceBundleControlProvider\\E"
}, {
"pattern":"\\QMETA-INF/services/kotlin.reflect.jvm.internal.impl.builtins.BuiltInsLoader\\E"
}, {
"pattern":"\\QMETA-INF/services/kotlin.reflect.jvm.internal.impl.resolve.ExternalOverridabilityCondition\\E"
}, {
"pattern":"\\QMETA-INF/services/kotlin.reflect.jvm.internal.impl.util.ModuleVisibilityHelper\\E"
}, {
"pattern":"\\QMETA-INF/services/org.freedesktop.dbus.spi.message.ISocketProvider\\E"
}, {
"pattern":"\\QMETA-INF/services/org.freedesktop.dbus.spi.transport.ITransportProvider\\E"
}, {
"pattern":"\\QMETA-INF/services/org.slf4j.spi.SLF4JServiceProvider\\E"
}, {
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_AG\\E"
}, {
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_AI\\E"
}, {
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_AR\\E"
}, {
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_AS\\E"
}, {
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_AT\\E"
}, {
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_AU\\E"
}, {
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_AZ\\E"
}, {
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_BB\\E"
}, {
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_BD\\E"
}, {
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_BE\\E"
}, {
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_BM\\E"
}, {
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_BO\\E"
}, {
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_BR\\E"
}, {
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_BS\\E"
}, {
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_CA\\E"
}, {
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_CH\\E"
}, {
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_CI\\E"
}, {
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_CL\\E"
}, {
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_CN\\E"
}, {
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_CO\\E"
}, {
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_CR\\E"
}, {
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_CZ\\E"
}, {
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_DE\\E"
}, {
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_DK\\E"
}, {
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_EC\\E"
}, {
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_EE\\E"
}, {
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_ES\\E"
}, {
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_FI\\E"
}, {
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_FR\\E"
}, {
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_GB\\E"
}, {
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_GR\\E"
}, {
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_HK\\E"
}, {
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_HR\\E"
}, {
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_HU\\E"
}, {
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_ID\\E"
}, {
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_IL\\E"
}, {
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_IN\\E"
}, {
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_IR\\E"
}, {
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_IT\\E"
}, {
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_JP\\E"
}, {
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_LV\\E"
}, {
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_MM\\E"
}, {
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_MO\\E"
}, {
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_MX\\E"
}, {
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_MY\\E"
}, {
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_NG\\E"
}, {
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_NL\\E"
}, {
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_NZ\\E"
}, {
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_PA\\E"
}, {
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_PE\\E"
}, {
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_PH\\E"
}, {
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_PL\\E"
}, {
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_RO\\E"
}, {
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_RU\\E"
}, {
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_SA\\E"
}, {
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_SI\\E"
}, {
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_SK\\E"
}, {
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_TH\\E"
}, {
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_TR\\E"
}, {
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_UA\\E"
}, {
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_UG\\E"
}, {
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_US\\E"
}, {
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_VE\\E"
}, {
"pattern":"\\Qcom/google/i18n/phonenumbers/data/PhoneNumberMetadataProto_XK\\E"
}, {
"pattern":"\\Qjni/x86_64-Linux/libjffi-1.2.so\\E"
}, {
"pattern":"\\Qkotlin/annotation/annotation.kotlin_builtins\\E"
}, {
"pattern":"\\Qkotlin/collections/collections.kotlin_builtins\\E"
}, {
"pattern":"\\Qkotlin/coroutines/coroutines.kotlin_builtins\\E"
}, {
"pattern":"\\Qkotlin/internal/internal.kotlin_builtins\\E"
}, {
"pattern":"\\Qkotlin/jvm/jvm.kotlin_builtins\\E"
}, {
"pattern":"\\Qkotlin/kotlin.kotlin_builtins\\E"
}, {
"pattern":"\\Qkotlin/ranges/ranges.kotlin_builtins\\E"
}, {
"pattern":"\\Qkotlin/reflect/reflect.kotlin_builtins\\E"
}, {
"pattern":"\\Qlibsignal_jni.so\\E"
}, {
"pattern":"\\Qlibsignal_jni_aarch64.dylib\\E"
}, {
"pattern":"\\Qlibsignal_jni_amd64.dylib\\E"
}, {
"pattern":"\\Qlibsignal_jni_amd64.so\\E"
}, {
"pattern":"\\Qorg/asamk/signal/manager/config/ias.store\\E"
}, {
"pattern":"\\Qorg/asamk/signal/manager/config/whisper.store\\E"
}, {
"pattern":"\\Qorg/slf4j/impl/StaticLoggerBinder.class\\E"
}, {
"pattern":"\\Qorg/sqlite/native/Linux/x86_64/libsqlitejdbc.so\\E"
}, {
"pattern":"\\Qsignal_jni.dll\\E"
}, {
"pattern":"\\Qsignal_jni_amd64.dll\\E"
}, {
"pattern":"\\Qsqlite-jdbc.properties\\E"
}, {
"pattern":"com/google/i18n/phonenumbers/data/.*"
}, {
"pattern":"java.base:\\Qjdk/internal/icu/impl/data/icudt67b/nfc.nrm\\E"
}, {
"pattern":"java.base:\\Qjdk/internal/icu/impl/data/icudt67b/uprops.icu\\E"
}, {
"pattern":"java.base:\\Qjdk/internal/icu/impl/data/icudt72b/nfc.nrm\\E"
}, {
"pattern":"java.base:\\Qjdk/internal/icu/impl/data/icudt72b/uprops.icu\\E"
}, {
"pattern":"java.base:\\Qsun/net/idn/uidna.spp\\E"
}, {
"pattern":"java.base:\\Qsun/net/www/content-types.properties\\E"
}, {
"pattern":"java.base:\\Qsun/text/resources/LineBreakIteratorData\\E"
}]},
"bundles":[{
"name":"net.sourceforge.argparse4j.internal.ArgumentParserImpl",
"locales":["", "de", "en", "und"]
}]
}

View File

@ -1,8 +0,0 @@
{
"types":[
],
"lambdaCapturingTypes":[
],
"proxies":[
]
}

View File

@ -1,18 +1,26 @@
[versions] [versions]
slf4j = "2.0.17" slf4j = "2.0.18"
junit = "6.0.1" junit = "6.1.0"
micronaut-json-schema = "2.0.1"
micronaut-core = "5.0.0"
signal-service = "2.15.3_unofficial_147"
[libraries] [libraries]
bouncycastle = "org.bouncycastle:bcprov-jdk18on:1.82" bouncycastle = "org.bouncycastle:bcprov-jdk18on:1.84"
jackson-databind = "com.fasterxml.jackson.core:jackson-databind:2.20.1" jackson-databind = "com.fasterxml.jackson.core:jackson-databind:2.20.2"
argparse4j = "net.sourceforge.argparse4j:argparse4j:0.9.0" argparse4j = "net.sourceforge.argparse4j:argparse4j:0.9.0"
dbusjava = "com.github.hypfvieh:dbus-java-transport-native-unixsocket:5.0.0" dbusjava = "com.github.hypfvieh:dbus-java-transport-native-unixsocket:5.0.0"
zxing = "com.google.zxing:core:3.5.4"
micronaut-json-schema-annotations = { module = "io.micronaut.jsonschema:micronaut-json-schema-annotations", version.ref = "micronaut-json-schema" }
micronaut-json-schema-processor = { module = "io.micronaut.jsonschema:micronaut-json-schema-processor", version.ref = "micronaut-json-schema" }
micronaut-json-schema-generator = { module = "io.micronaut.jsonschema:micronaut-json-schema-generator", version.ref = "micronaut-json-schema" }
micronaut-inject-java = { module = "io.micronaut:micronaut-inject-java", version.ref = "micronaut-core" }
slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" } slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" }
slf4j-jul = { module = "org.slf4j:jul-to-slf4j", version.ref = "slf4j" } slf4j-jul = { module = "org.slf4j:jul-to-slf4j", version.ref = "slf4j" }
logback = "ch.qos.logback:logback-classic:1.5.21" logback = "ch.qos.logback:logback-classic:1.5.32"
signalservice = "com.github.turasa:signal-service-java:2.15.3_unofficial_134" signalnetwork = { module = "com.github.turasa:signal-network", version.ref = "signal-service" }
sqlite = "org.xerial:sqlite-jdbc:3.51.0.0" sqlite = "org.xerial:sqlite-jdbc:3.53.1.0"
hikari = "com.zaxxer:HikariCP:7.0.2" hikari = "com.zaxxer:HikariCP:7.0.2"
junit-jupiter-bom = { module = "org.junit:junit-bom", version.ref = "junit" } junit-jupiter-bom = { module = "org.junit:junit-bom", version.ref = "junit" }
junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" } junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" }

Binary file not shown.

View File

@ -1,7 +1,9 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.0-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip
networkTimeout=10000 networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists

31
gradlew.bat vendored
View File

@ -23,8 +23,8 @@
@rem @rem
@rem ########################################################################## @rem ##########################################################################
@rem Set local scope for the variables with windows NT shell @rem Set local scope for the variables, and ensure extensions are enabled
if "%OS%"=="Windows_NT" setlocal setlocal EnableExtensions
set DIRNAME=%~dp0 set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=. if "%DIRNAME%"=="" set DIRNAME=.
@ -51,7 +51,7 @@ echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2 echo location of your Java installation. 1>&2
goto fail "%COMSPEC%" /c exit 1
:findJavaFromJavaHome :findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=% set JAVA_HOME=%JAVA_HOME:"=%
@ -65,7 +65,7 @@ echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2 echo location of your Java installation. 1>&2
goto fail "%COMSPEC%" /c exit 1
:execute :execute
@rem Setup the command line @rem Setup the command line
@ -73,21 +73,10 @@ goto fail
@rem Execute Gradle @rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* @rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
:end :exitWithErrorLevel
@rem End local scope for the variables with windows NT shell @rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
if %ERRORLEVEL% equ 0 goto mainEnd "%COMSPEC%" /c exit %ERRORLEVEL%
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@ -4,11 +4,13 @@ plugins {
} }
java { java {
sourceCompatibility = JavaVersion.VERSION_21 sourceCompatibility = JavaVersion.VERSION_25
targetCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_25
toolchain { if (!JavaVersion.current().isCompatibleWith(targetCompatibility)) {
languageVersion.set(JavaLanguageVersion.of(21)) toolchain {
languageVersion.set(JavaLanguageVersion.of(targetCompatibility.majorVersion))
}
} }
} }
@ -16,9 +18,9 @@ val libsignalClientPath = project.findProperty("libsignal_client_path")?.toStrin
dependencies { dependencies {
if (libsignalClientPath == null) { if (libsignalClientPath == null) {
implementation(libs.signalservice) implementation(libs.signalnetwork)
} else { } else {
implementation(libs.signalservice) { implementation(libs.signalnetwork) {
exclude(group = "org.signal", module = "libsignal-client") exclude(group = "org.signal", module = "libsignal-client")
} }
implementation(files(libsignalClientPath)) implementation(files(libsignalClientPath))

View File

@ -4,6 +4,8 @@ import com.google.i18n.phonenumbers.PhoneNumberUtil;
import org.asamk.signal.manager.api.AlreadyReceivingException; import org.asamk.signal.manager.api.AlreadyReceivingException;
import org.asamk.signal.manager.api.AttachmentInvalidException; import org.asamk.signal.manager.api.AttachmentInvalidException;
import org.asamk.signal.manager.api.CallInfo;
import org.asamk.signal.manager.api.CallOffer;
import org.asamk.signal.manager.api.CaptchaRejectedException; import org.asamk.signal.manager.api.CaptchaRejectedException;
import org.asamk.signal.manager.api.CaptchaRequiredException; import org.asamk.signal.manager.api.CaptchaRequiredException;
import org.asamk.signal.manager.api.Configuration; import org.asamk.signal.manager.api.Configuration;
@ -37,11 +39,13 @@ import org.asamk.signal.manager.api.ReceiveConfig;
import org.asamk.signal.manager.api.Recipient; import org.asamk.signal.manager.api.Recipient;
import org.asamk.signal.manager.api.RecipientIdentifier; import org.asamk.signal.manager.api.RecipientIdentifier;
import org.asamk.signal.manager.api.SendGroupMessageResults; import org.asamk.signal.manager.api.SendGroupMessageResults;
import org.asamk.signal.manager.api.SendMessageResult;
import org.asamk.signal.manager.api.SendMessageResults; import org.asamk.signal.manager.api.SendMessageResults;
import org.asamk.signal.manager.api.StickerPack; import org.asamk.signal.manager.api.StickerPack;
import org.asamk.signal.manager.api.StickerPackId; import org.asamk.signal.manager.api.StickerPackId;
import org.asamk.signal.manager.api.StickerPackInvalidException; import org.asamk.signal.manager.api.StickerPackInvalidException;
import org.asamk.signal.manager.api.StickerPackUrl; import org.asamk.signal.manager.api.StickerPackUrl;
import org.asamk.signal.manager.api.TurnServer;
import org.asamk.signal.manager.api.TypingAction; import org.asamk.signal.manager.api.TypingAction;
import org.asamk.signal.manager.api.UnregisteredRecipientException; import org.asamk.signal.manager.api.UnregisteredRecipientException;
import org.asamk.signal.manager.api.UpdateGroup; import org.asamk.signal.manager.api.UpdateGroup;
@ -154,6 +158,8 @@ public interface Manager extends Closeable {
List<Device> getLinkedDevices() throws IOException; List<Device> getLinkedDevices() throws IOException;
void updateLinkedDevice(int deviceId, String name) throws IOException, NotPrimaryDeviceException;
void removeLinkedDevices(int deviceId) throws IOException, NotPrimaryDeviceException; void removeLinkedDevices(int deviceId) throws IOException, NotPrimaryDeviceException;
void addDeviceLink(DeviceLinkUrl linkUri) throws IOException, InvalidDeviceLinkException, NotPrimaryDeviceException, DeviceLimitExceededException; void addDeviceLink(DeviceLinkUrl linkUri) throws IOException, InvalidDeviceLinkException, NotPrimaryDeviceException, DeviceLimitExceededException;
@ -222,13 +228,38 @@ public interface Manager extends Closeable {
final boolean isStory final boolean isStory
) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException, UnregisteredRecipientException; ) 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( SendMessageResults sendPaymentNotificationMessage(
byte[] receipt, byte[] receipt,
String note, String note,
RecipientIdentifier.Single recipient RecipientIdentifier.Single recipient
) throws IOException; ) throws IOException;
SendMessageResults sendEndSessionMessage(Set<RecipientIdentifier.Single> recipients) throws IOException; void sendEndSessionMessage(Set<RecipientIdentifier.Single> recipients) throws IOException;
SendMessageResults sendMessageRequestResponse( SendMessageResults sendMessageRequestResponse(
MessageEnvelope.Sync.MessageRequestResponse.Type type, MessageEnvelope.Sync.MessageRequestResponse.Type type,
@ -271,7 +302,7 @@ public interface Manager extends Closeable {
final String nickGivenName, final String nickGivenName,
final String nickFamilyName, final String nickFamilyName,
final String note final String note
) throws NotPrimaryDeviceException, UnregisteredRecipientException; ) throws UnregisteredRecipientException;
void setContactsBlocked( void setContactsBlocked(
Collection<RecipientIdentifier.Single> recipient, Collection<RecipientIdentifier.Single> recipient,
@ -386,9 +417,52 @@ public interface Manager extends Closeable {
InputStream retrieveSticker(final StickerPackId stickerPackId, final int stickerId) throws IOException; InputStream retrieveSticker(final StickerPackId stickerPackId, final int stickerId) throws IOException;
// --- Voice call methods ---
CallInfo startCall(RecipientIdentifier.Single recipient) throws IOException, UnregisteredRecipientException;
CallInfo acceptCall(long callId) throws IOException;
void hangupCall(long callId) throws IOException;
SendMessageResult rejectCall(long callId) throws IOException;
List<CallInfo> listActiveCalls();
void sendCallOffer(
RecipientIdentifier.Single recipient,
CallOffer offer
) throws IOException, UnregisteredRecipientException;
void sendCallAnswer(
RecipientIdentifier.Single recipient,
long callId,
byte[] answerOpaque
) throws IOException, UnregisteredRecipientException;
void sendIceUpdate(
RecipientIdentifier.Single recipient,
long callId,
List<byte[]> iceCandidates
) throws IOException, UnregisteredRecipientException;
void sendHangup(
RecipientIdentifier.Single recipient,
long callId,
MessageEnvelope.Call.Hangup.Type type
) throws IOException, UnregisteredRecipientException;
void sendBusy(RecipientIdentifier.Single recipient, long callId) throws IOException, UnregisteredRecipientException;
List<TurnServer> getTurnServerInfo() throws IOException;
@Override @Override
void close(); void close();
void addCallEventListener(CallEventListener listener);
void removeCallEventListener(CallEventListener listener);
interface ReceiveMessageHandler { interface ReceiveMessageHandler {
ReceiveMessageHandler EMPTY = (envelope, e) -> { ReceiveMessageHandler EMPTY = (envelope, e) -> {
@ -396,4 +470,9 @@ public interface Manager extends Closeable {
void handleMessage(MessageEnvelope envelope, Throwable e); void handleMessage(MessageEnvelope envelope, Throwable e);
} }
interface CallEventListener {
void handleCallEvent(CallInfo callInfo, String reason);
}
} }

View File

@ -1,10 +1,12 @@
package org.asamk.signal.manager; package org.asamk.signal.manager;
import org.asamk.signal.manager.internal.LibSignalLogger; import org.asamk.signal.manager.internal.LibSignalLogger;
import org.asamk.signal.manager.internal.SignalLogger;
public class ManagerLogger { public class ManagerLogger {
public static void initLogger() { public static void initLogger() {
LibSignalLogger.initLogger(); LibSignalLogger.initLogger();
SignalLogger.initLogger();
} }
} }

View File

@ -64,7 +64,7 @@ public class SignalAccountFiles {
return accountsStore.getAllNumbers(); return accountsStore.getAllNumbers();
} }
public MultiAccountManager initMultiAccountManager() throws IOException, AccountCheckException { public MultiAccountManager initMultiAccountManager() throws IOException {
final var managerPairs = accountsStore.getAllAccounts().parallelStream().map(a -> { final var managerPairs = accountsStore.getAllAccounts().parallelStream().map(a -> {
try { try {
return new Pair<Manager, Throwable>(initManager(a.number(), a.path()), null); return new Pair<Manager, Throwable>(initManager(a.number(), a.path()), null);
@ -80,12 +80,13 @@ public class SignalAccountFiles {
for (final var pair : managerPairs) { for (final var pair : managerPairs) {
if (pair.second() instanceof IOException e) { if (pair.second() instanceof IOException e) {
throw e; throw e;
} else if (pair.second() instanceof AccountCheckException e) {
throw e;
} }
} }
final var managers = managerPairs.stream().map(Pair::first).toList(); final var managers = managerPairs.stream()
.filter(p -> p != null && p.first() != null)
.map(Pair::first)
.toList();
return new MultiAccountManagerImpl(managers, this); return new MultiAccountManagerImpl(managers, this);
} }
@ -132,7 +133,7 @@ public class SignalAccountFiles {
manager.checkAccountState(); manager.checkAccountState();
} catch (DeprecatedVersionException e) { } catch (DeprecatedVersionException e) {
manager.close(); manager.close();
throw new AccountCheckException("signal-cli version is too old for the Signal-Server, please update."); throw new IOException("signal-cli version is too old for the Signal-Server, please update.");
} catch (IOException e) { } catch (IOException e) {
manager.close(); manager.close();
throw new AccountCheckException("Error while checking account " + number + ": " + e.getMessage(), e); throw new AccountCheckException("Error while checking account " + number + ": " + e.getMessage(), e);

View File

@ -2,7 +2,7 @@ package org.asamk.signal.manager.actions;
import org.asamk.signal.manager.helper.Context; import org.asamk.signal.manager.helper.Context;
import org.asamk.signal.manager.storage.recipients.RecipientId; import org.asamk.signal.manager.storage.recipients.RecipientId;
import org.whispersystems.signalservice.api.push.ServiceId; import org.signal.core.models.ServiceId;
public class RenewSessionAction implements HandleAction { public class RenewSessionAction implements HandleAction {

View File

@ -0,0 +1,20 @@
package org.asamk.signal.manager.actions;
import org.asamk.signal.manager.helper.Context;
public class RetrieveDeviceNameAction implements HandleAction {
private static final RetrieveDeviceNameAction INSTANCE = new RetrieveDeviceNameAction();
public static RetrieveDeviceNameAction create() {
return INSTANCE;
}
private RetrieveDeviceNameAction() {
}
@Override
public void execute(Context context) throws Throwable {
context.getAccountHelper().refreshDeviceName();
}
}

View File

@ -60,7 +60,7 @@ public class SendRetryMessageRequestAction implements HandleAction {
return CiphertextMessage.WHISPER_TYPE; return CiphertextMessage.WHISPER_TYPE;
} }
return switch (type) { return switch (type) {
case PREKEY_BUNDLE -> CiphertextMessage.PREKEY_TYPE; case PREKEY_MESSAGE -> CiphertextMessage.PREKEY_TYPE;
case UNIDENTIFIED_SENDER -> CiphertextMessage.SENDERKEY_TYPE; case UNIDENTIFIED_SENDER -> CiphertextMessage.SENDERKEY_TYPE;
case PLAINTEXT_CONTENT -> CiphertextMessage.PLAINTEXT_CONTENT_TYPE; case PLAINTEXT_CONTENT -> CiphertextMessage.PLAINTEXT_CONTENT_TYPE;
default -> CiphertextMessage.WHISPER_TYPE; default -> CiphertextMessage.WHISPER_TYPE;

View File

@ -0,0 +1,21 @@
package org.asamk.signal.manager.api;
public record CallInfo(
long callId,
State state,
RecipientAddress recipient,
String inputDeviceName,
String outputDeviceName,
boolean isOutgoing
) {
public enum State {
IDLE,
RINGING_INCOMING,
RINGING_OUTGOING,
CONNECTING,
CONNECTED,
RECONNECTING,
ENDED
}
}

View File

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

View File

@ -2,11 +2,11 @@ package org.asamk.signal.manager.api;
public class CaptchaRequiredException extends Exception { public class CaptchaRequiredException extends Exception {
private long nextAttemptTimestamp; private long nextVerificationAttemptMilliseconds;
public CaptchaRequiredException(final long nextAttemptTimestamp) { public CaptchaRequiredException(final long nextVerificationAttemptMilliseconds) {
super("Captcha required"); super("Captcha required");
this.nextAttemptTimestamp = nextAttemptTimestamp; this.nextVerificationAttemptMilliseconds = nextVerificationAttemptMilliseconds;
} }
public CaptchaRequiredException(final String message) { public CaptchaRequiredException(final String message) {
@ -17,7 +17,7 @@ public class CaptchaRequiredException extends Exception {
super(message, cause); super(message, cause);
} }
public long getNextAttemptTimestamp() { public long getNextVerificationAttemptMilliseconds() {
return nextAttemptTimestamp; return nextVerificationAttemptMilliseconds;
} }
} }

View File

@ -12,10 +12,9 @@ public record Group(
String title, String title,
String description, String description,
GroupInviteLinkUrl groupInviteLinkUrl, GroupInviteLinkUrl groupInviteLinkUrl,
Set<RecipientAddress> members, Set<GroupMember> members,
Set<RecipientAddress> pendingMembers, Set<RecipientAddress> pendingMembers,
Set<RecipientAddress> requestingMembers, Set<RecipientAddress> requestingMembers,
Set<RecipientAddress> adminMembers,
Set<RecipientAddress> bannedMembers, Set<RecipientAddress> bannedMembers,
boolean isBlocked, boolean isBlocked,
int messageExpirationTimer, int messageExpirationTimer,
@ -37,8 +36,7 @@ public record Group(
groupInfo.getGroupInviteLink(), groupInfo.getGroupInviteLink(),
groupInfo.getMembers() groupInfo.getMembers()
.stream() .stream()
.map(recipientStore::resolveRecipientAddress) .map(m -> org.asamk.signal.manager.api.GroupMember.from(m, recipientStore))
.map(org.asamk.signal.manager.storage.recipients.RecipientAddress::toApiRecipientAddress)
.collect(Collectors.toSet()), .collect(Collectors.toSet()),
groupInfo.getPendingMembers() groupInfo.getPendingMembers()
.stream() .stream()
@ -50,11 +48,6 @@ public record Group(
.map(recipientStore::resolveRecipientAddress) .map(recipientStore::resolveRecipientAddress)
.map(org.asamk.signal.manager.storage.recipients.RecipientAddress::toApiRecipientAddress) .map(org.asamk.signal.manager.storage.recipients.RecipientAddress::toApiRecipientAddress)
.collect(Collectors.toSet()), .collect(Collectors.toSet()),
groupInfo.getAdminMembers()
.stream()
.map(recipientStore::resolveRecipientAddress)
.map(org.asamk.signal.manager.storage.recipients.RecipientAddress::toApiRecipientAddress)
.collect(Collectors.toSet()),
groupInfo.getBannedMembers() groupInfo.getBannedMembers()
.stream() .stream()
.map(recipientStore::resolveRecipientAddress) .map(recipientStore::resolveRecipientAddress)

View File

@ -4,8 +4,8 @@ import org.asamk.signal.manager.groups.GroupLinkPassword;
import org.signal.core.util.Base64; import org.signal.core.util.Base64;
import org.signal.libsignal.zkgroup.InvalidInputException; import org.signal.libsignal.zkgroup.InvalidInputException;
import org.signal.libsignal.zkgroup.groups.GroupMasterKey; import org.signal.libsignal.zkgroup.groups.GroupMasterKey;
import org.signal.storageservice.protos.groups.GroupInviteLink; import org.signal.storageservice.storage.protos.groups.GroupInviteLink;
import org.signal.storageservice.protos.groups.local.DecryptedGroup; import org.signal.storageservice.storage.protos.groups.local.DecryptedGroup;
import java.io.IOException; import java.io.IOException;
import java.net.URI; import java.net.URI;
@ -52,8 +52,8 @@ public final class GroupInviteLinkUrl {
var bytes = Base64.decode(encoding); var bytes = Base64.decode(encoding);
GroupInviteLink groupInviteLink = GroupInviteLink.ADAPTER.decode(bytes); GroupInviteLink groupInviteLink = GroupInviteLink.ADAPTER.decode(bytes);
if (groupInviteLink.v1Contents != null) { if (groupInviteLink.contentsV1 != null) {
var groupInviteLinkContentsV1 = groupInviteLink.v1Contents; var groupInviteLinkContentsV1 = groupInviteLink.contentsV1;
var groupMasterKey = new GroupMasterKey(groupInviteLinkContentsV1.groupMasterKey.toByteArray()); var groupMasterKey = new GroupMasterKey(groupInviteLinkContentsV1.groupMasterKey.toByteArray());
var password = GroupLinkPassword.fromBytes(groupInviteLinkContentsV1.inviteLinkPassword.toByteArray()); var password = GroupLinkPassword.fromBytes(groupInviteLinkContentsV1.inviteLinkPassword.toByteArray());
@ -90,7 +90,7 @@ public final class GroupInviteLinkUrl {
} }
private static String createUrl(GroupMasterKey groupMasterKey, GroupLinkPassword password) { private static String createUrl(GroupMasterKey groupMasterKey, GroupLinkPassword password) {
var groupInviteLink = new GroupInviteLink.Builder().v1Contents(new GroupInviteLink.GroupInviteLinkContentsV1.Builder().groupMasterKey( var groupInviteLink = new GroupInviteLink.Builder().contentsV1(new GroupInviteLink.GroupInviteLinkContentsV1.Builder().groupMasterKey(
ByteString.of(groupMasterKey.serialize())) ByteString.of(groupMasterKey.serialize()))
.inviteLinkPassword(ByteString.of(password.serialize())) .inviteLinkPassword(ByteString.of(password.serialize()))
.build()).build(); .build()).build();

View File

@ -0,0 +1,14 @@
package org.asamk.signal.manager.api;
import org.asamk.signal.manager.helper.RecipientAddressResolver;
import org.asamk.signal.manager.storage.groups.GroupMemberInfo;
public record GroupMember(
RecipientAddress recipientAddress, boolean isAdmin, String labelEmoji, String label
) {
public static GroupMember from(final GroupMemberInfo memberInfo, final RecipientAddressResolver recipientStore) {
return new GroupMember(recipientStore.resolveRecipientAddress(memberInfo.getRecipientId())
.toApiRecipientAddress(), memberInfo.isAdmin(), memberInfo.labelEmoji(), memberInfo.labelString());
}
}

View File

@ -7,12 +7,14 @@ public record Message(
String messageText, String messageText,
List<String> attachments, List<String> attachments,
boolean viewOnce, boolean viewOnce,
boolean voiceNote,
List<Mention> mentions, List<Mention> mentions,
Optional<Quote> quote, Optional<Quote> quote,
Optional<Sticker> sticker, Optional<Sticker> sticker,
List<Preview> previews, List<Preview> previews,
Optional<StoryReply> storyReply, Optional<StoryReply> storyReply,
List<TextStyle> textStyles List<TextStyle> textStyles,
boolean urgent
) { ) {
public record Mention(RecipientIdentifier.Single recipient, int start, int length) {} public record Mention(RecipientIdentifier.Single recipient, int start, int length) {}

View File

@ -3,6 +3,7 @@ package org.asamk.signal.manager.api;
import org.asamk.signal.manager.groups.GroupUtils; import org.asamk.signal.manager.groups.GroupUtils;
import org.asamk.signal.manager.helper.RecipientAddressResolver; import org.asamk.signal.manager.helper.RecipientAddressResolver;
import org.asamk.signal.manager.storage.recipients.RecipientResolver; import org.asamk.signal.manager.storage.recipients.RecipientResolver;
import org.asamk.signal.manager.util.MimeUtils;
import org.signal.libsignal.metadata.ProtocolException; import org.signal.libsignal.metadata.ProtocolException;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment; import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer; import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer;
@ -32,11 +33,11 @@ import org.whispersystems.signalservice.api.messages.multidevice.SentTranscriptM
import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage; import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage;
import org.whispersystems.signalservice.api.messages.multidevice.ViewOnceOpenMessage; import org.whispersystems.signalservice.api.messages.multidevice.ViewOnceOpenMessage;
import org.whispersystems.signalservice.api.messages.multidevice.ViewedMessage; import org.whispersystems.signalservice.api.messages.multidevice.ViewedMessage;
import org.whispersystems.signalservice.api.push.ServiceId;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@ -120,15 +121,30 @@ public record MessageEnvelope(
Optional<PollTerminate> pollTerminate, Optional<PollTerminate> pollTerminate,
List<Mention> mentions, List<Mention> mentions,
List<Preview> previews, List<Preview> previews,
List<TextStyle> textStyles List<TextStyle> textStyles,
Optional<PinMessage> pinMessage,
Optional<UnpinMessage> unpinMessage,
Optional<AdminDelete> adminDelete
) { ) {
static Data from( static Data from(
final SignalServiceDataMessage dataMessage, final SignalServiceDataMessage dataMessage,
Map<String, String> longTexts,
RecipientResolver recipientResolver, RecipientResolver recipientResolver,
RecipientAddressResolver addressResolver, RecipientAddressResolver addressResolver,
final AttachmentFileProvider fileProvider 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(), return new Data(dataMessage.getTimestamp(),
dataMessage.getGroupContext().map(GroupContext::from), dataMessage.getGroupContext().map(GroupContext::from),
dataMessage.getStoryContext() dataMessage.getStoryContext()
@ -136,11 +152,11 @@ public record MessageEnvelope(
recipientResolver, recipientResolver,
addressResolver)), addressResolver)),
dataMessage.getGroupCallUpdate().map(GroupCallUpdate::from), dataMessage.getGroupCallUpdate().map(GroupCallUpdate::from),
dataMessage.getBody(), body,
dataMessage.getExpiresInSeconds(), dataMessage.getExpiresInSeconds(),
dataMessage.isExpirationUpdate(), dataMessage.isExpirationUpdate(),
dataMessage.isViewOnce(), dataMessage.isViewOnce(),
dataMessage.isEndSession(), false,
dataMessage.isProfileKeyUpdate(), dataMessage.isProfileKeyUpdate(),
dataMessage.getProfileKey().isPresent(), dataMessage.getProfileKey().isPresent(),
dataMessage.getReaction().map(r -> Reaction.from(r, recipientResolver, addressResolver)), dataMessage.getReaction().map(r -> Reaction.from(r, recipientResolver, addressResolver)),
@ -169,7 +185,10 @@ public record MessageEnvelope(
.orElse(List.of()), .orElse(List.of()),
dataMessage.getBodyRanges() dataMessage.getBodyRanges()
.map(a -> a.stream().filter(r -> r.style != null).map(TextStyle::from).toList()) .map(a -> a.stream().filter(r -> r.style != null).map(TextStyle::from).toList())
.orElse(List.of())); .orElse(List.of()),
dataMessage.getPinnedMessage().map(p -> PinMessage.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) { public record GroupContext(GroupId groupId, boolean isGroupUpdate, int revision) {
@ -248,19 +267,19 @@ public record MessageEnvelope(
quote.getMentions() == null quote.getMentions() == null
? List.of() ? List.of()
: quote.getMentions() : quote.getMentions()
.stream() .stream()
.map(m -> Mention.from(m, recipientResolver, addressResolver)) .map(m -> Mention.from(m, recipientResolver, addressResolver))
.toList(), .toList(),
quote.getAttachments() == null quote.getAttachments() == null
? List.of() ? List.of()
: quote.getAttachments().stream().map(a -> Attachment.from(a, fileProvider)).toList(), : quote.getAttachments().stream().map(a -> Attachment.from(a, fileProvider)).toList(),
quote.getBodyRanges() == null quote.getBodyRanges() == null
? List.of() ? List.of()
: quote.getBodyRanges() : quote.getBodyRanges()
.stream() .stream()
.filter(r -> r.style != null) .filter(r -> r.style != null)
.map(TextStyle::from) .map(TextStyle::from)
.toList()); .toList());
} }
} }
@ -564,18 +583,68 @@ public record MessageEnvelope(
} }
} }
public record PinMessage(
RecipientAddress targetAuthor, long targetSentTimestamp, long pinDurationSeconds
) {
static PinMessage from(
SignalServiceDataMessage.PinnedMessage pinnedMessage,
RecipientResolver recipientResolver,
RecipientAddressResolver addressResolver
) {
return new PinMessage(addressResolver.resolveRecipientAddress(recipientResolver.resolveRecipient(
pinnedMessage.getTargetAuthor())).toApiRecipientAddress(),
pinnedMessage.getTargetSentTimestamp(),
Boolean.TRUE.equals(pinnedMessage.getForever())
? -1
: pinnedMessage.getPinDurationInSeconds() == null
? 0
: pinnedMessage.getPinDurationInSeconds());
}
}
public record UnpinMessage(RecipientAddress targetAuthor, long targetSentTimestamp) {
static UnpinMessage from(
SignalServiceDataMessage.UnpinnedMessage unpinnedMessage,
RecipientResolver recipientResolver,
RecipientAddressResolver addressResolver
) {
return new UnpinMessage(addressResolver.resolveRecipientAddress(recipientResolver.resolveRecipient(
unpinnedMessage.getTargetAuthor())).toApiRecipientAddress(),
unpinnedMessage.getTargetSentTimestamp());
}
}
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 record Edit(long targetSentTimestamp, Data dataMessage) {
public static Edit from( public static Edit from(
final SignalServiceEditMessage editMessage, final SignalServiceEditMessage editMessage,
Map<String, String> longTexts,
RecipientResolver recipientResolver, RecipientResolver recipientResolver,
RecipientAddressResolver addressResolver, RecipientAddressResolver addressResolver,
final AttachmentFileProvider fileProvider final AttachmentFileProvider fileProvider
) { ) {
return new Edit(editMessage.getTargetSentTimestamp(), return new Edit(editMessage.getTargetSentTimestamp(),
Data.from(editMessage.getDataMessage(), recipientResolver, addressResolver, fileProvider)); Data.from(editMessage.getDataMessage(),
longTexts,
recipientResolver,
addressResolver,
fileProvider));
} }
} }
@ -592,12 +661,13 @@ public record MessageEnvelope(
public static Sync from( public static Sync from(
final SignalServiceSyncMessage syncMessage, final SignalServiceSyncMessage syncMessage,
Map<String, String> longTexts,
RecipientResolver recipientResolver, RecipientResolver recipientResolver,
RecipientAddressResolver addressResolver, RecipientAddressResolver addressResolver,
final AttachmentFileProvider fileProvider final AttachmentFileProvider fileProvider
) { ) {
return new Sync(syncMessage.getSent() 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.getBlockedList().map(b -> Blocked.from(b, recipientResolver, addressResolver)),
syncMessage.getRead() syncMessage.getRead()
.map(r -> r.stream().map(rm -> Read.from(rm, recipientResolver, addressResolver)).toList()) .map(r -> r.stream().map(rm -> Read.from(rm, recipientResolver, addressResolver)).toList())
@ -626,6 +696,7 @@ public record MessageEnvelope(
static Sent from( static Sent from(
SentTranscriptMessage sentMessage, SentTranscriptMessage sentMessage,
Map<String, String> longTexts,
RecipientResolver recipientResolver, RecipientResolver recipientResolver,
RecipientAddressResolver addressResolver, RecipientAddressResolver addressResolver,
final AttachmentFileProvider fileProvider final AttachmentFileProvider fileProvider
@ -641,9 +712,17 @@ public record MessageEnvelope(
.toApiRecipientAddress()) .toApiRecipientAddress())
.collect(Collectors.toSet()), .collect(Collectors.toSet()),
sentMessage.getDataMessage() sentMessage.getDataMessage()
.map(message -> Data.from(message, recipientResolver, addressResolver, fileProvider)), .map(message -> Data.from(message,
longTexts,
recipientResolver,
addressResolver,
fileProvider)),
sentMessage.getEditMessage() 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))); sentMessage.getStoryMessage().map(s -> Story.from(s, fileProvider)));
} }
} }
@ -942,23 +1021,24 @@ public record MessageEnvelope(
public static MessageEnvelope from( public static MessageEnvelope from(
SignalServiceEnvelope envelope, SignalServiceEnvelope envelope,
SignalServiceContent content, SignalServiceContent content,
Map<String, String> longTexts,
RecipientResolver recipientResolver, RecipientResolver recipientResolver,
RecipientAddressResolver addressResolver, RecipientAddressResolver addressResolver,
final AttachmentFileProvider fileProvider, final AttachmentFileProvider fileProvider,
Exception exception Exception exception
) { ) {
final var serviceId = envelope.getSourceServiceId().map(ServiceId::parseOrNull).orElse(null); final var serviceId = envelope.getSourceServiceId();
final var source = !envelope.isUnidentifiedSender() && serviceId != null final var source = !envelope.isUnidentifiedSender() && serviceId != null
? recipientResolver.resolveRecipient(serviceId) ? recipientResolver.resolveRecipient(serviceId)
: envelope.isUnidentifiedSender() && content != null : envelope.isUnidentifiedSender() && content != null
? recipientResolver.resolveRecipient(content.getSender()) ? recipientResolver.resolveRecipient(content.getSender())
: exception instanceof ProtocolException e : exception instanceof ProtocolException e
? recipientResolver.resolveRecipient(e.getSender()) ? recipientResolver.resolveRecipient(e.getSender())
: null; : null;
final var sourceDevice = envelope.hasSourceDevice() final var sourceDevice = envelope.hasSourceDevice()
? envelope.getSourceDevice() ? envelope.getSourceDevice()
: content != null : content != null
? content.getSenderDevice() ? content.getSenderDevice()
: exception instanceof ProtocolException e ? e.getSenderDevice() : 0; : exception instanceof ProtocolException e ? e.getSenderDevice() : 0;
Optional<Receipt> receipt; Optional<Receipt> receipt;
@ -972,9 +1052,15 @@ public record MessageEnvelope(
receipt = content.getReceiptMessage().map(Receipt::from); receipt = content.getReceiptMessage().map(Receipt::from);
typing = content.getTypingMessage().map(Typing::from); typing = content.getTypingMessage().map(Typing::from);
data = content.getDataMessage() data = content.getDataMessage()
.map(dataMessage -> Data.from(dataMessage, recipientResolver, addressResolver, fileProvider)); .map(dataMessage -> Data.from(dataMessage,
edit = content.getEditMessage().map(s -> Edit.from(s, recipientResolver, addressResolver, fileProvider)); longTexts,
sync = content.getSyncMessage().map(s -> Sync.from(s, recipientResolver, addressResolver, fileProvider)); 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); call = content.getCallMessage().map(Call::from);
story = content.getStoryMessage().map(s -> Story.from(s, fileProvider)); story = content.getStoryMessage().map(s -> Story.from(s, fileProvider));
} else { } else {

View File

@ -10,12 +10,19 @@ public class ProofRequiredException extends Exception {
private final String token; private final String token;
private final Set<Option> options; private final Set<Option> options;
private final long retryAfterSeconds; private final long retryAfterMilliseconds;
public ProofRequiredException(org.whispersystems.signalservice.api.push.exceptions.ProofRequiredException e) { public ProofRequiredException(final String token, final Set<Option> options, final long retryAfterMilliseconds) {
this.token = e.getToken(); super("Rate limit");
this.options = e.getOptions().stream().map(Option::from).collect(Collectors.toSet()); this.token = token;
this.retryAfterSeconds = e.getRetryAfterSeconds(); this.options = options;
this.retryAfterMilliseconds = retryAfterMilliseconds;
}
public static ProofRequiredException from(org.whispersystems.signalservice.api.push.exceptions.ProofRequiredException e) {
return new ProofRequiredException(e.getToken(),
e.getOptions().stream().map(Option::from).collect(Collectors.toSet()),
e.getRetryAfterSeconds() * 1000L);
} }
public String getToken() { public String getToken() {
@ -26,8 +33,8 @@ public class ProofRequiredException extends Exception {
return options; return options;
} }
public long getRetryAfterSeconds() { public long getRetryAfterMilliseconds() {
return retryAfterSeconds; return retryAfterMilliseconds;
} }
public enum Option { public enum Option {

View File

@ -2,14 +2,18 @@ package org.asamk.signal.manager.api;
public class RateLimitException extends Exception { public class RateLimitException extends Exception {
private final long nextAttemptTimestamp; private final Long retryAfterMilliseconds;
public RateLimitException(final long nextAttemptTimestamp) { public RateLimitException(final Long retryAfterMilliseconds) {
super("Rate limit"); super("Rate limit");
this.nextAttemptTimestamp = nextAttemptTimestamp; this.retryAfterMilliseconds = retryAfterMilliseconds;
} }
public long getNextAttemptTimestamp() { public static RateLimitException from(org.whispersystems.signalservice.api.push.exceptions.RateLimitException e) {
return nextAttemptTimestamp; return new RateLimitException(e.getRetryAfterMilliseconds().orElse(null));
}
public Long getRetryAfterMilliseconds() {
return retryAfterMilliseconds;
} }
} }

View File

@ -1,3 +1,9 @@
package org.asamk.signal.manager.api; package org.asamk.signal.manager.api;
public record ReceiveConfig(boolean ignoreAttachments, boolean ignoreStories, boolean sendReadReceipts) {} public record ReceiveConfig(
boolean ignoreAttachments,
boolean ignoreStories,
boolean ignoreAvatars,
boolean ignoreStickers,
boolean sendReadReceipts
) {}

View File

@ -1,6 +1,6 @@
package org.asamk.signal.manager.api; package org.asamk.signal.manager.api;
import org.whispersystems.signalservice.api.util.UuidUtil; import org.signal.core.util.UuidUtil;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;

View File

@ -1,9 +1,9 @@
package org.asamk.signal.manager.api; package org.asamk.signal.manager.api;
import org.asamk.signal.manager.util.PhoneNumberFormatter; import org.asamk.signal.manager.util.PhoneNumberFormatter;
import org.signal.core.util.UuidUtil;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.util.UuidUtil;
import java.util.UUID; import java.util.UUID;
@ -24,13 +24,13 @@ public sealed interface RecipientIdentifier {
sealed interface Single extends RecipientIdentifier { sealed interface Single extends RecipientIdentifier {
static Single fromString(String identifier, String localNumber) throws InvalidNumberException { static Single fromString(String identifier, String localNumber) throws InvalidNumberException {
if (UuidUtil.isUuid(identifier)) { if (UuidUtil.INSTANCE.isUuid(identifier)) {
return new Uuid(UUID.fromString(identifier)); return new Uuid(UUID.fromString(identifier));
} }
if (identifier.startsWith("PNI:")) { if (identifier.startsWith("PNI:")) {
final var pni = identifier.substring(4); final var pni = identifier.substring(4);
if (!UuidUtil.isUuid(pni)) { if (!UuidUtil.INSTANCE.isUuid(pni)) {
throw new InvalidNumberException("Invalid PNI"); throw new InvalidNumberException("Invalid PNI");
} }
return new Pni(UUID.fromString(pni)); return new Pni(UUID.fromString(pni));

View File

@ -9,13 +9,13 @@ public record SendMessageResult(
boolean isNetworkFailure, boolean isNetworkFailure,
boolean isUnregisteredFailure, boolean isUnregisteredFailure,
boolean isIdentityFailure, boolean isIdentityFailure,
boolean isRateLimitFailure, RateLimitException rateLimitException,
ProofRequiredException proofRequiredFailure, ProofRequiredException proofRequiredFailure,
boolean isInvalidPreKeyFailure boolean isInvalidPreKeyFailure
) { ) {
public static SendMessageResult unregisteredFailure(RecipientAddress address) { public static SendMessageResult unregisteredFailure(RecipientAddress address) {
return new SendMessageResult(address, false, false, true, false, false, null, false); return new SendMessageResult(address, false, false, true, false, null, null, false);
} }
public static SendMessageResult from( public static SendMessageResult from(
@ -23,16 +23,30 @@ public record SendMessageResult(
RecipientResolver recipientResolver, RecipientResolver recipientResolver,
RecipientAddressResolver addressResolver RecipientAddressResolver addressResolver
) { ) {
final var rateLimitFailure = sendMessageResult.getRateLimitFailure();
final var proofRequiredFailure = sendMessageResult.getProofRequiredFailure();
return new SendMessageResult(addressResolver.resolveRecipientAddress(recipientResolver.resolveRecipient( return new SendMessageResult(addressResolver.resolveRecipientAddress(recipientResolver.resolveRecipient(
sendMessageResult.getAddress())).toApiRecipientAddress(), sendMessageResult.getAddress())).toApiRecipientAddress(),
sendMessageResult.isSuccess(), sendMessageResult.isSuccess(),
sendMessageResult.isNetworkFailure(), sendMessageResult.isNetworkFailure(),
sendMessageResult.isUnregisteredFailure(), sendMessageResult.isUnregisteredFailure(),
sendMessageResult.getIdentityFailure() != null, sendMessageResult.getIdentityFailure() != null,
sendMessageResult.getRateLimitFailure() != null || sendMessageResult.getProofRequiredFailure() != null, rateLimitFailure == null ? null : RateLimitException.from(rateLimitFailure),
sendMessageResult.getProofRequiredFailure() == null proofRequiredFailure == null ? null : ProofRequiredException.from(proofRequiredFailure),
? null
: new ProofRequiredException(sendMessageResult.getProofRequiredFailure()),
sendMessageResult.isInvalidPreKeyFailure()); sendMessageResult.isInvalidPreKeyFailure());
} }
public boolean isRateLimitFailure() {
return this.rateLimitException != null || this.proofRequiredFailure != null;
}
public Long rateLimitRetryAfterMilliseconds() {
if (proofRequiredFailure != null) {
return proofRequiredFailure.getRetryAfterMilliseconds();
} else if (rateLimitException != null) {
return rateLimitException.getRetryAfterMilliseconds();
} else {
return null;
}
}
} }

View File

@ -2,6 +2,7 @@ package org.asamk.signal.manager.api;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects;
public record SendMessageResults(long timestamp, Map<RecipientIdentifier, List<SendMessageResult>> results) { public record SendMessageResults(long timestamp, Map<RecipientIdentifier, List<SendMessageResult>> results) {
@ -26,4 +27,18 @@ public record SendMessageResults(long timestamp, Map<RecipientIdentifier, List<S
.flatMap(res -> res.stream().map(SendMessageResult::isRateLimitFailure)) .flatMap(res -> res.stream().map(SendMessageResult::isRateLimitFailure))
.allMatch(r -> r) && results.values().stream().mapToInt(List::size).sum() > 0; .allMatch(r -> r) && results.values().stream().mapToInt(List::size).sum() > 0;
} }
/**
* Longest rate-limit retry-after window across all rate-limited recipients, in milliseconds.
* Null when no recipient reported one (server omitted Retry-After, or no rate-limit failures).
*/
public Long maxRateLimitRetryAfterMilliseconds() {
return results.values()
.stream()
.flatMap(List::stream)
.map(SendMessageResult::rateLimitRetryAfterMilliseconds)
.filter(Objects::nonNull)
.max(Long::compareTo)
.orElse(null);
}
} }

View File

@ -1,6 +1,6 @@
package org.asamk.signal.manager.api; package org.asamk.signal.manager.api;
import org.whispersystems.signalservice.internal.util.Hex; import org.signal.core.util.Hex;
import java.util.Arrays; import java.util.Arrays;

View File

@ -1,7 +1,7 @@
package org.asamk.signal.manager.api; package org.asamk.signal.manager.api;
import org.asamk.signal.manager.util.Utils; import org.asamk.signal.manager.util.Utils;
import org.whispersystems.signalservice.internal.util.Hex; import org.signal.core.util.Hex;
import java.io.IOException; import java.io.IOException;
import java.net.URI; import java.net.URI;

View File

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

View File

@ -19,6 +19,8 @@ public class UpdateGroup {
private final String avatarFile; private final String avatarFile;
private final Integer expirationTimer; private final Integer expirationTimer;
private final Boolean isAnnouncementGroup; private final Boolean isAnnouncementGroup;
private final String labelEmoji;
private final String labelString;
private UpdateGroup(final Builder builder) { private UpdateGroup(final Builder builder) {
name = builder.name; name = builder.name;
@ -36,6 +38,8 @@ public class UpdateGroup {
avatarFile = builder.avatarFile; avatarFile = builder.avatarFile;
expirationTimer = builder.expirationTimer; expirationTimer = builder.expirationTimer;
isAnnouncementGroup = builder.isAnnouncementGroup; isAnnouncementGroup = builder.isAnnouncementGroup;
labelEmoji = builder.labelEmoji;
labelString = builder.labelString;
} }
public static Builder newBuilder() { public static Builder newBuilder() {
@ -57,7 +61,9 @@ public class UpdateGroup {
copy.editDetailsPermission, copy.editDetailsPermission,
copy.avatarFile, copy.avatarFile,
copy.expirationTimer, copy.expirationTimer,
copy.isAnnouncementGroup); copy.isAnnouncementGroup,
copy.labelEmoji,
copy.labelString);
} }
public static Builder newBuilder( public static Builder newBuilder(
@ -75,7 +81,9 @@ public class UpdateGroup {
final GroupPermission editDetailsPermission, final GroupPermission editDetailsPermission,
final String avatarFile, final String avatarFile,
final Integer expirationTimer, final Integer expirationTimer,
final Boolean isAnnouncementGroup final Boolean isAnnouncementGroup,
final String labelEmoji,
final String labelString
) { ) {
return new Builder(name, return new Builder(name,
description, description,
@ -91,7 +99,9 @@ public class UpdateGroup {
editDetailsPermission, editDetailsPermission,
avatarFile, avatarFile,
expirationTimer, expirationTimer,
isAnnouncementGroup); isAnnouncementGroup,
labelEmoji,
labelString);
} }
public String getName() { public String getName() {
@ -154,6 +164,14 @@ public class UpdateGroup {
return isAnnouncementGroup; return isAnnouncementGroup;
} }
public String getLabelEmoji() {
return labelEmoji;
}
public String getLabelString() {
return labelString;
}
public static final class Builder { public static final class Builder {
private String name; private String name;
@ -171,6 +189,8 @@ public class UpdateGroup {
private String avatarFile; private String avatarFile;
private Integer expirationTimer; private Integer expirationTimer;
private Boolean isAnnouncementGroup; private Boolean isAnnouncementGroup;
private String labelEmoji;
private String labelString;
private Builder() { private Builder() {
} }
@ -190,7 +210,9 @@ public class UpdateGroup {
final GroupPermission editDetailsPermission, final GroupPermission editDetailsPermission,
final String avatarFile, final String avatarFile,
final Integer expirationTimer, final Integer expirationTimer,
final Boolean isAnnouncementGroup final Boolean isAnnouncementGroup,
final String labelEmoji,
final String labelString
) { ) {
this.name = name; this.name = name;
this.description = description; this.description = description;
@ -207,6 +229,8 @@ public class UpdateGroup {
this.avatarFile = avatarFile; this.avatarFile = avatarFile;
this.expirationTimer = expirationTimer; this.expirationTimer = expirationTimer;
this.isAnnouncementGroup = isAnnouncementGroup; this.isAnnouncementGroup = isAnnouncementGroup;
this.labelEmoji = labelEmoji;
this.labelString = labelString;
} }
public Builder withName(final String val) { public Builder withName(final String val) {
@ -284,6 +308,16 @@ public class UpdateGroup {
return this; return this;
} }
public Builder withLabelEmoji(final String val) {
labelEmoji = val;
return this;
}
public Builder withLabelString(final String val) {
labelString = val;
return this;
}
public UpdateGroup build() { public UpdateGroup build() {
return new UpdateGroup(this); return new UpdateGroup(this);
} }

View File

@ -1,8 +1,8 @@
package org.asamk.signal.manager.api; package org.asamk.signal.manager.api;
import org.signal.core.util.Base64; import org.signal.core.util.Base64;
import org.signal.core.util.UuidUtil;
import org.whispersystems.signalservice.api.push.UsernameLinkComponents; import org.whispersystems.signalservice.api.push.UsernameLinkComponents;
import org.whispersystems.signalservice.api.util.UuidUtil;
import java.io.IOException; import java.io.IOException;
import java.util.Arrays; import java.util.Arrays;
@ -36,7 +36,7 @@ public final class UsernameLinkUrl {
final var entropy = Arrays.copyOfRange(allBytes, 0, 32); final var entropy = Arrays.copyOfRange(allBytes, 0, 32);
final var serverId = Arrays.copyOfRange(allBytes, 32, allBytes.length); final var serverId = Arrays.copyOfRange(allBytes, 32, allBytes.length);
final var serverIdUuid = UuidUtil.parseOrNull(serverId); final var serverIdUuid = UuidUtil.INSTANCE.parseOrNull(serverId);
if (serverIdUuid == null) { if (serverIdUuid == null) {
throw new InvalidUsernameLinkException("Invalid serverId"); throw new InvalidUsernameLinkException("Invalid serverId");
} }

View File

@ -20,6 +20,7 @@ public class ServiceConfig {
public static final int MAX_ATTACHMENT_SIZE = 150 * 1024 * 1024; public static final int MAX_ATTACHMENT_SIZE = 150 * 1024 * 1024;
public static final long MAX_ENVELOPE_SIZE = 0; public static final long MAX_ENVELOPE_SIZE = 0;
public static final int MAX_INCREMENTAL_MACS_PER_ENVELOPE = 10;
public static final int MAX_MESSAGE_SIZE_BYTES = 2000; public static final int MAX_MESSAGE_SIZE_BYTES = 2000;
public static final long AVATAR_DOWNLOAD_FAILSAFE_MAX_SIZE = 10 * 1024 * 1024; public static final long AVATAR_DOWNLOAD_FAILSAFE_MAX_SIZE = 10 * 1024 * 1024;
public static final boolean AUTOMATIC_NETWORK_RETRY = true; public static final boolean AUTOMATIC_NETWORK_RETRY = true;
@ -29,7 +30,7 @@ public class ServiceConfig {
public static AccountAttributes.Capabilities getCapabilities(boolean isPrimaryDevice) { public static AccountAttributes.Capabilities getCapabilities(boolean isPrimaryDevice) {
final var attachmentBackfill = !isPrimaryDevice; final var attachmentBackfill = !isPrimaryDevice;
final var spqr = !isPrimaryDevice; final var spqr = true;
return new AccountAttributes.Capabilities(true, true, attachmentBackfill, spqr); return new AccountAttributes.Capabilities(true, true, attachmentBackfill, spqr);
} }

View File

@ -1,6 +1,6 @@
package org.asamk.signal.manager.helper; package org.asamk.signal.manager.helper;
import org.whispersystems.signalservice.api.push.ServiceId.ACI; import org.signal.core.models.ServiceId.ACI;
public interface AccountFileUpdater { public interface AccountFileUpdater {

View File

@ -14,6 +14,8 @@ import org.asamk.signal.manager.storage.SignalAccount;
import org.asamk.signal.manager.util.KeyUtils; import org.asamk.signal.manager.util.KeyUtils;
import org.asamk.signal.manager.util.NumberVerificationUtils; import org.asamk.signal.manager.util.NumberVerificationUtils;
import org.asamk.signal.manager.util.Utils; import org.asamk.signal.manager.util.Utils;
import org.signal.core.models.ServiceId.ACI;
import org.signal.core.models.ServiceId.PNI;
import org.signal.core.util.Base64; import org.signal.core.util.Base64;
import org.signal.libsignal.protocol.IdentityKeyPair; import org.signal.libsignal.protocol.IdentityKeyPair;
import org.signal.libsignal.protocol.InvalidKeyException; import org.signal.libsignal.protocol.InvalidKeyException;
@ -23,13 +25,12 @@ import org.signal.libsignal.protocol.state.SignedPreKeyRecord;
import org.signal.libsignal.protocol.util.KeyHelper; import org.signal.libsignal.protocol.util.KeyHelper;
import org.signal.libsignal.usernames.BaseUsernameException; import org.signal.libsignal.usernames.BaseUsernameException;
import org.signal.libsignal.usernames.Username; import org.signal.libsignal.usernames.Username;
import org.signal.network.exceptions.NonSuccessfulResponseCodeException;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.account.ChangePhoneNumberRequest; import org.whispersystems.signalservice.api.account.ChangePhoneNumberRequest;
import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException; import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException;
import org.whispersystems.signalservice.api.link.LinkedDeviceVerificationCodeResponse; import org.whispersystems.signalservice.api.link.LinkedDeviceVerificationCodeResponse;
import org.whispersystems.signalservice.api.push.ServiceId.ACI;
import org.whispersystems.signalservice.api.push.ServiceId.PNI;
import org.whispersystems.signalservice.api.push.ServiceIdType; import org.whispersystems.signalservice.api.push.ServiceIdType;
import org.whispersystems.signalservice.api.push.SignalServiceAddress; import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.push.SignedPreKeyEntity; import org.whispersystems.signalservice.api.push.SignedPreKeyEntity;
@ -37,9 +38,6 @@ import org.whispersystems.signalservice.api.push.UsernameLinkComponents;
import org.whispersystems.signalservice.api.push.exceptions.AlreadyVerifiedException; import org.whispersystems.signalservice.api.push.exceptions.AlreadyVerifiedException;
import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedException; import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedException;
import org.whispersystems.signalservice.api.push.exceptions.DeprecatedVersionException; import org.whispersystems.signalservice.api.push.exceptions.DeprecatedVersionException;
import org.whispersystems.signalservice.api.push.exceptions.UsernameIsNotReservedException;
import org.whispersystems.signalservice.api.push.exceptions.UsernameMalformedException;
import org.whispersystems.signalservice.api.push.exceptions.UsernameTakenException;
import org.whispersystems.signalservice.api.util.DeviceNameUtil; import org.whispersystems.signalservice.api.util.DeviceNameUtil;
import org.whispersystems.signalservice.internal.push.DeviceLimitExceededException; import org.whispersystems.signalservice.internal.push.DeviceLimitExceededException;
import org.whispersystems.signalservice.internal.push.KyberPreKeyEntity; import org.whispersystems.signalservice.internal.push.KyberPreKeyEntity;
@ -82,16 +80,9 @@ public class AccountHelper {
} }
public void checkAccountState() throws IOException { public void checkAccountState() throws IOException {
if (account.getLastReceiveTimestamp() == 0) { if (account.getAci() == null) {
logger.info("The Signal protocol expects that incoming messages are regularly received."); account.setRegistered(false);
} else { throw new IOException("Account without ACI");
var diffInMilliseconds = System.currentTimeMillis() - account.getLastReceiveTimestamp();
long days = TimeUnit.DAYS.convert(diffInMilliseconds, TimeUnit.MILLISECONDS);
if (days > 7) {
logger.warn(
"Messages have been last received {} days ago. The Signal protocol expects that incoming messages are regularly received.",
days);
}
} }
try { try {
updateAccountAttributes(); updateAccountAttributes();
@ -100,7 +91,7 @@ public class AccountHelper {
} else { } else {
context.getPreKeyHelper().refreshPreKeysIfNecessary(); context.getPreKeyHelper().refreshPreKeysIfNecessary();
} }
if (account.getAci() == null || account.getPni() == null) { if (account.getPni() == null) {
checkWhoAmiI(); checkWhoAmiI();
} }
if (!account.isPrimaryDevice() && account.getPniIdentityKeyPair() == null) { if (!account.isPrimaryDevice() && account.getPniIdentityKeyPair() == null) {
@ -125,6 +116,17 @@ public class AccountHelper {
account.setRegistered(false); account.setRegistered(false);
throw e; throw e;
} }
if (account.getLastReceiveTimestamp() == 0) {
logger.info("The Signal protocol expects that incoming messages are regularly received.");
} else {
var diffInMilliseconds = System.currentTimeMillis() - account.getLastReceiveTimestamp();
long days = TimeUnit.DAYS.convert(diffInMilliseconds, TimeUnit.MILLISECONDS);
if (days > 7) {
logger.warn(
"Messages have been last received {} days ago. The Signal protocol expects that incoming messages are regularly received.",
days);
}
}
} }
public void checkWhoAmiI() throws IOException { public void checkWhoAmiI() throws IOException {
@ -311,6 +313,7 @@ public class AccountHelper {
Utils.mapKeys(pniRegistrationIds, Object::toString)))); Utils.mapKeys(pniRegistrationIds, Object::toString))));
}); });
account.clearSessionId();
final var updatePni = PNI.parseOrThrow(result.first().getPni()); final var updatePni = PNI.parseOrThrow(result.first().getPni());
if (updatePni.equals(account.getPni())) { if (updatePni.equals(account.getPni())) {
logger.debug("PNI is unchanged after change number"); logger.debug("PNI is unchanged after change number");
@ -460,7 +463,7 @@ public class AccountHelper {
logger.debug("Attempting to resynchronize username."); logger.debug("Attempting to resynchronize username.");
try { try {
tryReserveConfirmUsername(username); tryReserveConfirmUsername(username);
} catch (UsernameMalformedException | UsernameTakenException | UsernameIsNotReservedException e) { } catch (NonSuccessfulResponseCodeException e) {
logger.debug("[confirmUsername] Failed to reserve confirm username: {} ({})", logger.debug("[confirmUsername] Failed to reserve confirm username: {} ({})",
e.getMessage(), e.getMessage(),
e.getClass().getSimpleName()); e.getClass().getSimpleName());
@ -517,6 +520,22 @@ public class AccountHelper {
account.setEncryptedDeviceName(encryptedDeviceName); account.setEncryptedDeviceName(encryptedDeviceName);
} }
public void setDeviceName(int deviceId, String deviceName) throws IOException {
final var privateKey = account.getAciIdentityKeyPair().getPrivateKey();
final var encryptedDeviceName = DeviceNameUtil.encryptDeviceName(deviceName, privateKey);
handleResponseException(dependencies.getLinkDeviceApi().setDeviceName(encryptedDeviceName, deviceId));
context.getSyncHelper().sendDeviceNameChange(deviceId);
}
public void refreshDeviceName() throws IOException {
final var devices = handleResponseException(dependencies.getLinkDeviceApi().getDevices());
final var deviceId = account.getDeviceId();
final var device = devices.stream().filter(d -> d.id == deviceId).findFirst();
if (device.isPresent()) {
account.setEncryptedDeviceName(device.get().name);
}
}
public void updateAccountAttributes() throws IOException { public void updateAccountAttributes() throws IOException {
handleResponseException(dependencies.getAccountApi().setAccountAttributes(account.getAccountAttributes(null))); handleResponseException(dependencies.getAccountApi().setAccountAttributes(account.getAccountAttributes(null)));
} }

View File

@ -6,15 +6,19 @@ import org.asamk.signal.manager.internal.SignalDependencies;
import org.asamk.signal.manager.storage.AttachmentStore; import org.asamk.signal.manager.storage.AttachmentStore;
import org.asamk.signal.manager.util.AttachmentUtils; import org.asamk.signal.manager.util.AttachmentUtils;
import org.asamk.signal.manager.util.IOUtils; import org.asamk.signal.manager.util.IOUtils;
import org.asamk.signal.manager.util.Utils;
import org.signal.libsignal.protocol.InvalidMessageException; import org.signal.libsignal.protocol.InvalidMessageException;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.crypto.AttachmentCipherInputStream; import org.whispersystems.signalservice.api.crypto.AttachmentCipherInputStream;
import org.whispersystems.signalservice.api.crypto.AttachmentCipherStreamUtil;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment; import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer; import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentStream; import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentStream;
import org.whispersystems.signalservice.api.push.exceptions.MissingConfigurationException; import org.whispersystems.signalservice.api.push.exceptions.MissingConfigurationException;
import org.whispersystems.signalservice.api.util.StreamDetails; import org.whispersystems.signalservice.api.util.StreamDetails;
import org.whispersystems.signalservice.internal.crypto.PaddingInputStream;
import org.whispersystems.signalservice.internal.push.http.ResumableUploadSpec;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
@ -30,8 +34,10 @@ public class AttachmentHelper {
private final SignalDependencies dependencies; private final SignalDependencies dependencies;
private final AttachmentStore attachmentStore; private final AttachmentStore attachmentStore;
private final Context context;
public AttachmentHelper(final Context context) { public AttachmentHelper(final Context context) {
this.context = context;
this.dependencies = context.getDependencies(); this.dependencies = context.getDependencies();
this.attachmentStore = context.getAttachmentStore(); this.attachmentStore = context.getAttachmentStore();
} }
@ -44,8 +50,11 @@ public class AttachmentHelper {
return attachmentStore.retrieveAttachment(id); return attachmentStore.retrieveAttachment(id);
} }
public List<SignalServiceAttachment> uploadAttachments(final List<String> attachments) throws AttachmentInvalidException, IOException { public List<SignalServiceAttachment> uploadAttachments(
final var attachmentStreams = createAttachmentStreams(attachments); final List<String> attachments,
boolean voiceNote
) throws AttachmentInvalidException, IOException {
final var attachmentStreams = createAttachmentStreams(attachments, voiceNote);
try { try {
// Upload attachments here, so we only upload once even for multiple recipients // Upload attachments here, so we only upload once even for multiple recipients
@ -61,21 +70,67 @@ public class AttachmentHelper {
} }
} }
private List<SignalServiceAttachmentStream> createAttachmentStreams(List<String> attachments) throws AttachmentInvalidException, IOException { public List<SignalServiceAttachment> uploadAttachments(final List<String> attachments) throws AttachmentInvalidException, IOException {
return uploadAttachments(attachments, false);
}
private List<SignalServiceAttachmentStream> createAttachmentStreams(
List<String> attachments,
boolean voiceNote
) throws AttachmentInvalidException, IOException {
if (attachments == null) { if (attachments == null) {
return null; return null;
} }
final var signalServiceAttachments = new ArrayList<SignalServiceAttachmentStream>(attachments.size()); final var signalServiceAttachments = new ArrayList<SignalServiceAttachmentStream>(attachments.size());
for (var attachment : attachments) { for (var attachment : attachments) {
final var uploadSpec = dependencies.getMessageSender().getResumableUploadSpec(); final var attachmentStream = getAttachmentStream(attachment, voiceNote);
signalServiceAttachments.add(AttachmentUtils.createAttachmentStream(attachment, uploadSpec)); signalServiceAttachments.add(attachmentStream);
} }
return signalServiceAttachments; return signalServiceAttachments;
} }
private SignalServiceAttachmentStream getAttachmentStream(
final String attachment,
final boolean voiceNote
) throws AttachmentInvalidException {
try {
// Reject local files that point into the signal-cli data directory
if (attachment != null && !attachment.startsWith("data:")) {
try {
final var file = new File(attachment);
final var canonical = file.getCanonicalFile();
final var dataPath = context.getAccount().getDataPath().getCanonicalFile();
if (canonical.toPath().startsWith(dataPath.toPath())) {
throw new AttachmentInvalidException(attachment,
new IOException("Attaching files from the signal-cli data directory is not allowed"));
}
} catch (IOException e) {
throw new AttachmentInvalidException(attachment, e);
}
}
final var streamDetailsAndFileName = Utils.createStreamDetails(attachment);
final var streamDetails = streamDetailsAndFileName.first();
final var uploadSpec = getResumableUploadSpec(streamDetails);
return AttachmentUtils.createAttachmentStream(streamDetails,
streamDetailsAndFileName.second(),
voiceNote,
uploadSpec);
} catch (IOException e) {
throw new AttachmentInvalidException(attachment, e);
}
}
public ResumableUploadSpec getResumableUploadSpec(final StreamDetails streamDetails) throws IOException {
final var streamLength = streamDetails.getLength();
final var ciphertextLength = AttachmentCipherStreamUtil.getCiphertextLength(PaddingInputStream.getPaddedSize(
streamLength));
return dependencies.getCdnService().getResumableUploadSpecBlocking(ciphertextLength);
}
public SignalServiceAttachmentPointer uploadAttachment(String attachment) throws IOException, AttachmentInvalidException { public SignalServiceAttachmentPointer uploadAttachment(String attachment) throws IOException, AttachmentInvalidException {
final var uploadSpec = dependencies.getMessageSender().getResumableUploadSpec(); final var attachmentStream = getAttachmentStream(attachment, false);
var attachmentStream = AttachmentUtils.createAttachmentStream(attachment, uploadSpec);
return uploadAttachment(attachmentStream); return uploadAttachment(attachmentStream);
} }

View File

@ -0,0 +1,809 @@
package org.asamk.signal.manager.helper;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.asamk.signal.manager.Manager;
import org.asamk.signal.manager.api.CallInfo;
import org.asamk.signal.manager.api.MessageEnvelope;
import org.asamk.signal.manager.api.TurnServer;
import org.asamk.signal.manager.internal.SignalDependencies;
import org.asamk.signal.manager.storage.SignalAccount;
import org.asamk.signal.manager.storage.recipients.RecipientId;
import org.asamk.signal.manager.util.Utils;
import org.signal.libsignal.protocol.IdentityKey;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.messages.SendMessageResult;
import org.whispersystems.signalservice.api.messages.calls.AnswerMessage;
import org.whispersystems.signalservice.api.messages.calls.BusyMessage;
import org.whispersystems.signalservice.api.messages.calls.HangupMessage;
import org.whispersystems.signalservice.api.messages.calls.IceUpdateMessage;
import org.whispersystems.signalservice.api.messages.calls.OfferMessage;
import org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage;
import org.whispersystems.signalservice.api.push.exceptions.ProofRequiredException;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.SecureRandom;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import static org.asamk.signal.manager.util.Utils.callIdUnsigned;
import static org.asamk.signal.manager.util.Utils.handleResponseException;
/**
* Manages active voice calls: tracks state, spawns/monitors the signal-call-tunnel
* subprocess, routes incoming call messages, and handles timeouts.
*/
public class CallManager implements AutoCloseable {
private static final Logger logger = LoggerFactory.getLogger(CallManager.class);
private static final long RING_TIMEOUT_MS = 60_000;
private static final ObjectMapper mapper = new ObjectMapper();
private final Context context;
private final SignalAccount account;
private final SignalDependencies dependencies;
private final Map<Long, CallState> activeCalls = new ConcurrentHashMap<>();
private final List<Manager.CallEventListener> callEventListeners = new CopyOnWriteArrayList<>();
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
var t = new Thread(r, "call-timeout-scheduler");
t.setDaemon(true);
return t;
});
public CallManager(final Context context) {
this.context = context;
this.account = context.getAccount();
this.dependencies = context.getDependencies();
}
public void addCallEventListener(Manager.CallEventListener listener) {
callEventListeners.add(listener);
}
public void removeCallEventListener(Manager.CallEventListener listener) {
callEventListeners.remove(listener);
}
private void fireCallEvent(CallState state, String reason) {
var callInfo = state.toCallInfo(account.getRecipientAddressResolver());
for (var listener : callEventListeners) {
try {
listener.handleCallEvent(callInfo, reason);
} catch (Throwable e) {
logger.warn("Call event listener failed, ignoring", e);
}
}
}
public CallInfo startOutgoingCall(
final RecipientId recipientId
) throws IOException {
var callId = generateCallId();
var recipientAddress = account.getRecipientAddressResolver().resolveRecipientAddress(recipientId);
var state = new CallState(callId, CallInfo.State.RINGING_OUTGOING, recipientId, null, true);
logger.debug("Starting outgoing call {} to {} (recipientId: {})",
callIdUnsigned(callId),
recipientAddress,
recipientId);
activeCalls.put(callId, state);
dependencies.getAuthenticatedSignalWebSocket().registerKeepAliveToken("call" + callId);
dependencies.getUnauthenticatedSignalWebSocket().registerKeepAliveToken("call" + callId);
fireCallEvent(state, null);
// Spawn call tunnel binary and connect control channel
spawnMediaTunnel(state);
// Fetch TURN servers
var turnServers = getTurnServers();
// Send createOutgoingCall + proceed via control channel
var createMsg = mapper.createObjectNode();
createMsg.put("type", "createOutgoingCall");
createMsg.put("callId", Utils.callIdUnsigned(callId));
createMsg.put("peerId", recipientAddress.toString());
sendControlMessage(state, writeJson(createMsg));
sendProceed(state, callId, turnServers);
// Schedule ring timeout
scheduler.schedule(() -> handleRingTimeout(callId), RING_TIMEOUT_MS, TimeUnit.MILLISECONDS);
logger.debug("Started outgoing call {} to {}", callIdUnsigned(callId), recipientAddress);
return state.toCallInfo(account.getRecipientAddressResolver());
}
public CallInfo acceptIncomingCall(final long callId) throws IOException {
final var state = getActiveCall(callId);
if (state.state != CallInfo.State.RINGING_INCOMING) {
throw new IOException("Call "
+ callId
+ " is not in RINGING_INCOMING state (current: "
+ state.state
+ ")");
}
// Defer the accept until the tunnel reports Ringing state.
// Sending accept too early (while RingRTC is in ConnectingBeforeAccepted)
// causes it to be silently dropped.
state.acceptPending = true;
// If the tunnel is already in Ringing state, send immediately
sendAcceptIfReady(state);
state.state = CallInfo.State.CONNECTING;
fireCallEvent(state, null);
logger.debug("Accepted incoming call {}", callIdUnsigned(callId));
return state.toCallInfo(account.getRecipientAddressResolver());
}
public void hangupCall(final long callId) throws IOException {
getActiveCall(callId);
endCall(callId, "local_hangup");
}
public SendMessageResult rejectCall(final long callId) throws IOException {
final var callState = getActiveCall(callId);
final var result = sendBusyMessage(callState.callId, callState.recipientId, callState.deviceId);
endCall(callId, "rejected");
return result;
}
public List<CallInfo> listActiveCalls() {
return activeCalls.values()
.stream()
.map((CallState callState) -> callState.toCallInfo(account.getRecipientAddressResolver()))
.toList();
}
public List<TurnServer> getTurnServers() throws IOException {
try {
var turnServerList = handleResponseException(dependencies.getCallingApi().getTurnServerInfo());
return turnServerList.stream()
.map(info -> new TurnServer(info.getUsername(), info.getPassword(), info.getUrls()))
.toList();
} catch (Throwable e) {
logger.warn("Failed to get TURN server info, returning empty list", e);
return List.of();
}
}
// --- Incoming call message handling ---
public void handleIncomingOffer(
final RecipientId recipientId,
final int deviceId,
final long callId,
final MessageEnvelope.Call.Offer.Type type,
final byte[] opaque
) {
if (callEventListeners.isEmpty()) {
logger.debug("Ignoring incoming offer for call {}: no call event listeners registered",
callIdUnsigned(callId));
return;
}
var senderAddress = account.getRecipientAddressResolver()
.resolveRecipientAddress(recipientId)
.toApiRecipientAddress();
logger.debug("Incoming offer opaque ({} bytes)", opaque == null ? 0 : opaque.length);
var state = new CallState(callId, CallInfo.State.RINGING_INCOMING, recipientId, deviceId, false);
logger.debug("Starting incoming call {} from {} (recipientId: {})",
callIdUnsigned(callId),
senderAddress,
recipientId);
activeCalls.put(callId, state);
// Spawn call tunnel binary immediately
spawnMediaTunnel(state);
// Get identity keys for the receivedOffer message
// Use raw 32-byte Curve25519 public key (without 0x05 DJB prefix) to match Signal Android
byte[] localIdentityKey = getRawIdentityKeyBytes(account.getAciIdentityKeyPair().getPublicKey());
byte[] remoteIdentityKey = getRemoteIdentityKey(state);
// Fetch TURN servers
List<TurnServer> turnServers;
try {
turnServers = getTurnServers();
} catch (IOException e) {
logger.warn("Failed to get TURN servers for incoming call {}", callIdUnsigned(callId), e);
turnServers = List.of();
}
// Send receivedOffer to subprocess
var offerMsg = mapper.createObjectNode();
offerMsg.put("type", "receivedOffer");
offerMsg.put("callId", Utils.callIdUnsigned(callId));
offerMsg.put("peerId", senderAddress.toString());
offerMsg.put("senderDeviceId", deviceId);
offerMsg.put("opaque", java.util.Base64.getEncoder().encodeToString(opaque));
offerMsg.put("age", 0);
offerMsg.put("senderIdentityKey", java.util.Base64.getEncoder().encodeToString(remoteIdentityKey));
offerMsg.put("receiverIdentityKey", java.util.Base64.getEncoder().encodeToString(localIdentityKey));
sendControlMessage(state, writeJson(offerMsg));
// Send proceed with TURN servers
sendProceed(state, callId, turnServers);
fireCallEvent(state, null);
// Schedule ring timeout
scheduler.schedule(() -> handleRingTimeout(callId), RING_TIMEOUT_MS, TimeUnit.MILLISECONDS);
logger.debug("Incoming call {} from {}", callIdUnsigned(callId), senderAddress);
}
public void handleIncomingAnswer(final long callId, final int deviceId, final byte[] opaque) {
var state = activeCalls.get(callId);
if (state == null) {
logger.warn("Received answer for unknown call {}", callIdUnsigned(callId));
return;
}
// Get identity keys
// Use raw 32-byte Curve25519 public key (without 0x05 DJB prefix) to match Signal Android
byte[] localIdentityKey = getRawIdentityKeyBytes(account.getAciIdentityKeyPair().getPublicKey());
byte[] remoteIdentityKey = getRemoteIdentityKey(state);
// Forward raw opaque to subprocess
var answerMsg = mapper.createObjectNode();
answerMsg.put("type", "receivedAnswer");
answerMsg.put("opaque", java.util.Base64.getEncoder().encodeToString(opaque));
answerMsg.put("senderDeviceId", deviceId);
answerMsg.put("senderIdentityKey", java.util.Base64.getEncoder().encodeToString(remoteIdentityKey));
answerMsg.put("receiverIdentityKey", java.util.Base64.getEncoder().encodeToString(localIdentityKey));
sendControlMessage(state, writeJson(answerMsg));
state.deviceId = deviceId;
state.state = CallInfo.State.CONNECTING;
fireCallEvent(state, null);
logger.debug("Received answer for call {}", callIdUnsigned(callId));
}
public void handleIncomingIceCandidate(final long callId, final byte[] opaque, final int deviceId) {
var state = activeCalls.get(callId);
if (state == null) {
logger.debug("Received ICE candidate for unknown call {}", callIdUnsigned(callId));
return;
}
// Forward to subprocess as receivedIce
var iceMsg = mapper.createObjectNode();
iceMsg.put("type", "receivedIce");
iceMsg.put("senderDeviceId", deviceId);
var candidates = iceMsg.putArray("candidates");
candidates.add(java.util.Base64.getEncoder().encodeToString(opaque));
sendControlMessage(state, writeJson(iceMsg));
logger.debug("Forwarded ICE candidate to tunnel for call {}", callIdUnsigned(callId));
}
public void handleIncomingHangup(final long callId) {
if (callEventListeners.isEmpty() && !activeCalls.containsKey(callId)) {
return;
}
endCall(callId, "remote_hangup");
}
public void handleIncomingBusy(final long callId) {
if (callEventListeners.isEmpty() && !activeCalls.containsKey(callId)) {
return;
}
endCall(callId, "remote_busy");
}
// --- Internal helpers ---
private CallState getActiveCall(final long callId) throws IOException {
var state = activeCalls.get(callId);
if (state == null) {
throw new IOException("No active call with id " + callIdUnsigned(callId));
}
return state;
}
private SendMessageResult sendBusyMessage(final long callId, final RecipientId recipientId, final int deviceId) {
var busyMessage = new BusyMessage(callId);
var callMessage = SignalServiceCallMessage.forBusy(busyMessage, deviceId);
return context.getSendHelper().sendCallMessage(callMessage, recipientId);
}
private void sendControlMessage(CallState state, String json) {
if (state.controlWriter == null) {
logger.debug("Queueing control message for call {} (not yet connected): {}",
callIdUnsigned(state.callId),
json);
state.pendingControlMessages.add(json);
return;
}
state.controlWriter.println(json);
}
private void sendProceed(CallState state, long callId, List<TurnServer> turnServers) {
var proceedMsg = mapper.createObjectNode();
proceedMsg.put("type", "proceed");
proceedMsg.put("callId", Utils.callIdUnsigned(callId));
proceedMsg.put("hideIp", false);
var iceServers = proceedMsg.putArray("iceServers");
for (var ts : turnServers) {
var server = iceServers.addObject();
server.put("username", ts.username());
server.put("password", ts.password());
var urls = server.putArray("urls");
for (var url : ts.urls()) {
urls.add(url);
}
}
sendControlMessage(state, writeJson(proceedMsg));
}
private void spawnMediaTunnel(CallState state) {
try {
var command = new ArrayList<>(List.of(findTunnelBinary()));
var processBuilder = new ProcessBuilder(command);
// Keep stdout and stderr separate: stdout = control protocol, stderr = logging
processBuilder.redirectErrorStream(false);
var process = processBuilder.start();
state.tunnelProcess = process;
// Write config JSON to stdin, then keep stdin open for control messages
var config = buildConfig(state);
var stdinStream = process.getOutputStream();
stdinStream.write(config.getBytes(StandardCharsets.UTF_8));
stdinStream.write('\n');
stdinStream.flush();
// stdin is the control write channel
state.controlWriter = new PrintWriter(new OutputStreamWriter(stdinStream, StandardCharsets.UTF_8), true);
// Flush any pending control messages
for (var msg : state.pendingControlMessages) {
state.controlWriter.println(msg);
}
state.pendingControlMessages.clear();
// If accept was deferred, send it now
sendAcceptIfReady(state);
// Read control events from subprocess stdout
Thread.ofVirtual()
.name("control-read-" + callIdUnsigned(state.callId))
.start(() -> readControlEvents(state, process.getInputStream()));
// Drain subprocess stderr to prevent pipe buffer deadlock
Thread.ofVirtual().name("tunnel-stderr-" + callIdUnsigned(state.callId)).start(() -> {
try (var reader = new BufferedReader(new InputStreamReader(process.getErrorStream(),
StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
logger.debug("[tunnel-{}] {}", callIdUnsigned(state.callId), line);
}
} catch (IOException ignored) {
}
});
// Monitor process exit
process.onExit().thenAcceptAsync(p -> {
logger.debug("Tunnel for call {} exited with code {}", callIdUnsigned(state.callId), p.exitValue());
if (activeCalls.containsKey(state.callId)) {
endCall(state.callId, "tunnel_exit");
}
});
logger.debug("Spawned signal-call-tunnel for call {}", callIdUnsigned(state.callId));
} catch (Exception e) {
logger.error("Failed to spawn tunnel for call {}", callIdUnsigned(state.callId), e);
endCall(state.callId, "tunnel_spawn_error");
}
}
private String findTunnelBinary() {
// Check environment variable first
var envPath = System.getenv("SIGNAL_CALL_TUNNEL_BIN");
if (envPath != null && !envPath.isEmpty()) {
return envPath;
}
// Check relative to the signal-cli installation directory
try {
var codeSource = CallManager.class.getProtectionDomain().getCodeSource();
if (codeSource != null) {
var jarPath = Path.of(codeSource.getLocation().toURI());
var binPath = tunnelBinaryFromCodeSourcePath(jarPath);
if (Files.isExecutable(binPath)) {
return binPath.toString();
}
}
} catch (Exception e) {
logger.debug("Failed to determine install dir from code source", e);
}
// Fall back to PATH
return "signal-call-tunnel";
}
/**
* Resolves the expected tunnel binary path from a code source path.
* The code source (jar or class dir) is expected to be in {@code <install>/lib/},
* so we go up two levels to reach the install root, then look for
* {@code bin/signal-call-tunnel}.
*/
static Path tunnelBinaryFromCodeSourcePath(Path codeSourcePath) {
var installDir = codeSourcePath.getParent().getParent();
return installDir.resolve("bin").resolve("signal-call-tunnel");
}
private String buildConfig(CallState state) {
var config = mapper.createObjectNode();
config.put("call_id", Utils.callIdUnsigned(state.callId));
config.put("is_outgoing", state.isOutgoing);
config.put("local_device_id", 1);
return writeJson(config);
}
private void readControlEvents(CallState state, java.io.InputStream inputStream) {
try (var reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
line = line.trim();
if (line.isEmpty()) continue;
logger.debug("Control event for call {}: {}", callIdUnsigned(state.callId), line);
try {
var json = mapper.readTree(line);
var type = json.has("type") ? json.get("type").asText() : "";
switch (type) {
case "ready" -> {
if (json.has("inputDeviceName")) {
state.inputDeviceName = json.get("inputDeviceName").asText();
}
if (json.has("outputDeviceName")) {
state.outputDeviceName = json.get("outputDeviceName").asText();
}
logger.debug("Tunnel ready for call {}: input={}, output={}",
callIdUnsigned(state.callId),
state.inputDeviceName,
state.outputDeviceName);
}
case "sendOffer" -> {
var opaqueB64 = json.get("opaque").asText();
var opaque = java.util.Base64.getDecoder().decode(opaqueB64);
logSendMessageResult(sendOfferViaSignal(state, opaque));
}
case "sendAnswer" -> {
var opaqueB64 = json.get("opaque").asText();
var opaque = java.util.Base64.getDecoder().decode(opaqueB64);
logSendMessageResult(sendAnswerViaSignal(state, opaque));
}
case "sendIce" -> {
var candidatesArr = json.get("candidates");
var opaqueList = new ArrayList<byte[]>();
for (var c : candidatesArr) {
opaqueList.add(java.util.Base64.getDecoder().decode(c.get("opaque").asText()));
}
logSendMessageResult(sendIceViaSignal(state, opaqueList));
}
case "sendHangup" -> {
// RingRTC wants us to send a hangup message via Signal protocol.
// This is NOT a local state change local state is handled by stateChange events.
var hangupType = json.has("hangupType")
? json.get("hangupType").asText("normal")
: "normal";
// Skip multi-device hangup types signal-cli is single-device,
// and sending these to the remote peer causes it to terminate the call.
if (hangupType.contains("onanotherdevice")) {
logger.debug("Ignoring multi-device hangup type: {}", hangupType);
} else {
logSendMessageResult(sendHangupViaSignal(state, hangupType));
}
}
case "sendBusy" -> {
logSendMessageResult(sendBusyViaSignal(state));
}
case "stateChange" -> {
var ringrtcState = json.get("state").asText();
var reason = json.has("reason") ? json.get("reason").asText(null) : null;
handleStateChange(state, ringrtcState, reason);
}
case "error" -> {
var message = json.has("message") ? json.get("message").asText("unknown") : "unknown";
logger.error("Tunnel error for call {}: {}", callIdUnsigned(state.callId), message);
endCall(state.callId, "tunnel_error");
}
default -> {
logger.debug("Unknown control event type '{}' for call {}",
type,
callIdUnsigned(state.callId));
}
}
} catch (Exception e) {
logger.warn("Failed to parse control event JSON for call {}: {}",
callIdUnsigned(state.callId),
e.getMessage());
}
}
} catch (IOException e) {
logger.debug("Control read ended for call {}: {}", callIdUnsigned(state.callId), e.getMessage());
}
}
private void handleStateChange(CallState state, String ringrtcState, String reason) {
if (ringrtcState.startsWith("Incoming")) {
// Don't downgrade if we've already accepted
if (state.state == CallInfo.State.CONNECTING) return;
state.state = CallInfo.State.RINGING_INCOMING;
} else if (ringrtcState.startsWith("Outgoing")) {
state.state = CallInfo.State.RINGING_OUTGOING;
} else if ("Ringing".equals(ringrtcState)) {
// Tunnel is now ready to accept flush deferred accept if pending
state.tunnelRinging = true;
sendAcceptIfReady(state);
return;
} else if ("Connected".equals(ringrtcState)) {
state.state = CallInfo.State.CONNECTED;
} else if ("Connecting".equals(ringrtcState)) {
state.state = CallInfo.State.RECONNECTING;
} else if ("Ended".equals(ringrtcState) || "Rejected".equals(ringrtcState)) {
endCall(state.callId, reason != null ? reason : ringrtcState.toLowerCase());
return;
} else if ("Concluded".equals(ringrtcState)) {
// Cleanup, no-op
return;
}
fireCallEvent(state, reason);
}
public static void logSendMessageResult(SendMessageResult result) {
var identifier = result.getAddress().getIdentifier();
if (result.getProofRequiredFailure() != null) {
final var failure = result.getProofRequiredFailure();
logger.warn(
"CAPTCHA proof required for sending to \"{}\", available options \"{}\" with challenge token \"{}\", or wait \"{}\" seconds.\n",
identifier,
failure.getOptions()
.stream()
.map(ProofRequiredException.Option::toString)
.collect(Collectors.joining(", ")),
failure.getToken(),
failure.getRetryAfterSeconds());
} else if (result.isNetworkFailure()) {
logger.warn("Network failure for \"{}\"", identifier);
} else if (result.getRateLimitFailure() != null) {
logger.warn("Rate limit failure for \"{}\"", identifier);
} else if (result.isUnregisteredFailure()) {
logger.warn("Unregistered user \"{}\"", identifier);
} else if (result.getIdentityFailure() != null) {
logger.warn("Untrusted Identity for \"{}\"", identifier);
}
}
private void sendAcceptIfReady(CallState state) {
if (state.acceptPending && state.tunnelRinging && state.controlWriter != null) {
state.acceptPending = false;
logger.debug("Sending deferred accept for call {}", callIdUnsigned(state.callId));
var acceptMsg = mapper.createObjectNode();
acceptMsg.put("type", "accept");
state.controlWriter.println(writeJson(acceptMsg));
}
}
private SendMessageResult sendOfferViaSignal(CallState state, byte[] opaque) {
var offerMessage = new OfferMessage(state.callId, OfferMessage.Type.AUDIO_CALL, opaque);
var callMessage = SignalServiceCallMessage.forOffer(offerMessage, state.deviceId);
final var result = context.getSendHelper().sendCallMessage(callMessage, state.recipientId);
logger.debug("Sent offer via Signal for call {}", callIdUnsigned(state.callId));
return result;
}
private SendMessageResult sendAnswerViaSignal(CallState state, byte[] opaque) {
var answerMessage = new AnswerMessage(state.callId, opaque);
var callMessage = SignalServiceCallMessage.forAnswer(answerMessage, state.deviceId);
final var result = context.getSendHelper().sendCallMessage(callMessage, state.recipientId);
logger.debug("Sent answer via Signal for call {}", callIdUnsigned(state.callId));
return result;
}
private SendMessageResult sendIceViaSignal(CallState state, List<byte[]> opaqueList) {
var iceUpdates = opaqueList.stream().map(opaque -> new IceUpdateMessage(state.callId, opaque)).toList();
var callMessage = SignalServiceCallMessage.forIceUpdates(iceUpdates, state.deviceId);
final var result = context.getSendHelper().sendCallMessage(callMessage, state.recipientId);
logger.debug("Sent {} ICE candidates via Signal for call {}", opaqueList.size(), callIdUnsigned(state.callId));
return result;
}
private SendMessageResult sendBusyViaSignal(CallState state) {
var busyMessage = new BusyMessage(state.callId);
var callMessage = SignalServiceCallMessage.forBusy(busyMessage, state.deviceId);
return context.getSendHelper().sendCallMessage(callMessage, state.recipientId);
}
private SendMessageResult sendHangupViaSignal(CallState state, String hangupType) {
var type = switch (hangupType) {
case "accepted", "acceptedonanotherdevice" -> HangupMessage.Type.ACCEPTED;
case "declined", "declinedonanotherdevice" -> HangupMessage.Type.DECLINED;
case "busy", "busyonanotherdevice" -> HangupMessage.Type.BUSY;
default -> HangupMessage.Type.NORMAL;
};
var hangupMessage = new HangupMessage(state.callId, type, state.deviceId);
var callMessage = SignalServiceCallMessage.forHangup(hangupMessage, state.deviceId);
final var result = context.getSendHelper().sendCallMessage(callMessage, state.recipientId);
logger.debug("Sent hangup ({}) via Signal for call {}", hangupType, callIdUnsigned(state.callId));
return result;
}
private byte[] getRemoteIdentityKey(CallState state) {
try {
var address = context.getRecipientHelper().resolveSignalServiceAddress(state.recipientId);
var serviceId = address.getServiceId();
var identityInfo = account.getIdentityKeyStore().getIdentityInfo(serviceId);
if (identityInfo != null) {
return getRawIdentityKeyBytes(identityInfo.getIdentityKey());
}
} catch (Exception e) {
logger.warn("Failed to get remote identity key for call {}", callIdUnsigned(state.callId), e);
}
logger.warn("Using local identity key as fallback for remote identity key");
return getRawIdentityKeyBytes(account.getAciIdentityKeyPair().getPublicKey());
}
/**
* Strip the 0x05 DJB type prefix from a serialized identity key to get the
* raw 32-byte Curve25519 public key. Signal Android does this via
* WebRtcUtil.getPublicKeyBytes() before passing keys to RingRTC.
*/
private static byte[] getRawIdentityKeyBytes(IdentityKey identityKey) {
var serializedKey = identityKey.serialize();
return getRawIdentityKeyBytes(serializedKey);
}
private static byte[] getRawIdentityKeyBytes(final byte[] serializedKey) {
if (serializedKey.length == 33 && serializedKey[0] == 0x05) {
return java.util.Arrays.copyOfRange(serializedKey, 1, serializedKey.length);
}
return serializedKey;
}
private static String writeJson(ObjectNode node) {
try {
return mapper.writeValueAsString(node);
} catch (com.fasterxml.jackson.core.JsonProcessingException e) {
throw new RuntimeException("Failed to serialize JSON", e);
}
}
private void endCall(final long callId, final String reason) {
var state = activeCalls.remove(callId);
dependencies.getAuthenticatedSignalWebSocket().removeKeepAliveToken("call" + callId);
dependencies.getUnauthenticatedSignalWebSocket().removeKeepAliveToken("call" + callId);
if (state == null) return;
state.state = CallInfo.State.ENDED;
fireCallEvent(state, reason);
logger.debug("Call {} ended: {}", callIdUnsigned(callId), reason);
// Send Signal protocol hangup to remote peer (unless they initiated the end)
if (!"remote_hangup".equals(reason)
&& !"rejected".equals(reason)
&& !"remote_busy".equals(reason)
&& !"ringrtc_hangup".equals(reason)) {
var hangupMessage = new HangupMessage(callId, HangupMessage.Type.NORMAL, state.deviceId);
var callMessage = SignalServiceCallMessage.forHangup(hangupMessage, null);
final var result = context.getSendHelper().sendCallMessage(callMessage, state.recipientId);
if (!result.isSuccess()) {
logger.warn("Failed to send hangup to remote for call {}", callIdUnsigned(callId));
logSendMessageResult(result);
}
}
// Send hangup via control channel (stdin) before killing process
if (state.controlWriter != null) {
try {
var hangupMsg = mapper.createObjectNode();
hangupMsg.put("type", "hangup");
state.controlWriter.println(writeJson(hangupMsg));
state.controlWriter.close();
} catch (Exception e) {
logger.debug("Failed to send hangup via control channel", e);
}
}
// Kill tunnel process
if (state.tunnelProcess != null && state.tunnelProcess.isAlive()) {
state.tunnelProcess.destroy();
}
}
private void handleRingTimeout(final long callId) {
var state = activeCalls.get(callId);
if (state == null) return;
if (state.state == CallInfo.State.RINGING_INCOMING || state.state == CallInfo.State.RINGING_OUTGOING) {
logger.debug("Call {} ring timeout", callIdUnsigned(callId));
endCall(callId, "ring_timeout");
}
}
private static long generateCallId() {
return new BigInteger(64, new SecureRandom()).longValue();
}
@Override
public void close() {
scheduler.shutdownNow();
for (var callId : new ArrayList<>(activeCalls.keySet())) {
endCall(callId, "shutdown");
}
synchronized (callEventListeners) {
callEventListeners.clear();
}
}
// --- Internal call state tracking ---
static class CallState {
final long callId;
volatile CallInfo.State state;
final RecipientId recipientId;
volatile Integer deviceId;
final boolean isOutgoing;
volatile String inputDeviceName;
volatile String outputDeviceName;
volatile Process tunnelProcess;
volatile PrintWriter controlWriter;
// Control messages queued before the tunnel process starts
final List<String> pendingControlMessages = Collections.synchronizedList(new ArrayList<>());
// Accept deferred until tunnel reports Ringing state
volatile boolean acceptPending = false;
// True once the tunnel has reported "Ringing" (ready to accept)
volatile boolean tunnelRinging = false;
CallState(
long callId,
CallInfo.State state,
RecipientId recipientId,
final Integer deviceId,
boolean isOutgoing
) {
this.callId = callId;
this.state = state;
this.recipientId = recipientId;
this.deviceId = deviceId;
this.isOutgoing = isOutgoing;
}
CallInfo toCallInfo(RecipientAddressResolver addressResolver) {
return new CallInfo(callId,
state,
addressResolver.resolveRecipientAddress(recipientId).toApiRecipientAddress(),
inputDeviceName,
outputDeviceName,
isOutgoing);
}
}
}

View File

@ -55,7 +55,7 @@ public class ContactHelper {
final var version = contact == null final var version = contact == null
? 1 ? 1
: contact.messageExpirationTimeVersion() == Integer.MAX_VALUE : contact.messageExpirationTimeVersion() == Integer.MAX_VALUE
? Integer.MAX_VALUE ? Integer.MAX_VALUE
: contact.messageExpirationTimeVersion() + 1; : contact.messageExpirationTimeVersion() + 1;
account.getContactStore() account.getContactStore()
.storeContact(recipientId, .storeContact(recipientId,

View File

@ -23,6 +23,7 @@ public class Context implements AutoCloseable {
private AccountHelper accountHelper; private AccountHelper accountHelper;
private AttachmentHelper attachmentHelper; private AttachmentHelper attachmentHelper;
private CallManager callManager;
private ContactHelper contactHelper; private ContactHelper contactHelper;
private GroupHelper groupHelper; private GroupHelper groupHelper;
private GroupV2Helper groupV2Helper; private GroupV2Helper groupV2Helper;
@ -92,6 +93,10 @@ public class Context implements AutoCloseable {
return getOrCreate(() -> attachmentHelper, () -> attachmentHelper = new AttachmentHelper(this)); return getOrCreate(() -> attachmentHelper, () -> attachmentHelper = new AttachmentHelper(this));
} }
public CallManager getCallManager() {
return getOrCreate(() -> callManager, () -> callManager = new CallManager(this));
}
public ContactHelper getContactHelper() { public ContactHelper getContactHelper() {
return getOrCreate(() -> contactHelper, () -> contactHelper = new ContactHelper(account)); return getOrCreate(() -> contactHelper, () -> contactHelper = new ContactHelper(account));
} }
@ -172,6 +177,9 @@ public class Context implements AutoCloseable {
@Override @Override
public void close() { public void close() {
if (callManager != null) {
callManager.close();
}
jobExecutor.close(); jobExecutor.close();
} }

View File

@ -28,15 +28,16 @@ import org.asamk.signal.manager.storage.recipients.RecipientId;
import org.asamk.signal.manager.util.AttachmentUtils; import org.asamk.signal.manager.util.AttachmentUtils;
import org.asamk.signal.manager.util.IOUtils; import org.asamk.signal.manager.util.IOUtils;
import org.asamk.signal.manager.util.Utils; import org.asamk.signal.manager.util.Utils;
import org.signal.core.models.ServiceId;
import org.signal.libsignal.zkgroup.InvalidInputException; import org.signal.libsignal.zkgroup.InvalidInputException;
import org.signal.libsignal.zkgroup.groups.GroupMasterKey; import org.signal.libsignal.zkgroup.groups.GroupMasterKey;
import org.signal.libsignal.zkgroup.groups.GroupSecretParams; import org.signal.libsignal.zkgroup.groups.GroupSecretParams;
import org.signal.libsignal.zkgroup.groupsend.GroupSendEndorsementsResponse; import org.signal.libsignal.zkgroup.groupsend.GroupSendEndorsementsResponse;
import org.signal.libsignal.zkgroup.profiles.ProfileKey; import org.signal.libsignal.zkgroup.profiles.ProfileKey;
import org.signal.storageservice.protos.groups.GroupChangeResponse; import org.signal.storageservice.storage.protos.groups.GroupChangeResponse;
import org.signal.storageservice.protos.groups.local.DecryptedGroup; import org.signal.storageservice.storage.protos.groups.local.DecryptedGroup;
import org.signal.storageservice.protos.groups.local.DecryptedGroupChange; import org.signal.storageservice.storage.protos.groups.local.DecryptedGroupChange;
import org.signal.storageservice.protos.groups.local.DecryptedGroupJoinInfo; import org.signal.storageservice.storage.protos.groups.local.DecryptedGroupJoinInfo;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupChangeLog; import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupChangeLog;
@ -47,7 +48,6 @@ import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentStre
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage;
import org.whispersystems.signalservice.api.messages.SignalServiceGroup; import org.whispersystems.signalservice.api.messages.SignalServiceGroup;
import org.whispersystems.signalservice.api.messages.SignalServiceGroupV2; import org.whispersystems.signalservice.api.messages.SignalServiceGroupV2;
import org.whispersystems.signalservice.api.push.ServiceId;
import org.whispersystems.signalservice.api.push.exceptions.ConflictException; import org.whispersystems.signalservice.api.push.exceptions.ConflictException;
import java.io.IOException; import java.io.IOException;
@ -123,14 +123,15 @@ public class GroupHelper {
return Optional.empty(); return Optional.empty();
} }
final var uploadSpec = dependencies.getMessageSender().getResumableUploadSpec(); final var uploadSpec = context.getAttachmentHelper().getResumableUploadSpec(streamDetails);
return Optional.of(AttachmentUtils.createAttachmentStream(streamDetails, Optional.empty(), uploadSpec)); return Optional.of(AttachmentUtils.createAttachmentStream(streamDetails, Optional.empty(), uploadSpec));
} }
public GroupInfoV2 getOrMigrateGroup( public GroupInfoV2 getOrMigrateGroup(
final GroupMasterKey groupMasterKey, final GroupMasterKey groupMasterKey,
final int revision, final int revision,
final byte[] signedGroupChange final byte[] signedGroupChange,
final boolean ignoreAvatars
) { ) {
final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey); final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey);
@ -166,7 +167,7 @@ public class GroupHelper {
if (group != null) { if (group != null) {
storeProfileKeysFromMembers(group); storeProfileKeysFromMembers(group);
final var avatar = group.avatar; final var avatar = group.avatar;
if (!avatar.isEmpty()) { if (!avatar.isEmpty() && !ignoreAvatars) {
downloadGroupAvatar(groupId, groupSecretParams, avatar); downloadGroupAvatar(groupId, groupSecretParams, avatar);
} }
} }
@ -192,9 +193,9 @@ public class GroupHelper {
final GroupInfoV2 groupInfoV2, final GroupInfoV2 groupInfoV2,
final GroupChangeResponse groupChangeResponse final GroupChangeResponse groupChangeResponse
) { ) {
if (groupChangeResponse.groupSendEndorsementsResponse.size() > 0) { if (groupChangeResponse.group_send_endorsements_response.size() > 0) {
try { try {
final var groupSendEndorsementsResponse = new GroupSendEndorsementsResponse(groupChangeResponse.groupSendEndorsementsResponse.toByteArray()); final var groupSendEndorsementsResponse = new GroupSendEndorsementsResponse(groupChangeResponse.group_send_endorsements_response.toByteArray());
updateGroupEndorsements(groupInfoV2.getGroupId(), updateGroupEndorsements(groupInfoV2.getGroupId(),
groupInfoV2.getMasterKey(), groupInfoV2.getMasterKey(),
@ -298,7 +299,9 @@ public class GroupHelper {
final GroupPermission editDetailsPermission, final GroupPermission editDetailsPermission,
final String avatarFile, final String avatarFile,
final Integer expirationTimer, final Integer expirationTimer,
final Boolean isAnnouncementGroup final Boolean isAnnouncementGroup,
final String labelEmoji,
final String labelString
) throws IOException, GroupNotFoundException, AttachmentInvalidException, NotAGroupMemberException, GroupSendingNotAllowedException { ) throws IOException, GroupNotFoundException, AttachmentInvalidException, NotAGroupMemberException, GroupSendingNotAllowedException {
var group = getGroupForUpdating(groupId); var group = getGroupForUpdating(groupId);
final var avatarBytes = readAvatarBytes(avatarFile); final var avatarBytes = readAvatarBytes(avatarFile);
@ -322,7 +325,9 @@ public class GroupHelper {
editDetailsPermission, editDetailsPermission,
avatarBytes, avatarBytes,
expirationTimer, expirationTimer,
isAnnouncementGroup); isAnnouncementGroup,
labelEmoji,
labelString);
} catch (ConflictException e) { } catch (ConflictException e) {
// Detected conflicting update, refreshing group and trying again // Detected conflicting update, refreshing group and trying again
group = getGroup(groupId, true); group = getGroup(groupId, true);
@ -341,7 +346,9 @@ public class GroupHelper {
editDetailsPermission, editDetailsPermission,
avatarBytes, avatarBytes,
expirationTimer, expirationTimer,
isAnnouncementGroup); isAnnouncementGroup,
labelEmoji,
labelString);
} }
} }
@ -391,7 +398,8 @@ public class GroupHelper {
.joinGroup(inviteLinkUrl.getGroupMasterKey(), inviteLinkUrl.getPassword(), groupJoinInfo); .joinGroup(inviteLinkUrl.getGroupMasterKey(), inviteLinkUrl.getPassword(), groupJoinInfo);
final var group = getOrMigrateGroup(inviteLinkUrl.getGroupMasterKey(), final var group = getOrMigrateGroup(inviteLinkUrl.getGroupMasterKey(),
groupJoinInfo.revision + 1, groupJoinInfo.revision + 1,
changeResponse.groupChange == null ? null : changeResponse.groupChange.encode()); changeResponse.group_change == null ? null : changeResponse.group_change.encode(),
false);
if (group.getGroup() == null) { if (group.getGroup() == null) {
// Only requested member, can't send update to group members // Only requested member, can't send update to group members
@ -550,16 +558,24 @@ public class GroupHelper {
private void storeProfileKeysFromMembers(final DecryptedGroup group) { private void storeProfileKeysFromMembers(final DecryptedGroup group) {
for (var member : group.members) { for (var member : group.members) {
final var serviceId = ServiceId.parseOrThrow(member.aciBytes); final var serviceId = ServiceId.parseOrThrow(member.aciBytes);
final var recipientId = account.getRecipientResolver().resolveRecipient(serviceId); storeProfileKeyIfMissing(serviceId, member.profileKey.toByteArray());
final var profileStore = account.getProfileStore(); }
if (profileStore.getProfileKey(recipientId) != null) { for (var member : group.requestingMembers) {
// We already have a profile key, not updating it from a non-authoritative source final var serviceId = ServiceId.parseOrThrow(member.aciBytes);
continue; storeProfileKeyIfMissing(serviceId, member.profileKey.toByteArray());
} }
try { }
profileStore.storeProfileKey(recipientId, new ProfileKey(member.profileKey.toByteArray()));
} catch (InvalidInputException ignored) { private void storeProfileKeyIfMissing(final ServiceId serviceId, final byte[] profileKeyBytes) {
} final var recipientId = account.getRecipientResolver().resolveRecipient(serviceId);
final var profileStore = account.getProfileStore();
if (profileStore.getProfileKey(recipientId) != null) {
// We already have a profile key, not updating it from a non-authoritative source
return;
}
try {
profileStore.storeProfileKey(recipientId, new ProfileKey(profileKeyBytes));
} catch (InvalidInputException ignored) {
} }
} }
@ -680,7 +696,7 @@ public class GroupHelper {
private void sendExpirationTimerUpdate(GroupIdV1 groupId) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException { private void sendExpirationTimerUpdate(GroupIdV1 groupId) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException {
final var messageBuilder = SignalServiceDataMessage.newBuilder().asExpirationUpdate(); final var messageBuilder = SignalServiceDataMessage.newBuilder().asExpirationUpdate();
context.getSendHelper().sendAsGroupMessage(messageBuilder, groupId, false, Optional.empty()); context.getSendHelper().sendAsGroupMessage(messageBuilder, groupId, false, Optional.empty(), true);
} }
private SendGroupMessageResults updateGroupV2( private SendGroupMessageResults updateGroupV2(
@ -699,7 +715,9 @@ public class GroupHelper {
final GroupPermission editDetailsPermission, final GroupPermission editDetailsPermission,
final byte[] avatarFile, final byte[] avatarFile,
final Integer expirationTimer, final Integer expirationTimer,
final Boolean isAnnouncementGroup final Boolean isAnnouncementGroup,
final String labelEmoji,
final String labelString
) throws IOException { ) throws IOException {
SendGroupMessageResults result = null; SendGroupMessageResults result = null;
final var groupV2Helper = context.getGroupV2Helper(); final var groupV2Helper = context.getGroupV2Helper();
@ -716,7 +734,7 @@ public class GroupHelper {
result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second()); result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
} }
final var newMembers = new HashSet<>(members); final var newMembers = new HashSet<>(members);
newMembers.removeAll(group.getMembers()); newMembers.removeAll(group.getMemberRecipientIds());
newMembers.removeAll(group.getRequestingMembers()); newMembers.removeAll(group.getRequestingMembers());
if (!newMembers.isEmpty()) { if (!newMembers.isEmpty()) {
var groupGroupChangePair = groupV2Helper.addMembers(group, newMembers); var groupGroupChangePair = groupV2Helper.addMembers(group, newMembers);
@ -729,7 +747,7 @@ public class GroupHelper {
if (banMembers != null) { if (banMembers != null) {
existingRemoveMembers.addAll(banMembers); existingRemoveMembers.addAll(banMembers);
} }
existingRemoveMembers.retainAll(group.getMembers()); existingRemoveMembers.retainAll(group.getMemberRecipientIds());
if (members != null) { if (members != null) {
existingRemoveMembers.removeAll(members); existingRemoveMembers.removeAll(members);
} }
@ -755,28 +773,20 @@ public class GroupHelper {
if (admins != null) { if (admins != null) {
final var newAdmins = new HashSet<>(admins); final var newAdmins = new HashSet<>(admins);
newAdmins.retainAll(group.getMembers()); newAdmins.retainAll(group.getMemberRecipientIds());
newAdmins.removeAll(group.getAdminMembers()); newAdmins.removeAll(group.getAdminMemberRecipientIds());
if (!newAdmins.isEmpty()) { if (!newAdmins.isEmpty()) {
for (var admin : newAdmins) { var groupGroupChangePair = groupV2Helper.setMemberAdmin(group, newAdmins, true);
var groupGroupChangePair = groupV2Helper.setMemberAdmin(group, admin, true); result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
result = sendUpdateGroupV2Message(group,
groupGroupChangePair.first(),
groupGroupChangePair.second());
}
} }
} }
if (removeAdmins != null) { if (removeAdmins != null) {
final var existingRemoveAdmins = new HashSet<>(removeAdmins); final var existingRemoveAdmins = new HashSet<>(removeAdmins);
existingRemoveAdmins.retainAll(group.getAdminMembers()); existingRemoveAdmins.retainAll(group.getAdminMemberRecipientIds());
if (!existingRemoveAdmins.isEmpty()) { if (!existingRemoveAdmins.isEmpty()) {
for (var admin : existingRemoveAdmins) { var groupGroupChangePair = groupV2Helper.setMemberAdmin(group, existingRemoveAdmins, false);
var groupGroupChangePair = groupV2Helper.setMemberAdmin(group, admin, false); result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
result = sendUpdateGroupV2Message(group,
groupGroupChangePair.first(),
groupGroupChangePair.second());
}
} }
} }
@ -828,6 +838,15 @@ public class GroupHelper {
result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second()); result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
} }
if (labelString != null || labelEmoji != null) {
final var selfRecipientId = account.getSelfRecipientId();
final var selfMember = group.getMember(selfRecipientId);
var groupGroupChangePair = groupV2Helper.setMemberLabels(group,
labelEmoji != null ? labelEmoji : selfMember.labelEmoji(),
labelString != null ? labelString : selfMember.labelString());
result = sendUpdateGroupV2Message(group, groupGroupChangePair.first(), groupGroupChangePair.second());
}
if (name != null || description != null || avatarFile != null) { if (name != null || description != null || avatarFile != null) {
var groupGroupChangePair = groupV2Helper.updateGroup(group, name, description, avatarFile); var groupGroupChangePair = groupV2Helper.updateGroup(group, name, description, avatarFile);
if (avatarFile != null) { if (avatarFile != null) {
@ -857,9 +876,9 @@ public class GroupHelper {
final GroupInfoV2 groupInfoV2, final GroupInfoV2 groupInfoV2,
final Set<RecipientId> newAdmins final Set<RecipientId> newAdmins
) throws LastGroupAdminException, IOException { ) throws LastGroupAdminException, IOException {
final var currentAdmins = groupInfoV2.getAdminMembers(); final var currentAdmins = groupInfoV2.getAdminMemberRecipientIds();
newAdmins.removeAll(currentAdmins); newAdmins.removeAll(currentAdmins);
newAdmins.retainAll(groupInfoV2.getMembers()); newAdmins.retainAll(groupInfoV2.getMemberRecipientIds());
if (currentAdmins.contains(account.getSelfRecipientId()) if (currentAdmins.contains(account.getSelfRecipientId())
&& currentAdmins.size() == 1 && currentAdmins.size() == 1
&& groupInfoV2.getMembers().size() > 1 && groupInfoV2.getMembers().size() > 1
@ -873,10 +892,10 @@ public class GroupHelper {
final var groupChangeResponse = groupGroupChangePair.second(); final var groupChangeResponse = groupGroupChangePair.second();
handleGroupChangeResponse(groupInfoV2, groupChangeResponse); handleGroupChangeResponse(groupInfoV2, groupChangeResponse);
if (groupChangeResponse.groupChange == null) { if (groupChangeResponse.group_change == null) {
throw new AssertionError("groupChange is null"); throw new AssertionError("groupChange is null");
} }
var messageBuilder = getGroupUpdateMessageBuilder(groupInfoV2, groupChangeResponse.groupChange.encode()); var messageBuilder = getGroupUpdateMessageBuilder(groupInfoV2, groupChangeResponse.group_change.encode());
return sendGroupMessage(messageBuilder, return sendGroupMessage(messageBuilder,
groupInfoV2.getMembersIncludingPendingWithout(account.getSelfRecipientId()), groupInfoV2.getMembersIncludingPendingWithout(account.getSelfRecipientId()),
groupInfoV2); groupInfoV2);
@ -886,7 +905,7 @@ public class GroupHelper {
var group = SignalServiceGroup.newBuilder(SignalServiceGroup.Type.UPDATE) var group = SignalServiceGroup.newBuilder(SignalServiceGroup.Type.UPDATE)
.withId(g.getGroupId().serialize()) .withId(g.getGroupId().serialize())
.withName(g.name) .withName(g.name)
.withMembers(g.getMembers() .withMembers(g.getMemberRecipientIds()
.stream() .stream()
.map(context.getRecipientHelper()::resolveSignalServiceAddress) .map(context.getRecipientHelper()::resolveSignalServiceAddress)
.toList()); .toList());
@ -924,10 +943,10 @@ public class GroupHelper {
members.addAll(group.getMembersIncludingPendingWithout(selfRecipientId)); members.addAll(group.getMembersIncludingPendingWithout(selfRecipientId));
account.getGroupStore().updateGroup(group); account.getGroupStore().updateGroup(group);
if (groupChangeResponse.groupChange == null) { if (groupChangeResponse.group_change == null) {
throw new AssertionError("groupChange is null"); throw new AssertionError("groupChange is null");
} }
final var messageBuilder = getGroupUpdateMessageBuilder(group, groupChangeResponse.groupChange.encode()); final var messageBuilder = getGroupUpdateMessageBuilder(group, groupChangeResponse.group_change.encode());
return sendGroupMessage(messageBuilder, members, group); return sendGroupMessage(messageBuilder, members, group);
} }

View File

@ -10,6 +10,10 @@ import org.asamk.signal.manager.internal.SignalDependencies;
import org.asamk.signal.manager.storage.groups.GroupInfoV2; import org.asamk.signal.manager.storage.groups.GroupInfoV2;
import org.asamk.signal.manager.storage.recipients.RecipientId; import org.asamk.signal.manager.storage.recipients.RecipientId;
import org.asamk.signal.manager.util.Utils; import org.asamk.signal.manager.util.Utils;
import org.signal.core.models.ServiceId;
import org.signal.core.models.ServiceId.ACI;
import org.signal.core.models.ServiceId.PNI;
import org.signal.core.util.UuidUtil;
import org.signal.libsignal.zkgroup.InvalidInputException; import org.signal.libsignal.zkgroup.InvalidInputException;
import org.signal.libsignal.zkgroup.VerificationFailedException; import org.signal.libsignal.zkgroup.VerificationFailedException;
import org.signal.libsignal.zkgroup.auth.AuthCredentialWithPniResponse; import org.signal.libsignal.zkgroup.auth.AuthCredentialWithPniResponse;
@ -17,15 +21,16 @@ import org.signal.libsignal.zkgroup.groups.GroupMasterKey;
import org.signal.libsignal.zkgroup.groups.GroupSecretParams; import org.signal.libsignal.zkgroup.groups.GroupSecretParams;
import org.signal.libsignal.zkgroup.groups.UuidCiphertext; import org.signal.libsignal.zkgroup.groups.UuidCiphertext;
import org.signal.libsignal.zkgroup.profiles.ProfileKey; import org.signal.libsignal.zkgroup.profiles.ProfileKey;
import org.signal.storageservice.protos.groups.AccessControl; import org.signal.network.exceptions.NonSuccessfulResponseCodeException;
import org.signal.storageservice.protos.groups.GroupChange; import org.signal.storageservice.storage.protos.groups.AccessControl;
import org.signal.storageservice.protos.groups.GroupChangeResponse; import org.signal.storageservice.storage.protos.groups.GroupChange;
import org.signal.storageservice.protos.groups.Member; import org.signal.storageservice.storage.protos.groups.GroupChangeResponse;
import org.signal.storageservice.protos.groups.local.DecryptedGroup; import org.signal.storageservice.storage.protos.groups.Member;
import org.signal.storageservice.protos.groups.local.DecryptedGroupChange; import org.signal.storageservice.storage.protos.groups.local.DecryptedGroup;
import org.signal.storageservice.protos.groups.local.DecryptedGroupJoinInfo; import org.signal.storageservice.storage.protos.groups.local.DecryptedGroupChange;
import org.signal.storageservice.protos.groups.local.DecryptedMember; import org.signal.storageservice.storage.protos.groups.local.DecryptedGroupJoinInfo;
import org.signal.storageservice.protos.groups.local.DecryptedPendingMember; import org.signal.storageservice.storage.protos.groups.local.DecryptedMember;
import org.signal.storageservice.storage.protos.groups.local.DecryptedPendingMember;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.groupsv2.DecryptChangeVerificationMode; import org.whispersystems.signalservice.api.groupsv2.DecryptChangeVerificationMode;
@ -38,12 +43,7 @@ import org.whispersystems.signalservice.api.groupsv2.GroupsV2AuthorizationString
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations; import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations;
import org.whispersystems.signalservice.api.groupsv2.InvalidGroupStateException; import org.whispersystems.signalservice.api.groupsv2.InvalidGroupStateException;
import org.whispersystems.signalservice.api.groupsv2.NotAbleToApplyGroupV2ChangeException; import org.whispersystems.signalservice.api.groupsv2.NotAbleToApplyGroupV2ChangeException;
import org.whispersystems.signalservice.api.push.ServiceId;
import org.whispersystems.signalservice.api.push.ServiceId.ACI;
import org.whispersystems.signalservice.api.push.ServiceId.PNI;
import org.whispersystems.signalservice.api.push.SignalServiceAddress; import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException;
import org.whispersystems.signalservice.api.util.UuidUtil;
import org.whispersystems.signalservice.internal.push.exceptions.NotInGroupException; import org.whispersystems.signalservice.internal.push.exceptions.NotInGroupException;
import java.io.IOException; import java.io.IOException;
@ -225,7 +225,7 @@ class GroupV2Helper {
change.modifyAvatar(new GroupChange.Actions.ModifyAvatarAction.Builder().avatar(avatarCdnKey).build()); change.modifyAvatar(new GroupChange.Actions.ModifyAvatarAction.Builder().avatar(avatarCdnKey).build());
} }
change.sourceServiceId(getSelfAci().toByteString()); change.sourceUserId(getSelfAci().toByteString());
return commitChange(groupInfoV2, change); return commitChange(groupInfoV2, change);
} }
@ -252,7 +252,7 @@ class GroupV2Helper {
final var aci = getSelfAci(); final var aci = getSelfAci();
final var change = groupOperations.createModifyGroupMembershipChange(candidates, bannedUuids, aci); final var change = groupOperations.createModifyGroupMembershipChange(candidates, bannedUuids, aci);
change.sourceServiceId(getSelfAci().toByteString()); change.sourceUserId(getSelfAci().toByteString());
return commitChange(groupInfoV2, change); return commitChange(groupInfoV2, change);
} }
@ -343,7 +343,7 @@ class GroupV2Helper {
false, false,
groupInfoV2.getGroup().bannedMembers); groupInfoV2.getGroup().bannedMembers);
change.sourceServiceId(getSelfAci().toByteString()); change.sourceUserId(getSelfAci().toByteString());
return commitChange(groupInfoV2, change); return commitChange(groupInfoV2, change);
} }
@ -360,7 +360,7 @@ class GroupV2Helper {
final var change = groupOperations.createUnbanServiceIdsChange(serviceIds); final var change = groupOperations.createUnbanServiceIdsChange(serviceIds);
change.sourceServiceId(getSelfAci().toByteString()); change.sourceUserId(getSelfAci().toByteString());
return commitChange(groupInfoV2, change); return commitChange(groupInfoV2, change);
} }
@ -436,7 +436,7 @@ class GroupV2Helper {
final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2); final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
final var change = groupOperations.createUpdateProfileKeyCredentialChange(profileKeyCredential); final var change = groupOperations.createUpdateProfileKeyCredentialChange(profileKeyCredential);
change.sourceServiceId(getSelfAci().toByteString()); change.sourceUserId(getSelfAci().toByteString());
return commitChange(groupInfoV2, change); return commitChange(groupInfoV2, change);
} }
@ -459,7 +459,7 @@ class GroupV2Helper {
? groupOperations.createGroupJoinRequest(profileKeyCredential) ? groupOperations.createGroupJoinRequest(profileKeyCredential)
: groupOperations.createGroupJoinDirect(profileKeyCredential); : groupOperations.createGroupJoinDirect(profileKeyCredential);
change.sourceServiceId(context.getRecipientHelper() change.sourceUserId(context.getRecipientHelper()
.resolveSignalServiceAddress(selfRecipientId) .resolveSignalServiceAddress(selfRecipientId)
.getServiceId() .getServiceId()
.toByteString()); .toByteString());
@ -476,28 +476,50 @@ class GroupV2Helper {
throw new IOException("Cannot join a V2 group as self does not have a versioned profile"); throw new IOException("Cannot join a V2 group as self does not have a versioned profile");
} }
final var change = groupOperations.createAcceptInviteChange(profileKeyCredential); // We need to accept the invite with the ACI or PNI that was used for the invitation
final var selfAddress = context.getRecipientHelper().resolveSignalServiceAddress(selfRecipientId);
final var selfServiceId = groupInfoV2.getPendingMemberAddresses()
.stream()
.filter(s -> s.matches(selfAddress))
.findFirst();
if (selfServiceId.isEmpty()) {
throw new IOException("Cannot find service ID for self to accept invite");
}
final var serviceId = selfServiceId.get().getServiceId();
final var aci = context.getRecipientHelper().resolveSignalServiceAddress(selfRecipientId).getServiceId(); final GroupChange.Actions.Builder change;
change.sourceServiceId(aci.toByteString()); if (serviceId instanceof ACI) {
change = groupOperations.createAcceptInviteChange(profileKeyCredential);
} else {
change = groupOperations.createAcceptPniInviteChange(profileKeyCredential);
}
change.sourceUserId(serviceId.toByteString());
return commitChange(groupInfoV2, change); return commitChange(groupInfoV2, change);
} }
Pair<DecryptedGroup, GroupChangeResponse> setMemberAdmin( Pair<DecryptedGroup, GroupChangeResponse> setMemberAdmin(
GroupInfoV2 groupInfoV2, GroupInfoV2 groupInfoV2,
RecipientId recipientId, Set<RecipientId> recipientIds,
boolean admin boolean admin
) throws IOException { ) throws IOException {
final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2); final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
final var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId);
final var newRole = admin ? Member.Role.ADMINISTRATOR : Member.Role.DEFAULT; final var newRole = admin ? Member.Role.ADMINISTRATOR : Member.Role.DEFAULT;
if (address.getServiceId() instanceof ACI aci) { final var change = new GroupChange.Actions.Builder();
final var change = groupOperations.createChangeMemberRole(aci, newRole); final var memberRoles = recipientIds.stream()
return commitChange(groupInfoV2, change); .map(context.getRecipientHelper()::resolveSignalServiceAddress)
} else { .map(SignalServiceAddress::getServiceId)
.filter(m -> m instanceof ACI)
.map(m -> (ACI) m)
.map(aci -> new GroupChange.Actions.ModifyMemberRoleAction.Builder().userId(groupOperations.encryptServiceId(
aci)).role(newRole).build())
.toList();
if (memberRoles.size() < recipientIds.size()) {
throw new IllegalArgumentException("Can't make a PNI a group admin."); throw new IllegalArgumentException("Can't make a PNI a group admin.");
} }
change.modifyMemberRoles(memberRoles);
return commitChange(groupInfoV2, change);
} }
Pair<DecryptedGroup, GroupChangeResponse> setMessageExpirationTimer( Pair<DecryptedGroup, GroupChangeResponse> setMessageExpirationTimer(
@ -518,6 +540,18 @@ class GroupV2Helper {
return commitChange(groupInfoV2, change); return commitChange(groupInfoV2, change);
} }
Pair<DecryptedGroup, GroupChangeResponse> setMemberLabels(
GroupInfoV2 groupInfoV2,
String labelEmoji,
String labelString
) throws IOException {
final GroupsV2Operations.GroupOperations groupOperations = getGroupOperations(groupInfoV2);
final var change = groupOperations.createChangeMemberLabel(getSelfAci(),
labelString == null ? "" : labelString,
labelEmoji);
return commitChange(groupInfoV2, change);
}
private AccessControl.AccessRequired toAccessControl(final GroupLinkState state) { private AccessControl.AccessRequired toAccessControl(final GroupLinkState state) {
return switch (state) { return switch (state) {
case DISABLED -> AccessControl.AccessRequired.UNSATISFIABLE; case DISABLED -> AccessControl.AccessRequired.UNSATISFIABLE;
@ -585,7 +619,7 @@ class GroupV2Helper {
final var groupOperations = dependencies.getGroupsV2Operations().forGroup(groupSecretParams); final var groupOperations = dependencies.getGroupsV2Operations().forGroup(groupSecretParams);
final var previousGroupState = groupInfoV2.getGroup(); final var previousGroupState = groupInfoV2.getGroup();
final var nextRevision = previousGroupState.revision + 1; final var nextRevision = previousGroupState.revision + 1;
final var changeActions = change.revision(nextRevision).build(); final var changeActions = change.version(nextRevision).build();
final DecryptedGroupChange decryptedChange; final DecryptedGroupChange decryptedChange;
final DecryptedGroup decryptedGroupState; final DecryptedGroup decryptedGroupState;
@ -611,7 +645,7 @@ class GroupV2Helper {
GroupLinkPassword password GroupLinkPassword password
) throws IOException { ) throws IOException {
final var nextRevision = currentRevision + 1; final var nextRevision = currentRevision + 1;
final var changeActions = change.revision(nextRevision).build(); final var changeActions = change.version(nextRevision).build();
return dependencies.getGroupsV2Api() return dependencies.getGroupsV2Api()
.patchGroup(changeActions, .patchGroup(changeActions,
@ -621,6 +655,9 @@ class GroupV2Helper {
Pair<ServiceId, ProfileKey> getAuthoritativeProfileKeyFromChange(final DecryptedGroupChange change) { Pair<ServiceId, ProfileKey> getAuthoritativeProfileKeyFromChange(final DecryptedGroupChange change) {
UUID editor = UuidUtil.fromByteStringOrNull(change.editorServiceIdBytes); UUID editor = UuidUtil.fromByteStringOrNull(change.editorServiceIdBytes);
if (editor == null) {
return null;
}
final var editorProfileKeyBytes = Stream.concat(Stream.of(change.newMembers.stream(), final var editorProfileKeyBytes = Stream.concat(Stream.of(change.newMembers.stream(),
change.promotePendingMembers.stream(), change.promotePendingMembers.stream(),
change.modifiedProfileKeys.stream()) change.modifiedProfileKeys.stream())

View File

@ -4,6 +4,7 @@ import org.asamk.signal.manager.api.TrustLevel;
import org.asamk.signal.manager.storage.SignalAccount; import org.asamk.signal.manager.storage.SignalAccount;
import org.asamk.signal.manager.storage.recipients.RecipientId; import org.asamk.signal.manager.storage.recipients.RecipientId;
import org.asamk.signal.manager.util.Utils; import org.asamk.signal.manager.util.Utils;
import org.signal.core.models.ServiceId;
import org.signal.libsignal.protocol.IdentityKey; import org.signal.libsignal.protocol.IdentityKey;
import org.signal.libsignal.protocol.fingerprint.Fingerprint; import org.signal.libsignal.protocol.fingerprint.Fingerprint;
import org.signal.libsignal.protocol.fingerprint.FingerprintParsingException; import org.signal.libsignal.protocol.fingerprint.FingerprintParsingException;
@ -12,7 +13,6 @@ import org.signal.libsignal.protocol.fingerprint.ScannableFingerprint;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.messages.SendMessageResult; import org.whispersystems.signalservice.api.messages.SendMessageResult;
import org.whispersystems.signalservice.api.push.ServiceId;
import java.util.Arrays; import java.util.Arrays;
import java.util.function.BiFunction; import java.util.function.BiFunction;

View File

@ -5,6 +5,7 @@ import org.asamk.signal.manager.actions.HandleAction;
import org.asamk.signal.manager.actions.RefreshPreKeysAction; import org.asamk.signal.manager.actions.RefreshPreKeysAction;
import org.asamk.signal.manager.actions.RenewSessionAction; import org.asamk.signal.manager.actions.RenewSessionAction;
import org.asamk.signal.manager.actions.ResendMessageAction; import org.asamk.signal.manager.actions.ResendMessageAction;
import org.asamk.signal.manager.actions.RetrieveDeviceNameAction;
import org.asamk.signal.manager.actions.RetrieveProfileAction; import org.asamk.signal.manager.actions.RetrieveProfileAction;
import org.asamk.signal.manager.actions.SendGroupInfoAction; import org.asamk.signal.manager.actions.SendGroupInfoAction;
import org.asamk.signal.manager.actions.SendGroupInfoRequestAction; import org.asamk.signal.manager.actions.SendGroupInfoRequestAction;
@ -34,6 +35,9 @@ import org.asamk.signal.manager.storage.groups.GroupInfoV1;
import org.asamk.signal.manager.storage.recipients.RecipientAddress; import org.asamk.signal.manager.storage.recipients.RecipientAddress;
import org.asamk.signal.manager.storage.recipients.RecipientId; import org.asamk.signal.manager.storage.recipients.RecipientId;
import org.asamk.signal.manager.storage.stickers.StickerPack; 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; import org.signal.libsignal.metadata.ProtocolInvalidKeyException;
import org.signal.libsignal.metadata.ProtocolInvalidKeyIdException; import org.signal.libsignal.metadata.ProtocolInvalidKeyIdException;
import org.signal.libsignal.metadata.ProtocolInvalidMessageException; import org.signal.libsignal.metadata.ProtocolInvalidMessageException;
@ -60,17 +64,21 @@ import org.whispersystems.signalservice.api.messages.SignalServiceGroupV2;
import org.whispersystems.signalservice.api.messages.SignalServicePniSignatureMessage; import org.whispersystems.signalservice.api.messages.SignalServicePniSignatureMessage;
import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage; import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage;
import org.whispersystems.signalservice.api.messages.SignalServiceStoryMessage; import org.whispersystems.signalservice.api.messages.SignalServiceStoryMessage;
import org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage;
import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage; import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage;
import org.whispersystems.signalservice.api.messages.multidevice.StickerPackOperationMessage; import org.whispersystems.signalservice.api.messages.multidevice.StickerPackOperationMessage;
import org.whispersystems.signalservice.api.push.ServiceId;
import org.whispersystems.signalservice.api.push.ServiceId.ACI;
import org.whispersystems.signalservice.api.push.ServiceIdType; import org.whispersystems.signalservice.api.push.ServiceIdType;
import org.whispersystems.signalservice.api.push.SignalServiceAddress; import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.internal.push.Envelope; import org.whispersystems.signalservice.internal.push.Envelope;
import org.whispersystems.signalservice.internal.push.UnsupportedDataMessageException; 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.ArrayList;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@ -101,8 +109,8 @@ public final class IncomingMessageHandler {
SignalServiceContent content = null; SignalServiceContent content = null;
if (!envelope.isReceipt()) { if (!envelope.isReceipt()) {
account.getIdentityKeyStore().setRetryingDecryption(true); account.getIdentityKeyStore().setRetryingDecryption(true);
final var destination = getDestination(envelope).serviceId();
try { try {
final var destination = getDestination(envelope).serviceId();
final var cipherResult = dependencies.getCipher(destination == null final var cipherResult = dependencies.getCipher(destination == null
|| destination.equals(account.getAci()) ? ServiceIdType.ACI : ServiceIdType.PNI) || destination.equals(account.getAci()) ? ServiceIdType.ACI : ServiceIdType.PNI)
.decrypt(envelope.getProto(), envelope.getServerDeliveredTimestamp()); .decrypt(envelope.getProto(), envelope.getServerDeliveredTimestamp());
@ -132,15 +140,30 @@ public final class IncomingMessageHandler {
final Manager.ReceiveMessageHandler handler final Manager.ReceiveMessageHandler handler
) { ) {
final var actions = new ArrayList<HandleAction>(); final var actions = new ArrayList<HandleAction>();
if (envelope.isPreKeySignalMessage()) {
actions.add(RefreshPreKeysAction.create());
}
SignalServiceContent content = null; SignalServiceContent content = null;
Exception exception = null; Exception exception = null;
envelope.getSourceServiceId().map(ServiceId::parseOrNull) if (envelope.getSourceServiceId() != null) {
// Store uuid if we don't have it already // Store uuid if we don't have it already
// uuid in envelope is sent by server // uuid in envelope is sent by server
.ifPresent(serviceId -> account.getRecipientResolver().resolveRecipient(serviceId)); account.getRecipientResolver().resolveRecipient(envelope.getSourceServiceId());
}
if (!envelope.isReceipt()) { if (!envelope.isReceipt()) {
final var destination = getDestination(envelope).serviceId();
try { try {
final var destination = getDestination(envelope).serviceId();
if (destination == account.getPni() && envelope.getSourceServiceId() == null) {
throw new InvalidMessageException(
"Got a sealed sender message to our PNI? Invalid message, ignoring.");
}
if (envelope.getSourceServiceId() instanceof ServiceId.PNI
&& envelope.getProto().type != Envelope.Type.SERVER_DELIVERY_RECEIPT) {
throw new InvalidMessageException("Got a message from a PNI that was not a SERVER_DELIVERY_RECEIPT.");
}
final var cipherResult = dependencies.getCipher(destination == null final var cipherResult = dependencies.getCipher(destination == null
|| destination.equals(account.getAci()) ? ServiceIdType.ACI : ServiceIdType.PNI) || destination.equals(account.getAci()) ? ServiceIdType.ACI : ServiceIdType.PNI)
.decrypt(envelope.getProto(), envelope.getServerDeliveredTimestamp()); .decrypt(envelope.getProto(), envelope.getServerDeliveredTimestamp());
@ -165,7 +188,13 @@ public final class IncomingMessageHandler {
logger.debug("Received invalid message from blocked contact, ignoring."); logger.debug("Received invalid message from blocked contact, ignoring.");
} else { } else {
var serviceId = ServiceId.parseOrNull(e.getSender()); var serviceId = ServiceId.parseOrNull(e.getSender());
if (serviceId != null) { ServiceId destination;
try {
destination = getDestination(envelope).serviceId();
} catch (InvalidMessageException ex) {
destination = null;
}
if (serviceId != null && destination != null) {
final var isSelf = sender.equals(account.getSelfRecipientId()) final var isSelf = sender.equals(account.getSelfRecipientId())
&& e.getSenderDevice() == account.getDeviceId(); && e.getSenderDevice() == account.getDeviceId();
logger.debug("Received invalid message, queuing renew session action."); logger.debug("Received invalid message, queuing renew session action.");
@ -199,7 +228,10 @@ public final class IncomingMessageHandler {
) throws ProtocolInvalidKeyException, ProtocolInvalidMessageException, UnsupportedDataMessageException, InvalidMessageStructureException { ) throws ProtocolInvalidKeyException, ProtocolInvalidMessageException, UnsupportedDataMessageException, InvalidMessageStructureException {
final var content = cipherResult.getContent(); final var content = cipherResult.getContent();
final var envelopeMetadata = cipherResult.getMetadata(); final var envelopeMetadata = cipherResult.getMetadata();
final var validationResult = EnvelopeContentValidator.INSTANCE.validate(envelope, content, account.getAci()); final var validationResult = EnvelopeContentValidator.INSTANCE.validate(envelope,
content,
account.getAci(),
cipherResult.getMetadata().getCiphertextMessageType());
if (validationResult instanceof EnvelopeContentValidator.Result.Invalid v) { if (validationResult instanceof EnvelopeContentValidator.Result.Invalid v) {
logger.warn("Invalid content! {}", v.getReason(), v.getThrowable()); logger.warn("Invalid content! {}", v.getReason(), v.getThrowable());
@ -253,7 +285,7 @@ public final class IncomingMessageHandler {
var notAllowedToSendToGroup = isNotAllowedToSendToGroup(envelope, content); var notAllowedToSendToGroup = isNotAllowedToSendToGroup(envelope, content);
final var groupContext = getGroupContext(content); final var groupContext = getGroupContext(content);
if (groupContext != null && groupContext.getGroupV2().isPresent()) { if (groupContext != null && groupContext.getGroupV2().isPresent()) {
handleGroupV2Context(groupContext.getGroupV2().get()); handleGroupV2Context(groupContext.getGroupV2().get(), receiveConfig.ignoreAvatars());
} }
// Check again in case the user just joined the group // Check again in case the user just joined the group
notAllowedToSendToGroup = notAllowedToSendToGroup && isNotAllowedToSendToGroup(envelope, content); notAllowedToSendToGroup = notAllowedToSendToGroup && isNotAllowedToSendToGroup(envelope, content);
@ -269,13 +301,18 @@ public final class IncomingMessageHandler {
return List.of(); return List.of();
} else { } else {
List<HandleAction> actions; List<HandleAction> actions;
Map<String, String> longTexts;
if (content != null) { if (content != null) {
actions = handleMessage(envelope, content, receiveConfig); final var results = handleMessage(envelope, content, receiveConfig);
actions = results.first();
longTexts = results.second();
} else { } else {
actions = List.of(); actions = List.of();
longTexts = Map.of();
} }
handler.handleMessage(MessageEnvelope.from(envelope, handler.handleMessage(MessageEnvelope.from(envelope,
content, content,
longTexts,
account.getRecipientResolver(), account.getRecipientResolver(),
account.getRecipientAddressResolver(), account.getRecipientAddressResolver(),
context.getAttachmentHelper()::getAttachmentFile, context.getAttachmentHelper()::getAttachmentFile,
@ -284,17 +321,23 @@ public final class IncomingMessageHandler {
} }
} }
public List<HandleAction> handleMessage( public Pair<List<HandleAction>, Map<String, String>> handleMessage(
SignalServiceEnvelope envelope, SignalServiceEnvelope envelope,
SignalServiceContent content, SignalServiceContent content,
ReceiveConfig receiveConfig 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 senderDeviceAddress = getSender(envelope, content);
final var sender = senderDeviceAddress.recipientId(); final var sender = senderDeviceAddress.recipientId();
final var senderServiceId = senderDeviceAddress.serviceId(); final var senderServiceId = senderDeviceAddress.serviceId();
final var senderDeviceId = senderDeviceAddress.deviceId(); final var senderDeviceId = senderDeviceAddress.deviceId();
final var destination = getDestination(envelope); final DeviceAddress destination;
try {
destination = getDestination(envelope);
} catch (InvalidMessageException e) {
throw new AssertionError(e);
}
if (account.getPni().equals(destination.serviceId)) { if (account.getPni().equals(destination.serviceId)) {
account.getRecipientStore().markNeedsPniSignature(destination.recipientId, true); account.getRecipientStore().markNeedsPniSignature(destination.recipientId, true);
@ -364,27 +407,73 @@ public final class IncomingMessageHandler {
message.getTimestamp())); message.getTimestamp()));
} }
actions.addAll(handleSignalServiceDataMessage(message, final var dataResults = handleSignalServiceDataMessage(message,
false, false,
senderDeviceAddress, senderDeviceAddress,
destination, destination,
receiveConfig.ignoreAttachments())); receiveConfig);
actions.addAll(dataResults.first());
longTexts.putAll(dataResults.second());
} }
if (content.getStoryMessage().isPresent()) { if (content.getStoryMessage().isPresent()) {
final var message = content.getStoryMessage().get(); final var message = content.getStoryMessage().get();
actions.addAll(handleSignalServiceStoryMessage(message, sender, receiveConfig.ignoreAttachments())); actions.addAll(handleSignalServiceStoryMessage(message, sender, receiveConfig));
} }
if (content.getSyncMessage().isPresent()) { if (content.getSyncMessage().isPresent()) {
var syncMessage = content.getSyncMessage().get(); var syncMessage = content.getSyncMessage().get();
actions.addAll(handleSyncMessage(envelope, final var syncResults = handleSyncMessage(envelope, syncMessage, senderDeviceAddress, receiveConfig);
syncMessage, actions.addAll(syncResults.first());
senderDeviceAddress, longTexts.putAll(syncResults.second());
receiveConfig.ignoreAttachments()));
} }
return actions; if (content.getCallMessage().isPresent()) {
handleCallMessage(content.getCallMessage().get(), sender, senderDeviceId);
}
return new Pair<>(actions, longTexts);
}
private void handleCallMessage(
final SignalServiceCallMessage callMessage,
final RecipientId sender,
final int deviceId
) {
var callManager = context.getCallManager();
if (callMessage.getDestinationDeviceId().isPresent()
&& callMessage.getDestinationDeviceId().get() != account.getDeviceId()) {
return;
}
callMessage.getOfferMessage().ifPresent(offer -> {
var type = offer.getType()
== org.whispersystems.signalservice.api.messages.calls.OfferMessage.Type.VIDEO_CALL
? org.asamk.signal.manager.api.MessageEnvelope.Call.Offer.Type.VIDEO_CALL
: org.asamk.signal.manager.api.MessageEnvelope.Call.Offer.Type.AUDIO_CALL;
callManager.handleIncomingOffer(sender, deviceId, offer.getId(), type, offer.getOpaque());
});
callMessage.getAnswerMessage()
.ifPresent(answer -> callManager.handleIncomingAnswer(answer.getId(), deviceId, answer.getOpaque()));
callMessage.getIceUpdateMessages().ifPresent(iceUpdates -> {
for (var ice : iceUpdates) {
callManager.handleIncomingIceCandidate(ice.getId(), ice.getOpaque(), deviceId);
}
});
callMessage.getHangupMessage().ifPresent(hangup -> {
// Only NORMAL hangups actually end the call. ACCEPTED/DECLINED/BUSY
// are multi-device notifications irrelevant for single-device signal-cli.
var hangupType = hangup.getType();
if (hangupType == org.whispersystems.signalservice.api.messages.calls.HangupMessage.Type.NORMAL
|| hangupType == null) {
callManager.handleIncomingHangup(hangup.getId());
}
});
callMessage.getBusyMessage().ifPresent(busy -> callManager.handleIncomingBusy(busy.getId()));
} }
private boolean handlePniSignatureMessage( private boolean handlePniSignatureMessage(
@ -470,19 +559,20 @@ public final class IncomingMessageHandler {
} }
} }
private List<HandleAction> handleSyncMessage( private Pair<List<HandleAction>, Map<String, String>> handleSyncMessage(
final SignalServiceEnvelope envelope, final SignalServiceEnvelope envelope,
final SignalServiceSyncMessage syncMessage, final SignalServiceSyncMessage syncMessage,
final DeviceAddress sender, final DeviceAddress sender,
final boolean ignoreAttachments final ReceiveConfig receiveConfig
) { ) {
var actions = new ArrayList<HandleAction>(); final var actions = new ArrayList<HandleAction>();
final var longTexts = new HashMap<String, String>();
account.setMultiDevice(true); account.setMultiDevice(true);
if (syncMessage.getSent().isPresent()) { if (syncMessage.getSent().isPresent()) {
var message = syncMessage.getSent().get(); var message = syncMessage.getSent().get();
final var destination = message.getDestination().orElse(null); final var destination = message.getDestination().orElse(null);
if (message.getDataMessage().isPresent()) { if (message.getDataMessage().isPresent()) {
actions.addAll(handleSignalServiceDataMessage(message.getDataMessage().get(), final var dataResults = handleSignalServiceDataMessage(message.getDataMessage().get(),
true, true,
sender, sender,
destination == null destination == null
@ -490,12 +580,14 @@ public final class IncomingMessageHandler {
: new DeviceAddress(account.getRecipientResolver().resolveRecipient(destination), : new DeviceAddress(account.getRecipientResolver().resolveRecipient(destination),
destination.getServiceId(), destination.getServiceId(),
0), 0),
ignoreAttachments)); receiveConfig);
actions.addAll(dataResults.first());
longTexts.putAll(dataResults.second());
} }
if (message.getStoryMessage().isPresent()) { if (message.getStoryMessage().isPresent()) {
actions.addAll(handleSignalServiceStoryMessage(message.getStoryMessage().get(), actions.addAll(handleSignalServiceStoryMessage(message.getStoryMessage().get(),
sender.recipientId(), sender.recipientId(),
ignoreAttachments)); receiveConfig));
} }
} }
if (syncMessage.getRequest().isPresent() && account.isPrimaryDevice()) { if (syncMessage.getRequest().isPresent() && account.isPrimaryDevice()) {
@ -521,7 +613,9 @@ public final class IncomingMessageHandler {
try { try {
final var groupsMessage = syncMessage.getGroups().get(); final var groupsMessage = syncMessage.getGroups().get();
context.getAttachmentHelper() context.getAttachmentHelper()
.retrieveAttachment(groupsMessage, context.getSyncHelper()::handleSyncDeviceGroups); .retrieveAttachment(groupsMessage,
input -> context.getSyncHelper()
.handleSyncDeviceGroups(input, receiveConfig.ignoreAvatars()));
} catch (Exception e) { } catch (Exception e) {
logger.warn("Failed to handle received sync groups, ignoring: {}", e.getMessage()); logger.warn("Failed to handle received sync groups, ignoring: {}", e.getMessage());
} }
@ -549,7 +643,8 @@ public final class IncomingMessageHandler {
final var contactsMessage = syncMessage.getContacts().get(); final var contactsMessage = syncMessage.getContacts().get();
context.getAttachmentHelper() context.getAttachmentHelper()
.retrieveAttachment(contactsMessage.getContactsStream(), .retrieveAttachment(contactsMessage.getContactsStream(),
context.getSyncHelper()::handleSyncDeviceContacts); input -> context.getSyncHelper()
.handleSyncDeviceContacts(input, receiveConfig.ignoreAvatars()));
} catch (Exception e) { } catch (Exception e) {
logger.warn("Failed to handle received sync contacts, ignoring: {}", e.getMessage()); logger.warn("Failed to handle received sync contacts, ignoring: {}", e.getMessage());
} }
@ -575,7 +670,7 @@ public final class IncomingMessageHandler {
final var sticker = context.getStickerHelper() final var sticker = context.getStickerHelper()
.addOrUpdateStickerPack(stickerPackId, stickerPackKey, installed); .addOrUpdateStickerPack(stickerPackId, stickerPackKey, installed);
if (sticker != null && installed) { if (sticker != null && installed && !receiveConfig.ignoreStickers()) {
context.getJobExecutor().enqueueJob(new RetrieveStickerPackJob(stickerPackId, sticker.packKey())); context.getJobExecutor().enqueueJob(new RetrieveStickerPackJob(stickerPackId, sticker.packKey()));
} }
} }
@ -592,10 +687,6 @@ public final class IncomingMessageHandler {
final var aep = keysMessage.getAccountEntropyPool(); final var aep = keysMessage.getAccountEntropyPool();
account.setAccountEntropyPool(aep); account.setAccountEntropyPool(aep);
actions.add(SyncStorageDataAction.create()); actions.add(SyncStorageDataAction.create());
} else if (keysMessage.getMaster() != null) {
final var masterKey = keysMessage.getMaster();
account.setMasterKey(masterKey);
actions.add(SyncStorageDataAction.create());
} else if (keysMessage.getStorageService() != null) { } else if (keysMessage.getStorageService() != null) {
final var storageKey = keysMessage.getStorageService(); final var storageKey = keysMessage.getStorageService();
account.setStorageKey(storageKey); account.setStorageKey(storageKey);
@ -633,7 +724,13 @@ public final class IncomingMessageHandler {
context.getAccountHelper().handlePniChangeNumberMessage(pniChangeNumber, updatedPni); context.getAccountHelper().handlePniChangeNumberMessage(pniChangeNumber, updatedPni);
} }
} }
return actions; if (syncMessage.getDeviceNameChange().isPresent()) {
final var deviceNameChange = syncMessage.getDeviceNameChange().get();
if (deviceNameChange.deviceId != null && deviceNameChange.deviceId == account.getDeviceId()) {
actions.add(RetrieveDeviceNameAction.create());
}
}
return new Pair<>(actions, longTexts);
} }
private SignalServiceGroupContext getGroupContext(SignalServiceContent content) { private SignalServiceGroupContext getGroupContext(SignalServiceContent content) {
@ -699,15 +796,21 @@ public final class IncomingMessageHandler {
} }
} }
var groupId = GroupUtils.getGroupId(groupContext); final var message = content.getDataMessage().orElse(null);
var group = context.getGroupHelper().getGroup(groupId);
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) { if (group == null) {
return false; return false;
} }
final var message = content.getDataMessage().orElse(null);
final var recipientId = account.getRecipientResolver().resolveRecipient(source);
if (!group.isMember(recipientId) && !( if (!group.isMember(recipientId) && !(
group.isPendingMember(recipientId) && message != null && message.isGroupV2Update() group.isPendingMember(recipientId) && message != null && message.isGroupV2Update()
)) { )) {
@ -726,13 +829,14 @@ public final class IncomingMessageHandler {
return false; return false;
} }
private List<HandleAction> handleSignalServiceDataMessage( private Pair<List<HandleAction>, Map<String, String>> handleSignalServiceDataMessage(
SignalServiceDataMessage message, SignalServiceDataMessage message,
boolean isSync, boolean isSync,
DeviceAddress source, DeviceAddress source,
DeviceAddress destination, DeviceAddress destination,
boolean ignoreAttachments ReceiveConfig receiveConfig
) { ) {
final var longTexts = new HashMap<String, String>();
var actions = new ArrayList<HandleAction>(); var actions = new ArrayList<HandleAction>();
if (message.getGroupContext().isPresent()) { if (message.getGroupContext().isPresent()) {
final var groupContext = message.getGroupContext().get(); final var groupContext = message.getGroupContext().get();
@ -748,7 +852,7 @@ public final class IncomingMessageHandler {
groupV1 = new GroupInfoV1(groupId); groupV1 = new GroupInfoV1(groupId);
} }
if (groupInfo.getAvatar().isPresent()) { if (groupInfo.getAvatar().isPresent() && !receiveConfig.ignoreAvatars()) {
var avatar = groupInfo.getAvatar().get(); var avatar = groupInfo.getAvatar().get();
context.getGroupHelper().downloadGroupAvatar(groupV1.getGroupId(), avatar); context.getGroupHelper().downloadGroupAvatar(groupV1.getGroupId(), avatar);
} }
@ -790,17 +894,12 @@ public final class IncomingMessageHandler {
} }
} }
if (groupContext.getGroupV2().isPresent()) { if (groupContext.getGroupV2().isPresent()) {
handleGroupV2Context(groupContext.getGroupV2().get()); handleGroupV2Context(groupContext.getGroupV2().get(), receiveConfig.ignoreAvatars());
} }
} }
final var selfAddress = isSync ? source : destination; final var selfAddress = isSync ? source : destination;
final var conversationPartnerAddress = isSync ? destination : source; final var conversationPartnerAddress = isSync ? destination : source;
if (conversationPartnerAddress != null && message.isEndSession()) {
account.getAccountData(selfAddress.serviceId())
.getSessionStore()
.deleteAllSessions(conversationPartnerAddress.serviceId());
}
if (message.isExpirationUpdate() || message.getBody().isPresent()) { if (message.isExpirationUpdate() || message.getBody().isPresent()) {
if (message.getGroupContext().isPresent()) { if (message.getGroupContext().isPresent()) {
final var groupContext = message.getGroupContext().get(); final var groupContext = message.getGroupContext().get();
@ -823,10 +922,21 @@ public final class IncomingMessageHandler {
message.getExpireTimerVersion()); message.getExpireTimerVersion());
} }
} }
if (!ignoreAttachments) { if (!receiveConfig.ignoreAttachments()) {
if (message.getAttachments().isPresent()) { if (message.getAttachments().isPresent()) {
for (var attachment : message.getAttachments().get()) { for (var attachment : message.getAttachments().get()) {
context.getAttachmentHelper().downloadAttachment(attachment); 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()) { if (message.getSharedContacts().isPresent()) {
@ -856,6 +966,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()) { if (message.getGiftBadge().isPresent()) {
handleIncomingGiftBadge(message.getGiftBadge().get()); handleIncomingGiftBadge(message.getGiftBadge().get());
@ -871,9 +996,12 @@ public final class IncomingMessageHandler {
sticker = new StickerPack(stickerPackId, messageSticker.getPackKey()); sticker = new StickerPack(stickerPackId, messageSticker.getPackKey());
account.getStickerStore().addStickerPack(sticker); account.getStickerStore().addStickerPack(sticker);
} }
context.getJobExecutor().enqueueJob(new RetrieveStickerPackJob(stickerPackId, messageSticker.getPackKey())); if (!receiveConfig.ignoreStickers()) {
context.getJobExecutor()
.enqueueJob(new RetrieveStickerPackJob(stickerPackId, messageSticker.getPackKey()));
}
} }
return actions; return new Pair<>(actions, longTexts);
} }
private void handleIncomingGiftBadge(final SignalServiceDataMessage.GiftBadge giftBadge) { private void handleIncomingGiftBadge(final SignalServiceDataMessage.GiftBadge giftBadge) {
@ -883,14 +1011,14 @@ public final class IncomingMessageHandler {
private List<HandleAction> handleSignalServiceStoryMessage( private List<HandleAction> handleSignalServiceStoryMessage(
SignalServiceStoryMessage message, SignalServiceStoryMessage message,
RecipientId source, RecipientId source,
boolean ignoreAttachments ReceiveConfig receiveConfig
) { ) {
var actions = new ArrayList<HandleAction>(); var actions = new ArrayList<HandleAction>();
if (message.getGroupContext().isPresent()) { if (message.getGroupContext().isPresent()) {
handleGroupV2Context(message.getGroupContext().get()); handleGroupV2Context(message.getGroupContext().get(), receiveConfig.ignoreAvatars());
} }
if (!ignoreAttachments) { if (!receiveConfig.ignoreAttachments()) {
if (message.getFileAttachment().isPresent()) { if (message.getFileAttachment().isPresent()) {
context.getAttachmentHelper().downloadAttachment(message.getFileAttachment().get()); context.getAttachmentHelper().downloadAttachment(message.getFileAttachment().get());
} }
@ -912,13 +1040,14 @@ public final class IncomingMessageHandler {
return actions; return actions;
} }
private void handleGroupV2Context(final SignalServiceGroupV2 groupContext) { private void handleGroupV2Context(final SignalServiceGroupV2 groupContext, final boolean ignoreAvatars) {
final var groupMasterKey = groupContext.getMasterKey(); final var groupMasterKey = groupContext.getMasterKey();
context.getGroupHelper() context.getGroupHelper()
.getOrMigrateGroup(groupMasterKey, .getOrMigrateGroup(groupMasterKey,
groupContext.getRevision(), groupContext.getRevision(),
groupContext.hasSignedGroupChange() ? groupContext.getSignedGroupChange() : null); groupContext.hasSignedGroupChange() ? groupContext.getSignedGroupChange() : null,
ignoreAvatars);
} }
private void handleIncomingProfileKey(final byte[] profileKeyBytes, final RecipientId source) { private void handleIncomingProfileKey(final byte[] profileKeyBytes, final RecipientId source) {
@ -939,7 +1068,7 @@ public final class IncomingMessageHandler {
} }
private SignalServiceAddress getSenderAddress(SignalServiceEnvelope envelope, SignalServiceContent content) { private SignalServiceAddress getSenderAddress(SignalServiceEnvelope envelope, SignalServiceContent content) {
final var serviceId = envelope.getSourceServiceId().map(ServiceId::parseOrNull).orElse(null); final var serviceId = envelope.getSourceServiceId();
if (!envelope.isUnidentifiedSender() && serviceId != null) { if (!envelope.isUnidentifiedSender() && serviceId != null) {
return new SignalServiceAddress(serviceId); return new SignalServiceAddress(serviceId);
} else if (content != null) { } else if (content != null) {
@ -950,7 +1079,7 @@ public final class IncomingMessageHandler {
} }
private DeviceAddress getSender(SignalServiceEnvelope envelope, SignalServiceContent content) { private DeviceAddress getSender(SignalServiceEnvelope envelope, SignalServiceContent content) {
final var serviceId = envelope.getSourceServiceId().map(ServiceId::parseOrNull).orElse(null); final var serviceId = envelope.getSourceServiceId();
if (!envelope.isUnidentifiedSender() && serviceId != null) { if (!envelope.isUnidentifiedSender() && serviceId != null) {
return new DeviceAddress(account.getRecipientResolver().resolveRecipient(serviceId), return new DeviceAddress(account.getRecipientResolver().resolveRecipient(serviceId),
serviceId, serviceId,
@ -962,10 +1091,13 @@ public final class IncomingMessageHandler {
} }
} }
private DeviceAddress getDestination(SignalServiceEnvelope envelope) { private DeviceAddress getDestination(SignalServiceEnvelope envelope) throws InvalidMessageException {
final var destination = envelope.getDestinationServiceId(); final var destination = envelope.getDestinationServiceId();
if (destination == null || destination.isUnknown()) { if (destination == null || destination.isUnknown()) {
return new DeviceAddress(account.getSelfRecipientId(), account.getAci(), account.getDeviceId()); throw new InvalidMessageException("Missing destination");
}
if (!account.getAci().equals(destination) && !account.getPni().equals(destination)) {
throw new InvalidMessageException("Message not intended for this account");
} }
return new DeviceAddress(account.getRecipientResolver().resolveRecipient(destination), return new DeviceAddress(account.getRecipientResolver().resolveRecipient(destination),
destination, destination,

View File

@ -1,9 +1,9 @@
package org.asamk.signal.manager.helper; package org.asamk.signal.manager.helper;
import org.asamk.signal.manager.api.IncorrectPinException; import org.asamk.signal.manager.api.IncorrectPinException;
import org.signal.core.models.MasterKey;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.kbs.MasterKey;
import org.whispersystems.signalservice.api.svr.SecureValueRecovery; import org.whispersystems.signalservice.api.svr.SecureValueRecovery;
import org.whispersystems.signalservice.internal.push.AuthCredentials; import org.whispersystems.signalservice.internal.push.AuthCredentials;
import org.whispersystems.signalservice.internal.push.LockedException; import org.whispersystems.signalservice.internal.push.LockedException;

View File

@ -9,6 +9,7 @@ import org.signal.libsignal.protocol.InvalidKeyIdException;
import org.signal.libsignal.protocol.state.KyberPreKeyRecord; import org.signal.libsignal.protocol.state.KyberPreKeyRecord;
import org.signal.libsignal.protocol.state.PreKeyRecord; import org.signal.libsignal.protocol.state.PreKeyRecord;
import org.signal.libsignal.protocol.state.SignedPreKeyRecord; import org.signal.libsignal.protocol.state.SignedPreKeyRecord;
import org.signal.network.exceptions.NonSuccessfulResponseCodeException;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.NetworkResultUtil; import org.whispersystems.signalservice.api.NetworkResultUtil;
@ -16,7 +17,6 @@ import org.whispersystems.signalservice.api.account.PreKeyUpload;
import org.whispersystems.signalservice.api.keys.OneTimePreKeyCounts; import org.whispersystems.signalservice.api.keys.OneTimePreKeyCounts;
import org.whispersystems.signalservice.api.push.ServiceIdType; import org.whispersystems.signalservice.api.push.ServiceIdType;
import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedException; import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedException;
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException;
import java.io.IOException; import java.io.IOException;
import java.util.List; import java.util.List;
@ -84,7 +84,8 @@ public class PreKeyHelper {
) throws IOException { ) throws IOException {
OneTimePreKeyCounts preKeyCounts; OneTimePreKeyCounts preKeyCounts;
try { try {
preKeyCounts = handleResponseException(dependencies.getKeysApi().getAvailablePreKeyCounts(serviceIdType)); preKeyCounts = handleResponseException(dependencies.getKeysApi()
.getAvailablePreKeyCountsSync(serviceIdType));
} catch (AuthorizationFailedException e) { } catch (AuthorizationFailedException e) {
logger.debug("Failed to get pre key count, ignoring: " + e.getClass().getSimpleName()); logger.debug("Failed to get pre key count, ignoring: " + e.getClass().getSimpleName());
preKeyCounts = new OneTimePreKeyCounts(0, 0); preKeyCounts = new OneTimePreKeyCounts(0, 0);
@ -145,7 +146,7 @@ public class PreKeyHelper {
kyberPreKeyRecords); kyberPreKeyRecords);
var needsReset = false; var needsReset = false;
try { try {
NetworkResultUtil.toPreKeysLegacy(dependencies.getKeysApi().setPreKeys(preKeyUpload)); NetworkResultUtil.toPreKeysLegacy(dependencies.getKeysApi().setPreKeysSync(preKeyUpload));
try { try {
if (preKeyRecords != null) { if (preKeyRecords != null) {
account.addPreKeys(serviceIdType, preKeyRecords); account.addPreKeys(serviceIdType, preKeyRecords);

View File

@ -21,6 +21,7 @@ import org.signal.libsignal.protocol.IdentityKey;
import org.signal.libsignal.protocol.InvalidKeyException; import org.signal.libsignal.protocol.InvalidKeyException;
import org.signal.libsignal.zkgroup.profiles.ExpiringProfileKeyCredential; import org.signal.libsignal.zkgroup.profiles.ExpiringProfileKeyCredential;
import org.signal.libsignal.zkgroup.profiles.ProfileKey; import org.signal.libsignal.zkgroup.profiles.ProfileKey;
import org.signal.network.exceptions.PushNetworkException;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.NetworkResultUtil; import org.whispersystems.signalservice.api.NetworkResultUtil;
@ -30,7 +31,6 @@ import org.whispersystems.signalservice.api.profiles.ProfileAndCredential;
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile; import org.whispersystems.signalservice.api.profiles.SignalServiceProfile;
import org.whispersystems.signalservice.api.push.SignalServiceAddress; import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.push.exceptions.NotFoundException; import org.whispersystems.signalservice.api.push.exceptions.NotFoundException;
import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException;
import org.whispersystems.signalservice.api.services.ProfileService; import org.whispersystems.signalservice.api.services.ProfileService;
import org.whispersystems.signalservice.api.util.ExpiringProfileCredentialUtil; import org.whispersystems.signalservice.api.util.ExpiringProfileCredentialUtil;
@ -116,7 +116,8 @@ public final class ProfileHelper {
.filter(recipientId -> !ExpiringProfileCredentialUtil.isValid(account.getProfileStore() .filter(recipientId -> !ExpiringProfileCredentialUtil.isValid(account.getProfileStore()
.getExpiringProfileKeyCredential(recipientId))) .getExpiringProfileKeyCredential(recipientId)))
.map(recipientId -> retrieveProfile(recipientId, .map(recipientId -> retrieveProfile(recipientId,
SignalServiceProfile.RequestType.PROFILE_AND_CREDENTIAL).onErrorComplete()); SignalServiceProfile.RequestType.PROFILE_AND_CREDENTIAL,
false).onErrorComplete());
Maybe.merge(profileFetches, 10).blockingSubscribe(); Maybe.merge(profileFetches, 10).blockingSubscribe();
return recipientIds.stream().map(r -> account.getProfileStore().getExpiringProfileKeyCredential(r)).toList(); return recipientIds.stream().map(r -> account.getProfileStore().getExpiringProfileKeyCredential(r)).toList();
@ -129,9 +130,13 @@ public final class ProfileHelper {
} }
try { try {
blockingGetProfile(retrieveProfile(recipientId, SignalServiceProfile.RequestType.PROFILE_AND_CREDENTIAL)); blockingGetProfile(retrieveProfile(recipientId,
SignalServiceProfile.RequestType.PROFILE_AND_CREDENTIAL,
false));
} catch (IOException e) { } catch (IOException e) {
logger.warn("Failed to retrieve profile key credential, ignoring: {}", e.getMessage()); logger.warn("Failed to retrieve profile key credential for {}, ignoring: {}",
context.getRecipientHelper().resolveSignalServiceAddress(recipientId).getIdentifier(),
e.getMessage());
return null; return null;
} }
@ -188,9 +193,11 @@ public final class ProfileHelper {
if (uploadProfile) { if (uploadProfile) {
final var streamDetails = avatar != null && avatar.isPresent() final var streamDetails = avatar != null && avatar.isPresent()
? Utils.createStreamDetails(avatar.get()) ? Utils.createStreamDetails(avatar.get())
.first() .first()
: forceUploadAvatar && avatar == null ? context.getAvatarStore() : forceUploadAvatar && avatar == null
.retrieveProfileAvatar(account.getSelfRecipientAddress()) : null; ? context.getAvatarStore()
.retrieveProfileAvatar(account.getSelfRecipientAddress())
: null;
try (streamDetails) { try (streamDetails) {
final var avatarUploadParams = streamDetails != null final var avatarUploadParams = streamDetails != null
? AvatarUploadParams.forAvatar(streamDetails) ? AvatarUploadParams.forAvatar(streamDetails)
@ -241,7 +248,8 @@ public final class ProfileHelper {
final var profileFetches = Flowable.fromIterable(recipientIds) final var profileFetches = Flowable.fromIterable(recipientIds)
.filter(recipientId -> force || isProfileRefreshRequired(profileStore.getProfile(recipientId))) .filter(recipientId -> force || isProfileRefreshRequired(profileStore.getProfile(recipientId)))
.map(recipientId -> retrieveProfile(recipientId, .map(recipientId -> retrieveProfile(recipientId,
SignalServiceProfile.RequestType.PROFILE).onErrorComplete()); SignalServiceProfile.RequestType.PROFILE,
false).onErrorComplete());
Maybe.merge(profileFetches, 10).blockingSubscribe(); Maybe.merge(profileFetches, 10).blockingSubscribe();
return recipientIds.stream().map(profileStore::getProfile).toList(); return recipientIds.stream().map(profileStore::getProfile).toList();
@ -255,9 +263,11 @@ public final class ProfileHelper {
} }
try { try {
blockingGetProfile(retrieveProfile(recipientId, SignalServiceProfile.RequestType.PROFILE)); blockingGetProfile(retrieveProfile(recipientId, SignalServiceProfile.RequestType.PROFILE, false));
} catch (IOException e) { } catch (IOException e) {
logger.warn("Failed to retrieve profile, ignoring: {}", e.getMessage()); logger.warn("Failed to retrieve profile for {}, ignoring: {}",
context.getRecipientHelper().resolveSignalServiceAddress(recipientId).getIdentifier(),
e.getMessage());
} }
return account.getProfileStore().getProfile(recipientId); return account.getProfileStore().getProfile(recipientId);
@ -272,17 +282,6 @@ public final class ProfileHelper {
return now - profile.getLastUpdateTimestamp() >= 6 * 60 * 60 * 1000; return now - profile.getLastUpdateTimestamp() >= 6 * 60 * 60 * 1000;
} }
private Profile decryptProfileAndDownloadAvatar(
final RecipientId recipientId,
final ProfileKey profileKey,
final SignalServiceProfile encryptedProfile
) {
final var avatarPath = encryptedProfile.getAvatar();
downloadProfileAvatar(recipientId, avatarPath, profileKey);
return ProfileUtils.decryptProfile(profileKey, encryptedProfile);
}
public void downloadProfileAvatar( public void downloadProfileAvatar(
final RecipientId recipientId, final RecipientId recipientId,
final String avatarPath, final String avatarPath,
@ -315,7 +314,8 @@ public final class ProfileHelper {
private Single<ProfileAndCredential> retrieveProfile( private Single<ProfileAndCredential> retrieveProfile(
RecipientId recipientId, RecipientId recipientId,
SignalServiceProfile.RequestType requestType SignalServiceProfile.RequestType requestType,
final boolean ignoreAvatars
) { ) {
var unidentifiedAccess = getUnidentifiedAccess(recipientId); var unidentifiedAccess = getUnidentifiedAccess(recipientId);
var profileKey = Optional.ofNullable(account.getProfileStore().getProfileKey(recipientId)); var profileKey = Optional.ofNullable(account.getProfileStore().getProfileKey(recipientId));
@ -341,7 +341,12 @@ public final class ProfileHelper {
Profile newProfile = null; Profile newProfile = null;
if (profileKey.isPresent()) { if (profileKey.isPresent()) {
logger.trace("Decrypting profile"); logger.trace("Decrypting profile");
newProfile = decryptProfileAndDownloadAvatar(recipientId, profileKey.get(), encryptedProfile); final var avatarPath = encryptedProfile.getAvatar();
if (!ignoreAvatars) {
downloadProfileAvatar(recipientId, avatarPath, profileKey.get());
}
newProfile = ProfileUtils.decryptProfile(profileKey.get(), encryptedProfile);
} }
if (newProfile == null) { if (newProfile == null) {
@ -380,7 +385,9 @@ public final class ProfileHelper {
logger.trace("Done handling retrieved profile"); logger.trace("Done handling retrieved profile");
}).doOnError(e -> { }).doOnError(e -> {
logger.warn("Failed to retrieve profile, ignoring: {}", e.getMessage()); logger.warn("Failed to retrieve profile for {}, ignoring: {}",
context.getRecipientHelper().resolveSignalServiceAddress(recipientId).getIdentifier(),
e.getMessage());
final var profile = account.getProfileStore().getProfile(recipientId); final var profile = account.getProfileStore().getProfile(recipientId);
final var newProfile = ( final var newProfile = (
profile == null ? Profile.newBuilder() : Profile.newBuilder(profile) profile == null ? Profile.newBuilder() : Profile.newBuilder(profile)

View File

@ -9,11 +9,10 @@ import org.asamk.signal.manager.jobs.CleanOldPreKeysJob;
import org.asamk.signal.manager.storage.SignalAccount; import org.asamk.signal.manager.storage.SignalAccount;
import org.asamk.signal.manager.storage.messageCache.CachedMessage; import org.asamk.signal.manager.storage.messageCache.CachedMessage;
import org.asamk.signal.manager.storage.recipients.RecipientAddress; import org.asamk.signal.manager.storage.recipients.RecipientAddress;
import org.signal.core.models.ServiceId.ACI;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.messages.SignalServiceEnvelope; import org.whispersystems.signalservice.api.messages.SignalServiceEnvelope;
import org.whispersystems.signalservice.api.push.ServiceId;
import org.whispersystems.signalservice.api.push.ServiceId.ACI;
import org.whispersystems.signalservice.api.websocket.SignalWebSocket; import org.whispersystems.signalservice.api.websocket.SignalWebSocket;
import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState; import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState;
import org.whispersystems.signalservice.api.websocket.WebSocketUnavailableException; import org.whispersystems.signalservice.api.websocket.WebSocketUnavailableException;
@ -40,7 +39,7 @@ public class ReceiveHelper {
private final SignalDependencies dependencies; private final SignalDependencies dependencies;
private final Context context; private final Context context;
private ReceiveConfig receiveConfig = new ReceiveConfig(false, false, false); private ReceiveConfig receiveConfig = new ReceiveConfig(false, false, false, false, false);
private boolean hasCaughtUpWithOldMessages = false; private boolean hasCaughtUpWithOldMessages = false;
private boolean isWaitingForMessage = false; private boolean isWaitingForMessage = false;
private boolean shouldStop = false; private boolean shouldStop = false;
@ -150,10 +149,10 @@ public class ReceiveHelper {
for (final var it : batch) { for (final var it : batch) {
SignalServiceEnvelope envelope1 = new SignalServiceEnvelope(it.getEnvelope(), SignalServiceEnvelope envelope1 = new SignalServiceEnvelope(it.getEnvelope(),
it.getServerDeliveredTimestamp()); it.getServerDeliveredTimestamp());
final var recipientId = envelope1.getSourceServiceId() final var sourceServiceId = envelope1.getSourceServiceId();
.map(ServiceId::parseOrNull) final var recipientId = sourceServiceId == null
.map(s -> account.getRecipientResolver().resolveRecipient(s)) ? null
.orElse(null); : account.getRecipientResolver().resolveRecipient(sourceServiceId);
logger.trace("Storing new message from {}", recipientId); logger.trace("Storing new message from {}", recipientId);
// store message on disk, before acknowledging receipt to the server // store message on disk, before acknowledging receipt to the server
cachedMessage[0] = account.getMessageCache().cacheMessage(envelope1, recipientId); cachedMessage[0] = account.getMessageCache().cacheMessage(envelope1, recipientId);
@ -238,7 +237,7 @@ public class ReceiveHelper {
if (exception instanceof UntrustedIdentityException) { if (exception instanceof UntrustedIdentityException) {
logger.debug("Keeping message with untrusted identity in message cache"); logger.debug("Keeping message with untrusted identity in message cache");
final var address = ((UntrustedIdentityException) exception).getSender(); final var address = ((UntrustedIdentityException) exception).getSender();
if (envelope.getSourceServiceId().isEmpty() && address.aci().isPresent()) { if (envelope.getSourceServiceId() == null && address.aci().isPresent()) {
final var recipientId = account.getRecipientResolver() final var recipientId = account.getRecipientResolver()
.resolveRecipient(ACI.parseOrThrow(address.aci().get())); .resolveRecipient(ACI.parseOrThrow(address.aci().get()));
try { try {
@ -292,7 +291,7 @@ public class ReceiveHelper {
cachedMessage.delete(); cachedMessage.delete();
return null; return null;
} }
if (envelope.getSourceServiceId().isEmpty()) { if (envelope.getSourceServiceId() == null) {
final var identifier = ((UntrustedIdentityException) exception).getSender(); final var identifier = ((UntrustedIdentityException) exception).getSender();
final var recipientId = account.getRecipientResolver() final var recipientId = account.getRecipientResolver()
.resolveRecipient(new RecipientAddress(identifier)); .resolveRecipient(new RecipientAddress(identifier));

View File

@ -6,18 +6,18 @@ import org.asamk.signal.manager.api.UsernameLinkUrl;
import org.asamk.signal.manager.internal.SignalDependencies; import org.asamk.signal.manager.internal.SignalDependencies;
import org.asamk.signal.manager.storage.SignalAccount; import org.asamk.signal.manager.storage.SignalAccount;
import org.asamk.signal.manager.storage.recipients.RecipientId; import org.asamk.signal.manager.storage.recipients.RecipientId;
import org.signal.core.models.ServiceId;
import org.signal.core.models.ServiceId.ACI;
import org.signal.core.models.ServiceId.PNI;
import org.signal.libsignal.usernames.BaseUsernameException; import org.signal.libsignal.usernames.BaseUsernameException;
import org.signal.libsignal.usernames.Username; import org.signal.libsignal.usernames.Username;
import org.signal.network.exceptions.NonSuccessfulResponseCodeException;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.cds.CdsiV2Service; import org.whispersystems.signalservice.api.cds.CdsiV2Service;
import org.whispersystems.signalservice.api.push.ServiceId;
import org.whispersystems.signalservice.api.push.ServiceId.ACI;
import org.whispersystems.signalservice.api.push.ServiceId.PNI;
import org.whispersystems.signalservice.api.push.SignalServiceAddress; import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.push.exceptions.CdsiInvalidArgumentException; import org.whispersystems.signalservice.api.push.exceptions.CdsiInvalidArgumentException;
import org.whispersystems.signalservice.api.push.exceptions.CdsiInvalidTokenException; import org.whispersystems.signalservice.api.push.exceptions.CdsiInvalidTokenException;
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException;
import java.io.IOException; import java.io.IOException;
import java.util.Collection; import java.util.Collection;
@ -145,11 +145,8 @@ public class RecipientHelper {
try { try {
final var usernameLinkUrl = UsernameLinkUrl.fromUri(username); final var usernameLinkUrl = UsernameLinkUrl.fromUri(username);
final var components = usernameLinkUrl.getComponents(); final var components = usernameLinkUrl.getComponents();
final var encryptedUsername = handleResponseException(dependencies.getUsernameApi() return handleResponseException(dependencies.getUsernameApi()
.getEncryptedUsernameFromLinkServerId(components.getServerId())); .getDecryptedUsernameFromLinkServerIdAndEntropy(components.getServerId(), components.getEntropy()));
final var link = new Username.UsernameLink(components.getEntropy(), encryptedUsername);
return Username.fromLink(link);
} catch (UsernameLinkUrl.InvalidUsernameLinkException e) { } catch (UsernameLinkUrl.InvalidUsernameLinkException e) {
return new Username(username); return new Username(username);
} }

View File

@ -15,6 +15,7 @@ import org.asamk.signal.manager.storage.groups.GroupInfo;
import org.asamk.signal.manager.storage.groups.GroupInfoV2; import org.asamk.signal.manager.storage.groups.GroupInfoV2;
import org.asamk.signal.manager.storage.recipients.RecipientId; import org.asamk.signal.manager.storage.recipients.RecipientId;
import org.asamk.signal.manager.storage.sendLog.MessageSendLogEntry; import org.asamk.signal.manager.storage.sendLog.MessageSendLogEntry;
import org.signal.core.models.ServiceId.ACI;
import org.signal.libsignal.protocol.InvalidKeyException; import org.signal.libsignal.protocol.InvalidKeyException;
import org.signal.libsignal.protocol.InvalidRegistrationIdException; import org.signal.libsignal.protocol.InvalidRegistrationIdException;
import org.signal.libsignal.protocol.NoSessionException; import org.signal.libsignal.protocol.NoSessionException;
@ -35,10 +36,10 @@ import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage;
import org.whispersystems.signalservice.api.messages.SignalServiceEditMessage; import org.whispersystems.signalservice.api.messages.SignalServiceEditMessage;
import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage; import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage;
import org.whispersystems.signalservice.api.messages.SignalServiceTypingMessage; import org.whispersystems.signalservice.api.messages.SignalServiceTypingMessage;
import org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage;
import org.whispersystems.signalservice.api.messages.multidevice.SentTranscriptMessage; import org.whispersystems.signalservice.api.messages.multidevice.SentTranscriptMessage;
import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage; import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage;
import org.whispersystems.signalservice.api.push.DistributionId; import org.whispersystems.signalservice.api.push.DistributionId;
import org.whispersystems.signalservice.api.push.ServiceId.ACI;
import org.whispersystems.signalservice.api.push.SignalServiceAddress; import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.push.exceptions.NotFoundException; import org.whispersystems.signalservice.api.push.exceptions.NotFoundException;
import org.whispersystems.signalservice.api.push.exceptions.ProofRequiredException; import org.whispersystems.signalservice.api.push.exceptions.ProofRequiredException;
@ -84,7 +85,8 @@ public class SendHelper {
public SendMessageResult sendMessage( public SendMessageResult sendMessage(
final SignalServiceDataMessage.Builder messageBuilder, final SignalServiceDataMessage.Builder messageBuilder,
final RecipientId recipientId, final RecipientId recipientId,
Optional<Long> editTargetTimestamp Optional<Long> editTargetTimestamp,
boolean urgent
) { ) {
var contact = account.getContactStore().getContact(recipientId); var contact = account.getContactStore().getContact(recipientId);
if (contact == null || !contact.isProfileSharingEnabled() || contact.isHidden()) { if (contact == null || !contact.isProfileSharingEnabled() || contact.isHidden()) {
@ -102,7 +104,7 @@ public class SendHelper {
} }
final var message = messageBuilder.build(); final var message = messageBuilder.build();
return sendMessage(message, recipientId, editTargetTimestamp); return sendMessage(message, recipientId, editTargetTimestamp, urgent);
} }
/** /**
@ -113,10 +115,11 @@ public class SendHelper {
final SignalServiceDataMessage.Builder messageBuilder, final SignalServiceDataMessage.Builder messageBuilder,
final GroupId groupId, final GroupId groupId,
final boolean includeSelf, final boolean includeSelf,
final Optional<Long> editTargetTimestamp final Optional<Long> editTargetTimestamp,
boolean urgent
) throws IOException, GroupNotFoundException, NotAGroupMemberException, GroupSendingNotAllowedException { ) throws IOException, GroupNotFoundException, NotAGroupMemberException, GroupSendingNotAllowedException {
final var g = getGroupForSending(groupId); final var g = getGroupForSending(groupId);
return sendAsGroupMessage(messageBuilder, g, includeSelf, editTargetTimestamp); return sendAsGroupMessage(messageBuilder, g, includeSelf, editTargetTimestamp, urgent);
} }
/** /**
@ -128,7 +131,7 @@ public class SendHelper {
final Set<RecipientId> recipientIds, final Set<RecipientId> recipientIds,
final GroupInfo groupInfo final GroupInfo groupInfo
) throws IOException { ) throws IOException {
return sendGroupMessage(message, recipientIds, groupInfo, ContentHint.IMPLICIT, Optional.empty()); return sendGroupMessage(message, recipientIds, groupInfo, ContentHint.IMPLICIT, Optional.empty(), true);
} }
public SendMessageResult sendReceiptMessage( public SendMessageResult sendReceiptMessage(
@ -307,17 +310,40 @@ public class SendHelper {
return result; return result;
} }
public SendMessageResult sendCallMessage(
final SignalServiceCallMessage callMessage,
final RecipientId recipientId
) {
final var messageSendLogStore = account.getMessageSendLogStore();
final var result = handleSendMessage(recipientId,
(messageSender, address, unidentifiedAccess, includePniSignature) -> messageSender.sendCallMessage(
address,
unidentifiedAccess,
callMessage));
if (callMessage.getTimestamp().isPresent()) {
messageSendLogStore.insertIfPossible(callMessage.getTimestamp().get(),
result,
ContentHint.IMPLICIT,
callMessage.isUrgent());
}
handleSendMessageResult(result);
return result;
}
private List<SendMessageResult> sendAsGroupMessage( private List<SendMessageResult> sendAsGroupMessage(
final SignalServiceDataMessage.Builder messageBuilder, final SignalServiceDataMessage.Builder messageBuilder,
final GroupInfo g, final GroupInfo g,
final boolean includeSelf, final boolean includeSelf,
final Optional<Long> editTargetTimestamp final Optional<Long> editTargetTimestamp,
boolean urgent
) throws IOException, GroupSendingNotAllowedException { ) throws IOException, GroupSendingNotAllowedException {
GroupUtils.setGroupContext(messageBuilder, g); GroupUtils.setGroupContext(messageBuilder, g);
messageBuilder.withExpiration(g.getMessageExpirationTimer()); messageBuilder.withExpiration(g.getMessageExpirationTimer());
final var message = messageBuilder.build(); final var message = messageBuilder.build();
final var recipients = includeSelf ? g.getMembers() : g.getMembersWithout(account.getSelfRecipientId()); final var recipients = includeSelf
? g.getMemberRecipientIds()
: g.getMembersWithout(account.getSelfRecipientId());
if (g.isAnnouncementGroup() && !g.isAdmin(account.getSelfRecipientId())) { if (g.isAnnouncementGroup() && !g.isAdmin(account.getSelfRecipientId())) {
if (message.getBody().isPresent() if (message.getBody().isPresent()
@ -330,7 +356,7 @@ public class SendHelper {
} }
} }
return sendGroupMessage(message, recipients, g, ContentHint.RESENDABLE, editTargetTimestamp); return sendGroupMessage(message, recipients, g, ContentHint.RESENDABLE, editTargetTimestamp, urgent);
} }
private List<SendMessageResult> sendGroupMessage( private List<SendMessageResult> sendGroupMessage(
@ -338,13 +364,13 @@ public class SendHelper {
final Set<RecipientId> recipientIds, final Set<RecipientId> recipientIds,
final GroupInfo groupInfo, final GroupInfo groupInfo,
final ContentHint contentHint, final ContentHint contentHint,
final Optional<Long> editTargetTimestamp final Optional<Long> editTargetTimestamp,
boolean urgent
) throws IOException { ) throws IOException {
final var messageSender = dependencies.getMessageSender(); final var messageSender = dependencies.getMessageSender();
final var messageSendLogStore = account.getMessageSendLogStore(); final var messageSendLogStore = account.getMessageSendLogStore();
final AtomicLong entryId = new AtomicLong(-1); final AtomicLong entryId = new AtomicLong(-1);
final var urgent = true;
final PartialSendCompleteListener partialSendCompleteListener = sendResult -> { final PartialSendCompleteListener partialSendCompleteListener = sendResult -> {
logger.trace("Partial message send result: {}", sendResult.isSuccess()); logger.trace("Partial message send result: {}", sendResult.isSuccess());
synchronized (entryId) { synchronized (entryId) {
@ -498,11 +524,11 @@ public class SendHelper {
Set<RecipientId> senderKeyTargets = groupInfo.getDistributionId() == null || groupSendEndorsements == null Set<RecipientId> senderKeyTargets = groupInfo.getDistributionId() == null || groupSendEndorsements == null
? Set.of() ? Set.of()
: recipientIds.stream() : recipientIds.stream()
.filter(s -> this.isSenderKeyCapable(s, .filter(s -> this.isSenderKeyCapable(s,
addressesMap.get(s), addressesMap.get(s),
unidentifiedAccessesMap.get(s), unidentifiedAccessesMap.get(s),
groupSendEndorsements)) groupSendEndorsements))
.collect(Collectors.toSet()); .collect(Collectors.toSet());
if (senderKeyTargets.size() < 2) { if (senderKeyTargets.size() < 2) {
logger.debug("Too few sender-key-capable users ({}). Doing all legacy sends.", senderKeyTargets.size()); logger.debug("Too few sender-key-capable users ({}). Doing all legacy sends.", senderKeyTargets.size());
senderKeyTargets = Set.of(); senderKeyTargets = Set.of();
@ -564,11 +590,11 @@ public class SendHelper {
final var expirationMs = Instant.ofEpochMilli(groupSendEndorsementsExpirationMs); final var expirationMs = Instant.ofEpochMilli(groupSendEndorsementsExpirationMs);
final var groupSendTokens = groupSendEndorsements != null && groupSecretParams != null final var groupSendTokens = groupSendEndorsements != null && groupSecretParams != null
? legacyTargets.stream() ? legacyTargets.stream()
.map(groupSendEndorsements::get) .map(groupSendEndorsements::get)
.map(endorsement -> Optional.ofNullable(endorsement) .map(endorsement -> Optional.ofNullable(endorsement)
.map(e -> e.toFullToken(groupSecretParams, expirationMs)) .map(e -> e.toFullToken(groupSecretParams, expirationMs))
.orElse(null)) .orElse(null))
.toList() .toList()
: null; : null;
final var sealedSenderAccesses = SealedSenderAccess.forFanOutGroupSend(groupSendTokens, final var sealedSenderAccesses = SealedSenderAccess.forFanOutGroupSend(groupSendTokens,
senderCertificate, senderCertificate,
@ -712,10 +738,10 @@ public class SendHelper {
private SendMessageResult sendMessage( private SendMessageResult sendMessage(
SignalServiceDataMessage message, SignalServiceDataMessage message,
RecipientId recipientId, RecipientId recipientId,
Optional<Long> editTargetTimestamp Optional<Long> editTargetTimestamp,
boolean urgent
) { ) {
final var messageSendLogStore = account.getMessageSendLogStore(); final var messageSendLogStore = account.getMessageSendLogStore();
final var urgent = true;
final var result = handleSendMessage(recipientId, final var result = handleSendMessage(recipientId,
editTargetTimestamp.isEmpty() editTargetTimestamp.isEmpty()
? (messageSender, address, unidentifiedAccess, includePniSignature) -> messageSender.sendDataMessage( ? (messageSender, address, unidentifiedAccess, includePniSignature) -> messageSender.sendDataMessage(

View File

@ -7,10 +7,10 @@ import org.asamk.signal.manager.storage.SignalAccount;
import org.asamk.signal.manager.storage.stickerPacks.JsonStickerPack; import org.asamk.signal.manager.storage.stickerPacks.JsonStickerPack;
import org.asamk.signal.manager.storage.stickers.StickerPack; import org.asamk.signal.manager.storage.stickers.StickerPack;
import org.asamk.signal.manager.util.IOUtils; import org.asamk.signal.manager.util.IOUtils;
import org.signal.core.util.Hex;
import org.signal.libsignal.protocol.InvalidMessageException; import org.signal.libsignal.protocol.InvalidMessageException;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.internal.util.Hex;
import java.io.IOException; import java.io.IOException;
import java.util.HashSet; import java.util.HashSet;

View File

@ -2,6 +2,7 @@ package org.asamk.signal.manager.helper;
import org.asamk.signal.manager.api.GroupIdV1; import org.asamk.signal.manager.api.GroupIdV1;
import org.asamk.signal.manager.api.GroupIdV2; import org.asamk.signal.manager.api.GroupIdV2;
import org.asamk.signal.manager.api.Pair;
import org.asamk.signal.manager.api.Profile; import org.asamk.signal.manager.api.Profile;
import org.asamk.signal.manager.internal.SignalDependencies; import org.asamk.signal.manager.internal.SignalDependencies;
import org.asamk.signal.manager.storage.SignalAccount; import org.asamk.signal.manager.storage.SignalAccount;
@ -14,8 +15,12 @@ import org.asamk.signal.manager.syncStorage.StorageSyncModels;
import org.asamk.signal.manager.syncStorage.StorageSyncValidations; import org.asamk.signal.manager.syncStorage.StorageSyncValidations;
import org.asamk.signal.manager.syncStorage.WriteOperationResult; import org.asamk.signal.manager.syncStorage.WriteOperationResult;
import org.asamk.signal.manager.util.KeyUtils; import org.asamk.signal.manager.util.KeyUtils;
import org.signal.core.models.storageservice.StorageKey;
import org.signal.core.util.SetUtil; import org.signal.core.util.SetUtil;
import org.signal.libsignal.protocol.InvalidKeyException; import org.signal.libsignal.protocol.InvalidKeyException;
import org.signal.network.service.StorageServiceService;
import org.signal.network.service.StorageServiceService.ManifestIfDifferentVersionResult;
import org.signal.network.service.StorageServiceService.WriteStorageRecordsResult;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.push.exceptions.NotFoundException; import org.whispersystems.signalservice.api.push.exceptions.NotFoundException;
@ -23,11 +28,7 @@ import org.whispersystems.signalservice.api.storage.RecordIkm;
import org.whispersystems.signalservice.api.storage.SignalStorageManifest; import org.whispersystems.signalservice.api.storage.SignalStorageManifest;
import org.whispersystems.signalservice.api.storage.SignalStorageRecord; import org.whispersystems.signalservice.api.storage.SignalStorageRecord;
import org.whispersystems.signalservice.api.storage.StorageId; import org.whispersystems.signalservice.api.storage.StorageId;
import org.whispersystems.signalservice.api.storage.StorageKey;
import org.whispersystems.signalservice.api.storage.StorageRecordConvertersKt; import org.whispersystems.signalservice.api.storage.StorageRecordConvertersKt;
import org.whispersystems.signalservice.api.storage.StorageServiceRepository;
import org.whispersystems.signalservice.api.storage.StorageServiceRepository.ManifestIfDifferentVersionResult;
import org.whispersystems.signalservice.api.storage.StorageServiceRepository.WriteStorageRecordsResult;
import org.whispersystems.signalservice.internal.storage.protos.ManifestRecord; import org.whispersystems.signalservice.internal.storage.protos.ManifestRecord;
import org.whispersystems.signalservice.internal.storage.protos.StorageRecord; import org.whispersystems.signalservice.internal.storage.protos.StorageRecord;
@ -38,6 +39,7 @@ import java.util.ArrayList;
import java.util.Base64; import java.util.Base64;
import java.util.Collection; import java.util.Collection;
import java.util.Collections; import java.util.Collections;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@ -211,10 +213,15 @@ public class StorageHelper {
remoteOnlyRecords.size()); remoteOnlyRecords.size());
} }
final var listListPair = processKnownRecords(connection, remoteOnlyRecords);
final var unknownInserts = listListPair.first();
final var updatedStorageIds = listListPair.second();
final var oldUnregisteredLocalOnlyIds = new HashSet<>(idDifference.localOnlyIds());
updatedStorageIds.forEach(oldUnregisteredLocalOnlyIds::remove);
if (!idDifference.localOnlyIds().isEmpty()) { if (!idDifference.localOnlyIds().isEmpty()) {
final var updated = account.getRecipientStore() final var updated = account.getRecipientStore()
.removeStorageIdsFromLocalOnlyUnregisteredRecipients(connection, .removeStorageIdsFromLocalOnlyUnregisteredRecipients(connection,
idDifference.localOnlyIds()); oldUnregisteredLocalOnlyIds);
if (updated > 0) { if (updated > 0) {
logger.warn( logger.warn(
@ -223,7 +230,6 @@ public class StorageHelper {
} }
} }
final var unknownInserts = processKnownRecords(connection, remoteOnlyRecords);
final var unknownDeletes = idDifference.localOnlyIds() final var unknownDeletes = idDifference.localOnlyIds()
.stream() .stream()
.filter(id -> !KNOWN_TYPES.contains(id.getType())) .filter(id -> !KNOWN_TYPES.contains(id.getType()))
@ -479,13 +485,13 @@ public class StorageHelper {
private Map<GroupIdV1, StorageId> generateGroupV1StorageIds(List<GroupIdV1> groupIds) { private Map<GroupIdV1, StorageId> generateGroupV1StorageIds(List<GroupIdV1> groupIds) {
return groupIds.stream() return groupIds.stream()
.collect(Collectors.toMap(recipientId -> recipientId, .collect(Collectors.toMap(recipientId -> recipientId,
recipientId -> StorageId.forGroupV1(KeyUtils.createRawStorageId()))); _ -> StorageId.forGroupV1(KeyUtils.createRawStorageId())));
} }
private Map<GroupIdV2, StorageId> generateGroupV2StorageIds(List<GroupIdV2> groupIds) { private Map<GroupIdV2, StorageId> generateGroupV2StorageIds(List<GroupIdV2> groupIds) {
return groupIds.stream() return groupIds.stream()
.collect(Collectors.toMap(recipientId -> recipientId, .collect(Collectors.toMap(recipientId -> recipientId,
recipientId -> StorageId.forGroupV2(KeyUtils.createRawStorageId()))); _ -> StorageId.forGroupV2(KeyUtils.createRawStorageId())));
} }
private void storeManifestLocally( private void storeManifestLocally(
@ -503,7 +509,7 @@ public class StorageHelper {
final var result = dependencies.getStorageServiceRepository() final var result = dependencies.getStorageServiceRepository()
.readStorageRecords(storageKey, manifest.recordIkm, storageIds); .readStorageRecords(storageKey, manifest.recordIkm, storageIds);
return switch (result) { return switch (result) {
case StorageServiceRepository.StorageRecordResult.DecryptionError decryptionError -> { case StorageServiceService.StorageRecordResult.DecryptionError decryptionError -> {
if (decryptionError.getException() instanceof InvalidKeyException) { if (decryptionError.getException() instanceof InvalidKeyException) {
logger.warn("Failed to read storage records, ignoring."); logger.warn("Failed to read storage records, ignoring.");
yield List.of(); yield List.of();
@ -513,11 +519,11 @@ public class StorageHelper {
throw new IOException(decryptionError.getException()); throw new IOException(decryptionError.getException());
} }
} }
case StorageServiceRepository.StorageRecordResult.NetworkError networkError -> case StorageServiceService.StorageRecordResult.NetworkError networkError ->
throw networkError.getException(); throw networkError.getException();
case StorageServiceRepository.StorageRecordResult.StatusCodeError statusCodeError -> case StorageServiceService.StorageRecordResult.StatusCodeError statusCodeError ->
throw statusCodeError.getException(); throw statusCodeError.getException();
case StorageServiceRepository.StorageRecordResult.Success success -> success.getRecords(); case StorageServiceService.StorageRecordResult.Success success -> success.getRecords();
default -> throw new IllegalStateException("Unexpected value: " + result); default -> throw new IllegalStateException("Unexpected value: " + result);
}; };
} }
@ -629,16 +635,17 @@ public class StorageHelper {
return new IdDifferenceResult(remoteOnlyKeys, localOnlyKeys, hasTypeMismatch); return new IdDifferenceResult(remoteOnlyKeys, localOnlyKeys, hasTypeMismatch);
} }
private List<StorageId> processKnownRecords( private Pair<List<StorageId>, List<StorageId>> processKnownRecords(
final Connection connection, final Connection connection,
List<SignalStorageRecord> records List<SignalStorageRecord> records
) throws SQLException { ) throws SQLException {
final var unknownRecords = new ArrayList<StorageId>(); final var unknownRecords = new ArrayList<StorageId>();
final var processedRecords = new ArrayList<StorageId>();
final var accountRecordProcessor = new AccountRecordProcessor(account, connection, context.getJobExecutor()); final var accountRecordProcessor = new AccountRecordProcessor(account, connection, context.getJobExecutor());
final var contactRecordProcessor = new ContactRecordProcessor(account, connection, context.getJobExecutor());
final var groupV1RecordProcessor = new GroupV1RecordProcessor(account, connection); final var groupV1RecordProcessor = new GroupV1RecordProcessor(account, connection);
final var groupV2RecordProcessor = new GroupV2RecordProcessor(account, connection); final var groupV2RecordProcessor = new GroupV2RecordProcessor(account, connection);
final var contactRecordProcessor = new ContactRecordProcessor(account, connection, context.getJobExecutor());
for (final var record : records) { for (final var record : records) {
if (record.getProto().account != null) { if (record.getProto().account != null) {
@ -661,8 +668,12 @@ public class StorageHelper {
unknownRecords.add(record.getId()); unknownRecords.add(record.getId());
} }
} }
processedRecords.addAll(accountRecordProcessor.getUpdatedStorageIds());
processedRecords.addAll(groupV1RecordProcessor.getUpdatedStorageIds());
processedRecords.addAll(groupV2RecordProcessor.getUpdatedStorageIds());
processedRecords.addAll(contactRecordProcessor.getUpdatedStorageIds());
return unknownRecords; return new Pair<>(unknownRecords, processedRecords);
} }
/** /**

View File

@ -12,6 +12,7 @@ import org.asamk.signal.manager.storage.stickers.StickerPack;
import org.asamk.signal.manager.util.IOUtils; import org.asamk.signal.manager.util.IOUtils;
import org.asamk.signal.manager.util.MimeUtils; import org.asamk.signal.manager.util.MimeUtils;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import org.signal.core.models.ServiceId;
import org.signal.libsignal.protocol.IdentityKey; import org.signal.libsignal.protocol.IdentityKey;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@ -36,8 +37,8 @@ import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSy
import org.whispersystems.signalservice.api.messages.multidevice.StickerPackOperationMessage; import org.whispersystems.signalservice.api.messages.multidevice.StickerPackOperationMessage;
import org.whispersystems.signalservice.api.messages.multidevice.VerifiedMessage; import org.whispersystems.signalservice.api.messages.multidevice.VerifiedMessage;
import org.whispersystems.signalservice.api.messages.multidevice.ViewedMessage; import org.whispersystems.signalservice.api.messages.multidevice.ViewedMessage;
import org.whispersystems.signalservice.api.push.ServiceId;
import org.whispersystems.signalservice.api.push.SignalServiceAddress; import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.util.StreamDetails;
import org.whispersystems.signalservice.internal.push.SyncMessage; import org.whispersystems.signalservice.internal.push.SyncMessage;
import java.io.FileInputStream; import java.io.FileInputStream;
@ -52,6 +53,8 @@ import java.util.Optional;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.util.stream.Stream; import java.util.stream.Stream;
import okio.ByteString;
public class SyncHelper { public class SyncHelper {
private static final Logger logger = LoggerFactory.getLogger(SyncHelper.class); private static final Logger logger = LoggerFactory.getLogger(SyncHelper.class);
@ -112,7 +115,7 @@ public class SyncHelper {
if (record instanceof GroupInfoV1 groupInfo) { if (record instanceof GroupInfoV1 groupInfo) {
out.write(new DeviceGroup(groupInfo.getGroupId().serialize(), out.write(new DeviceGroup(groupInfo.getGroupId().serialize(),
Optional.ofNullable(groupInfo.name), Optional.ofNullable(groupInfo.name),
groupInfo.getMembers() groupInfo.getMemberRecipientIds()
.stream() .stream()
.map(context.getRecipientHelper()::resolveSignalServiceAddress) .map(context.getRecipientHelper()::resolveSignalServiceAddress)
.toList(), .toList(),
@ -129,11 +132,14 @@ public class SyncHelper {
if (groupsFile.exists() && groupsFile.length() > 0) { if (groupsFile.exists() && groupsFile.length() > 0) {
try (var groupsFileStream = new FileInputStream(groupsFile)) { try (var groupsFileStream = new FileInputStream(groupsFile)) {
final var uploadSpec = context.getDependencies().getMessageSender().getResumableUploadSpec(); final var streamDetails = new StreamDetails(groupsFileStream,
MimeUtils.OCTET_STREAM,
groupsFile.length());
final var uploadSpec = context.getAttachmentHelper().getResumableUploadSpec(streamDetails);
var attachmentStream = SignalServiceAttachment.newStreamBuilder() var attachmentStream = SignalServiceAttachment.newStreamBuilder()
.withStream(groupsFileStream) .withStream(streamDetails.getStream())
.withContentType(MimeUtils.OCTET_STREAM) .withContentType(streamDetails.getContentType())
.withLength(groupsFile.length()) .withLength(streamDetails.getLength())
.withResumableUploadSpec(uploadSpec) .withResumableUploadSpec(uploadSpec)
.build(); .build();
@ -154,7 +160,7 @@ public class SyncHelper {
try { try {
try (OutputStream fos = new FileOutputStream(contactsFile)) { try (OutputStream fos = new FileOutputStream(contactsFile)) {
var out = new DeviceContactsOutputStream(fos, false, true); var out = new DeviceContactsOutputStream(fos);
for (var contactPair : account.getContactStore().getContacts()) { for (var contactPair : account.getContactStore().getContacts()) {
final var recipientId = contactPair.first(); final var recipientId = contactPair.first();
final var contact = contactPair.second(); final var contact = contactPair.second();
@ -188,11 +194,14 @@ public class SyncHelper {
if (contactsFile.exists() && contactsFile.length() > 0) { if (contactsFile.exists() && contactsFile.length() > 0) {
try (var contactsFileStream = new FileInputStream(contactsFile)) { try (var contactsFileStream = new FileInputStream(contactsFile)) {
final var uploadSpec = context.getDependencies().getMessageSender().getResumableUploadSpec(); final var streamDetails = new StreamDetails(contactsFileStream,
MimeUtils.OCTET_STREAM,
contactsFile.length());
final var uploadSpec = context.getAttachmentHelper().getResumableUploadSpec(streamDetails);
var attachmentStream = SignalServiceAttachment.newStreamBuilder() var attachmentStream = SignalServiceAttachment.newStreamBuilder()
.withStream(contactsFileStream) .withStream(streamDetails.getStream())
.withContentType(MimeUtils.OCTET_STREAM) .withContentType(streamDetails.getContentType())
.withLength(contactsFile.length()) .withLength(streamDetails.getLength())
.withResumableUploadSpec(uploadSpec) .withResumableUploadSpec(uploadSpec)
.build(); .build();
@ -256,7 +265,6 @@ public class SyncHelper {
public SendMessageResult sendKeysMessage() { public SendMessageResult sendKeysMessage() {
var keysMessage = new KeysMessage(account.getOrCreateStorageKey(), var keysMessage = new KeysMessage(account.getOrCreateStorageKey(),
account.getOrCreatePinMasterKey(),
account.getOrCreateAccountEntropyPool(), account.getOrCreateAccountEntropyPool(),
account.getOrCreateMediaRootBackupKey()); account.getOrCreateMediaRootBackupKey());
return context.getSendHelper().sendSyncMessage(SignalServiceSyncMessage.forKeys(keysMessage)); return context.getSendHelper().sendSyncMessage(SignalServiceSyncMessage.forKeys(keysMessage));
@ -291,7 +299,7 @@ public class SyncHelper {
return context.getSendHelper().sendSyncMessage(SignalServiceSyncMessage.forConfiguration(configurationMessage)); return context.getSendHelper().sendSyncMessage(SignalServiceSyncMessage.forConfiguration(configurationMessage));
} }
public void handleSyncDeviceGroups(final InputStream input) { public void handleSyncDeviceGroups(final InputStream input, final boolean ignoreAvatars) {
final var s = new DeviceGroupsInputStream(input); final var s = new DeviceGroupsInputStream(input);
DeviceGroup g; DeviceGroup g;
while (true) { while (true) {
@ -324,7 +332,7 @@ public class SyncHelper {
syncGroup.color = g.getColor().get(); syncGroup.color = g.getColor().get();
} }
if (g.getAvatar().isPresent()) { if (g.getAvatar().isPresent() && !ignoreAvatars) {
context.getGroupHelper().downloadGroupAvatar(syncGroup.getGroupId(), g.getAvatar().get()); context.getGroupHelper().downloadGroupAvatar(syncGroup.getGroupId(), g.getAvatar().get());
} }
syncGroup.archived = g.isArchived(); syncGroup.archived = g.isArchived();
@ -333,7 +341,7 @@ public class SyncHelper {
} }
} }
public void handleSyncDeviceContacts(final InputStream input) throws IOException { public void handleSyncDeviceContacts(final InputStream input, final boolean ignoreAvatars) throws IOException {
final var s = new DeviceContactsInputStream(input); final var s = new DeviceContactsInputStream(input);
DeviceContact c; DeviceContact c;
while (true) { while (true) {
@ -379,7 +387,11 @@ public class SyncHelper {
account.getContactStore().storeContact(recipientId, builder.build()); account.getContactStore().storeContact(recipientId, builder.build());
if (c.getAvatar().isPresent()) { if (c.getAvatar().isPresent()) {
storeContactAvatar(c.getAvatar().get(), address); if (!ignoreAvatars) {
storeContactAvatar(c.getAvatar().get(), address);
} else {
IOUtils.discardStream(c.getAvatar().get().getInputStream());
}
} }
} }
} }
@ -406,6 +418,11 @@ public class SyncHelper {
return context.getSendHelper().sendSyncMessage(SignalServiceSyncMessage.forMessageRequestResponse(response)); return context.getSendHelper().sendSyncMessage(SignalServiceSyncMessage.forMessageRequestResponse(response));
} }
public SendMessageResult sendDeviceNameChange(final int deviceId) {
final var deviceNameChange = new SyncMessage.DeviceNameChange(deviceId, ByteString.EMPTY);
return context.getSendHelper().sendSyncMessage(SignalServiceSyncMessage.forDeviceNameChange(deviceNameChange));
}
private SendMessageResult requestSyncData(final SyncMessage.Request.Type type) { private SendMessageResult requestSyncData(final SyncMessage.Request.Type type) {
var r = new SyncMessage.Request.Builder().type(type).build(); var r = new SyncMessage.Request.Builder().type(type).build();
var message = SignalServiceSyncMessage.forRequest(new RequestMessage(r)); var message = SignalServiceSyncMessage.forRequest(new RequestMessage(r));

View File

@ -2,7 +2,7 @@ package org.asamk.signal.manager.internal;
import org.asamk.signal.manager.helper.AccountFileUpdater; import org.asamk.signal.manager.helper.AccountFileUpdater;
import org.asamk.signal.manager.storage.accounts.AccountsStore; import org.asamk.signal.manager.storage.accounts.AccountsStore;
import org.whispersystems.signalservice.api.push.ServiceId.ACI; import org.signal.core.models.ServiceId.ACI;
public class AccountFileUpdaterImpl implements AccountFileUpdater { public class AccountFileUpdaterImpl implements AccountFileUpdater {

View File

@ -20,7 +20,7 @@ public class JobExecutor implements AutoCloseable {
public JobExecutor(final Context context) { public JobExecutor(final Context context) {
this.context = context; this.context = context;
this.executorService = Executors.newCachedThreadPool(); this.executorService = Executors.newVirtualThreadPerTaskExecutor();
} }
public void enqueueJob(Job job) { public void enqueueJob(Job job) {

View File

@ -19,6 +19,8 @@ package org.asamk.signal.manager.internal;
import org.asamk.signal.manager.Manager; import org.asamk.signal.manager.Manager;
import org.asamk.signal.manager.api.AlreadyReceivingException; import org.asamk.signal.manager.api.AlreadyReceivingException;
import org.asamk.signal.manager.api.AttachmentInvalidException; import org.asamk.signal.manager.api.AttachmentInvalidException;
import org.asamk.signal.manager.api.CallInfo;
import org.asamk.signal.manager.api.CallOffer;
import org.asamk.signal.manager.api.CaptchaRejectedException; import org.asamk.signal.manager.api.CaptchaRejectedException;
import org.asamk.signal.manager.api.CaptchaRequiredException; import org.asamk.signal.manager.api.CaptchaRequiredException;
import org.asamk.signal.manager.api.Configuration; import org.asamk.signal.manager.api.Configuration;
@ -62,6 +64,7 @@ import org.asamk.signal.manager.api.StickerPackId;
import org.asamk.signal.manager.api.StickerPackInvalidException; import org.asamk.signal.manager.api.StickerPackInvalidException;
import org.asamk.signal.manager.api.StickerPackUrl; import org.asamk.signal.manager.api.StickerPackUrl;
import org.asamk.signal.manager.api.TextStyle; import org.asamk.signal.manager.api.TextStyle;
import org.asamk.signal.manager.api.TurnServer;
import org.asamk.signal.manager.api.TypingAction; import org.asamk.signal.manager.api.TypingAction;
import org.asamk.signal.manager.api.UnregisteredRecipientException; import org.asamk.signal.manager.api.UnregisteredRecipientException;
import org.asamk.signal.manager.api.UpdateGroup; import org.asamk.signal.manager.api.UpdateGroup;
@ -91,9 +94,14 @@ import org.asamk.signal.manager.util.KeyUtils;
import org.asamk.signal.manager.util.MimeUtils; import org.asamk.signal.manager.util.MimeUtils;
import org.asamk.signal.manager.util.PhoneNumberFormatter; import org.asamk.signal.manager.util.PhoneNumberFormatter;
import org.asamk.signal.manager.util.StickerUtils; import org.asamk.signal.manager.util.StickerUtils;
import org.signal.core.models.ServiceId;
import org.signal.core.models.ServiceId.ACI;
import org.signal.core.models.ServiceId.PNI;
import org.signal.core.util.Base64; import org.signal.core.util.Base64;
import org.signal.core.util.Hex;
import org.signal.libsignal.protocol.InvalidMessageException; import org.signal.libsignal.protocol.InvalidMessageException;
import org.signal.libsignal.usernames.BaseUsernameException; import org.signal.libsignal.usernames.BaseUsernameException;
import org.signal.network.exceptions.NonSuccessfulResponseCodeException;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment; import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
@ -101,17 +109,17 @@ import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage;
import org.whispersystems.signalservice.api.messages.SignalServicePreview; import org.whispersystems.signalservice.api.messages.SignalServicePreview;
import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage; import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage;
import org.whispersystems.signalservice.api.messages.SignalServiceTypingMessage; import org.whispersystems.signalservice.api.messages.SignalServiceTypingMessage;
import org.whispersystems.signalservice.api.messages.calls.AnswerMessage;
import org.whispersystems.signalservice.api.messages.calls.BusyMessage;
import org.whispersystems.signalservice.api.messages.calls.HangupMessage;
import org.whispersystems.signalservice.api.messages.calls.IceUpdateMessage;
import org.whispersystems.signalservice.api.messages.calls.OfferMessage;
import org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage;
import org.whispersystems.signalservice.api.messages.multidevice.DeviceInfo; import org.whispersystems.signalservice.api.messages.multidevice.DeviceInfo;
import org.whispersystems.signalservice.api.push.ServiceId;
import org.whispersystems.signalservice.api.push.ServiceId.ACI;
import org.whispersystems.signalservice.api.push.ServiceId.PNI;
import org.whispersystems.signalservice.api.push.ServiceIdType; import org.whispersystems.signalservice.api.push.ServiceIdType;
import org.whispersystems.signalservice.api.push.exceptions.CdsiResourceExhaustedException; import org.whispersystems.signalservice.api.push.exceptions.CdsiResourceExhaustedException;
import org.whispersystems.signalservice.api.push.exceptions.UsernameMalformedException;
import org.whispersystems.signalservice.api.push.exceptions.UsernameTakenException;
import org.whispersystems.signalservice.api.util.DeviceNameUtil; import org.whispersystems.signalservice.api.util.DeviceNameUtil;
import org.whispersystems.signalservice.api.util.StreamDetails; import org.whispersystems.signalservice.api.util.StreamDetails;
import org.whispersystems.signalservice.internal.util.Hex;
import org.whispersystems.signalservice.internal.util.Util; import org.whispersystems.signalservice.internal.util.Util;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
@ -157,7 +165,7 @@ public class ManagerImpl implements Manager {
private final SignalDependencies dependencies; private final SignalDependencies dependencies;
private final Context context; private final Context context;
private final ExecutorService executor = Executors.newCachedThreadPool(); private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
private Thread receiveThread; private Thread receiveThread;
private boolean isReceivingSynchronous; private boolean isReceivingSynchronous;
@ -182,6 +190,7 @@ public class ManagerImpl implements Manager {
userAgent, userAgent,
account.getCredentialsProvider(), account.getCredentialsProvider(),
account.getSignalServiceDataStore(), account.getSignalServiceDataStore(),
account.getDeviceId(),
executor, executor,
sessionLock); sessionLock);
final var avatarStore = new AvatarStore(pathConfig.avatarsPath()); final var avatarStore = new AvatarStore(pathConfig.avatarsPath());
@ -270,7 +279,7 @@ public class ManagerImpl implements Manager {
registeredUsers = context.getRecipientHelper().getRegisteredUsers(canonicalizedNumbersSet); registeredUsers = context.getRecipientHelper().getRegisteredUsers(canonicalizedNumbersSet);
} catch (CdsiResourceExhaustedException e) { } catch (CdsiResourceExhaustedException e) {
logger.debug("CDSI resource exhausted: {}", e.getMessage()); logger.debug("CDSI resource exhausted: {}", e.getMessage());
throw new RateLimitException(System.currentTimeMillis() + e.getRetryAfterSeconds() * 1000L); throw new RateLimitException(e.getRetryAfterSeconds() * 1000L);
} }
return numbers.stream().collect(Collectors.toMap(n -> n, n -> { return numbers.stream().collect(Collectors.toMap(n -> n, n -> {
@ -280,7 +289,7 @@ public class ManagerImpl implements Manager {
final var profile = serviceId == null final var profile = serviceId == null
? null ? null
: context.getProfileHelper() : context.getProfileHelper()
.getRecipientProfile(account.getRecipientResolver().resolveRecipient(serviceId)); .getRecipientProfile(account.getRecipientResolver().resolveRecipient(serviceId));
return new UserStatus(number.isEmpty() ? null : number, return new UserStatus(number.isEmpty() ? null : number,
serviceId == null ? null : serviceId.getRawUuid(), serviceId == null ? null : serviceId.getRawUuid(),
profile != null profile != null
@ -307,7 +316,7 @@ public class ManagerImpl implements Manager {
final var profile = serviceId == null final var profile = serviceId == null
? null ? null
: context.getProfileHelper() : context.getProfileHelper()
.getRecipientProfile(account.getRecipientResolver().resolveRecipient(serviceId)); .getRecipientProfile(account.getRecipientResolver().resolveRecipient(serviceId));
return new UsernameStatus(username, return new UsernameStatus(username,
serviceId == null ? null : serviceId.getRawUuid(), serviceId == null ? null : serviceId.getRawUuid(),
profile != null profile != null
@ -402,10 +411,8 @@ public class ManagerImpl implements Manager {
} else { } else {
context.getAccountHelper().reserveUsernameFromNickname(username); context.getAccountHelper().reserveUsernameFromNickname(username);
} }
} catch (UsernameMalformedException e) { } catch (NonSuccessfulResponseCodeException e) {
throw new InvalidUsernameException("Username is malformed", e); throw new InvalidUsernameException("Username is malformed or already taken", e);
} catch (UsernameTakenException e) {
throw new InvalidUsernameException("Username is already registered", e);
} catch (BaseUsernameException e) { } catch (BaseUsernameException e) {
throw new InvalidUsernameException(e.getMessage() + " (" + e.getClass().getSimpleName() + ")", e); throw new InvalidUsernameException(e.getMessage() + " (" + e.getClass().getSimpleName() + ")", e);
} }
@ -487,6 +494,21 @@ public class ManagerImpl implements Manager {
}).toList(); }).toList();
} }
@Override
public void updateLinkedDevice(
final int deviceId,
final String name
) throws IOException, NotPrimaryDeviceException {
if (deviceId == account.getDeviceId()) {
context.getAccountHelper().setDeviceName(name);
} else {
if (!account.isPrimaryDevice()) {
throw new NotPrimaryDeviceException();
}
context.getAccountHelper().setDeviceName(deviceId, name);
}
}
private Long getPlaintextCreatedAt(DeviceInfo d) { private Long getPlaintextCreatedAt(DeviceInfo d) {
final var DECRYPTION_INFO = "deviceCreatedAt"; final var DECRYPTION_INFO = "deviceCreatedAt";
var identityKey = account.getAciIdentityKeyPair().getPrivateKey(); var identityKey = account.getAciIdentityKeyPair().getPrivateKey();
@ -618,7 +640,9 @@ public class ManagerImpl implements Manager {
updateGroup.getEditDetailsPermission(), updateGroup.getEditDetailsPermission(),
updateGroup.getAvatarFile(), updateGroup.getAvatarFile(),
updateGroup.getExpirationTimer(), updateGroup.getExpirationTimer(),
updateGroup.getIsAnnouncementGroup()); updateGroup.getIsAnnouncementGroup(),
updateGroup.getLabelEmoji(),
updateGroup.getLabelString());
} }
@Override @Override
@ -651,14 +675,15 @@ public class ManagerImpl implements Manager {
Set<RecipientIdentifier> recipients, Set<RecipientIdentifier> recipients,
boolean notifySelf boolean notifySelf
) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException { ) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException {
return sendMessage(messageBuilder, recipients, notifySelf, Optional.empty()); return sendMessage(messageBuilder, recipients, notifySelf, Optional.empty(), true);
} }
private SendMessageResults sendMessage( private SendMessageResults sendMessage(
SignalServiceDataMessage.Builder messageBuilder, SignalServiceDataMessage.Builder messageBuilder,
Set<RecipientIdentifier> recipients, Set<RecipientIdentifier> recipients,
boolean notifySelf, boolean notifySelf,
Optional<Long> editTargetTimestamp Optional<Long> editTargetTimestamp,
boolean urgent
) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException { ) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException {
var results = new HashMap<RecipientIdentifier, List<SendMessageResult>>(); var results = new HashMap<RecipientIdentifier, List<SendMessageResult>>();
long timestamp = getNextMessageTimestamp(); long timestamp = getNextMessageTimestamp();
@ -670,22 +695,25 @@ public class ManagerImpl implements Manager {
)) { )) {
final var result = notifySelf final var result = notifySelf
? context.getSendHelper() ? context.getSendHelper()
.sendMessage(messageBuilder, account.getSelfRecipientId(), editTargetTimestamp) .sendMessage(messageBuilder,
account.getSelfRecipientId(),
editTargetTimestamp,
urgent)
: context.getSendHelper().sendSelfMessage(messageBuilder, editTargetTimestamp); : context.getSendHelper().sendSelfMessage(messageBuilder, editTargetTimestamp);
results.put(recipient, List.of(toSendMessageResult(result))); results.put(recipient, List.of(toSendMessageResult(result)));
} else if (recipient instanceof RecipientIdentifier.Single single) { } else if (recipient instanceof RecipientIdentifier.Single single) {
try { try {
final var recipientId = context.getRecipientHelper().resolveRecipient(single); final var recipientId = context.getRecipientHelper().resolveRecipient(single);
final var result = context.getSendHelper() final var result = context.getSendHelper()
.sendMessage(messageBuilder, recipientId, editTargetTimestamp); .sendMessage(messageBuilder, recipientId, editTargetTimestamp, urgent);
results.put(recipient, List.of(toSendMessageResult(result))); results.put(recipient, List.of(toSendMessageResult(result)));
} catch (UnregisteredRecipientException e) { } catch (UnregisteredRecipientException e) {
results.put(recipient, results.put(recipient,
List.of(SendMessageResult.unregisteredFailure(single.toPartialRecipientAddress()))); List.of(SendMessageResult.unregisteredFailure(single.toPartialRecipientAddress())));
} }
} else if (recipient instanceof RecipientIdentifier.Group group) { } else if (recipient instanceof RecipientIdentifier.Group(GroupId groupId)) {
final var result = context.getSendHelper() final var result = context.getSendHelper()
.sendAsGroupMessage(messageBuilder, group.groupId(), notifySelf, editTargetTimestamp); .sendAsGroupMessage(messageBuilder, groupId, notifySelf, editTargetTimestamp, urgent);
results.put(recipient, result.stream().map(this::toSendMessageResult).toList()); results.put(recipient, result.stream().map(this::toSendMessageResult).toList());
} }
} }
@ -784,7 +812,7 @@ public class ManagerImpl implements Manager {
} }
final var messageBuilder = SignalServiceDataMessage.newBuilder(); final var messageBuilder = SignalServiceDataMessage.newBuilder();
applyMessage(messageBuilder, message); applyMessage(messageBuilder, message);
return sendMessage(messageBuilder, recipients, notifySelf); return sendMessage(messageBuilder, recipients, notifySelf, Optional.empty(), message.urgent());
} }
@Override @Override
@ -795,7 +823,7 @@ public class ManagerImpl implements Manager {
) throws IOException, AttachmentInvalidException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException, UnregisteredRecipientException, InvalidStickerException { ) throws IOException, AttachmentInvalidException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException, UnregisteredRecipientException, InvalidStickerException {
final var messageBuilder = SignalServiceDataMessage.newBuilder(); final var messageBuilder = SignalServiceDataMessage.newBuilder();
applyMessage(messageBuilder, message); applyMessage(messageBuilder, message);
return sendMessage(messageBuilder, recipients, false, Optional.of(editTargetTimestamp)); return sendMessage(messageBuilder, recipients, false, Optional.of(editTargetTimestamp), message.urgent());
} }
private void applyMessage( private void applyMessage(
@ -809,10 +837,10 @@ public class ManagerImpl implements Manager {
final var remainder = result.getSecond(); final var remainder = result.getSecond();
if (remainder != null) { if (remainder != null) {
final var messageBytes = message.messageText().getBytes(StandardCharsets.UTF_8); final var messageBytes = message.messageText().getBytes(StandardCharsets.UTF_8);
final var uploadSpec = dependencies.getMessageSender().getResumableUploadSpec();
final var streamDetails = new StreamDetails(new ByteArrayInputStream(messageBytes), final var streamDetails = new StreamDetails(new ByteArrayInputStream(messageBytes),
MimeUtils.LONG_TEXT, MimeUtils.LONG_TEXT,
messageBytes.length); messageBytes.length);
final var uploadSpec = context.getAttachmentHelper().getResumableUploadSpec(streamDetails);
final var textAttachment = AttachmentUtils.createAttachmentStream(streamDetails, final var textAttachment = AttachmentUtils.createAttachmentStream(streamDetails,
Optional.empty(), Optional.empty(),
uploadSpec); uploadSpec);
@ -825,7 +853,8 @@ public class ManagerImpl implements Manager {
messageBuilder.withBody(message.messageText()); messageBuilder.withBody(message.messageText());
} }
if (!message.attachments().isEmpty()) { if (!message.attachments().isEmpty()) {
final var uploadedAttachments = context.getAttachmentHelper().uploadAttachments(message.attachments()); final var uploadedAttachments = context.getAttachmentHelper()
.uploadAttachments(message.attachments(), message.voiceNote());
if (!additionalAttachments.isEmpty()) { if (!additionalAttachments.isEmpty()) {
additionalAttachments.addAll(uploadedAttachments); additionalAttachments.addAll(uploadedAttachments);
messageBuilder.withAttachments(additionalAttachments); messageBuilder.withAttachments(additionalAttachments);
@ -879,7 +908,7 @@ public class ManagerImpl implements Manager {
if (streamDetails == null) { if (streamDetails == null) {
throw new InvalidStickerException("Missing local sticker file"); throw new InvalidStickerException("Missing local sticker file");
} }
final var uploadSpec = dependencies.getMessageSender().getResumableUploadSpec(); final var uploadSpec = context.getAttachmentHelper().getResumableUploadSpec(streamDetails);
final var stickerAttachment = AttachmentUtils.createAttachmentStream(streamDetails, final var stickerAttachment = AttachmentUtils.createAttachmentStream(streamDetails,
Optional.empty(), Optional.empty(),
uploadSpec); uploadSpec);
@ -893,7 +922,7 @@ public class ManagerImpl implements Manager {
final var previews = new ArrayList<SignalServicePreview>(message.previews().size()); final var previews = new ArrayList<SignalServicePreview>(message.previews().size());
for (final var p : message.previews()) { for (final var p : message.previews()) {
final var image = p.image().isPresent() ? context.getAttachmentHelper() final var image = p.image().isPresent() ? context.getAttachmentHelper()
.uploadAttachment(p.image().get()) : null; .uploadAttachment(p.image().get()) : null;
previews.add(new SignalServicePreview(p.url(), previews.add(new SignalServicePreview(p.url(),
p.title(), p.title(),
p.description(), p.description(),
@ -931,12 +960,10 @@ public class ManagerImpl implements Manager {
var delete = new SignalServiceDataMessage.RemoteDelete(targetSentTimestamp); var delete = new SignalServiceDataMessage.RemoteDelete(targetSentTimestamp);
final var messageBuilder = SignalServiceDataMessage.newBuilder().withRemoteDelete(delete); final var messageBuilder = SignalServiceDataMessage.newBuilder().withRemoteDelete(delete);
for (final var recipient : recipients) { for (final var recipient : recipients) {
if (recipient instanceof RecipientIdentifier.Uuid u) { if (recipient instanceof RecipientIdentifier.Uuid(var uuid)) {
account.getMessageSendLogStore() account.getMessageSendLogStore().deleteEntryForRecipientNonGroup(targetSentTimestamp, ACI.from(uuid));
.deleteEntryForRecipientNonGroup(targetSentTimestamp, ACI.from(u.uuid())); } else if (recipient instanceof RecipientIdentifier.Pni(var pni)) {
} else if (recipient instanceof RecipientIdentifier.Pni pni) { account.getMessageSendLogStore().deleteEntryForRecipientNonGroup(targetSentTimestamp, PNI.from(pni));
account.getMessageSendLogStore()
.deleteEntryForRecipientNonGroup(targetSentTimestamp, PNI.from(pni.pni()));
} else if (recipient instanceof RecipientIdentifier.Single r) { } else if (recipient instanceof RecipientIdentifier.Single r) {
try { try {
final var recipientId = context.getRecipientHelper().resolveRecipient(r); final var recipientId = context.getRecipientHelper().resolveRecipient(r);
@ -947,8 +974,8 @@ public class ManagerImpl implements Manager {
} }
} catch (UnregisteredRecipientException ignored) { } catch (UnregisteredRecipientException ignored) {
} }
} else if (recipient instanceof RecipientIdentifier.Group r) { } else if (recipient instanceof RecipientIdentifier.Group(var groupId)) {
account.getMessageSendLogStore().deleteEntryForGroup(targetSentTimestamp, r.groupId()); account.getMessageSendLogStore().deleteEntryForGroup(targetSentTimestamp, groupId);
} }
} }
return sendMessage(messageBuilder, recipients, false); return sendMessage(messageBuilder, recipients, false);
@ -977,6 +1004,77 @@ public class ManagerImpl implements Manager {
return sendMessage(messageBuilder, recipients, notifySelf); 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 @Override
public SendMessageResults sendPaymentNotificationMessage( public SendMessageResults sendPaymentNotificationMessage(
byte[] receipt, byte[] receipt,
@ -994,30 +1092,26 @@ public class ManagerImpl implements Manager {
} }
@Override @Override
public SendMessageResults sendEndSessionMessage(Set<RecipientIdentifier.Single> recipients) throws IOException { public void sendEndSessionMessage(Set<RecipientIdentifier.Single> recipients) throws IOException {
var messageBuilder = SignalServiceDataMessage.newBuilder().asEndSessionMessage(); for (var recipient : recipients) {
final RecipientId recipientId;
try { try {
return sendMessage(messageBuilder, recipientId = context.getRecipientHelper().resolveRecipient(recipient);
recipients.stream().map(RecipientIdentifier.class::cast).collect(Collectors.toSet()), } catch (UnregisteredRecipientException e) {
false); continue;
} catch (GroupNotFoundException | NotAGroupMemberException | GroupSendingNotAllowedException e) { }
throw new AssertionError(e); final var recipientAddress = context.getAccount()
} finally { .getRecipientAddressResolver()
for (var recipient : recipients) { .resolveRecipientAddress(recipientId);
final RecipientId recipientId; final var aciSessionStore = account.getAccountData(ServiceIdType.ACI).getSessionStore();
try { final var pniSessionStore = account.getAccountData(ServiceIdType.PNI).getSessionStore();
recipientId = context.getRecipientHelper().resolveRecipient(recipient); if (recipientAddress.aci().isPresent()) {
} catch (UnregisteredRecipientException e) { aciSessionStore.archiveSessions(recipientAddress.aci().get());
continue; pniSessionStore.archiveSessions(recipientAddress.aci().get());
} }
final var serviceId = context.getAccount() if (recipientAddress.pni().isPresent()) {
.getRecipientAddressResolver() aciSessionStore.archiveSessions(recipientAddress.pni().get());
.resolveRecipientAddress(recipientId) pniSessionStore.archiveSessions(recipientAddress.pni().get());
.serviceId();
if (serviceId.isPresent()) {
account.getAccountData(ServiceIdType.ACI).getSessionStore().deleteAllSessions(serviceId.get());
}
} }
} }
} }
@ -1050,8 +1144,8 @@ public class ManagerImpl implements Manager {
results.put(recipient, results.put(recipient,
List.of(SendMessageResult.unregisteredFailure(single.toPartialRecipientAddress()))); List.of(SendMessageResult.unregisteredFailure(single.toPartialRecipientAddress())));
} }
} else if (recipient instanceof RecipientIdentifier.Group group) { } else if (recipient instanceof RecipientIdentifier.Group(GroupId groupId)) {
final var result = context.getSyncHelper().sendMessageRequestResponse(type, group.groupId()); final var result = context.getSyncHelper().sendMessageRequestResponse(type, groupId);
results.put(recipient, List.of(toSendMessageResult(result))); results.put(recipient, List.of(toSendMessageResult(result)));
} }
} }
@ -1065,7 +1159,7 @@ public class ManagerImpl implements Manager {
final List<String> options, final List<String> options,
final Set<RecipientIdentifier> recipients, final Set<RecipientIdentifier> recipients,
final boolean notifySelf final boolean notifySelf
) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException, UnregisteredRecipientException { ) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException {
final var pollCreate = new SignalServiceDataMessage.PollCreate(question, allowMultiple, options); final var pollCreate = new SignalServiceDataMessage.PollCreate(question, allowMultiple, options);
final var messageBuilder = SignalServiceDataMessage.newBuilder().withPollCreate(pollCreate); final var messageBuilder = SignalServiceDataMessage.newBuilder().withPollCreate(pollCreate);
return sendMessage(messageBuilder, recipients, notifySelf); return sendMessage(messageBuilder, recipients, notifySelf);
@ -1097,7 +1191,7 @@ public class ManagerImpl implements Manager {
final long targetSentTimestamp, final long targetSentTimestamp,
final Set<RecipientIdentifier> recipients, final Set<RecipientIdentifier> recipients,
final boolean notifySelf final boolean notifySelf
) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException, UnregisteredRecipientException { ) throws IOException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException {
final var pollTerminate = new SignalServiceDataMessage.PollTerminate(targetSentTimestamp); final var pollTerminate = new SignalServiceDataMessage.PollTerminate(targetSentTimestamp);
final var messageBuilder = SignalServiceDataMessage.newBuilder().withPollTerminate(pollTerminate); final var messageBuilder = SignalServiceDataMessage.newBuilder().withPollTerminate(pollTerminate);
return sendMessage(messageBuilder, recipients, notifySelf); return sendMessage(messageBuilder, recipients, notifySelf);
@ -1138,10 +1232,7 @@ public class ManagerImpl implements Manager {
final String nickGivenName, final String nickGivenName,
final String nickFamilyName, final String nickFamilyName,
final String note final String note
) throws NotPrimaryDeviceException, UnregisteredRecipientException { ) throws UnregisteredRecipientException {
if (!account.isPrimaryDevice()) {
throw new NotPrimaryDeviceException();
}
context.getContactHelper() context.getContactHelper()
.setContactName(context.getRecipientHelper().resolveRecipient(recipient), .setContactName(context.getRecipientHelper().resolveRecipient(recipient),
givenName, givenName,
@ -1467,7 +1558,21 @@ public class ManagerImpl implements Manager {
return List.of(); return List.of();
} }
// refresh profiles of explicitly given recipients // refresh profiles of explicitly given recipients
context.getProfileHelper().refreshRecipientProfiles(recipientIds); if (recipientIds.isEmpty()) {
final var rIds = account.getRecipientStore()
.getRecipients(onlyContacts, blocked, recipientIds, name)
.stream()
.filter(r -> r.isRegistered())
.map(r -> r.getRecipientId())
.toList();
try {
context.getProfileHelper().getRecipientProfiles(rIds);
} catch (Exception e) {
logger.warn("Failed to refresh profiles for recipients", e);
}
} else {
context.getProfileHelper().refreshRecipientProfiles(recipientIds);
}
return account.getRecipientStore() return account.getRecipientStore()
.getRecipients(onlyContacts, blocked, recipientIds, name) .getRecipients(onlyContacts, blocked, recipientIds, name)
.stream() .stream()
@ -1604,6 +1709,16 @@ public class ManagerImpl implements Manager {
} }
} }
@Override
public void addCallEventListener(final CallEventListener listener) {
context.getCallManager().addCallEventListener(listener);
}
@Override
public void removeCallEventListener(final CallEventListener listener) {
context.getCallManager().removeCallEventListener(listener);
}
@Override @Override
public InputStream retrieveAttachment(final String id) throws IOException { public InputStream retrieveAttachment(final String id) throws IOException {
return context.getAttachmentHelper().retrieveAttachment(id).getStream(); return context.getAttachmentHelper().retrieveAttachment(id).getStream();
@ -1661,6 +1776,132 @@ public class ManagerImpl implements Manager {
return streamDetails.getStream(); return streamDetails.getStream();
} }
// --- Voice call methods ---
@Override
public CallInfo startCall(final RecipientIdentifier.Single recipient) throws IOException, UnregisteredRecipientException {
final var recipientId = context.getRecipientHelper().resolveRecipient(recipient);
return context.getCallManager().startOutgoingCall(recipientId);
}
@Override
public CallInfo acceptCall(final long callId) throws IOException {
return context.getCallManager().acceptIncomingCall(callId);
}
@Override
public void hangupCall(final long callId) throws IOException {
context.getCallManager().hangupCall(callId);
}
@Override
public SendMessageResult rejectCall(final long callId) throws IOException {
final var result = context.getCallManager().rejectCall(callId);
return toSendMessageResult(result);
}
@Override
public List<CallInfo> listActiveCalls() {
return context.getCallManager().listActiveCalls();
}
@Override
public void sendCallOffer(
final RecipientIdentifier.Single recipient,
final CallOffer offer
) throws IOException, UnregisteredRecipientException {
final var recipientId = context.getRecipientHelper().resolveRecipient(recipient);
final var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId);
var offerMessage = new OfferMessage(offer.callId(),
offer.type() == CallOffer.Type.VIDEO ? OfferMessage.Type.VIDEO_CALL : OfferMessage.Type.AUDIO_CALL,
offer.opaque());
var callMessage = SignalServiceCallMessage.forOffer(offerMessage, null);
try {
dependencies.getMessageSender().sendCallMessage(address, null, callMessage);
} catch (org.whispersystems.signalservice.api.crypto.UntrustedIdentityException e) {
throw new IOException("Untrusted identity for call recipient", e);
}
}
@Override
public void sendCallAnswer(
final RecipientIdentifier.Single recipient,
final long callId,
final byte[] answerOpaque
) throws IOException, UnregisteredRecipientException {
final var recipientId = context.getRecipientHelper().resolveRecipient(recipient);
final var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId);
var answerMessage = new AnswerMessage(callId, answerOpaque);
var callMessage = SignalServiceCallMessage.forAnswer(answerMessage, null);
try {
dependencies.getMessageSender().sendCallMessage(address, null, callMessage);
} catch (org.whispersystems.signalservice.api.crypto.UntrustedIdentityException e) {
throw new IOException("Untrusted identity for call recipient", e);
}
}
@Override
public void sendIceUpdate(
final RecipientIdentifier.Single recipient,
final long callId,
final List<byte[]> iceCandidates
) throws IOException, UnregisteredRecipientException {
final var recipientId = context.getRecipientHelper().resolveRecipient(recipient);
final var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId);
var iceUpdates = iceCandidates.stream().map(opaque -> new IceUpdateMessage(callId, opaque)).toList();
var callMessage = SignalServiceCallMessage.forIceUpdates(iceUpdates, null);
try {
dependencies.getMessageSender().sendCallMessage(address, null, callMessage);
} catch (org.whispersystems.signalservice.api.crypto.UntrustedIdentityException e) {
throw new IOException("Untrusted identity for call recipient", e);
}
}
@Override
public void sendHangup(
final RecipientIdentifier.Single recipient,
final long callId,
final MessageEnvelope.Call.Hangup.Type type
) throws IOException, UnregisteredRecipientException {
final var recipientId = context.getRecipientHelper().resolveRecipient(recipient);
final var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId);
var hangupType = switch (type) {
case NORMAL -> HangupMessage.Type.NORMAL;
case ACCEPTED -> HangupMessage.Type.ACCEPTED;
case DECLINED -> HangupMessage.Type.DECLINED;
case BUSY -> HangupMessage.Type.BUSY;
case NEED_PERMISSION -> HangupMessage.Type.NEED_PERMISSION;
};
var hangupMessage = new HangupMessage(callId, hangupType, 0);
var callMessage = SignalServiceCallMessage.forHangup(hangupMessage, null);
try {
dependencies.getMessageSender().sendCallMessage(address, null, callMessage);
} catch (org.whispersystems.signalservice.api.crypto.UntrustedIdentityException e) {
throw new IOException("Untrusted identity for call recipient", e);
}
}
@Override
public void sendBusy(
final RecipientIdentifier.Single recipient,
final long callId
) throws IOException, UnregisteredRecipientException {
final var recipientId = context.getRecipientHelper().resolveRecipient(recipient);
final var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId);
var busyMessage = new BusyMessage(callId);
var callMessage = SignalServiceCallMessage.forBusy(busyMessage, null);
try {
dependencies.getMessageSender().sendCallMessage(address, null, callMessage);
} catch (org.whispersystems.signalservice.api.crypto.UntrustedIdentityException e) {
throw new IOException("Untrusted identity for call recipient", e);
}
}
@Override
public List<TurnServer> getTurnServerInfo() throws IOException {
return context.getCallManager().getTurnServers();
}
@Override @Override
public void close() { public void close() {
Thread thread; Thread thread;

View File

@ -145,7 +145,6 @@ public class ProvisioningManagerImpl implements ProvisioningManager {
ret.getAciIdentity(), ret.getAciIdentity(),
ret.getPniIdentity(), ret.getPniIdentity(),
profileKey, profileKey,
ret.getMasterKey(),
ret.getAccountEntropyPool(), ret.getAccountEntropyPool(),
ret.getMediaRootBackupKey()); ret.getMediaRootBackupKey());

View File

@ -33,18 +33,19 @@ import org.asamk.signal.manager.helper.PinHelper;
import org.asamk.signal.manager.storage.SignalAccount; import org.asamk.signal.manager.storage.SignalAccount;
import org.asamk.signal.manager.util.KeyUtils; import org.asamk.signal.manager.util.KeyUtils;
import org.asamk.signal.manager.util.NumberVerificationUtils; import org.asamk.signal.manager.util.NumberVerificationUtils;
import org.signal.core.models.MasterKey;
import org.signal.core.models.ServiceId.ACI;
import org.signal.core.models.ServiceId.PNI;
import org.signal.libsignal.usernames.BaseUsernameException; import org.signal.libsignal.usernames.BaseUsernameException;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.SignalServiceAccountManager; import org.whispersystems.signalservice.api.SignalServiceAccountManager;
import org.whispersystems.signalservice.api.account.PreKeyCollection; import org.whispersystems.signalservice.api.account.PreKeyCollection;
import org.whispersystems.signalservice.api.kbs.MasterKey;
import org.whispersystems.signalservice.api.push.ServiceId.ACI;
import org.whispersystems.signalservice.api.push.ServiceId.PNI;
import org.whispersystems.signalservice.api.push.ServiceIdType; import org.whispersystems.signalservice.api.push.ServiceIdType;
import org.whispersystems.signalservice.api.push.SignalServiceAddress; import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.push.exceptions.AlreadyVerifiedException; import org.whispersystems.signalservice.api.push.exceptions.AlreadyVerifiedException;
import org.whispersystems.signalservice.api.push.exceptions.DeprecatedVersionException; import org.whispersystems.signalservice.api.push.exceptions.DeprecatedVersionException;
import org.whispersystems.signalservice.api.push.exceptions.MustRequestNewCodeException;
import org.whispersystems.signalservice.api.svr.SecureValueRecovery; import org.whispersystems.signalservice.api.svr.SecureValueRecovery;
import org.whispersystems.signalservice.internal.push.VerifyAccountResponse; import org.whispersystems.signalservice.internal.push.VerifyAccountResponse;
@ -230,6 +231,7 @@ public class RegistrationManagerImpl implements RegistrationManager {
userAgent, userAgent,
account.getCredentialsProvider(), account.getCredentialsProvider(),
account.getSignalServiceDataStore(), account.getSignalServiceDataStore(),
0,
null, null,
new ReentrantSignalSessionLock()); new ReentrantSignalSessionLock());
handleResponseException(dependencies.getAccountApi() handleResponseException(dependencies.getAccountApi()
@ -262,6 +264,8 @@ public class RegistrationManagerImpl implements RegistrationManager {
final var registrationApi = unauthenticatedAccountManager.getRegistrationApi(); final var registrationApi = unauthenticatedAccountManager.getRegistrationApi();
try { try {
handleResponseException(registrationApi.verifyAccount(sessionId, verificationCode)); handleResponseException(registrationApi.verifyAccount(sessionId, verificationCode));
} catch (MustRequestNewCodeException e) {
throw new IOException("Verification code expired, please request a new one by registering again.", e);
} catch (AlreadyVerifiedException e) { } catch (AlreadyVerifiedException e) {
// Already verified so can continue registering // Already verified so can continue registering
} }

View File

@ -5,7 +5,18 @@ import org.asamk.signal.manager.config.ServiceEnvironmentConfig;
import org.asamk.signal.manager.util.Utils; import org.asamk.signal.manager.util.Utils;
import org.signal.libsignal.metadata.certificate.CertificateValidator; import org.signal.libsignal.metadata.certificate.CertificateValidator;
import org.signal.libsignal.net.Network; import org.signal.libsignal.net.Network;
import org.signal.libsignal.protocol.SignalProtocolAddress;
import org.signal.libsignal.zkgroup.profiles.ClientZkProfileOperations; import org.signal.libsignal.zkgroup.profiles.ClientZkProfileOperations;
import org.signal.network.api.AttachmentApi;
import org.signal.network.api.CallingApi;
import org.signal.network.api.CdsApi;
import org.signal.network.api.CertificateApi;
import org.signal.network.api.LinkDeviceApi;
import org.signal.network.api.RateLimitChallengeApi;
import org.signal.network.api.UsernameApi;
import org.signal.network.rest.SignalRestClient;
import org.signal.network.service.CdnService;
import org.signal.network.service.StorageServiceService;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.SignalServiceAccountManager; import org.whispersystems.signalservice.api.SignalServiceAccountManager;
@ -14,26 +25,20 @@ import org.whispersystems.signalservice.api.SignalServiceMessageReceiver;
import org.whispersystems.signalservice.api.SignalServiceMessageSender; import org.whispersystems.signalservice.api.SignalServiceMessageSender;
import org.whispersystems.signalservice.api.SignalSessionLock; import org.whispersystems.signalservice.api.SignalSessionLock;
import org.whispersystems.signalservice.api.account.AccountApi; import org.whispersystems.signalservice.api.account.AccountApi;
import org.whispersystems.signalservice.api.attachment.AttachmentApi;
import org.whispersystems.signalservice.api.cds.CdsApi;
import org.whispersystems.signalservice.api.certificate.CertificateApi;
import org.whispersystems.signalservice.api.crypto.SignalServiceCipher; import org.whispersystems.signalservice.api.crypto.SignalServiceCipher;
import org.whispersystems.signalservice.api.groupsv2.ClientZkOperations; import org.whispersystems.signalservice.api.groupsv2.ClientZkOperations;
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Api; import org.whispersystems.signalservice.api.groupsv2.GroupsV2Api;
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations; import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations;
import org.whispersystems.signalservice.api.keys.KeysApi; import org.whispersystems.signalservice.api.keys.KeysApi;
import org.whispersystems.signalservice.api.link.LinkDeviceApi; import org.whispersystems.signalservice.api.keys.PreKeyRepository;
import org.whispersystems.signalservice.api.message.MessageApi; import org.whispersystems.signalservice.api.message.MessageApi;
import org.whispersystems.signalservice.api.profiles.ProfileApi; import org.whispersystems.signalservice.api.profiles.ProfileApi;
import org.whispersystems.signalservice.api.push.ServiceIdType; import org.whispersystems.signalservice.api.push.ServiceIdType;
import org.whispersystems.signalservice.api.push.SignalServiceAddress; import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.ratelimit.RateLimitChallengeApi;
import org.whispersystems.signalservice.api.registration.RegistrationApi; import org.whispersystems.signalservice.api.registration.RegistrationApi;
import org.whispersystems.signalservice.api.services.ProfileService; import org.whispersystems.signalservice.api.services.ProfileService;
import org.whispersystems.signalservice.api.storage.StorageServiceApi; import org.whispersystems.signalservice.api.storage.StorageServiceApi;
import org.whispersystems.signalservice.api.storage.StorageServiceRepository;
import org.whispersystems.signalservice.api.svr.SecureValueRecovery; import org.whispersystems.signalservice.api.svr.SecureValueRecovery;
import org.whispersystems.signalservice.api.username.UsernameApi;
import org.whispersystems.signalservice.api.util.CredentialsProvider; import org.whispersystems.signalservice.api.util.CredentialsProvider;
import org.whispersystems.signalservice.api.util.UptimeSleepTimer; import org.whispersystems.signalservice.api.util.UptimeSleepTimer;
import org.whispersystems.signalservice.api.websocket.SignalWebSocket; import org.whispersystems.signalservice.api.websocket.SignalWebSocket;
@ -60,6 +65,7 @@ public class SignalDependencies {
private final String userAgent; private final String userAgent;
private final CredentialsProvider credentialsProvider; private final CredentialsProvider credentialsProvider;
private final SignalServiceDataStore dataStore; private final SignalServiceDataStore dataStore;
private final int deviceId;
private final ExecutorService executor; private final ExecutorService executor;
private final SignalSessionLock sessionLock; private final SignalSessionLock sessionLock;
@ -76,10 +82,16 @@ public class SignalDependencies {
private StorageServiceApi storageServiceApi; private StorageServiceApi storageServiceApi;
private CertificateApi certificateApi; private CertificateApi certificateApi;
private AttachmentApi attachmentApi; private AttachmentApi attachmentApi;
private CallingApi callingApi;
private MessageApi messageApi; private MessageApi messageApi;
private KeysApi keysApi; private KeysApi keysApi;
private GroupsV2Operations groupsV2Operations; private GroupsV2Operations groupsV2Operations;
private ClientZkOperations clientZkOperations; private ClientZkOperations clientZkOperations;
private ProfileService profileService;
private ProfileApi profileApi;
private CdnService cdnService;
private PreKeyRepository preKeyRepository;
private SignalRestClient signalRestClient;
private PushServiceSocket pushServiceSocket; private PushServiceSocket pushServiceSocket;
private Network libSignalNetwork; private Network libSignalNetwork;
@ -89,14 +101,13 @@ public class SignalDependencies {
private SignalServiceMessageSender messageSender; private SignalServiceMessageSender messageSender;
private List<SecureValueRecovery> secureValueRecovery; private List<SecureValueRecovery> secureValueRecovery;
private ProfileService profileService;
private ProfileApi profileApi;
SignalDependencies( SignalDependencies(
final ServiceEnvironmentConfig serviceEnvironmentConfig, final ServiceEnvironmentConfig serviceEnvironmentConfig,
final String userAgent, final String userAgent,
final CredentialsProvider credentialsProvider, final CredentialsProvider credentialsProvider,
final SignalServiceDataStore dataStore, final SignalServiceDataStore dataStore,
final int deviceId,
final ExecutorService executor, final ExecutorService executor,
final SignalSessionLock sessionLock final SignalSessionLock sessionLock
) { ) {
@ -104,6 +115,7 @@ public class SignalDependencies {
this.userAgent = userAgent; this.userAgent = userAgent;
this.credentialsProvider = credentialsProvider; this.credentialsProvider = credentialsProvider;
this.dataStore = dataStore; this.dataStore = dataStore;
this.deviceId = deviceId;
this.executor = executor; this.executor = executor;
this.sessionLock = sessionLock; this.sessionLock = sessionLock;
} }
@ -241,8 +253,8 @@ public class SignalDependencies {
getPushServiceSocket())); getPushServiceSocket()));
} }
public StorageServiceRepository getStorageServiceRepository() { public StorageServiceService getStorageServiceRepository() {
return new StorageServiceRepository(getStorageServiceApi()); return new StorageServiceService(getStorageServiceApi());
} }
public CertificateApi getCertificateApi() { public CertificateApi getCertificateApi() {
@ -255,6 +267,13 @@ public class SignalDependencies {
() -> attachmentApi = new AttachmentApi(getAuthenticatedSignalWebSocket(), getPushServiceSocket())); () -> attachmentApi = new AttachmentApi(getAuthenticatedSignalWebSocket(), getPushServiceSocket()));
} }
public CallingApi getCallingApi() {
return getOrCreate(() -> callingApi,
() -> callingApi = new CallingApi(getAuthenticatedSignalWebSocket(),
getUnauthenticatedSignalWebSocket(),
getPushServiceSocket()));
}
public MessageApi getMessageApi() { public MessageApi getMessageApi() {
return getOrCreate(() -> messageApi, return getOrCreate(() -> messageApi,
() -> messageApi = new MessageApi(getAuthenticatedSignalWebSocket(), () -> messageApi = new MessageApi(getAuthenticatedSignalWebSocket(),
@ -292,7 +311,7 @@ public class SignalDependencies {
getLibSignalNetwork(), getLibSignalNetwork(),
credentialsProvider, credentialsProvider,
allowStories, allowStories,
healthMonitor), () -> true, timer, TimeUnit.SECONDS.toMillis(10)); healthMonitor), () -> true, timer, TimeUnit.SECONDS.toMillis(30));
healthMonitor.monitor(authenticatedSignalWebSocket); healthMonitor.monitor(authenticatedSignalWebSocket);
}); });
} }
@ -307,7 +326,7 @@ public class SignalDependencies {
getLibSignalNetwork(), getLibSignalNetwork(),
null, null,
allowStories, allowStories,
healthMonitor), () -> true, timer, TimeUnit.SECONDS.toMillis(10)); healthMonitor), () -> true, timer, TimeUnit.SECONDS.toMillis(30));
healthMonitor.monitor(unauthenticatedSignalWebSocket); healthMonitor.monitor(unauthenticatedSignalWebSocket);
}); });
} }
@ -317,20 +336,41 @@ public class SignalDependencies {
() -> messageReceiver = new SignalServiceMessageReceiver(getPushServiceSocket())); () -> messageReceiver = new SignalServiceMessageReceiver(getPushServiceSocket()));
} }
private SignalRestClient getSignalRestClient() {
return getOrCreate(() -> signalRestClient,
() -> signalRestClient = new SignalRestClient(serviceEnvironmentConfig.signalServiceConfiguration(),
userAgent,
credentialsProvider,
ServiceConfig.AUTOMATIC_NETWORK_RETRY));
}
public CdnService getCdnService() {
return getOrCreate(() -> cdnService,
() -> cdnService = new CdnService(getSignalRestClient(), getAttachmentApi()));
}
public PreKeyRepository getPreKeyRepository() {
final SignalProtocolAddress localProtocolAddress = credentialsProvider.getAci().toProtocolAddress(deviceId);
return getOrCreate(() -> preKeyRepository,
() -> preKeyRepository = new PreKeyRepository(getKeysApi(),
dataStore.aci(),
localProtocolAddress,
Runnable::run));
}
public SignalServiceMessageSender getMessageSender() { public SignalServiceMessageSender getMessageSender() {
return getOrCreate(() -> messageSender, return getOrCreate(() -> messageSender,
() -> messageSender = new SignalServiceMessageSender(getPushServiceSocket(), () -> messageSender = new SignalServiceMessageSender(getPushServiceSocket(),
dataStore, dataStore,
sessionLock, sessionLock,
getAttachmentApi(),
getMessageApi(), getMessageApi(),
getKeysApi(), getKeysApi(),
Optional.empty(), Optional.empty(),
executor, executor,
ServiceConfig.MAX_ENVELOPE_SIZE, ServiceConfig.MAX_ENVELOPE_SIZE,
ServiceConfig.MAX_INCREMENTAL_MACS_PER_ENVELOPE,
() -> true, () -> true,
false, getPreKeyRepository()));
true));
} }
public List<SecureValueRecovery> getSecureValueRecovery() { public List<SecureValueRecovery> getSecureValueRecovery() {
@ -358,7 +398,10 @@ public class SignalDependencies {
public SignalServiceCipher getCipher(ServiceIdType serviceIdType) { public SignalServiceCipher getCipher(ServiceIdType serviceIdType) {
final var certificateValidator = new CertificateValidator(serviceEnvironmentConfig.unidentifiedSenderTrustRoots()); final var certificateValidator = new CertificateValidator(serviceEnvironmentConfig.unidentifiedSenderTrustRoots());
final var address = new SignalServiceAddress(credentialsProvider.getAci(), credentialsProvider.getE164()); final var serviceId = serviceIdType == ServiceIdType.ACI
? credentialsProvider.getAci()
: credentialsProvider.getPni();
final var address = new SignalServiceAddress(serviceId, credentialsProvider.getE164());
final var deviceId = credentialsProvider.getDeviceId(); final var deviceId = credentialsProvider.getDeviceId();
return new SignalServiceCipher(address, return new SignalServiceCipher(address,
deviceId, deviceId,

View File

@ -0,0 +1,46 @@
package org.asamk.signal.manager.internal;
import org.signal.core.util.logging.Log;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class SignalLogger extends Log.Logger {
private static final Logger logger = LoggerFactory.getLogger("LibSignalService");
public static void initLogger() {
Log.initialize(() -> true, new SignalLogger());
}
private SignalLogger() {
}
@Override
public void v(final String tag, final String message, final Throwable throwable, final boolean b) {
logger.trace("[{}]: {}", tag, message, throwable);
}
@Override
public void d(final String tag, final String message, final Throwable throwable, final boolean b) {
logger.debug("[{}]: {}", tag, message, throwable);
}
@Override
public void i(final String tag, final String message, final Throwable throwable, final boolean b) {
logger.info("[{}]: {}", tag, message, throwable);
}
@Override
public void w(final String tag, final String message, final Throwable throwable, final boolean b) {
logger.warn("[{}]: {}", tag, message, throwable);
}
@Override
public void e(final String tag, final String message, final Throwable throwable, final boolean b) {
logger.error("[{}]: {}", tag, message, throwable);
}
@Override
public void flush() {
}
}

View File

@ -1,8 +1,9 @@
package org.asamk.signal.manager.internal; package org.asamk.signal.manager.internal;
import org.jetbrains.annotations.NotNull;
import org.signal.network.util.Preconditions;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.util.Preconditions;
import org.whispersystems.signalservice.api.util.SleepTimer; import org.whispersystems.signalservice.api.util.SleepTimer;
import org.whispersystems.signalservice.api.websocket.HealthMonitor; import org.whispersystems.signalservice.api.websocket.HealthMonitor;
import org.whispersystems.signalservice.api.websocket.SignalWebSocket; import org.whispersystems.signalservice.api.websocket.SignalWebSocket;
@ -94,6 +95,14 @@ final class SignalWebSocketHealthMonitor implements HealthMonitor {
return needsKeepAlive && webSocket != null && webSocket.shouldSendKeepAlives(); return needsKeepAlive && webSocket != null && webSocket.shouldSendKeepAlives();
} }
@Override
public void onReceivedAlerts(@NotNull final String[] strings, final boolean b) {
if (strings.length == 0) {
return;
}
logger.info("Received alerts: {}", String.join(", ", strings));
}
/** /**
* Sends periodic heartbeats/keep-alives over the WebSocket to prevent connection timeouts. If * Sends periodic heartbeats/keep-alives over the WebSocket to prevent connection timeouts. If
* the WebSocket fails to get a return heartbeat after [KEEP_ALIVE_TIMEOUT] seconds, it is forced to be recreated. * the WebSocket fails to get a return heartbeat after [KEEP_ALIVE_TIMEOUT] seconds, it is forced to be recreated.

View File

@ -2,10 +2,10 @@ package org.asamk.signal.manager.jobs;
import org.asamk.signal.manager.api.StickerPackId; import org.asamk.signal.manager.api.StickerPackId;
import org.asamk.signal.manager.helper.Context; import org.asamk.signal.manager.helper.Context;
import org.signal.core.util.Hex;
import org.signal.libsignal.protocol.InvalidMessageException; import org.signal.libsignal.protocol.InvalidMessageException;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.internal.util.Hex;
import java.io.IOException; import java.io.IOException;

View File

@ -16,11 +16,11 @@ import org.asamk.signal.manager.storage.senderKeys.SenderKeyRecordStore;
import org.asamk.signal.manager.storage.senderKeys.SenderKeySharedStore; import org.asamk.signal.manager.storage.senderKeys.SenderKeySharedStore;
import org.asamk.signal.manager.storage.sessions.SessionStore; import org.asamk.signal.manager.storage.sessions.SessionStore;
import org.asamk.signal.manager.storage.stickers.StickerStore; import org.asamk.signal.manager.storage.stickers.StickerStore;
import org.signal.core.models.ServiceId;
import org.signal.core.models.ServiceId.ACI;
import org.signal.core.util.UuidUtil;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.push.ServiceId;
import org.whispersystems.signalservice.api.push.ServiceId.ACI;
import org.whispersystems.signalservice.api.util.UuidUtil;
import java.io.File; import java.io.File;
import java.sql.Connection; import java.sql.Connection;
@ -646,9 +646,10 @@ public class AccountDatabase extends Database {
try (final var preparedStatement = connection.prepareStatement(sql)) { try (final var preparedStatement = connection.prepareStatement(sql)) {
try (var result = Utils.executeQueryForStream(preparedStatement, (resultSet) -> { try (var result = Utils.executeQueryForStream(preparedStatement, (resultSet) -> {
final var pni = Optional.ofNullable(resultSet.getBytes("pni")) final var pni = Optional.ofNullable(resultSet.getBytes("pni"))
.map(UuidUtil::parseOrNull) .map(UuidUtil.INSTANCE::parseOrNull)
.map(ServiceId.PNI::from); .map(ServiceId.PNI::from);
final var serviceIdUuid = Optional.ofNullable(resultSet.getBytes("uuid")).map(UuidUtil::parseOrNull); final var serviceIdUuid = Optional.ofNullable(resultSet.getBytes("uuid"))
.map(UuidUtil.INSTANCE::parseOrNull);
final var serviceId = serviceIdUuid.isPresent() && pni.isPresent() && serviceIdUuid.get() final var serviceId = serviceIdUuid.isPresent() && pni.isPresent() && serviceIdUuid.get()
.equals(pni.get().getRawUuid()) .equals(pni.get().getRawUuid())
? pni.<ServiceId>map(p -> p) ? pni.<ServiceId>map(p -> p)

View File

@ -44,7 +44,8 @@ public class AttachmentStore {
} }
public StreamDetails retrieveAttachment(final String id) throws IOException { public StreamDetails retrieveAttachment(final String id) throws IOException {
final var attachmentFile = new File(attachmentsPath, id); final var safeId = sanitizeId(id);
final var attachmentFile = new File(attachmentsPath, safeId);
return Utils.createStreamDetailsFromFile(attachmentFile); return Utils.createStreamDetailsFromFile(attachmentFile);
} }
@ -61,7 +62,8 @@ public class AttachmentStore {
Optional<String> contentType Optional<String> contentType
) { ) {
final var extension = getAttachmentExtension(filename, contentType); final var extension = getAttachmentExtension(filename, contentType);
return new File(attachmentsPath, attachmentId.toString() + extension + ".preview"); final var safe = sanitizeId(attachmentId.toString());
return new File(attachmentsPath, safe + extension + ".preview");
} }
private File getAttachmentFile( private File getAttachmentFile(
@ -70,7 +72,15 @@ public class AttachmentStore {
Optional<String> contentType Optional<String> contentType
) { ) {
final var extension = getAttachmentExtension(filename, contentType); final var extension = getAttachmentExtension(filename, contentType);
return new File(attachmentsPath, attachmentId.toString() + extension); final var safe = sanitizeId(attachmentId.toString());
return new File(attachmentsPath, safe + extension);
}
private static String sanitizeId(final String id) {
if (id == null) {
return "";
}
return id.replaceAll("[^A-Za-z0-9_.-]", "_");
} }
private static String getAttachmentExtension(final Optional<String> filename, final Optional<String> contentType) { private static String getAttachmentExtension(final Optional<String> filename, final Optional<String> contentType) {

View File

@ -53,6 +53,14 @@ import org.asamk.signal.manager.storage.stickers.StickerStore;
import org.asamk.signal.manager.storage.threads.LegacyJsonThreadStore; import org.asamk.signal.manager.storage.threads.LegacyJsonThreadStore;
import org.asamk.signal.manager.util.IOUtils; import org.asamk.signal.manager.util.IOUtils;
import org.asamk.signal.manager.util.KeyUtils; import org.asamk.signal.manager.util.KeyUtils;
import org.signal.core.models.AccountEntropyPool;
import org.signal.core.models.MasterKey;
import org.signal.core.models.ServiceId;
import org.signal.core.models.ServiceId.ACI;
import org.signal.core.models.ServiceId.PNI;
import org.signal.core.models.backup.MediaRootBackupKey;
import org.signal.core.models.storageservice.StorageKey;
import org.signal.core.util.UuidUtil;
import org.signal.libsignal.protocol.IdentityKeyPair; import org.signal.libsignal.protocol.IdentityKeyPair;
import org.signal.libsignal.protocol.InvalidMessageException; import org.signal.libsignal.protocol.InvalidMessageException;
import org.signal.libsignal.protocol.SignalProtocolAddress; import org.signal.libsignal.protocol.SignalProtocolAddress;
@ -65,24 +73,16 @@ import org.signal.libsignal.zkgroup.InvalidInputException;
import org.signal.libsignal.zkgroup.profiles.ProfileKey; import org.signal.libsignal.zkgroup.profiles.ProfileKey;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.AccountEntropyPool;
import org.whispersystems.signalservice.api.SignalServiceAccountDataStore; import org.whispersystems.signalservice.api.SignalServiceAccountDataStore;
import org.whispersystems.signalservice.api.SignalServiceDataStore; import org.whispersystems.signalservice.api.SignalServiceDataStore;
import org.whispersystems.signalservice.api.account.AccountAttributes; import org.whispersystems.signalservice.api.account.AccountAttributes;
import org.whispersystems.signalservice.api.account.PreKeyCollection; import org.whispersystems.signalservice.api.account.PreKeyCollection;
import org.whispersystems.signalservice.api.backup.MediaRootBackupKey;
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess; import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess;
import org.whispersystems.signalservice.api.kbs.MasterKey;
import org.whispersystems.signalservice.api.push.ServiceId;
import org.whispersystems.signalservice.api.push.ServiceId.ACI;
import org.whispersystems.signalservice.api.push.ServiceId.PNI;
import org.whispersystems.signalservice.api.push.ServiceIdType; import org.whispersystems.signalservice.api.push.ServiceIdType;
import org.whispersystems.signalservice.api.push.SignalServiceAddress; import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.push.UsernameLinkComponents; import org.whispersystems.signalservice.api.push.UsernameLinkComponents;
import org.whispersystems.signalservice.api.storage.SignalStorageManifest; import org.whispersystems.signalservice.api.storage.SignalStorageManifest;
import org.whispersystems.signalservice.api.storage.StorageKey;
import org.whispersystems.signalservice.api.util.CredentialsProvider; import org.whispersystems.signalservice.api.util.CredentialsProvider;
import org.whispersystems.signalservice.api.util.UuidUtil;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
@ -192,6 +192,10 @@ public class SignalAccount implements Closeable {
this.lock = lock; this.lock = lock;
} }
public File getDataPath() {
return dataPath;
}
public static SignalAccount load( public static SignalAccount load(
File dataPath, File dataPath,
String accountPath, String accountPath,
@ -292,7 +296,6 @@ public class SignalAccount implements Closeable {
final IdentityKeyPair aciIdentity, final IdentityKeyPair aciIdentity,
final IdentityKeyPair pniIdentity, final IdentityKeyPair pniIdentity,
final ProfileKey profileKey, final ProfileKey profileKey,
final MasterKey masterKey,
final AccountEntropyPool accountEntropyPool, final AccountEntropyPool accountEntropyPool,
final MediaRootBackupKey mediaRootBackupKey final MediaRootBackupKey mediaRootBackupKey
) { ) {
@ -314,7 +317,7 @@ public class SignalAccount implements Closeable {
this.pinMasterKey = null; this.pinMasterKey = null;
this.accountEntropyPool = accountEntropyPool; this.accountEntropyPool = accountEntropyPool;
} else { } else {
this.pinMasterKey = masterKey; this.pinMasterKey = null;
this.accountEntropyPool = null; this.accountEntropyPool = null;
} }
this.mediaRootBackupKey = mediaRootBackupKey; this.mediaRootBackupKey = mediaRootBackupKey;
@ -376,6 +379,7 @@ public class SignalAccount implements Closeable {
trustSelfIdentity(ServiceIdType.ACI); trustSelfIdentity(ServiceIdType.ACI);
trustSelfIdentity(ServiceIdType.PNI); trustSelfIdentity(ServiceIdType.PNI);
getKeyValueStore().storeEntry(lastRecipientsRefresh, null); getKeyValueStore().storeEntry(lastRecipientsRefresh, null);
clearSessionId();
} }
public void initDatabase() { public void initDatabase() {
@ -941,7 +945,7 @@ public class SignalAccount implements Closeable {
profile.isUnrestrictedUnidentifiedAccess() profile.isUnrestrictedUnidentifiedAccess()
? Profile.UnidentifiedAccessMode.UNRESTRICTED ? Profile.UnidentifiedAccessMode.UNRESTRICTED
: profile.getUnidentifiedAccess() != null : profile.getUnidentifiedAccess() != null
? Profile.UnidentifiedAccessMode.ENABLED ? Profile.UnidentifiedAccessMode.ENABLED
: Profile.UnidentifiedAccessMode.DISABLED, : Profile.UnidentifiedAccessMode.DISABLED,
capabilities, capabilities,
null); null);
@ -961,7 +965,7 @@ public class SignalAccount implements Closeable {
continue; continue;
} }
try { try {
if (UuidUtil.isUuid(thread.id) || thread.id.startsWith("+")) { if (UuidUtil.INSTANCE.isUuid(thread.id) || thread.id.startsWith("+")) {
final var recipientId = getRecipientResolver().resolveRecipient(thread.id); final var recipientId = getRecipientResolver().resolveRecipient(thread.id);
var contact = getContactStore().getContact(recipientId); var contact = getContactStore().getContact(recipientId);
if (contact != null) { if (contact != null) {
@ -1485,6 +1489,12 @@ public class SignalAccount implements Closeable {
keyValueStore.storeEntry(verificationSessionId, sessionId); keyValueStore.storeEntry(verificationSessionId, sessionId);
} }
public void clearSessionId() {
final var keyValueStore = getKeyValueStore();
keyValueStore.storeEntry(verificationSessionNumber, null);
keyValueStore.storeEntry(verificationSessionId, null);
}
public void setEncryptedDeviceName(final String encryptedDeviceName) { public void setEncryptedDeviceName(final String encryptedDeviceName) {
this.encryptedDeviceName = encryptedDeviceName; this.encryptedDeviceName = encryptedDeviceName;
save(); save();

View File

@ -10,11 +10,11 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.databind.SerializationFeature;
import org.asamk.signal.manager.storage.recipients.RecipientAddress; import org.asamk.signal.manager.storage.recipients.RecipientAddress;
import org.signal.core.models.ServiceId;
import org.signal.core.util.UuidUtil;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.push.ServiceId;
import org.whispersystems.signalservice.api.push.ServiceIdType; import org.whispersystems.signalservice.api.push.ServiceIdType;
import org.whispersystems.signalservice.api.util.UuidUtil;
import java.io.InvalidObjectException; import java.io.InvalidObjectException;
import java.sql.PreparedStatement; import java.sql.PreparedStatement;
@ -57,7 +57,7 @@ public class Utils {
} }
public static RecipientAddress getRecipientAddressFromLegacyIdentifier(final String identifier) { public static RecipientAddress getRecipientAddressFromLegacyIdentifier(final String identifier) {
if (UuidUtil.isUuid(identifier)) { if (UuidUtil.INSTANCE.isUuid(identifier)) {
return new RecipientAddress(ServiceId.parseOrThrow(identifier)); return new RecipientAddress(ServiceId.parseOrThrow(identifier));
} else { } else {
return new RecipientAddress(Optional.empty(), Optional.of(identifier)); return new RecipientAddress(Optional.empty(), Optional.of(identifier));

View File

@ -8,9 +8,9 @@ import org.asamk.signal.manager.api.ServiceEnvironment;
import org.asamk.signal.manager.storage.SignalAccount; import org.asamk.signal.manager.storage.SignalAccount;
import org.asamk.signal.manager.storage.Utils; import org.asamk.signal.manager.storage.Utils;
import org.asamk.signal.manager.util.IOUtils; import org.asamk.signal.manager.util.IOUtils;
import org.signal.core.models.ServiceId.ACI;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.push.ServiceId.ACI;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;

View File

@ -4,7 +4,7 @@ import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonProperty;
import org.asamk.signal.manager.storage.recipients.RecipientAddress; import org.asamk.signal.manager.storage.recipients.RecipientAddress;
import org.whispersystems.signalservice.api.push.ServiceId.ACI; import org.signal.core.models.ServiceId.ACI;
import java.util.UUID; import java.util.UUID;

View File

@ -6,6 +6,7 @@ import org.asamk.signal.manager.api.GroupPermission;
import org.asamk.signal.manager.storage.recipients.RecipientId; import org.asamk.signal.manager.storage.recipients.RecipientId;
import org.whispersystems.signalservice.api.push.DistributionId; import org.whispersystems.signalservice.api.push.DistributionId;
import java.util.Collection;
import java.util.Set; import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.util.stream.Stream; import java.util.stream.Stream;
@ -24,7 +25,18 @@ public sealed abstract class GroupInfo permits GroupInfoV1, GroupInfoV2 {
public abstract GroupInviteLinkUrl getGroupInviteLink(); public abstract GroupInviteLinkUrl getGroupInviteLink();
public abstract Set<RecipientId> getMembers(); public abstract Collection<GroupMemberInfo> getMembers();
public Set<RecipientId> getMemberRecipientIds() {
return getMembers().stream().map(GroupMemberInfo::getRecipientId).collect(Collectors.toSet());
}
public GroupMemberInfo getMember(RecipientId recipientId) {
return getMembers().stream()
.filter(member -> member.getRecipientId().equals(recipientId))
.findFirst()
.orElseThrow();
}
public Set<RecipientId> getBannedMembers() { public Set<RecipientId> getBannedMembers() {
return Set.of(); return Set.of();
@ -38,7 +50,7 @@ public sealed abstract class GroupInfo permits GroupInfoV1, GroupInfoV2 {
return Set.of(); return Set.of();
} }
public Set<RecipientId> getAdminMembers() { public Set<RecipientId> getAdminMemberRecipientIds() {
return Set.of(); return Set.of();
} }
@ -61,21 +73,23 @@ public sealed abstract class GroupInfo permits GroupInfoV1, GroupInfoV2 {
public abstract GroupPermission getPermissionSendMessage(); public abstract GroupPermission getPermissionSendMessage();
public Set<RecipientId> getMembersWithout(RecipientId recipientId) { public Set<RecipientId> getMembersWithout(RecipientId recipientId) {
return getMembers().stream().filter(member -> !member.equals(recipientId)).collect(Collectors.toSet()); return getMemberRecipientIds().stream()
.filter(member -> !member.equals(recipientId))
.collect(Collectors.toSet());
} }
public Set<RecipientId> getMembersIncludingPendingWithout(RecipientId recipientId) { public Set<RecipientId> getMembersIncludingPendingWithout(RecipientId recipientId) {
return Stream.concat(getMembers().stream(), getPendingMembers().stream()) return Stream.concat(getMemberRecipientIds().stream(), getPendingMembers().stream())
.filter(member -> !member.equals(recipientId)) .filter(member -> !member.equals(recipientId))
.collect(Collectors.toSet()); .collect(Collectors.toSet());
} }
public boolean isMember(RecipientId recipientId) { public boolean isMember(RecipientId recipientId) {
return getMembers().contains(recipientId); return getMembers().stream().anyMatch(m -> m.getRecipientId().equals(recipientId));
} }
public boolean isAdmin(RecipientId recipientId) { public boolean isAdmin(RecipientId recipientId) {
return getAdminMembers().contains(recipientId); return getMembers().stream().anyMatch(m -> m.isAdmin() && m.getRecipientId().equals(recipientId));
} }
public boolean isPendingMember(RecipientId recipientId) { public boolean isPendingMember(RecipientId recipientId) {

View File

@ -80,8 +80,8 @@ public final class GroupInfoV1 extends GroupInfo {
return null; return null;
} }
public Set<RecipientId> getMembers() { public Collection<GroupMemberInfo> getMembers() {
return new HashSet<>(members); return members.stream().map(m -> (GroupMemberInfo) new GroupMemberInfoV1(m)).toList();
} }
@Override @Override

View File

@ -3,17 +3,17 @@ package org.asamk.signal.manager.storage.groups;
import org.asamk.signal.manager.api.GroupIdV2; import org.asamk.signal.manager.api.GroupIdV2;
import org.asamk.signal.manager.api.GroupInviteLinkUrl; import org.asamk.signal.manager.api.GroupInviteLinkUrl;
import org.asamk.signal.manager.api.GroupPermission; import org.asamk.signal.manager.api.GroupPermission;
import org.asamk.signal.manager.storage.recipients.RecipientAddress;
import org.asamk.signal.manager.storage.recipients.RecipientId; import org.asamk.signal.manager.storage.recipients.RecipientId;
import org.asamk.signal.manager.storage.recipients.RecipientResolver; import org.asamk.signal.manager.storage.recipients.RecipientResolver;
import org.signal.core.models.ServiceId;
import org.signal.libsignal.zkgroup.groups.GroupMasterKey; import org.signal.libsignal.zkgroup.groups.GroupMasterKey;
import org.signal.storageservice.protos.groups.AccessControl; import org.signal.storageservice.storage.protos.groups.AccessControl;
import org.signal.storageservice.protos.groups.Member; import org.signal.storageservice.storage.protos.groups.local.DecryptedGroup;
import org.signal.storageservice.protos.groups.local.DecryptedGroup; import org.signal.storageservice.storage.protos.groups.local.EnabledState;
import org.signal.storageservice.protos.groups.local.EnabledState;
import org.whispersystems.signalservice.api.push.DistributionId; import org.whispersystems.signalservice.api.push.DistributionId;
import org.whispersystems.signalservice.api.push.ServiceId; import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import java.util.Collection;
import java.util.Set; import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@ -121,14 +121,11 @@ public final class GroupInfoV2 extends GroupInfo {
} }
@Override @Override
public Set<RecipientId> getMembers() { public Collection<GroupMemberInfo> getMembers() {
if (this.group == null) { if (this.group == null) {
return Set.of(); return Set.of();
} }
return group.members.stream() return group.members.stream().map(m -> (GroupMemberInfo) new GroupMemberInfoV2(m, recipientResolver)).toList();
.map(m -> ServiceId.parseOrThrow(m.aciBytes))
.map(recipientResolver::resolveRecipient)
.collect(Collectors.toSet());
} }
@Override @Override
@ -153,6 +150,15 @@ public final class GroupInfoV2 extends GroupInfo {
.collect(Collectors.toSet()); .collect(Collectors.toSet());
} }
public Set<SignalServiceAddress> getPendingMemberAddresses() {
if (this.group == null) {
return Set.of();
}
return group.pendingMembers.stream()
.map(m -> new SignalServiceAddress(ServiceId.parseOrThrow(m.serviceIdBytes)))
.collect(Collectors.toSet());
}
@Override @Override
public Set<RecipientId> getRequestingMembers() { public Set<RecipientId> getRequestingMembers() {
if (this.group == null) { if (this.group == null) {
@ -165,16 +171,11 @@ public final class GroupInfoV2 extends GroupInfo {
} }
@Override @Override
public Set<RecipientId> getAdminMembers() { public Set<RecipientId> getAdminMemberRecipientIds() {
if (this.group == null) { return this.getMembers()
return Set.of(); .stream()
} .filter(GroupMemberInfo::isAdmin)
return group.members.stream() .map(GroupMemberInfo::getRecipientId)
.filter(m -> m.role == Member.Role.ADMINISTRATOR)
.map(m -> new RecipientAddress(ServiceId.ACI.parseOrNull(m.aciBytes),
ServiceId.PNI.parseOrNull(m.pniBytes),
null))
.map(recipientResolver::resolveRecipient)
.collect(Collectors.toSet()); .collect(Collectors.toSet());
} }

View File

@ -0,0 +1,20 @@
package org.asamk.signal.manager.storage.groups;
import org.asamk.signal.manager.storage.recipients.RecipientId;
public interface GroupMemberInfo {
RecipientId getRecipientId();
default boolean isAdmin() {
return false;
}
default String labelEmoji() {
return null;
}
default String labelString() {
return null;
}
}

View File

@ -0,0 +1,17 @@
package org.asamk.signal.manager.storage.groups;
import org.asamk.signal.manager.storage.recipients.RecipientId;
public class GroupMemberInfoV1 implements GroupMemberInfo {
private final RecipientId recipientId;
public GroupMemberInfoV1(final RecipientId recipientId) {
this.recipientId = recipientId;
}
@Override
public RecipientId getRecipientId() {
return this.recipientId;
}
}

View File

@ -0,0 +1,38 @@
package org.asamk.signal.manager.storage.groups;
import org.asamk.signal.manager.storage.recipients.RecipientId;
import org.asamk.signal.manager.storage.recipients.RecipientResolver;
import org.signal.core.models.ServiceId;
import org.signal.storageservice.storage.protos.groups.Member;
import org.signal.storageservice.storage.protos.groups.local.DecryptedMember;
public class GroupMemberInfoV2 implements GroupMemberInfo {
private final RecipientResolver recipientResolver;
private final DecryptedMember member;
public GroupMemberInfoV2(final DecryptedMember member, final RecipientResolver recipientResolver) {
this.recipientResolver = recipientResolver;
this.member = member;
}
@Override
public RecipientId getRecipientId() {
return recipientResolver.resolveRecipient(ServiceId.ACI.parseOrThrow(member.aciBytes));
}
@Override
public boolean isAdmin() {
return member.role == Member.Role.ADMINISTRATOR;
}
@Override
public String labelEmoji() {
return member.labelEmoji.isEmpty() ? null : member.labelEmoji;
}
@Override
public String labelString() {
return member.labelString.isEmpty() ? null : member.labelString;
}
}

View File

@ -11,16 +11,16 @@ import org.asamk.signal.manager.storage.recipients.RecipientId;
import org.asamk.signal.manager.storage.recipients.RecipientIdCreator; import org.asamk.signal.manager.storage.recipients.RecipientIdCreator;
import org.asamk.signal.manager.storage.recipients.RecipientResolver; import org.asamk.signal.manager.storage.recipients.RecipientResolver;
import org.asamk.signal.manager.util.KeyUtils; import org.asamk.signal.manager.util.KeyUtils;
import org.signal.core.util.UuidUtil;
import org.signal.libsignal.zkgroup.InvalidInputException; import org.signal.libsignal.zkgroup.InvalidInputException;
import org.signal.libsignal.zkgroup.groups.GroupMasterKey; import org.signal.libsignal.zkgroup.groups.GroupMasterKey;
import org.signal.libsignal.zkgroup.groups.GroupSecretParams; import org.signal.libsignal.zkgroup.groups.GroupSecretParams;
import org.signal.libsignal.zkgroup.groupsend.GroupSendEndorsement; import org.signal.libsignal.zkgroup.groupsend.GroupSendEndorsement;
import org.signal.storageservice.protos.groups.local.DecryptedGroup; import org.signal.storageservice.storage.protos.groups.local.DecryptedGroup;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.push.DistributionId; import org.whispersystems.signalservice.api.push.DistributionId;
import org.whispersystems.signalservice.api.storage.StorageId; import org.whispersystems.signalservice.api.storage.StorageId;
import org.whispersystems.signalservice.api.util.UuidUtil;
import java.io.IOException; import java.io.IOException;
import java.sql.Connection; import java.sql.Connection;
@ -152,6 +152,7 @@ public class GroupStore {
statement.setBytes(2, groupId.serialize()); statement.setBytes(2, groupId.serialize());
final var result = Utils.executeQueryForOptional(statement, Utils::getIdMapper); final var result = Utils.executeQueryForOptional(statement, Utils::getIdMapper);
if (result.isEmpty()) { if (result.isEmpty()) {
connection.commit();
return; return;
} }
internalId = result.get(); internalId = result.get();
@ -648,7 +649,7 @@ public class GroupStore {
ON CONFLICT (group_id, recipient_id) DO NOTHING ON CONFLICT (group_id, recipient_id) DO NOTHING
""".formatted(TABLE_GROUP_V1_MEMBER); """.formatted(TABLE_GROUP_V1_MEMBER);
try (final var statement = connection.prepareStatement(sqlInsertMember)) { try (final var statement = connection.prepareStatement(sqlInsertMember)) {
for (final var recipient : groupV1.getMembers()) { for (final var recipient : groupV1.getMemberRecipientIds()) {
statement.setLong(1, internalId); statement.setLong(1, internalId);
statement.setLong(2, recipient.id()); statement.setLong(2, recipient.id());
statement.executeUpdate(); statement.executeUpdate();
@ -876,9 +877,9 @@ public class GroupStore {
final var members = membersString == null final var members = membersString == null
? Set.<RecipientId>of() ? Set.<RecipientId>of()
: Arrays.stream(membersString.split(",")) : Arrays.stream(membersString.split(","))
.map(Integer::valueOf) .map(Integer::valueOf)
.map(recipientIdCreator::create) .map(recipientIdCreator::create)
.collect(Collectors.toSet()); .collect(Collectors.toSet());
final var expirationTime = resultSet.getInt("expiration_time"); final var expirationTime = resultSet.getInt("expiration_time");
final var blocked = resultSet.getBoolean("blocked"); final var blocked = resultSet.getBoolean("blocked");
final var archived = resultSet.getBoolean("archived"); final var archived = resultSet.getBoolean("archived");

View File

@ -12,14 +12,14 @@ import org.asamk.signal.manager.api.GroupIdV1;
import org.asamk.signal.manager.api.GroupIdV2; import org.asamk.signal.manager.api.GroupIdV2;
import org.asamk.signal.manager.storage.recipients.RecipientAddress; import org.asamk.signal.manager.storage.recipients.RecipientAddress;
import org.asamk.signal.manager.storage.recipients.RecipientResolver; import org.asamk.signal.manager.storage.recipients.RecipientResolver;
import org.signal.core.models.ServiceId;
import org.signal.core.util.Hex;
import org.signal.libsignal.zkgroup.InvalidInputException; import org.signal.libsignal.zkgroup.InvalidInputException;
import org.signal.libsignal.zkgroup.groups.GroupMasterKey; import org.signal.libsignal.zkgroup.groups.GroupMasterKey;
import org.signal.storageservice.protos.groups.local.DecryptedGroup; import org.signal.storageservice.storage.protos.groups.local.DecryptedGroup;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.push.DistributionId; import org.whispersystems.signalservice.api.push.DistributionId;
import org.whispersystems.signalservice.api.push.ServiceId;
import org.whispersystems.signalservice.internal.util.Hex;
import java.io.File; import java.io.File;
import java.io.FileInputStream; import java.io.FileInputStream;

View File

@ -1,8 +1,8 @@
package org.asamk.signal.manager.storage.identities; package org.asamk.signal.manager.storage.identities;
import org.asamk.signal.manager.api.TrustLevel; import org.asamk.signal.manager.api.TrustLevel;
import org.signal.core.models.ServiceId;
import org.signal.libsignal.protocol.IdentityKey; import org.signal.libsignal.protocol.IdentityKey;
import org.whispersystems.signalservice.api.push.ServiceId;
public class IdentityInfo { public class IdentityInfo {

Some files were not shown because too many files have changed in this diff Show More