Compare commits

...

17 Commits

Author SHA1 Message Date
Kostia R
b26f342ba4
Merge a041826cd595e1c9f21ebbb4205076f49a6afcfb into c26e0632c51a0bc209763c61ff211e4fd025590a 2026-07-14 23:37:43 +03:00
AsamK
c26e0632c5 Prepare next release 2026-07-13 09:12:11 +02:00
AsamK
21dd00d96a Bump version to 0.14.6 2026-07-13 00:16:14 +02:00
AsamK
95c6dae927 Replace deprecated native-image-configure 2026-07-13 00:16:14 +02:00
AsamK
bb2409a7f2 Improve tests 2026-07-13 00:16:14 +02:00
AsamK
89756bb7a7 Refactor group story send 2026-07-12 17:04:32 +02:00
tonycpsu
6808b66897
Add group story support via --group-id (#2083)
* Add group story support to core library layer

Extend Manager.sendStory() with an optional GroupId parameter and add
SendHelper.sendGroupStoryMessage() for endorsement-aware group story
delivery, laying the groundwork for group story support (task 1 of 4).
The existing My Story code path is unchanged.

* Add --group-id support to SendStoryCommand

Passes an optional GroupId through to Manager.sendStory so stories can
be posted to a group instead of only My Story, and surfaces
GroupNotFoundException / NotAGroupMemberException as user errors.

* Update stubs for 3-parameter sendStory and add empty-recipient guard

Updates DbusManagerImpl and StubManager (in SubscribeCallEventsTest) to
match the new 3-parameter sendStory signature: (String attachment,
boolean allowsReplies, Optional<GroupId> groupId).

Also includes the empty-recipient guard added after Task 1 review to
prevent stories from being sent to groups where the user is the only member.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MR2KF56Qcf9qNH1URj3XWs

* Document group story support in man page and changelog

- Add --group-id (-g) option to sendStory command in man page
- Update CHANGELOG to mention group story support via --group-id
- Maintain alphabetical order of options in sendStory section

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MR2KF56Qcf9qNH1URj3XWs

* Address review findings: endorsement safety and error handling

- Filter group story recipients by ACI type and endorsement availability
  to prevent ClassCastException and NPE on edge cases
- Add empty-recipient guard after endorsement filtering
- Skip known-unregistered recipients before address resolution, matching
  the pattern from sendGroupMessageInternal
- Add debug logging when recipients are filtered out
- Fix redundant error message prefixing in SendStoryCommand
- Add .superpowers/ to .gitignore

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MR2KF56Qcf9qNH1URj3XWs

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-12 16:32:40 +02:00
AsamK
14f98602d2 Update README 2026-07-12 15:35:50 +02:00
AsamK
fd98cce8b3 Don't send read receipt if disabled in configuration
Only send sync read message in this case
2026-07-12 10:50:43 +02:00
AsamK
4f4a4b9fb4 Refactor manager mock 2026-07-12 10:21:37 +02:00
AsamK
e15c29e4e8 Remove old svr2 enclave 2026-07-12 10:10:36 +02:00
dependabot[bot]
2e367644d9
Bump com.fasterxml.jackson.core:jackson-databind from 2.20.2 to 2.21.5 (#2085)
Bumps [com.fasterxml.jackson.core:jackson-databind](https://github.com/FasterXML/jackson) from 2.20.2 to 2.21.5.
- [Commits](https://github.com/FasterXML/jackson/commits)

---
updated-dependencies:
- dependency-name: com.fasterxml.jackson.core:jackson-databind
  dependency-version: 2.21.5
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-12 10:03:21 +02:00
ziggy
a041826cd5 Report invalid content from retried envelopes 2026-07-10 19:16:03 +03:00
ziggy
712c9ea741 Preserve group filters for invalid content 2026-07-10 18:27:17 +03:00
ziggy
8b74f0653d Handle invalid range variants safely 2026-07-10 18:01:23 +03:00
ziggy
a770b03fe6 Complete invalid envelope diagnostics 2026-07-10 17:54:03 +03:00
ziggy
1cce283aca Expose invalid envelope content in receive output 2026-07-10 17:48:12 +03:00
26 changed files with 935 additions and 1113 deletions

1
.gitignore vendored
View File

@ -20,3 +20,4 @@ man/*.1
man/*.5
man/man1
man/man5
.superpowers/

View File

@ -2,9 +2,16 @@
## [Unreleased]
## [0.14.6] - 2026-07-12
### Added
- New `sendStory` command to post file attachment stories to "My Story"
- New `sendStory` command to post file attachment stories to "My Story" or to a group via `--group-id`
### Improved
- The account parameter `-a` now supports ACI in addition to phone number
- Disabling read receipts in configuration now prevents sending read receipts (only sync message to linked devices is still sent)
### Fixed

View File

@ -3,7 +3,7 @@
signal-cli is a commandline interface for the [Signal messenger](https://signal.org/).
It supports registering, verifying, sending and receiving messages.
signal-cli uses a [patched libsignal-service-java](https://github.com/Turasa/libsignal-service-java),
extracted from the [Signal-Android source code](https://github.com/signalapp/Signal-Android/tree/main/libsignal-service).
extracted from the [Signal-Android source code](https://github.com/signalapp/Signal-Android/tree/main/lib/libsignal-service).
For registering you need a phone number where you can receive SMS or incoming calls.
signal-cli is primarily intended to be used on servers to notify admins of important events.

View File

@ -10,7 +10,7 @@ plugins {
allprojects {
group = "org.asamk"
version = "0.14.6-SNAPSHOT"
version = "0.14.7-SNAPSHOT"
}
java {

View File

@ -45,6 +45,9 @@
<content_attribute id="social-chat">intense</content_attribute>
</content_rating>
<releases>
<release version="0.14.6" date="2026-07-12">
<url type="details">https://github.com/AsamK/signal-cli/releases/tag/v0.14.6</url>
</release>
<release version="0.14.5" date="2026-06-11">
<url type="details">https://github.com/AsamK/signal-cli/releases/tag/v0.14.5</url>
</release>

View File

@ -7,7 +7,7 @@ signal-service = "2.15.3_unofficial_149"
[libraries]
bouncycastle = "org.bouncycastle:bcprov-jdk18on:1.84"
jackson-databind = "com.fasterxml.jackson.core:jackson-databind:2.20.2"
jackson-databind = "com.fasterxml.jackson.core:jackson-databind:2.21.5"
argparse4j = "net.sourceforge.argparse4j:argparse4j:0.9.0"
dbusjava = "com.github.hypfvieh:dbus-java-transport-native-unixsocket:5.0.0"
zxing = "com.google.zxing:core:3.5.4"

View File

@ -221,12 +221,17 @@ public interface Manager extends Closeable {
) throws IOException, AttachmentInvalidException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException, UnregisteredRecipientException, InvalidStickerException;
/**
* Post a file attachment story to "My Story".
* Post a file attachment story to "My Story" or to a group.
*
* @param attachment path to the file to upload and post as a story
* @param allowsReplies whether other users are allowed to reply to this story
* @param groupId if present, post the story to this group instead of "My Story"
*/
SendMessageResults sendStory(String attachment, boolean allowsReplies) throws IOException, AttachmentInvalidException;
SendMessageResults sendStory(
String attachment,
boolean allowsReplies,
Optional<GroupId> groupId
) throws IOException, AttachmentInvalidException, GroupNotFoundException, NotAGroupMemberException;
SendMessageResults sendRemoteDeleteMessage(
long targetSentTimestamp,

View File

@ -0,0 +1,54 @@
package org.asamk.signal.manager.api;
import java.util.List;
public final class InvalidEnvelopeContentException extends Exception {
public static final String INVALID_ENVELOPE_CONTENT = "INVALID_ENVELOPE_CONTENT";
public static final String DATA_MESSAGE_BODY_RANGE_OUT_OF_BOUNDS = "DATA_MESSAGE_BODY_RANGE_OUT_OF_BOUNDS";
private final String code;
private final String sender;
private final int senderDevice;
private final Integer bodyLength;
private final List<InvalidBodyRange> invalidBodyRanges;
public InvalidEnvelopeContentException(
final String message,
final String code,
final String sender,
final int senderDevice,
final Integer bodyLength,
final List<InvalidBodyRange> invalidBodyRanges,
final Throwable cause
) {
super(message, cause);
this.code = code;
this.sender = sender;
this.senderDevice = senderDevice;
this.bodyLength = bodyLength;
this.invalidBodyRanges = List.copyOf(invalidBodyRanges);
}
public String getCode() {
return code;
}
public String getSender() {
return sender;
}
public int getSenderDevice() {
return senderDevice;
}
public Integer getBodyLength() {
return bodyLength;
}
public List<InvalidBodyRange> getInvalidBodyRanges() {
return invalidBodyRanges;
}
public record InvalidBodyRange(int index, Integer start, Integer length, String type) {}
}

View File

@ -4,6 +4,7 @@ import org.asamk.signal.manager.groups.GroupUtils;
import org.asamk.signal.manager.helper.RecipientAddressResolver;
import org.asamk.signal.manager.storage.recipients.RecipientResolver;
import org.asamk.signal.manager.util.MimeUtils;
import org.signal.core.models.ServiceId;
import org.signal.libsignal.metadata.ProtocolException;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer;
@ -1032,6 +1033,8 @@ public record MessageEnvelope(
? recipientResolver.resolveRecipient(serviceId)
: envelope.isUnidentifiedSender() && content != null
? recipientResolver.resolveRecipient(content.getSender())
: exception instanceof InvalidEnvelopeContentException e && e.getSender() != null
? recipientResolver.resolveRecipient(ServiceId.parseOrThrow(e.getSender()))
: exception instanceof ProtocolException e
? recipientResolver.resolveRecipient(e.getSender())
: null;
@ -1039,6 +1042,7 @@ public record MessageEnvelope(
? envelope.getSourceDevice()
: content != null
? content.getSenderDevice()
: exception instanceof InvalidEnvelopeContentException e ? e.getSenderDevice()
: exception instanceof ProtocolException e ? e.getSenderDevice() : 0;
Optional<Receipt> receipt;

View File

@ -32,7 +32,6 @@ class LiveConfig {
private static final String CDSI_MRENCLAVE = "0f6fd79cdfdaa5b2e6337f534d3baf999318b0c462a7ac1f41297a3e4b424a57";
private static final String SVR2_MRENCLAVE = "ced8217b26228e4b210c985786999d095c4958a94faf37b14acaf25c4cbb02a4";
private static final String SVR2_MRENCLAVE_LEGACY = "1240acbd4aa26974184844c8a46b1022d3957ac8a76c1fd8f5b1a15141ee0708";
private static final String SVR2_MRENCLAVE_OLD_LEGACY = "29cd63c87bea751e3bfd0fbd401279192e2e5c99948b4ee9437eafc4968355fb";
private static final String URL = "https://chat.signal.org";
private static final String CDN_URL = "https://cdn.signal.org";
@ -95,7 +94,7 @@ class LiveConfig {
createDefaultServiceConfiguration(interceptors),
getUnidentifiedSenderTrustRoots(),
CDSI_MRENCLAVE,
List.of(SVR2_MRENCLAVE, SVR2_MRENCLAVE_LEGACY, SVR2_MRENCLAVE_OLD_LEGACY));
List.of(SVR2_MRENCLAVE, SVR2_MRENCLAVE_LEGACY));
}
private LiveConfig() {

View File

@ -32,7 +32,6 @@ class StagingConfig {
private static final String CDSI_MRENCLAVE = "0f6fd79cdfdaa5b2e6337f534d3baf999318b0c462a7ac1f41297a3e4b424a57";
private static final String SVR2_MRENCLAVE = "3c699f4975aaa3d172c0aad042f94f031b2b03e10b9c19a45116a01693d83302";
private static final String SVR2_MRENCLAVE_LEGACY = "97f151f6ed078edbbfd72fa9cae694dcc08353f1f5e8d9ccd79a971b10ffc535";
private static final String SVR2_MRENCLAVE_OLD_LEGACY = "a75542d82da9f6914a1e31f8a7407053b99cc99a0e7291d8fbd394253e19b036";
private static final String URL = "https://chat.staging.signal.org";
private static final String CDN_URL = "https://cdn-staging.signal.org";
@ -95,7 +94,7 @@ class StagingConfig {
createDefaultServiceConfiguration(interceptors),
getUnidentifiedSenderTrustRoots(),
CDSI_MRENCLAVE,
List.of(SVR2_MRENCLAVE, SVR2_MRENCLAVE_LEGACY, SVR2_MRENCLAVE_OLD_LEGACY));
List.of(SVR2_MRENCLAVE, SVR2_MRENCLAVE_LEGACY));
}
private StagingConfig() {

View File

@ -21,6 +21,7 @@ import org.asamk.signal.manager.actions.SyncStorageDataAction;
import org.asamk.signal.manager.actions.UpdateAccountAttributesAction;
import org.asamk.signal.manager.api.GroupId;
import org.asamk.signal.manager.api.GroupNotFoundException;
import org.asamk.signal.manager.api.InvalidEnvelopeContentException;
import org.asamk.signal.manager.api.MessageEnvelope;
import org.asamk.signal.manager.api.Pair;
import org.asamk.signal.manager.api.ReceiveConfig;
@ -31,6 +32,7 @@ import org.asamk.signal.manager.groups.GroupUtils;
import org.asamk.signal.manager.internal.SignalDependencies;
import org.asamk.signal.manager.jobs.RetrieveStickerPackJob;
import org.asamk.signal.manager.storage.SignalAccount;
import org.asamk.signal.manager.storage.groups.GroupInfo;
import org.asamk.signal.manager.storage.groups.GroupInfoV1;
import org.asamk.signal.manager.storage.recipients.RecipientAddress;
import org.asamk.signal.manager.storage.recipients.RecipientId;
@ -48,10 +50,12 @@ import org.signal.libsignal.protocol.InvalidMessageException;
import org.signal.libsignal.protocol.groups.GroupSessionBuilder;
import org.signal.libsignal.protocol.message.DecryptionErrorMessage;
import org.signal.libsignal.zkgroup.InvalidInputException;
import org.signal.libsignal.zkgroup.groups.GroupMasterKey;
import org.signal.libsignal.zkgroup.profiles.ProfileKey;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.InvalidMessageStructureException;
import org.whispersystems.signalservice.api.crypto.EnvelopeMetadata;
import org.whispersystems.signalservice.api.crypto.SignalGroupSessionBuilder;
import org.whispersystems.signalservice.api.crypto.SignalServiceCipherResult;
import org.whispersystems.signalservice.api.messages.EnvelopeContentValidator;
@ -69,7 +73,12 @@ import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSy
import org.whispersystems.signalservice.api.messages.multidevice.StickerPackOperationMessage;
import org.whispersystems.signalservice.api.push.ServiceIdType;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.internal.push.BodyRange;
import org.whispersystems.signalservice.internal.push.Content;
import org.whispersystems.signalservice.internal.push.DataMessage;
import org.whispersystems.signalservice.internal.push.Envelope;
import org.whispersystems.signalservice.internal.push.GroupContext;
import org.whispersystems.signalservice.internal.push.GroupContextV2;
import org.whispersystems.signalservice.internal.push.UnsupportedDataMessageException;
import java.io.IOException;
@ -85,6 +94,12 @@ import java.util.stream.Collectors;
public final class IncomingMessageHandler {
private static final Logger logger = LoggerFactory.getLogger(IncomingMessageHandler.class);
private static final String DATA_MESSAGE_BODY_RANGE_OUT_OF_BOUNDS_REASON =
"[DataMessage] Body range with out-of-bounds start/length!";
private static final String DATA_MESSAGE_QUOTE_BODY_RANGE_OUT_OF_BOUNDS_REASON =
"[DataMessage] Quote body range with out-of-bounds start/length!";
private static final String EDIT_MESSAGE_BODY_RANGE_OUT_OF_BOUNDS_REASON =
"[EditMessage] Body range with out-of-bounds start/length!";
private final SignalAccount account;
private final SignalDependencies dependencies;
@ -107,6 +122,8 @@ public final class IncomingMessageHandler {
}
SignalServiceContent content = null;
Content decryptedContent = null;
InvalidEnvelopeContentException validationException = null;
if (!envelope.isReceipt()) {
account.getIdentityKeyStore().setRetryingDecryption(true);
try {
@ -114,6 +131,7 @@ public final class IncomingMessageHandler {
final var cipherResult = dependencies.getCipher(destination == null
|| destination.equals(account.getAci()) ? ServiceIdType.ACI : ServiceIdType.PNI)
.decrypt(envelope.getProto(), envelope.getServerDeliveredTimestamp());
decryptedContent = cipherResult.getContent();
content = validate(envelope.getProto(), cipherResult, envelope.getServerDeliveredTimestamp());
if (content == null) {
return new Pair<>(List.of(), null);
@ -124,14 +142,21 @@ public final class IncomingMessageHandler {
.resolveRecipientAddress(recipientId)
.toApiRecipientAddress(), e.getSenderDevice());
return new Pair<>(List.of(), exception);
} catch (InvalidEnvelopeContentException e) {
validationException = e;
} catch (Exception e) {
return new Pair<>(List.of(), e);
} finally {
account.getIdentityKeyStore().setRetryingDecryption(false);
}
}
actions.addAll(checkAndHandleMessage(envelope, content, receiveConfig, handler, null));
return new Pair<>(actions, null);
actions.addAll(checkAndHandleMessage(envelope,
content,
validationException == null ? null : decryptedContent,
receiveConfig,
handler,
validationException));
return new Pair<>(actions, validationException);
}
public Pair<List<HandleAction>, Exception> handleEnvelope(
@ -144,6 +169,7 @@ public final class IncomingMessageHandler {
actions.add(RefreshPreKeysAction.create());
}
SignalServiceContent content = null;
Content decryptedContent = null;
Exception exception = null;
if (envelope.getSourceServiceId() != null) {
// Store uuid if we don't have it already
@ -167,6 +193,7 @@ public final class IncomingMessageHandler {
final var cipherResult = dependencies.getCipher(destination == null
|| destination.equals(account.getAci()) ? ServiceIdType.ACI : ServiceIdType.PNI)
.decrypt(envelope.getProto(), envelope.getServerDeliveredTimestamp());
decryptedContent = cipherResult.getContent();
content = validate(envelope.getProto(), cipherResult, envelope.getServerDeliveredTimestamp());
if (content == null) {
return new Pair<>(List.of(), null);
@ -217,7 +244,12 @@ public final class IncomingMessageHandler {
}
}
actions.addAll(checkAndHandleMessage(envelope, content, receiveConfig, handler, exception));
actions.addAll(checkAndHandleMessage(envelope,
content,
exception instanceof InvalidEnvelopeContentException ? decryptedContent : null,
receiveConfig,
handler,
exception));
return new Pair<>(actions, exception);
}
@ -225,7 +257,8 @@ public final class IncomingMessageHandler {
Envelope envelope,
SignalServiceCipherResult cipherResult,
long serverDeliveredTimestamp
) throws ProtocolInvalidKeyException, ProtocolInvalidMessageException, UnsupportedDataMessageException, InvalidMessageStructureException {
) throws ProtocolInvalidKeyException, ProtocolInvalidMessageException, UnsupportedDataMessageException,
InvalidMessageStructureException, InvalidEnvelopeContentException {
final var content = cipherResult.getContent();
final var envelopeMetadata = cipherResult.getMetadata();
final var validationResult = EnvelopeContentValidator.INSTANCE.validate(envelope,
@ -234,8 +267,17 @@ public final class IncomingMessageHandler {
cipherResult.getMetadata().getCiphertextMessageType());
if (validationResult instanceof EnvelopeContentValidator.Result.Invalid v) {
logger.warn("Invalid content! {}", v.getReason(), v.getThrowable());
return null;
final var exception = createInvalidEnvelopeContentException(v, envelopeMetadata, content);
logger.warn("Invalid content! reason={} code={} source={} sourceDevice={} timestamp={} bodyLength={} invalidBodyRanges={}",
exception.getMessage(),
exception.getCode(),
exception.getSender(),
exception.getSenderDevice(),
envelope.clientTimestamp,
exception.getBodyLength(),
exception.getInvalidBodyRanges());
logger.debug("Invalid content validation location", v.getThrowable());
throw exception;
}
if (validationResult instanceof EnvelopeContentValidator.Result.UnsupportedDataMessage v) {
@ -252,9 +294,105 @@ public final class IncomingMessageHandler {
serverDeliveredTimestamp);
}
static InvalidEnvelopeContentException createInvalidEnvelopeContentException(
final EnvelopeContentValidator.Result.Invalid validationResult,
final EnvelopeMetadata envelopeMetadata,
final Content content
) {
final var reason = validationResult.getReason();
final var dataMessage = getDataMessage(content, reason);
final String body;
final List<BodyRange> bodyRanges;
if (DATA_MESSAGE_QUOTE_BODY_RANGE_OUT_OF_BOUNDS_REASON.equals(reason)) {
if (dataMessage == null || dataMessage.quote == null) {
body = null;
bodyRanges = null;
} else {
body = dataMessage.quote.text;
bodyRanges = dataMessage.quote.bodyRanges;
}
} else if (dataMessage == null) {
body = null;
bodyRanges = null;
} else {
body = dataMessage.body;
bodyRanges = dataMessage.bodyRanges;
}
final Integer bodyLength = bodyRanges == null ? null : body == null ? 0 : body.length();
final List<InvalidEnvelopeContentException.InvalidBodyRange> invalidBodyRanges = new ArrayList<>();
if (bodyRanges != null) {
for (int i = 0; i < bodyRanges.size(); i++) {
final var range = bodyRanges.get(i);
final long start = range.start == null ? 0 : range.start;
final long length = range.length == null ? 0 : range.length;
if (start < 0 || length < 0 || start + length > bodyLength) {
invalidBodyRanges.add(new InvalidEnvelopeContentException.InvalidBodyRange(i,
range.start,
range.length,
getBodyRangeType(range)));
}
}
}
final var code = isBodyRangeOutOfBoundsReason(reason)
? InvalidEnvelopeContentException.DATA_MESSAGE_BODY_RANGE_OUT_OF_BOUNDS
: InvalidEnvelopeContentException.INVALID_ENVELOPE_CONTENT;
return new InvalidEnvelopeContentException(reason,
code,
envelopeMetadata.getSourceServiceId().toString(),
envelopeMetadata.getSourceDeviceId(),
bodyLength,
invalidBodyRanges,
validationResult.getThrowable());
}
private static DataMessage getDataMessage(final Content content, final String validationReason) {
if (validationReason.startsWith("[EditMessage]")) {
return getEditDataMessage(content);
}
if (content.dataMessage != null) {
return content.dataMessage;
}
if (content.syncMessage != null && content.syncMessage.sent != null
&& content.syncMessage.sent.message != null) {
return content.syncMessage.sent.message;
}
return getEditDataMessage(content);
}
private static DataMessage getEditDataMessage(final Content content) {
if (content.editMessage != null) {
return content.editMessage.dataMessage;
}
if (content.syncMessage != null && content.syncMessage.sent != null
&& content.syncMessage.sent.editMessage != null) {
return content.syncMessage.sent.editMessage.dataMessage;
}
return null;
}
private static boolean isBodyRangeOutOfBoundsReason(final String reason) {
return DATA_MESSAGE_BODY_RANGE_OUT_OF_BOUNDS_REASON.equals(reason)
|| DATA_MESSAGE_QUOTE_BODY_RANGE_OUT_OF_BOUNDS_REASON.equals(reason)
|| EDIT_MESSAGE_BODY_RANGE_OUT_OF_BOUNDS_REASON.equals(reason);
}
private static String getBodyRangeType(final BodyRange range) {
if (range.style != null) {
return "STYLE_" + range.style.name();
}
if (range.mentionAci != null || range.mentionAciBinary != null) {
return "MENTION";
}
return "UNKNOWN";
}
private List<HandleAction> checkAndHandleMessage(
final SignalServiceEnvelope envelope,
final SignalServiceContent content,
final Content invalidContent,
final ReceiveConfig receiveConfig,
final Manager.ReceiveMessageHandler handler,
final Exception exception
@ -282,19 +420,21 @@ public final class IncomingMessageHandler {
account.getMessageSendLogStore().deleteEntryForRecipient(envelope.getTimestamp(), sender, senderDeviceId);
}
var notAllowedToSendToGroup = isNotAllowedToSendToGroup(envelope, content);
final var groupFilterInfo = getGroupFilterInfo(content, invalidContent, exception);
var notAllowedToSendToGroup = isNotAllowedToSendToGroup(envelope, content, exception, groupFilterInfo);
final var groupContext = getGroupContext(content);
if (groupContext != null && groupContext.getGroupV2().isPresent()) {
handleGroupV2Context(groupContext.getGroupV2().get(), receiveConfig.ignoreAvatars());
}
// Check again in case the user just joined the group
notAllowedToSendToGroup = notAllowedToSendToGroup && isNotAllowedToSendToGroup(envelope, content);
notAllowedToSendToGroup = notAllowedToSendToGroup
&& isNotAllowedToSendToGroup(envelope, content, exception, groupFilterInfo);
if (isMessageBlocked(envelope, content)) {
if (isMessageBlocked(envelope, content, exception, groupFilterInfo)) {
logger.info("Ignoring a message from blocked user/group: {}", envelope.getTimestamp());
return List.of();
} else if (notAllowedToSendToGroup) {
final var senderAddress = getSenderAddress(envelope, content);
final var senderAddress = getSenderAddress(envelope, content, exception);
logger.info("Ignoring a group message from an unauthorized sender (no member or admin): {} {}",
senderAddress == null ? null : senderAddress.getIdentifier(),
envelope.getTimestamp());
@ -733,7 +873,7 @@ public final class IncomingMessageHandler {
return new Pair<>(actions, longTexts);
}
private SignalServiceGroupContext getGroupContext(SignalServiceContent content) {
private static SignalServiceGroupContext getGroupContext(SignalServiceContent content) {
if (content == null) {
return null;
}
@ -759,51 +899,62 @@ public final class IncomingMessageHandler {
return null;
}
private boolean isMessageBlocked(SignalServiceEnvelope envelope, SignalServiceContent content) {
SignalServiceAddress source = getSenderAddress(envelope, content);
if (source == null) {
return false;
}
final var recipientId = account.getRecipientResolver().resolveRecipient(source);
if (context.getContactHelper().isContactBlocked(recipientId)) {
return true;
private boolean isMessageBlocked(
SignalServiceEnvelope envelope,
SignalServiceContent content,
Exception exception,
GroupFilterInfo groupFilterInfo
) {
SignalServiceAddress source = getSenderAddress(envelope, content, exception);
if (source != null) {
final var recipientId = account.getRecipientResolver().resolveRecipient(source);
if (context.getContactHelper().isContactBlocked(recipientId)) {
return true;
}
}
final var groupContext = getGroupContext(content);
if (groupContext != null) {
var groupId = GroupUtils.getGroupId(groupContext);
return context.getGroupHelper().isGroupBlocked(groupId);
if (groupFilterInfo != null) {
return isGroupBlocked(context.getGroupHelper().getGroup(groupFilterInfo.groupId()));
}
return false;
}
private boolean isNotAllowedToSendToGroup(SignalServiceEnvelope envelope, SignalServiceContent content) {
SignalServiceAddress source = getSenderAddress(envelope, content);
private boolean isNotAllowedToSendToGroup(
SignalServiceEnvelope envelope,
SignalServiceContent content,
Exception exception,
GroupFilterInfo groupFilterInfo
) {
SignalServiceAddress source = getSenderAddress(envelope, content, exception);
if (source == null) {
return false;
}
final var groupContext = getGroupContext(content);
if (groupContext == null) {
if (groupFilterInfo == null) {
return false;
}
if (groupContext.getGroupV1().isPresent()) {
var groupInfo = groupContext.getGroupV1().get();
if (groupInfo.getType() == SignalServiceGroup.Type.QUIT) {
return false;
}
if (groupFilterInfo.isQuit()) {
return false;
}
final var message = content.getDataMessage().orElse(null);
final var recipientId = account.getRecipientResolver().resolveRecipient(source);
final var groupId = GroupUtils.getGroupId(groupContext);
final var group = context.getGroupHelper().getGroup(groupId);
final var group = context.getGroupHelper().getGroup(groupFilterInfo.groupId());
return isNotAllowedToSendToGroup(group, recipientId, groupFilterInfo);
}
if (message != null && message.getAdminDelete().isPresent() && (group == null || !group.isAdmin(recipientId))) {
static boolean isGroupBlocked(final GroupInfo group) {
return group != null && group.isBlocked();
}
static boolean isNotAllowedToSendToGroup(
final GroupInfo group,
final RecipientId recipientId,
final GroupFilterInfo groupFilterInfo
) {
if (groupFilterInfo.hasAdminDelete() && (group == null || !group.isAdmin(recipientId))) {
return true;
}
@ -811,24 +962,102 @@ public final class IncomingMessageHandler {
return false;
}
if (!group.isMember(recipientId) && !(
group.isPendingMember(recipientId) && message != null && message.isGroupV2Update()
)) {
if (!group.isMember(recipientId)
&& !(group.isPendingMember(recipientId) && groupFilterInfo.isGroupV2Update())) {
return true;
}
if (group.isAnnouncementGroup() && !group.isAdmin(recipientId)) {
return message == null
|| message.getBody().isPresent()
|| message.getAttachments().isPresent()
|| message.getQuote().isPresent()
|| message.getPreviews().isPresent()
|| message.getMentions().isPresent()
|| message.getSticker().isPresent();
return groupFilterInfo.hasAnnouncementContent();
}
return false;
}
static GroupFilterInfo getGroupFilterInfo(
final SignalServiceContent content,
final Content invalidContent,
final Exception exception
) {
if (content != null) {
final var groupContext = getGroupContext(content);
if (groupContext == null) {
return null;
}
final var message = content.getDataMessage().orElse(null);
return new GroupFilterInfo(GroupUtils.getGroupId(groupContext),
groupContext.getGroupV1()
.map(group -> group.getType() == SignalServiceGroup.Type.QUIT)
.orElse(false),
message != null && message.getAdminDelete().isPresent(),
message != null && message.isGroupV2Update(),
message == null
|| message.getBody().isPresent()
|| message.getAttachments().isPresent()
|| message.getQuote().isPresent()
|| message.getPreviews().isPresent()
|| message.getMentions().isPresent()
|| message.getSticker().isPresent());
}
if (invalidContent == null || !(exception instanceof InvalidEnvelopeContentException e)) {
return null;
}
final var message = getDataMessage(invalidContent, e.getMessage());
if (message != null) {
final var groupId = getGroupId(message);
if (groupId == null) {
return null;
}
return new GroupFilterInfo(groupId,
message.group != null
&& message.group.type == GroupContext.Type.QUIT,
message.adminDelete != null,
message.groupV2 != null
&& message.groupV2.groupChange != null
&& message.groupV2.groupChange.size() > 0,
message.body != null
|| !message.attachments.isEmpty()
|| message.quote != null
|| !message.preview.isEmpty()
|| message.bodyRanges.stream()
.anyMatch(range -> range.mentionAci != null || range.mentionAciBinary != null)
|| message.sticker != null);
}
if (invalidContent.storyMessage != null && invalidContent.storyMessage.group != null) {
final var groupId = getGroupId(invalidContent.storyMessage.group);
return groupId == null ? null : new GroupFilterInfo(groupId, false, false, false, true);
}
return null;
}
private static GroupId getGroupId(final DataMessage message) {
if (message.group != null && message.group.id != null) {
return GroupId.v1(message.group.id.toByteArray());
}
return message.groupV2 == null ? null : getGroupId(message.groupV2);
}
private static GroupId getGroupId(final GroupContextV2 groupContext) {
if (groupContext.masterKey == null) {
return null;
}
try {
return GroupUtils.getGroupIdV2(new GroupMasterKey(groupContext.masterKey.toByteArray()));
} catch (InvalidInputException e) {
return null;
}
}
record GroupFilterInfo(
GroupId groupId,
boolean isQuit,
boolean hasAdminDelete,
boolean isGroupV2Update,
boolean hasAnnouncementContent
) {}
private Pair<List<HandleAction>, Map<String, String>> handleSignalServiceDataMessage(
SignalServiceDataMessage message,
boolean isSync,
@ -1067,7 +1296,10 @@ public final class IncomingMessageHandler {
this.account.getProfileStore().storeProfileKey(source, profileKey);
}
private SignalServiceAddress getSenderAddress(SignalServiceEnvelope envelope, SignalServiceContent content) {
private static SignalServiceAddress getSenderAddress(
final SignalServiceEnvelope envelope,
final SignalServiceContent content
) {
final var serviceId = envelope.getSourceServiceId();
if (!envelope.isUnidentifiedSender() && serviceId != null) {
return new SignalServiceAddress(serviceId);
@ -1078,6 +1310,24 @@ public final class IncomingMessageHandler {
}
}
static SignalServiceAddress getSenderAddress(
final SignalServiceEnvelope envelope,
final SignalServiceContent content,
final Exception exception
) {
final var source = getSenderAddress(envelope, content);
if (source != null) {
return source;
}
if (exception instanceof InvalidEnvelopeContentException e && e.getSender() != null) {
final var sender = ServiceId.parseOrNull(e.getSender());
if (sender != null) {
return new SignalServiceAddress(sender);
}
}
return null;
}
private DeviceAddress getSender(SignalServiceEnvelope envelope, SignalServiceContent content) {
final var serviceId = envelope.getSourceServiceId();
if (!envelope.isUnidentifiedSender() && serviceId != null) {

View File

@ -368,7 +368,8 @@ public class SendHelper {
timestamp,
storyMessageRecipients,
null);
} catch (UntrustedIdentityException | InvalidKeyException | NoSessionException | InvalidRegistrationIdException e) {
} catch (UntrustedIdentityException | InvalidKeyException | NoSessionException |
InvalidRegistrationIdException e) {
throw new IOException(e);
}
@ -379,6 +380,43 @@ public class SendHelper {
return results;
}
/**
* Send a story message (file attachment) to a group.
*/
public List<SendMessageResult> sendGroupStoryMessage(
SignalServiceStoryMessage storyMessage,
long timestamp,
GroupInfoV2 groupInfo,
boolean allowsReplies
) throws IOException {
final var messageSender = dependencies.getMessageSender();
final var allRecipientIds = groupInfo.getMembersWithout(account.getSelfRecipientId());
final SenderKeySenderHandler senderKeySender = (distId, recipients, unidentifiedAccess, groupSendEndorsements, isRecipientUpdate) -> messageSender.sendGroupStory(
distId,
Optional.of(groupInfo.getMasterKey().serialize()),
recipients,
unidentifiedAccess,
groupSendEndorsements,
isRecipientUpdate,
storyMessage,
timestamp,
recipients.stream()
.map(address -> new SignalServiceStoryMessageRecipient(address,
List.of(groupInfo.getDistributionId().asUuid().toString()),
allowsReplies))
.collect(Collectors.toSet()),
null);
final var results = sendGroupMessageInternal(null, senderKeySender, allRecipientIds, groupInfo, false);
for (var r : results) {
handleSendMessageResult(r);
}
return results;
}
private List<SendMessageResult> sendAsGroupMessage(
final SignalServiceDataMessage.Builder messageBuilder,
final GroupInfo g,
@ -646,7 +684,7 @@ public class SendHelper {
legacyTargets.removeAll(senderKeyTargets);
final boolean onlyTargetIsSelfWithLinkedDevice = targetRecipientIds.isEmpty() && account.isMultiDevice();
if (!legacyTargets.isEmpty() || onlyTargetIsSelfWithLinkedDevice) {
if (legacySender != null && (!legacyTargets.isEmpty() || onlyTargetIsSelfWithLinkedDevice)) {
if (!legacyTargets.isEmpty()) {
logger.debug("Need to do {} legacy sends.", legacyTargets.size());
} else {

View File

@ -83,6 +83,7 @@ import org.asamk.signal.manager.storage.AttachmentStore;
import org.asamk.signal.manager.storage.AvatarStore;
import org.asamk.signal.manager.storage.SignalAccount;
import org.asamk.signal.manager.storage.groups.GroupInfo;
import org.asamk.signal.manager.storage.groups.GroupInfoV2;
import org.asamk.signal.manager.storage.identities.IdentityInfo;
import org.asamk.signal.manager.storage.recipients.RecipientAddress;
import org.asamk.signal.manager.storage.recipients.RecipientId;
@ -108,6 +109,7 @@ import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage;
import org.whispersystems.signalservice.api.messages.SignalServiceGroupV2;
import org.whispersystems.signalservice.api.messages.SignalServicePreview;
import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage;
import org.whispersystems.signalservice.api.messages.SignalServiceStoryMessage;
@ -794,13 +796,20 @@ public class ManagerImpl implements Manager {
) {
try {
final var recipientId = context.getRecipientHelper().resolveRecipient(sender);
final var result = context.getSendHelper().sendReceiptMessage(receiptMessage, recipientId);
List<SendMessageResult> results;
if (receiptMessage.isDeliveryReceipt() || !Boolean.FALSE.equals(account.getConfigurationStore()
.getReadReceipts())) {
final var result = context.getSendHelper().sendReceiptMessage(receiptMessage, recipientId);
results = List.of(toSendMessageResult(result));
} else {
results = List.of();
}
final var aci = account.getRecipientAddressResolver().resolveRecipientAddress(recipientId).aci();
if (aci.isPresent()) {
context.getSyncHelper().sendSyncReceiptMessage(aci.get(), receiptMessage);
}
return new SendMessageResults(timestamp, Map.of(sender, List.of(toSendMessageResult(result))));
return new SendMessageResults(timestamp, Map.of(sender, results));
} catch (UnregisteredRecipientException e) {
return new SendMessageResults(timestamp,
Map.of(sender, List.of(SendMessageResult.unregisteredFailure(sender.toPartialRecipientAddress()))));
@ -837,8 +846,9 @@ public class ManagerImpl implements Manager {
@Override
public SendMessageResults sendStory(
String attachment,
boolean allowsReplies
) throws IOException, AttachmentInvalidException {
boolean allowsReplies,
Optional<GroupId> groupId
) throws IOException, AttachmentInvalidException, GroupNotFoundException, NotAGroupMemberException {
final var file = new File(attachment);
final var mimeType = MimeUtils.getFileMimeType(file);
if (mimeType.isEmpty() || (!mimeType.get().startsWith("image/") && !mimeType.get().startsWith("video/"))) {
@ -846,6 +856,10 @@ public class ManagerImpl implements Manager {
new IOException("Stories only support image and video attachments"));
}
if (groupId.isPresent()) {
return sendGroupStory(attachment, allowsReplies, groupId.get());
}
final var recipients = account.getRecipientStore()
.getRecipients(true, Optional.of(false), Set.of(), Optional.empty());
final var recipientIds = recipients.stream()
@ -878,6 +892,45 @@ public class ManagerImpl implements Manager {
return new SendMessageResults(timestamp, results);
}
private SendMessageResults sendGroupStory(
String attachment,
boolean allowsReplies,
GroupId groupId
) throws IOException, AttachmentInvalidException, GroupNotFoundException, NotAGroupMemberException {
final var groupInfo = context.getGroupHelper().getGroup(groupId);
if (groupInfo == null) {
throw new GroupNotFoundException(groupId);
}
if (!groupInfo.isMember(account.getSelfRecipientId())) {
throw new NotAGroupMemberException(groupId, groupInfo.getTitle());
}
if (!(groupInfo instanceof GroupInfoV2 groupInfoV2)) {
throw new IOException("Stories are only supported for V2 groups");
}
final var uploadedAttachment = context.getAttachmentHelper().uploadAttachment(attachment);
final var groupContext = SignalServiceGroupV2.newBuilder(groupInfoV2.getMasterKey())
.withRevision(groupInfoV2.getGroup() == null ? 0 : groupInfoV2.getGroup().revision)
.build();
final var storyMessage = SignalServiceStoryMessage.forFileAttachment(account.getProfileKey().serialize(),
groupContext,
uploadedAttachment,
allowsReplies,
List.of());
final var timestamp = getNextMessageTimestamp();
final var sendResults = context.getSendHelper()
.sendGroupStoryMessage(storyMessage, timestamp, groupInfoV2, allowsReplies);
final var results = new HashMap<RecipientIdentifier, List<SendMessageResult>>();
for (final var sendResult : sendResults) {
final var result = toSendMessageResult(sendResult);
results.put(RecipientIdentifier.Single.fromAddress(result.address()), List.of(result));
}
return new SendMessageResults(timestamp, results);
}
private void applyMessage(
final SignalServiceDataMessage.Builder messageBuilder,
final Message message
@ -1870,7 +1923,7 @@ public class ManagerImpl implements Manager {
var callMessage = SignalServiceCallMessage.forOffer(offerMessage, null);
try {
dependencies.getMessageSender().sendCallMessage(address, null, callMessage);
} catch (org.whispersystems.signalservice.api.crypto.UntrustedIdentityException e) {
} catch (UntrustedIdentityException e) {
throw new IOException("Untrusted identity for call recipient", e);
} catch (NoSessionException e) {
throw new IOException("No session for call recipient", e);
@ -1889,7 +1942,7 @@ public class ManagerImpl implements Manager {
var callMessage = SignalServiceCallMessage.forAnswer(answerMessage, null);
try {
dependencies.getMessageSender().sendCallMessage(address, null, callMessage);
} catch (org.whispersystems.signalservice.api.crypto.UntrustedIdentityException e) {
} catch (UntrustedIdentityException e) {
throw new IOException("Untrusted identity for call recipient", e);
} catch (NoSessionException e) {
throw new IOException("No session for call recipient", e);
@ -1908,7 +1961,7 @@ public class ManagerImpl implements Manager {
var callMessage = SignalServiceCallMessage.forIceUpdates(iceUpdates, null);
try {
dependencies.getMessageSender().sendCallMessage(address, null, callMessage);
} catch (org.whispersystems.signalservice.api.crypto.UntrustedIdentityException e) {
} catch (UntrustedIdentityException e) {
throw new IOException("Untrusted identity for call recipient", e);
} catch (NoSessionException e) {
throw new IOException("No session for call recipient", e);
@ -1934,7 +1987,7 @@ public class ManagerImpl implements Manager {
var callMessage = SignalServiceCallMessage.forHangup(hangupMessage, null);
try {
dependencies.getMessageSender().sendCallMessage(address, null, callMessage);
} catch (org.whispersystems.signalservice.api.crypto.UntrustedIdentityException e) {
} catch (UntrustedIdentityException e) {
throw new IOException("Untrusted identity for call recipient", e);
} catch (NoSessionException e) {
throw new IOException("No session for call recipient", e);
@ -1952,7 +2005,7 @@ public class ManagerImpl implements Manager {
var callMessage = SignalServiceCallMessage.forBusy(busyMessage, null);
try {
dependencies.getMessageSender().sendCallMessage(address, null, callMessage);
} catch (org.whispersystems.signalservice.api.crypto.UntrustedIdentityException e) {
} catch (UntrustedIdentityException e) {
throw new IOException("Untrusted identity for call recipient", e);
} catch (NoSessionException e) {
throw new IOException("No session for call recipient", e);

View File

@ -0,0 +1,174 @@
package org.asamk.signal.manager.helper;
import okio.ByteString;
import org.asamk.signal.manager.api.GroupId;
import org.asamk.signal.manager.api.InvalidEnvelopeContentException;
import org.asamk.signal.manager.storage.groups.GroupInfoV1;
import org.asamk.signal.manager.storage.recipients.TestRecipientId;
import org.junit.jupiter.api.Test;
import org.signal.core.models.ServiceId.ACI;
import org.whispersystems.signalservice.api.crypto.EnvelopeMetadata;
import org.whispersystems.signalservice.api.messages.EnvelopeContentValidator;
import org.whispersystems.signalservice.api.messages.SignalServiceEnvelope;
import org.whispersystems.signalservice.internal.push.BodyRange;
import org.whispersystems.signalservice.internal.push.Content;
import org.whispersystems.signalservice.internal.push.DataMessage;
import org.whispersystems.signalservice.internal.push.EditMessage;
import org.whispersystems.signalservice.internal.push.Envelope;
import org.whispersystems.signalservice.internal.push.GroupContext;
import org.whispersystems.signalservice.internal.push.SyncMessage;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class IncomingMessageHandlerTest {
@Test
void invalidEnvelopeContentReportsOutOfBoundsBodyRange() {
final var sender = ACI.parseOrThrow("2a04f0cc-199f-4b93-99d8-13c6b10a70de");
final var bodyRange = new BodyRange.Builder().start(4).length(3).style(BodyRange.Style.BOLD).build();
final var dataMessage = new DataMessage.Builder().body("hello").bodyRanges(List.of(bodyRange)).build();
final var content = new Content.Builder().dataMessage(dataMessage).build();
final var metadata = new EnvelopeMetadata(sender, null, 2, false, null, sender, 1);
final var validationResult = new EnvelopeContentValidator.Result.Invalid(
"[DataMessage] Body range with out-of-bounds start/length!",
new Throwable());
final var exception = IncomingMessageHandler.createInvalidEnvelopeContentException(validationResult,
metadata,
content);
assertEquals(InvalidEnvelopeContentException.DATA_MESSAGE_BODY_RANGE_OUT_OF_BOUNDS, exception.getCode());
assertEquals(sender.toString(), exception.getSender());
assertEquals(2, exception.getSenderDevice());
assertEquals(5, exception.getBodyLength());
assertEquals(List.of(new InvalidEnvelopeContentException.InvalidBodyRange(0, 4, 3, "STYLE_BOLD")),
exception.getInvalidBodyRanges());
}
@Test
void invalidEnvelopeContentReportsOutOfBoundsBodyRangeFromSyncMessage() {
final var sender = ACI.parseOrThrow("2a04f0cc-199f-4b93-99d8-13c6b10a70de");
final var bodyRange = new BodyRange.Builder().start(-1).length(2).mentionAci(sender.toString()).build();
final var dataMessage = new DataMessage.Builder().body("hello").bodyRanges(List.of(bodyRange)).build();
final var sent = new SyncMessage.Sent.Builder().message(dataMessage).build();
final var content = new Content.Builder().syncMessage(new SyncMessage.Builder().sent(sent).build()).build();
final var metadata = new EnvelopeMetadata(sender, null, 2, false, null, sender, 1);
final var validationResult = new EnvelopeContentValidator.Result.Invalid(
"[DataMessage] Body range with out-of-bounds start/length!",
new Throwable());
final var exception = IncomingMessageHandler.createInvalidEnvelopeContentException(validationResult,
metadata,
content);
assertEquals(InvalidEnvelopeContentException.DATA_MESSAGE_BODY_RANGE_OUT_OF_BOUNDS, exception.getCode());
assertEquals(5, exception.getBodyLength());
assertEquals(List.of(new InvalidEnvelopeContentException.InvalidBodyRange(0, -1, 2, "MENTION")),
exception.getInvalidBodyRanges());
}
@Test
void invalidEnvelopeContentReportsOutOfBoundsBodyRangeFromEditMessage() {
final var sender = ACI.parseOrThrow("2a04f0cc-199f-4b93-99d8-13c6b10a70de");
final var bodyRange = new BodyRange.Builder().start(5).length(1).style(BodyRange.Style.ITALIC).build();
final var dataMessage = new DataMessage.Builder().body("hello").bodyRanges(List.of(bodyRange)).build();
final var editMessage = new EditMessage.Builder().targetSentTimestamp(1L).dataMessage(dataMessage).build();
final var content = new Content.Builder().editMessage(editMessage).build();
final var metadata = new EnvelopeMetadata(sender, null, 2, false, null, sender, 1);
final var validationResult = new EnvelopeContentValidator.Result.Invalid(
"[EditMessage] Body range with out-of-bounds start/length!",
new Throwable());
final var exception = IncomingMessageHandler.createInvalidEnvelopeContentException(validationResult,
metadata,
content);
assertEquals(InvalidEnvelopeContentException.DATA_MESSAGE_BODY_RANGE_OUT_OF_BOUNDS, exception.getCode());
assertEquals(5, exception.getBodyLength());
assertEquals(List.of(new InvalidEnvelopeContentException.InvalidBodyRange(0, 5, 1, "STYLE_ITALIC")),
exception.getInvalidBodyRanges());
}
@Test
void invalidEnvelopeContentReportsOutOfBoundsBodyRangeFromQuote() {
final var sender = ACI.parseOrThrow("2a04f0cc-199f-4b93-99d8-13c6b10a70de");
final var bodyRange = new BodyRange.Builder().start(2).length(2).style(BodyRange.Style.MONOSPACE).build();
final var quote = new DataMessage.Quote.Builder().text("hey").bodyRanges(List.of(bodyRange)).build();
final var dataMessage = new DataMessage.Builder().body("outer body").quote(quote).build();
final var content = new Content.Builder().dataMessage(dataMessage).build();
final var metadata = new EnvelopeMetadata(sender, null, 2, false, null, sender, 1);
final var validationResult = new EnvelopeContentValidator.Result.Invalid(
"[DataMessage] Quote body range with out-of-bounds start/length!",
new Throwable());
final var exception = IncomingMessageHandler.createInvalidEnvelopeContentException(validationResult,
metadata,
content);
assertEquals(InvalidEnvelopeContentException.DATA_MESSAGE_BODY_RANGE_OUT_OF_BOUNDS, exception.getCode());
assertEquals(3, exception.getBodyLength());
assertEquals(List.of(new InvalidEnvelopeContentException.InvalidBodyRange(0, 2, 2, "STYLE_MONOSPACE")),
exception.getInvalidBodyRanges());
}
@Test
void invalidSealedSenderCanBeResolvedForBlocking() {
final var sender = ACI.parseOrThrow("2a04f0cc-199f-4b93-99d8-13c6b10a70de");
final var envelope = new SignalServiceEnvelope(new Envelope.Builder()
.type(Envelope.Type.UNIDENTIFIED_SENDER)
.clientTimestamp(1L)
.build(), 2L);
final var exception = new InvalidEnvelopeContentException("invalid body range",
InvalidEnvelopeContentException.DATA_MESSAGE_BODY_RANGE_OUT_OF_BOUNDS,
sender.toString(),
2,
5,
List.of(),
new Throwable());
final var source = IncomingMessageHandler.getSenderAddress(envelope, null, exception);
assertEquals(sender, source.getServiceId());
}
@Test
void invalidGroupContextIsAvailableForFiltering() {
final var sender = ACI.parseOrThrow("2a04f0cc-199f-4b93-99d8-13c6b10a70de");
final var groupId = new byte[16];
final var group = new GroupContext.Builder()
.id(ByteString.of(groupId))
.type(GroupContext.Type.DELIVER)
.build();
final var bodyRange = new BodyRange.Builder().start(4).length(3).style(BodyRange.Style.BOLD).build();
final var dataMessage = new DataMessage.Builder()
.body("hello")
.bodyRanges(List.of(bodyRange))
.group(group)
.build();
final var content = new Content.Builder().dataMessage(dataMessage).build();
final var metadata = new EnvelopeMetadata(sender, null, 2, false, null, sender, 1);
final var validationResult = new EnvelopeContentValidator.Result.Invalid(
"[DataMessage] Body range with out-of-bounds start/length!",
new Throwable());
final var exception = IncomingMessageHandler.createInvalidEnvelopeContentException(validationResult,
metadata,
content);
final var filterInfo = IncomingMessageHandler.getGroupFilterInfo(null, content, exception);
assertNotNull(filterInfo);
assertEquals(GroupId.v1(groupId), filterInfo.groupId());
assertTrue(filterInfo.hasAnnouncementContent());
final var storedGroup = new GroupInfoV1(GroupId.v1(groupId));
storedGroup.setBlocked(true);
assertTrue(IncomingMessageHandler.isGroupBlocked(storedGroup));
assertTrue(IncomingMessageHandler.isNotAllowedToSendToGroup(storedGroup,
TestRecipientId.createTestId(1),
filterInfo));
}
}

View File

@ -1 +1 @@
0.94.4
0.96.3

View File

@ -639,6 +639,10 @@ Post a file attachment story to your Story, visible to all contacts.
*-a* ATTACHMENT, *--attachment* ATTACHMENT::
Specify the file path to the image or video to post as a story.
*-g* GROUP, *--group-id* GROUP::
Specify a group to post the story to.
Without this flag, the story is posted to "My Story".
*--no-replies*::
Disable replies on this story. By default, replies are allowed.

View File

@ -62,15 +62,15 @@ run() {
fi
elif [ "$DBUS" -eq 1 ]; then
if [ -n "$STD_BUF" ]; then
$STD_BUF "$SIGNAL_CLI" --dbus --verbose --verbose $@ | grep --line-buffered -v 'Warning:' | grep --line-buffered -v 'at org'
$STD_BUF "$SIGNAL_CLI" --dbus --verbose --verbose $@ > >(grep --line-buffered -v 'Warning:' | grep --line-buffered -v 'at org' || true)
else
"$SIGNAL_CLI" --dbus --verbose --verbose $@ | grep --line-buffered -v 'Warning:' | grep --line-buffered -v 'at org'
"$SIGNAL_CLI" --dbus --verbose --verbose $@ > >(grep --line-buffered -v 'Warning:' | grep --line-buffered -v 'at org' || true)
fi
else
if [ -n "$STD_BUF" ]; then
$STD_BUF "$SIGNAL_CLI" --service-environment="staging" --verbose --verbose $@ | grep --line-buffered -v 'Warning:' | grep --line-buffered -v 'at org'
$STD_BUF "$SIGNAL_CLI" --service-environment="staging" --verbose --verbose $@ > >(grep --line-buffered -v 'Warning:' | grep --line-buffered -v 'at org' || true)
else
"$SIGNAL_CLI" --service-environment="staging" --verbose --verbose $@ | grep --line-buffered -v 'Warning:' | grep --line-buffered -v 'at org'
"$SIGNAL_CLI" --service-environment="staging" --verbose --verbose $@ > >(grep --line-buffered -v 'Warning:' | grep --line-buffered -v 'at org' || true)
fi
fi
set +x
@ -294,6 +294,7 @@ fi
run_main -a "$NUMBER_2" deleteLocalAccountData || true
if [ ! -z "$GRAALVM_HOME" ]; then
"$GRAALVM_HOME"/lib/svm/bin/native-image-configure generate --input-dir=src/main/resources/META-INF/native-image/org.asamk/signal-cli/ --input-dir=graalvm-config-dir-linked/ --input-dir=graalvm-config-dir-main/ --output-dir=src/main/resources/META-INF/native-image/org.asamk/signal-cli/
NATIVE_IMAGE_UTILS="$GRAALVM_HOME/lib/svm/bin/native-image-utils"
"$NATIVE_IMAGE_UTILS" generate --input-dir=src/main/resources/META-INF/native-image/org.asamk/signal-cli/ --input-dir=graalvm-config-dir-linked/ --input-dir=graalvm-config-dir-main/ --output-dir=src/main/resources/META-INF/native-image/org.asamk/signal-cli/
rm -r graalvm-config-dir-main graalvm-config-dir-linked
fi

View File

@ -9,9 +9,14 @@ import org.asamk.signal.commands.exceptions.UnexpectedErrorException;
import org.asamk.signal.commands.exceptions.UserErrorException;
import org.asamk.signal.manager.Manager;
import org.asamk.signal.manager.api.AttachmentInvalidException;
import org.asamk.signal.manager.api.GroupId;
import org.asamk.signal.manager.api.GroupNotFoundException;
import org.asamk.signal.manager.api.NotAGroupMemberException;
import org.asamk.signal.output.OutputWriter;
import org.asamk.signal.util.CommandUtil;
import java.io.IOException;
import java.util.Optional;
import static org.asamk.signal.util.SendMessageResultUtils.outputResult;
@ -31,6 +36,8 @@ public class SendStoryCommand implements JsonRpcLocalCommand {
subparser.addArgument("--no-replies")
.action(Arguments.storeTrue())
.help("Disable replies on this story.");
subparser.addArgument("-g", "--group-id")
.help("Specify a group to post the story to. Without this, posts to My Story.");
}
@Override
@ -46,12 +53,19 @@ public class SendStoryCommand implements JsonRpcLocalCommand {
final var noReplies = Boolean.TRUE.equals(ns.getBoolean("no-replies"));
final var groupIdStr = ns.getString("group-id");
final var groupId = groupIdStr != null
? Optional.of(CommandUtil.getGroupId(groupIdStr))
: Optional.<GroupId>empty();
try {
final var results = m.sendStory(attachment, !noReplies);
final var results = m.sendStory(attachment, !noReplies, groupId);
outputResult(outputWriter, results);
} catch (AttachmentInvalidException | IOException e) {
throw new UnexpectedErrorException("Failed to send story: " + e.getMessage() + " (" + e.getClass()
.getSimpleName() + ")", e);
} catch (GroupNotFoundException | NotAGroupMemberException e) {
throw new UserErrorException(e.getMessage());
}
}
}

View File

@ -548,7 +548,11 @@ public class DbusManagerImpl implements Manager {
}
@Override
public SendMessageResults sendStory(String attachment, boolean allowsReplies) {
public SendMessageResults sendStory(
String attachment,
boolean allowsReplies,
Optional<GroupId> groupId
) {
throw new UnsupportedOperationException();
}

View File

@ -1,11 +1,29 @@
package org.asamk.signal.json;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.micronaut.jsonschema.JsonSchema;
import org.asamk.signal.manager.api.InvalidEnvelopeContentException;
import java.util.List;
@JsonSchema(title = "Error")
public record JsonError(String message, String type) {
public record JsonError(
String message,
String type,
@JsonInclude(JsonInclude.Include.NON_NULL) Details details
) {
public static JsonError from(Throwable exception) {
return new JsonError(exception.getMessage(), exception.getClass().getSimpleName());
final var details = exception instanceof InvalidEnvelopeContentException e
? new Details(e.getCode(), e.getBodyLength(), e.getInvalidBodyRanges())
: null;
return new JsonError(exception.getMessage(), exception.getClass().getSimpleName(), details);
}
public record Details(
String code,
Integer bodyLength,
List<InvalidEnvelopeContentException.InvalidBodyRange> invalidBodyRanges
) {}
}

View File

@ -2949,6 +2949,12 @@
"allDeclaredMethods": true,
"allDeclaredConstructors": true
},
{
"type": "org.asamk.signal.json.JsonError$Details",
"allDeclaredFields": true,
"allDeclaredMethods": true,
"allDeclaredConstructors": true
},
{
"type": "org.asamk.signal.json.JsonGroupInfo",
"allDeclaredFields": true,
@ -3419,6 +3425,12 @@
{
"type": "org.asamk.signal.logging.LogConfigurator"
},
{
"type": "org.asamk.signal.manager.api.InvalidEnvelopeContentException$InvalidBodyRange",
"allDeclaredFields": true,
"allDeclaredMethods": true,
"allDeclaredConstructors": true
},
{
"type": "org.asamk.signal.manager.api.PhoneNumberSharingMode",
"allDeclaredFields": true

View File

@ -1,51 +1,15 @@
package org.asamk.signal.http;
import org.asamk.signal.manager.Manager;
import org.asamk.signal.manager.api.CallInfo;
import org.asamk.signal.manager.api.CallOffer;
import org.asamk.signal.manager.api.Configuration;
import org.asamk.signal.manager.api.Device;
import org.asamk.signal.manager.api.DeviceLinkUrl;
import org.asamk.signal.manager.api.Group;
import org.asamk.signal.manager.api.GroupId;
import org.asamk.signal.manager.api.GroupInviteLinkUrl;
import org.asamk.signal.manager.api.Identity;
import org.asamk.signal.manager.api.IdentityVerificationCode;
import org.asamk.signal.manager.api.Message;
import org.asamk.signal.manager.api.MessageEnvelope;
import org.asamk.signal.manager.api.Pair;
import org.asamk.signal.manager.api.ReceiveConfig;
import org.asamk.signal.manager.api.Recipient;
import org.asamk.signal.manager.api.RecipientIdentifier;
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.StickerPack;
import org.asamk.signal.manager.api.StickerPackId;
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.UpdateGroup;
import org.asamk.signal.manager.api.UpdateProfile;
import org.asamk.signal.manager.api.UserStatus;
import org.asamk.signal.manager.api.UsernameLinkUrl;
import org.asamk.signal.manager.api.UsernameStatus;
import org.asamk.signal.testutil.ManagerMock;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.File;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.URI;
import java.time.Duration;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
@ -77,7 +41,8 @@ class SseInitialFlushTest {
@BeforeEach
void setUp() throws Exception {
port = freePort();
handler = new HttpServerHandler(new InetSocketAddress("127.0.0.1", port), new MinimalStubManager());
final Manager manager = ManagerMock.create("+10000000000");
handler = new HttpServerHandler(new InetSocketAddress("127.0.0.1", port), manager);
handler.init();
}
@ -111,436 +76,4 @@ class SseInitialFlushTest {
}, "SSE endpoint did not return the initial response within 2 seconds");
}
// -------------------------------------------------------------------------
// Minimal Manager stub only receive-handler methods need real behaviour;
// everything else is a no-op stub.
// -------------------------------------------------------------------------
private static final class MinimalStubManager implements Manager {
@Override
public String getSelfNumber() {
return "+10000000000";
}
@Override
public String getSelfACI() {
return "00000000-0000-0000-0000-000000000000";
}
@Override
public void addReceiveHandler(ReceiveMessageHandler handler, boolean isWeakListener) {
// no-op
}
@Override
public void removeReceiveHandler(ReceiveMessageHandler handler) {
// no-op
}
@Override
public boolean isReceiving() {
return false;
}
@Override
public void receiveMessages(Optional<Duration> timeout, Optional<Integer> maxMessages, ReceiveMessageHandler handler) {
}
@Override
public void stopReceiveMessages() {
}
@Override
public void setReceiveConfig(ReceiveConfig receiveConfig) {
}
@Override
public Map<String, UserStatus> getUserStatus(Set<String> numbers) {
return Map.of();
}
@Override
public Map<String, UsernameStatus> getUsernameStatus(Set<String> usernames) {
return Map.of();
}
@Override
public void updateAccountAttributes(String deviceName, Boolean unidentifiedDeliveryIndicators, Boolean discoverableByNumber, Boolean numberSharing) {
}
@Override
public Configuration getConfiguration() {
return null;
}
@Override
public void updateConfiguration(Configuration configuration) {
}
@Override
public void updateProfile(UpdateProfile updateProfile) {
}
@Override
public String getUsername() {
return null;
}
@Override
public UsernameLinkUrl getUsernameLink() {
return null;
}
@Override
public void setUsername(String username) {
}
@Override
public void deleteUsername() {
}
@Override
public void startChangeNumber(String newNumber, boolean voiceVerification, String captcha) {
}
@Override
public void finishChangeNumber(String newNumber, String verificationCode, String pin) {
}
@Override
public void unregister() {
}
@Override
public void deleteAccount() {
}
@Override
public void submitRateLimitRecaptchaChallenge(String challenge, String captcha) {
}
@Override
public List<Device> getLinkedDevices() {
return List.of();
}
@Override
public void updateLinkedDevice(int deviceId, String name) {
}
@Override
public void removeLinkedDevices(int deviceId) {
}
@Override
public void addDeviceLink(DeviceLinkUrl deviceLinkUrl) {
}
@Override
public void setRegistrationLockPin(Optional<String> pin) {
}
@Override
public List<Group> getGroups() {
return List.of();
}
@Override
public List<Group> getGroups(Collection<GroupId> groupIds) {
return List.of();
}
@Override
public SendGroupMessageResults quitGroup(GroupId groupId, Set<RecipientIdentifier.Single> administrators) {
return null;
}
@Override
public void deleteGroup(GroupId groupId) {
}
@Override
public Pair<GroupId, SendGroupMessageResults> createGroup(String name, Set<RecipientIdentifier.Single> members, String avatarFile) {
return null;
}
@Override
public SendGroupMessageResults updateGroup(GroupId groupId, UpdateGroup updateGroup) {
return null;
}
@Override
public Pair<GroupId, SendGroupMessageResults> joinGroup(GroupInviteLinkUrl inviteLinkUrl) {
return null;
}
@Override
public SendMessageResults sendTypingMessage(TypingAction action, Set<RecipientIdentifier> recipients) {
return null;
}
@Override
public SendMessageResults sendReadReceipt(RecipientIdentifier.Single sender, List<Long> messageIds) {
return null;
}
@Override
public SendMessageResults sendViewedReceipt(RecipientIdentifier.Single sender, List<Long> messageIds) {
return null;
}
@Override
public SendMessageResults sendMessage(Message message, Set<RecipientIdentifier> recipients, boolean notifySelf) {
return null;
}
@Override
public SendMessageResults sendEditMessage(Message message, Set<RecipientIdentifier> recipients, long targetSentTimestamp) {
return null;
}
@Override
public SendMessageResults sendRemoteDeleteMessage(long targetSentTimestamp, Set<RecipientIdentifier> recipients) {
return null;
}
@Override
public SendMessageResults sendMessageReaction(String emoji, boolean remove, RecipientIdentifier.Single targetAuthor, long targetSentTimestamp, Set<RecipientIdentifier> recipients, boolean notifySelf, boolean story) {
return null;
}
@Override
public SendMessageResults sendAdminDelete(RecipientIdentifier.Single targetAuthor, long targetSentTimestamp, Set<RecipientIdentifier.Group> recipients, boolean notifySelf, boolean story) {
return null;
}
@Override
public SendMessageResults sendPinMessage(int duration, RecipientIdentifier.Single targetAuthor, long targetSentTimestamp, Set<RecipientIdentifier> recipients, boolean notifySelf, boolean story) {
return null;
}
@Override
public SendMessageResults sendUnpinMessage(RecipientIdentifier.Single targetAuthor, long targetSentTimestamp, Set<RecipientIdentifier> recipients, boolean notifySelf, boolean story) {
return null;
}
@Override
public SendMessageResults sendPaymentNotificationMessage(byte[] receipt, String note, RecipientIdentifier.Single recipient) {
return null;
}
@Override
public void sendEndSessionMessage(Set<RecipientIdentifier.Single> recipients) {
}
@Override
public SendMessageResults sendMessageRequestResponse(MessageEnvelope.Sync.MessageRequestResponse.Type type, Set<RecipientIdentifier> recipients) {
return null;
}
@Override
public SendMessageResults sendPollCreateMessage(String question, boolean multipleChoice, List<String> options, Set<RecipientIdentifier> recipients, boolean notifySelf) {
return null;
}
@Override
public SendMessageResults sendPollVoteMessage(RecipientIdentifier.Single author, long timestamp, List<Integer> optionIds, int version, Set<RecipientIdentifier> recipients, boolean notifySelf) {
return null;
}
@Override
public SendMessageResults sendPollTerminateMessage(long timestamp, Set<RecipientIdentifier> recipients, boolean notifySelf) {
return null;
}
@Override
public SendMessageResults sendStory(String attachment, boolean allowsReplies) {
return new SendMessageResults(0, Map.of());
}
@Override
public void hideRecipient(RecipientIdentifier.Single recipient) {
}
@Override
public void deleteRecipient(RecipientIdentifier.Single recipient) {
}
@Override
public void deleteContact(RecipientIdentifier.Single recipient) {
}
@Override
public void setContactName(RecipientIdentifier.Single recipient, String givenName, String familyName, String newGivenName, String newFamilyName, String nick) {
}
@Override
public void setContactsBlocked(Collection<RecipientIdentifier.Single> recipients, boolean blocked) {
}
@Override
public void setGroupsBlocked(Collection<GroupId> groupIds, boolean blocked) {
}
@Override
public void setExpirationTimer(RecipientIdentifier.Single recipient, int messageExpirationTimer) {
}
@Override
public StickerPackUrl uploadStickerPack(File path) {
return null;
}
@Override
public void installStickerPack(StickerPackUrl url) {
}
@Override
public List<StickerPack> getStickerPacks() {
return List.of();
}
@Override
public void requestAllSyncData() {
}
@Override
public boolean isContactBlocked(RecipientIdentifier.Single recipient) {
return false;
}
@Override
public void sendContacts() {
}
@Override
public List<Recipient> getRecipients(boolean onlyWithProfile, Optional<Boolean> blocked, Collection<RecipientIdentifier.Single> addresses, Optional<String> name) {
return List.of();
}
@Override
public String getContactOrProfileName(RecipientIdentifier.Single recipient) {
return null;
}
@Override
public Group getGroup(GroupId groupId) {
return null;
}
@Override
public List<Identity> getIdentities() {
return List.of();
}
@Override
public List<Identity> getIdentities(RecipientIdentifier.Single recipient) {
return List.of();
}
@Override
public boolean trustIdentityVerified(RecipientIdentifier.Single recipient, IdentityVerificationCode verificationCode) {
return false;
}
@Override
public boolean trustIdentityAllKeys(RecipientIdentifier.Single recipient) {
return false;
}
@Override
public void addAddressChangedListener(Runnable listener) {
}
@Override
public void addClosedListener(Runnable listener) {
}
@Override
public InputStream retrieveAttachment(String id) {
return null;
}
@Override
public InputStream retrieveContactAvatar(RecipientIdentifier.Single recipient) {
return null;
}
@Override
public InputStream retrieveProfileAvatar(RecipientIdentifier.Single recipient) {
return null;
}
@Override
public InputStream retrieveGroupAvatar(GroupId groupId) {
return null;
}
@Override
public InputStream retrieveSticker(StickerPackId stickerPackId, int stickerId) {
return null;
}
@Override
public CallInfo startCall(RecipientIdentifier.Single recipient) {
return null;
}
@Override
public CallInfo acceptCall(long callId) {
return null;
}
@Override
public void hangupCall(long callId) {
}
@Override
public SendMessageResult rejectCall(long callId) {
return null;
}
@Override
public List<CallInfo> listActiveCalls() {
return List.of();
}
@Override
public void sendCallOffer(RecipientIdentifier.Single recipient, CallOffer callOffer) {
}
@Override
public void sendCallAnswer(RecipientIdentifier.Single recipient, long callId, byte[] answer) {
}
@Override
public void sendIceUpdate(RecipientIdentifier.Single recipient, long callId, List<byte[]> iceCandidates) {
}
@Override
public void sendHangup(RecipientIdentifier.Single recipient, long callId, MessageEnvelope.Call.Hangup.Type type) {
}
@Override
public void sendBusy(RecipientIdentifier.Single recipient, long callId) {
}
@Override
public List<TurnServer> getTurnServerInfo() {
return List.of();
}
@Override
public void close() {
}
@Override
public void addCallEventListener(CallEventListener listener) {
}
@Override
public void removeCallEventListener(CallEventListener listener) {
}
}
}

View File

@ -0,0 +1,50 @@
package org.asamk.signal.json;
import org.asamk.signal.manager.api.InvalidEnvelopeContentException;
import org.asamk.signal.util.Util;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class JsonErrorTest {
@Test
void invalidEnvelopeContentIncludesStructuredDetails() throws Exception {
final var invalidRange = new InvalidEnvelopeContentException.InvalidBodyRange(2, 8, 4, "STYLE_BOLD");
final var exception = new InvalidEnvelopeContentException("invalid body range",
InvalidEnvelopeContentException.DATA_MESSAGE_BODY_RANGE_OUT_OF_BOUNDS,
null,
3,
10,
List.of(invalidRange),
new Throwable());
final var error = JsonError.from(exception);
assertEquals("invalid body range", error.message());
assertEquals("InvalidEnvelopeContentException", error.type());
assertEquals(InvalidEnvelopeContentException.DATA_MESSAGE_BODY_RANGE_OUT_OF_BOUNDS,
error.details().code());
assertEquals(10, error.details().bodyLength());
assertEquals(List.of(invalidRange), error.details().invalidBodyRanges());
final var json = Util.createJsonObjectMapper().writeValueAsString(error);
assertTrue(json.contains("\"code\":\"DATA_MESSAGE_BODY_RANGE_OUT_OF_BOUNDS\""));
assertTrue(json.contains("\"start\":8"));
}
@Test
void ordinaryExceptionDoesNotIncludeDetails() throws Exception {
final var error = JsonError.from(new IllegalArgumentException("bad argument"));
assertEquals("bad argument", error.message());
assertEquals("IllegalArgumentException", error.type());
assertNull(error.details());
final var json = Util.createJsonObjectMapper().writeValueAsString(error);
assertFalse(json.contains("details"));
}
}

View File

@ -4,52 +4,16 @@ import org.asamk.signal.manager.Manager;
import org.asamk.signal.manager.MultiAccountManager;
import org.asamk.signal.manager.ProvisioningManager;
import org.asamk.signal.manager.RegistrationManager;
import org.asamk.signal.manager.api.CallInfo;
import org.asamk.signal.manager.api.CallOffer;
import org.asamk.signal.manager.api.Configuration;
import org.asamk.signal.manager.api.Device;
import org.asamk.signal.manager.api.DeviceLinkUrl;
import org.asamk.signal.manager.api.Group;
import org.asamk.signal.manager.api.GroupId;
import org.asamk.signal.manager.api.GroupInviteLinkUrl;
import org.asamk.signal.manager.api.Identity;
import org.asamk.signal.manager.api.IdentityVerificationCode;
import org.asamk.signal.manager.api.Message;
import org.asamk.signal.manager.api.MessageEnvelope;
import org.asamk.signal.manager.api.Pair;
import org.asamk.signal.manager.api.ReceiveConfig;
import org.asamk.signal.manager.api.Recipient;
import org.asamk.signal.manager.api.RecipientIdentifier;
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.StickerPack;
import org.asamk.signal.manager.api.StickerPackId;
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.UpdateGroup;
import org.asamk.signal.manager.api.UpdateProfile;
import org.asamk.signal.manager.api.UserStatus;
import org.asamk.signal.manager.api.UsernameLinkUrl;
import org.asamk.signal.manager.api.UsernameStatus;
import org.asamk.signal.output.JsonWriter;
import org.asamk.signal.testutil.ManagerMock;
import org.junit.jupiter.api.Test;
import java.io.File;
import java.io.InputStream;
import java.net.URI;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Queue;
import java.util.Set;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;
import static org.junit.jupiter.api.Assertions.assertEquals;
@ -89,499 +53,12 @@ class SubscribeCallEventsTest {
}
}
/**
* Minimal Manager stub that tracks call event listener add/remove calls.
*/
private static class StubManager implements Manager {
private record ManagedManager(Manager manager, ManagerMock.State state) {
}
final List<CallEventListener> listeners = new ArrayList<>();
final AtomicInteger addCount = new AtomicInteger(0);
final AtomicInteger removeCount = new AtomicInteger(0);
final String selfNumber;
StubManager(String selfNumber) {
this.selfNumber = selfNumber;
}
@Override
public void addCallEventListener(CallEventListener listener) {
addCount.incrementAndGet();
listeners.add(listener);
}
@Override
public void removeCallEventListener(CallEventListener listener) {
removeCount.incrementAndGet();
listeners.remove(listener);
}
@Override
public String getSelfNumber() {
return selfNumber;
}
@Override
public String getSelfACI() {
return "00000000-0000-0000-0000-000000000000";
}
// --- Stubs for remaining Manager interface methods ---
@Override
public Map<String, UserStatus> getUserStatus(Set<String> n) {
return Map.of();
}
@Override
public Map<String, UsernameStatus> getUsernameStatus(Set<String> u) {
return Map.of();
}
@Override
public void updateAccountAttributes(String d, Boolean u, Boolean dn, Boolean ns) {
}
@Override
public Configuration getConfiguration() {
return null;
}
@Override
public void updateConfiguration(Configuration c) {
}
@Override
public void updateProfile(UpdateProfile u) {
}
@Override
public String getUsername() {
return null;
}
@Override
public UsernameLinkUrl getUsernameLink() {
return null;
}
@Override
public void setUsername(String u) {
}
@Override
public void deleteUsername() {
}
@Override
public void startChangeNumber(String n, boolean v, String c) {
}
@Override
public void finishChangeNumber(String n, String v, String p) {
}
@Override
public void unregister() {
}
@Override
public void deleteAccount() {
}
@Override
public void submitRateLimitRecaptchaChallenge(String c, String cap) {
}
@Override
public List<Device> getLinkedDevices() {
return List.of();
}
@Override
public void updateLinkedDevice(int d, String n) {
}
@Override
public void removeLinkedDevices(int d) {
}
@Override
public void addDeviceLink(DeviceLinkUrl u) {
}
@Override
public void setRegistrationLockPin(Optional<String> p) {
}
@Override
public List<Group> getGroups() {
return List.of();
}
@Override
public List<Group> getGroups(Collection<GroupId> g) {
return List.of();
}
@Override
public SendGroupMessageResults quitGroup(GroupId g, Set<RecipientIdentifier.Single> a) {
return null;
}
@Override
public void deleteGroup(GroupId g) {
}
@Override
public Pair<GroupId, SendGroupMessageResults> createGroup(
String n,
Set<RecipientIdentifier.Single> m,
String a
) {
return null;
}
@Override
public SendGroupMessageResults updateGroup(GroupId g, UpdateGroup u) {
return null;
}
@Override
public Pair<GroupId, SendGroupMessageResults> joinGroup(GroupInviteLinkUrl u) {
return null;
}
@Override
public SendMessageResults sendTypingMessage(TypingAction a, Set<RecipientIdentifier> r) {
return null;
}
@Override
public SendMessageResults sendReadReceipt(RecipientIdentifier.Single s, List<Long> m) {
return null;
}
@Override
public SendMessageResults sendViewedReceipt(RecipientIdentifier.Single s, List<Long> m) {
return null;
}
@Override
public SendMessageResults sendMessage(Message m, Set<RecipientIdentifier> r, boolean n) {
return null;
}
@Override
public SendMessageResults sendEditMessage(Message m, Set<RecipientIdentifier> r, long t) {
return null;
}
@Override
public SendMessageResults sendRemoteDeleteMessage(long t, Set<RecipientIdentifier> r) {
return null;
}
@Override
public SendMessageResults sendMessageReaction(
String e,
boolean rm,
RecipientIdentifier.Single a,
long t,
Set<RecipientIdentifier> r,
boolean n,
boolean s
) {
return null;
}
@Override
public SendMessageResults sendAdminDelete(
RecipientIdentifier.Single a,
long t,
Set<RecipientIdentifier.Group> r,
boolean n,
boolean s
) {
return null;
}
@Override
public SendMessageResults sendPinMessage(
int d,
RecipientIdentifier.Single a,
long t,
Set<RecipientIdentifier> r,
boolean n,
boolean s
) {
return null;
}
@Override
public SendMessageResults sendUnpinMessage(
RecipientIdentifier.Single a,
long t,
Set<RecipientIdentifier> r,
boolean n,
boolean s
) {
return null;
}
@Override
public SendMessageResults sendPaymentNotificationMessage(byte[] r, String n, RecipientIdentifier.Single re) {
return null;
}
@Override
public void sendEndSessionMessage(Set<RecipientIdentifier.Single> r) {
}
@Override
public SendMessageResults sendMessageRequestResponse(
MessageEnvelope.Sync.MessageRequestResponse.Type t,
Set<RecipientIdentifier> r
) {
return null;
}
@Override
public SendMessageResults sendPollCreateMessage(
String q,
boolean a,
List<String> o,
Set<RecipientIdentifier> r,
boolean n
) {
return null;
}
@Override
public SendMessageResults sendPollVoteMessage(
RecipientIdentifier.Single a,
long t,
List<Integer> o,
int v,
Set<RecipientIdentifier> r,
boolean n
) {
return null;
}
@Override
public SendMessageResults sendPollTerminateMessage(long t, Set<RecipientIdentifier> r, boolean n) {
return null;
}
@Override
public SendMessageResults sendStory(String attachment, boolean allowsReplies) {
return new SendMessageResults(0, Map.of());
}
@Override
public void hideRecipient(RecipientIdentifier.Single r) {
}
@Override
public void deleteRecipient(RecipientIdentifier.Single r) {
}
@Override
public void deleteContact(RecipientIdentifier.Single r) {
}
@Override
public void setContactName(RecipientIdentifier.Single r, String g, String f, String ng, String nf, String n) {
}
@Override
public void setContactsBlocked(Collection<RecipientIdentifier.Single> r, boolean b) {
}
@Override
public void setGroupsBlocked(Collection<GroupId> g, boolean b) {
}
@Override
public void setExpirationTimer(RecipientIdentifier.Single r, int t) {
}
@Override
public StickerPackUrl uploadStickerPack(File p) {
return null;
}
@Override
public void installStickerPack(StickerPackUrl u) {
}
@Override
public List<StickerPack> getStickerPacks() {
return List.of();
}
@Override
public void requestAllSyncData() {
}
@Override
public void addReceiveHandler(ReceiveMessageHandler h, boolean w) {
}
@Override
public void removeReceiveHandler(ReceiveMessageHandler h) {
}
@Override
public boolean isReceiving() {
return false;
}
@Override
public void receiveMessages(Optional<Duration> t, Optional<Integer> m, ReceiveMessageHandler h) {
}
@Override
public void stopReceiveMessages() {
}
@Override
public void setReceiveConfig(ReceiveConfig r) {
}
@Override
public boolean isContactBlocked(RecipientIdentifier.Single r) {
return false;
}
@Override
public void sendContacts() {
}
@Override
public List<Recipient> getRecipients(
boolean o,
Optional<Boolean> b,
Collection<RecipientIdentifier.Single> a,
Optional<String> n
) {
return List.of();
}
@Override
public String getContactOrProfileName(RecipientIdentifier.Single r) {
return null;
}
@Override
public Group getGroup(GroupId g) {
return null;
}
@Override
public List<Identity> getIdentities() {
return List.of();
}
@Override
public List<Identity> getIdentities(RecipientIdentifier.Single r) {
return List.of();
}
@Override
public boolean trustIdentityVerified(RecipientIdentifier.Single r, IdentityVerificationCode v) {
return false;
}
@Override
public boolean trustIdentityAllKeys(RecipientIdentifier.Single r) {
return false;
}
@Override
public void addAddressChangedListener(Runnable l) {
}
@Override
public void addClosedListener(Runnable l) {
}
@Override
public InputStream retrieveAttachment(String id) {
return null;
}
@Override
public InputStream retrieveContactAvatar(RecipientIdentifier.Single r) {
return null;
}
@Override
public InputStream retrieveProfileAvatar(RecipientIdentifier.Single r) {
return null;
}
@Override
public InputStream retrieveGroupAvatar(GroupId g) {
return null;
}
@Override
public InputStream retrieveSticker(StickerPackId s, int i) {
return null;
}
@Override
public CallInfo startCall(RecipientIdentifier.Single r) {
return null;
}
@Override
public CallInfo acceptCall(long c) {
return null;
}
@Override
public void hangupCall(long c) {
}
@Override
public SendMessageResult rejectCall(long c) {
return null;
}
@Override
public List<CallInfo> listActiveCalls() {
return List.of();
}
@Override
public void sendCallOffer(RecipientIdentifier.Single r, CallOffer o) {
}
@Override
public void sendCallAnswer(RecipientIdentifier.Single r, long c, byte[] a) {
}
@Override
public void sendIceUpdate(RecipientIdentifier.Single r, long c, List<byte[]> i) {
}
@Override
public void sendHangup(RecipientIdentifier.Single r, long c, MessageEnvelope.Call.Hangup.Type t) {
}
@Override
public void sendBusy(RecipientIdentifier.Single r, long c) {
}
@Override
public List<TurnServer> getTurnServerInfo() {
return List.of();
}
@Override
public void close() {
}
private static ManagedManager createManager(final String selfNumber) {
final var state = new ManagerMock.State();
return new ManagedManager(ManagerMock.create(selfNumber, state), state);
}
/**
@ -652,21 +129,21 @@ class SubscribeCallEventsTest {
@Test
void callEventsNotSubscribedByDefault() {
var manager = new StubManager("+15551234567");
var manager = createManager("+15551234567");
var feeder = new LineFeeder();
var writer = new CapturingJsonWriter();
// Send no subscribeCallEvents, just end the connection
var handler = new SignalJsonRpcDispatcherHandler(writer, feeder::getLine, true);
handler.handleConnection(manager);
handler.handleConnection(manager.manager());
// No listeners should have been added
assertEquals(0, manager.addCount.get(), "call events should not be auto-subscribed");
assertEquals(0, manager.state().addCallEventListenerCount.get(), "call events should not be auto-subscribed");
}
@Test
void subscribeCallEventsAddsListener() {
var manager = new StubManager("+15551234567");
var manager = createManager("+15551234567");
var feeder = new LineFeeder();
var writer = new CapturingJsonWriter();
@ -674,17 +151,17 @@ class SubscribeCallEventsTest {
// null terminates the read loop
var handler = new SignalJsonRpcDispatcherHandler(writer, feeder::getLine, true);
handler.handleConnection(manager);
handler.handleConnection(manager.manager());
assertEquals(1, manager.addCount.get(), "subscribeCallEvents should add one listener");
assertEquals(1, manager.state().addCallEventListenerCount.get(), "subscribeCallEvents should add one listener");
// Cleanup in finally block should remove it
assertEquals(1, manager.removeCount.get(), "cleanup should remove the listener");
assertEquals(0, manager.listeners.size(), "no listeners should remain after cleanup");
assertEquals(1, manager.state().removeCallEventListenerCount.get(), "cleanup should remove the listener");
assertEquals(0, manager.state().callEventListeners.size(), "no listeners should remain after cleanup");
}
@Test
void subscribeCallEventsCanBeCalledMultipleTimes() {
var manager = new StubManager("+15551234567");
var manager = createManager("+15551234567");
var feeder = new LineFeeder();
var writer = new CapturingJsonWriter();
@ -692,15 +169,15 @@ class SubscribeCallEventsTest {
feeder.addLine(jsonRpcCall(2, "subscribeCallEvents"));
var handler = new SignalJsonRpcDispatcherHandler(writer, feeder::getLine, true);
handler.handleConnection(manager);
handler.handleConnection(manager.manager());
// The implementation allows multiple subscriptions, so two calls add two listeners
assertEquals(2, manager.addCount.get(), "multiple subscribeCallEvents should add multiple listeners");
assertEquals(2, manager.state().addCallEventListenerCount.get(), "multiple subscribeCallEvents should add multiple listeners");
}
@Test
void unsubscribeCallEventsRemovesListener() {
var manager = new StubManager("+15551234567");
var manager = createManager("+15551234567");
var feeder = new LineFeeder();
var writer = new CapturingJsonWriter();
@ -708,37 +185,37 @@ class SubscribeCallEventsTest {
feeder.addLine(jsonRpcCall(2, "unsubscribeCallEvents", "{\"subscription\":0}"));
var handler = new SignalJsonRpcDispatcherHandler(writer, feeder::getLine, true);
handler.handleConnection(manager);
handler.handleConnection(manager.manager());
assertEquals(1, manager.addCount.get(), "should have subscribed once");
assertEquals(1, manager.state().addCallEventListenerCount.get(), "should have subscribed once");
// removeCount: 1 from explicit unsubscribe. The finally block's unsubscribeAllCallEvents
// iterates an empty list so adds 0 more.
assertEquals(1, manager.removeCount.get(), "should have unsubscribed once");
assertEquals(0, manager.listeners.size());
assertEquals(1, manager.state().removeCallEventListenerCount.get(), "should have unsubscribed once");
assertEquals(0, manager.state().callEventListeners.size());
}
@Test
void unsubscribeWithoutSubscribeIsNoOp() {
var manager = new StubManager("+15551234567");
var manager = createManager("+15551234567");
var feeder = new LineFeeder();
var writer = new CapturingJsonWriter();
feeder.addLine(jsonRpcCall(1, "unsubscribeCallEvents"));
var handler = new SignalJsonRpcDispatcherHandler(writer, feeder::getLine, true);
handler.handleConnection(manager);
handler.handleConnection(manager.manager());
assertEquals(0, manager.addCount.get());
assertEquals(0, manager.removeCount.get());
assertEquals(0, manager.state().addCallEventListenerCount.get());
assertEquals(0, manager.state().removeCallEventListenerCount.get());
}
// --- Multi-account mode tests ---
@Test
void multiAccountSubscribeCallEventsSubscribesAllManagers() {
var manager1 = new StubManager("+15551111111");
var manager2 = new StubManager("+15552222222");
var multi = new StubMultiAccountManager(List.of(manager1, manager2));
var manager1 = createManager("+15551111111");
var manager2 = createManager("+15552222222");
var multi = new StubMultiAccountManager(List.of(manager1.manager(), manager2.manager()));
var feeder = new LineFeeder();
var writer = new CapturingJsonWriter();
@ -748,17 +225,17 @@ class SubscribeCallEventsTest {
var handler = new SignalJsonRpcDispatcherHandler(writer, feeder::getLine, true);
handler.handleConnection(multi);
assertEquals(1, manager1.addCount.get(), "manager1 should have one listener");
assertEquals(1, manager2.addCount.get(), "manager2 should have one listener");
assertEquals(1, manager1.state().addCallEventListenerCount.get(), "manager1 should have one listener");
assertEquals(1, manager2.state().addCallEventListenerCount.get(), "manager2 should have one listener");
// Also registers an onManagerAdded handler for receive and one for call events
assertEquals(2, multi.addedHandlers.size(), "should register onManagerAdded handlers");
}
@Test
void multiAccountUnsubscribeCallEventsCleansUpAll() {
var manager1 = new StubManager("+15551111111");
var manager2 = new StubManager("+15552222222");
var multi = new StubMultiAccountManager(List.of(manager1, manager2));
var manager1 = createManager("+15551111111");
var manager2 = createManager("+15552222222");
var multi = new StubMultiAccountManager(List.of(manager1.manager(), manager2.manager()));
var feeder = new LineFeeder();
var writer = new CapturingJsonWriter();
@ -769,16 +246,16 @@ class SubscribeCallEventsTest {
var handler = new SignalJsonRpcDispatcherHandler(writer, feeder::getLine, true);
handler.handleConnection(multi);
assertEquals(1, manager1.addCount.get());
assertEquals(1, manager2.addCount.get());
assertEquals(1, manager1.removeCount.get(), "manager1 listener should be removed");
assertEquals(1, manager2.removeCount.get(), "manager2 listener should be removed");
assertEquals(1, manager1.state().addCallEventListenerCount.get());
assertEquals(1, manager2.state().addCallEventListenerCount.get());
assertEquals(1, manager1.state().removeCallEventListenerCount.get(), "manager1 listener should be removed");
assertEquals(1, manager2.state().removeCallEventListenerCount.get(), "manager2 listener should be removed");
}
@Test
void multiAccountCallEventsNotSubscribedByDefault() {
var manager1 = new StubManager("+15551111111");
var multi = new StubMultiAccountManager(List.of(manager1));
var manager1 = createManager("+15551111111");
var multi = new StubMultiAccountManager(List.of(manager1.manager()));
var feeder = new LineFeeder();
var writer = new CapturingJsonWriter();
@ -786,6 +263,6 @@ class SubscribeCallEventsTest {
var handler = new SignalJsonRpcDispatcherHandler(writer, feeder::getLine, true);
handler.handleConnection(multi);
assertEquals(0, manager1.addCount.get(), "call events should not be auto-subscribed in multi mode");
assertEquals(0, manager1.state().addCallEventListenerCount.get(), "call events should not be auto-subscribed in multi mode");
}
}

View File

@ -0,0 +1,122 @@
package org.asamk.signal.testutil;
import org.asamk.signal.manager.Manager;
import java.lang.reflect.Proxy;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
public final class ManagerMock {
private static final String DEFAULT_ACI = "00000000-0000-0000-0000-000000000000";
private ManagerMock() {
}
public static final class State {
public final List<Manager.CallEventListener> callEventListeners = new ArrayList<>();
public final AtomicInteger addCallEventListenerCount = new AtomicInteger(0);
public final AtomicInteger removeCallEventListenerCount = new AtomicInteger(0);
public final List<Manager.ReceiveMessageHandler> receiveHandlers = new ArrayList<>();
public final AtomicInteger addReceiveHandlerCount = new AtomicInteger(0);
public final AtomicInteger removeReceiveHandlerCount = new AtomicInteger(0);
}
public static Manager create(final String selfNumber) {
return create(selfNumber, new State());
}
public static Manager create(final String selfNumber, final State state) {
return (Manager) Proxy.newProxyInstance(
Manager.class.getClassLoader(),
new Class<?>[]{Manager.class},
(proxy, method, args) -> {
final var methodName = method.getName();
switch (methodName) {
case "getSelfNumber":
return selfNumber;
case "getSelfACI":
return DEFAULT_ACI;
case "addCallEventListener":
state.addCallEventListenerCount.incrementAndGet();
state.callEventListeners.add((Manager.CallEventListener) args[0]);
return null;
case "removeCallEventListener":
state.removeCallEventListenerCount.incrementAndGet();
state.callEventListeners.remove((Manager.CallEventListener) args[0]);
return null;
case "addReceiveHandler":
state.addReceiveHandlerCount.incrementAndGet();
state.receiveHandlers.add((Manager.ReceiveMessageHandler) args[0]);
return null;
case "removeReceiveHandler":
state.removeReceiveHandlerCount.incrementAndGet();
state.receiveHandlers.remove((Manager.ReceiveMessageHandler) args[0]);
return null;
case "equals":
return proxy == args[0];
case "hashCode":
return System.identityHashCode(proxy);
case "toString":
return "ManagerMock{" + selfNumber + "}";
default:
return defaultValue(method.getReturnType());
}
}
);
}
private static Object defaultValue(final Class<?> returnType) {
if (returnType == Void.TYPE) {
return null;
}
if (returnType == Boolean.TYPE) {
return false;
}
if (returnType == Byte.TYPE) {
return (byte) 0;
}
if (returnType == Short.TYPE) {
return (short) 0;
}
if (returnType == Integer.TYPE) {
return 0;
}
if (returnType == Long.TYPE) {
return 0L;
}
if (returnType == Float.TYPE) {
return 0F;
}
if (returnType == Double.TYPE) {
return 0D;
}
if (returnType == Character.TYPE) {
return '\0';
}
if (returnType == Optional.class) {
return Optional.empty();
}
if (returnType == List.class) {
return List.of();
}
if (returnType == Set.class) {
return Set.of();
}
if (returnType == Collection.class) {
return List.of();
}
if (returnType == Map.class) {
return Map.of();
}
return null;
}
}