();
final var host = address == null ? null : address.getHostString();
diff --git a/src/main/java/org/asamk/signal/http/SseEventBuffer.java b/src/main/java/org/asamk/signal/http/SseEventBuffer.java
new file mode 100644
index 00000000..00355202
--- /dev/null
+++ b/src/main/java/org/asamk/signal/http/SseEventBuffer.java
@@ -0,0 +1,153 @@
+package org.asamk.signal.http;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import org.asamk.signal.json.JsonReceiveMessageHandler;
+import org.asamk.signal.manager.Manager;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Keeps the most recent SSE events and sends new events to the connected clients.
+ * A client that reconnects with a Last-Event-ID header first gets the buffered events it missed.
+ *
+ * Event ids have the form "{start time}-{sequence}", so an id from an earlier daemon run never matches.
+ */
+final class SseEventBuffer implements AutoCloseable {
+
+ private static final Logger logger = LoggerFactory.getLogger(SseEventBuffer.class);
+
+ static final int MAX_EVENTS = 1000;
+
+ private final ObjectMapper objectMapper;
+ private final String idPrefix = System.currentTimeMillis() + "-";
+ private final Map recorders = new HashMap<>();
+ private final ArrayDeque events = new ArrayDeque<>();
+ private final List clients = new ArrayList<>();
+ private long nextSequence = 1;
+
+ SseEventBuffer(final ObjectMapper objectMapper) {
+ this.objectMapper = objectMapper;
+ }
+
+ /**
+ * Start recording the messages of the given account.
+ * The handler is registered as a weak listener, so it never starts receiving by itself.
+ */
+ void record(final Manager m) {
+ synchronized (recorders) {
+ if (recorders.containsKey(m)) {
+ return;
+ }
+ final var handler = new JsonReceiveMessageHandler(m, s -> add(m, s));
+ m.addReceiveHandler(handler, true);
+ recorders.put(m, handler);
+ }
+ }
+
+ /**
+ * Register a client for the given accounts.
+ * If lastEventId is set, the buffered events after it are sent first.
+ * An unknown id (too old, or from an earlier run) sends all buffered events.
+ */
+ synchronized Client connect(
+ final Collection managers,
+ final ServerSentEventSender sender,
+ final String lastEventId,
+ final Runnable onClosed
+ ) {
+ final var client = new Client(managers, sender, onClosed);
+ if (lastEventId != null) {
+ final var lastSequence = getSequence(lastEventId);
+ for (final var event : events) {
+ if (event.sequence() > lastSequence && managers.contains(event.manager())) {
+ client.send(event);
+ }
+ }
+ }
+ clients.add(client);
+ return client;
+ }
+
+ synchronized void disconnect(final Client client) {
+ clients.remove(client);
+ }
+
+ @Override
+ public void close() {
+ synchronized (recorders) {
+ recorders.forEach(Manager::removeReceiveHandler);
+ recorders.clear();
+ }
+ }
+
+ private void add(final Manager m, final Object message) {
+ final String data;
+ try {
+ data = objectMapper.writeValueAsString(message);
+ } catch (IOException e) {
+ logger.warn("Failed to serialize received message, ignoring", e);
+ return;
+ }
+
+ final Event event;
+ final List receivers;
+ synchronized (this) {
+ event = new Event(nextSequence++, m, data);
+ events.addLast(event);
+ if (events.size() > MAX_EVENTS) {
+ events.removeFirst();
+ }
+ receivers = clients.stream().filter(c -> c.managers.contains(m)).toList();
+ }
+ // Sent outside the lock, so a slow client doesn't block the other accounts
+ for (final var client : receivers) {
+ client.send(event);
+ }
+ }
+
+ private long getSequence(final String eventId) {
+ if (eventId.startsWith(idPrefix)) {
+ try {
+ return Long.parseLong(eventId.substring(idPrefix.length()));
+ } catch (NumberFormatException ignored) {
+ }
+ }
+ return 0;
+ }
+
+ private record Event(long sequence, Manager manager, String data) {}
+
+ final class Client {
+
+ private final Collection managers;
+ private final ServerSentEventSender sender;
+ private final Runnable onClosed;
+
+ private Client(
+ final Collection managers,
+ final ServerSentEventSender sender,
+ final Runnable onClosed
+ ) {
+ this.managers = managers;
+ this.sender = sender;
+ this.onClosed = onClosed;
+ }
+
+ private void send(final Event event) {
+ try {
+ sender.sendEvent(idPrefix + event.sequence(), "receive", List.of(event.data()));
+ } catch (IOException e) {
+ onClosed.run();
+ }
+ }
+ }
+}
diff --git a/src/main/java/org/asamk/signal/json/JsonAdminDelete.java b/src/main/java/org/asamk/signal/json/JsonAdminDelete.java
index c8586c9e..42b24c10 100644
--- a/src/main/java/org/asamk/signal/json/JsonAdminDelete.java
+++ b/src/main/java/org/asamk/signal/json/JsonAdminDelete.java
@@ -8,7 +8,11 @@ import io.micronaut.jsonschema.JsonSchema;
@JsonSchema(title = "AdminDelete")
public record JsonAdminDelete(
- @Deprecated String targetAuthor, String targetAuthorNumber, String targetAuthorUuid, long targetSentTimestamp
+ @Deprecated String targetAuthor,
+ String targetAuthorNumber,
+ String targetAuthorUuid,
+ long targetTimestamp,
+ @Deprecated long targetSentTimestamp
) {
static JsonAdminDelete from(MessageEnvelope.Data.AdminDelete adminDelete) {
@@ -16,9 +20,13 @@ public record JsonAdminDelete(
final var targetAuthor = address.getLegacyIdentifier();
final var targetAuthorNumber = address.number().orElse(null);
final var targetAuthorUuid = address.uuid().map(UUID::toString).orElse(null);
- final var targetSentTimestamp = adminDelete.targetSentTimestamp();
+ final var targetTimestamp = adminDelete.targetSentTimestamp();
- return new JsonAdminDelete(targetAuthor, targetAuthorNumber, targetAuthorUuid, targetSentTimestamp);
+ return new JsonAdminDelete(targetAuthor,
+ targetAuthorNumber,
+ targetAuthorUuid,
+ targetTimestamp,
+ targetTimestamp);
}
}
diff --git a/src/main/java/org/asamk/signal/json/JsonDataMessage.java b/src/main/java/org/asamk/signal/json/JsonDataMessage.java
index 9fd20781..f2ea5b5b 100644
--- a/src/main/java/org/asamk/signal/json/JsonDataMessage.java
+++ b/src/main/java/org/asamk/signal/json/JsonDataMessage.java
@@ -43,7 +43,7 @@ record JsonDataMessage(
static JsonDataMessage from(MessageEnvelope.Data dataMessage, Manager m) {
final var timestamp = dataMessage.timestamp();
final var groupInfo = dataMessage.groupContext().isPresent() ? JsonGroupInfo.from(dataMessage.groupContext()
- .get(), m) : null;
+ .get(), m) : null;
final var storyContext = dataMessage.storyContext().isPresent()
? JsonStoryContext.from(dataMessage.storyContext().get())
: null;
@@ -59,32 +59,32 @@ record JsonDataMessage(
final var quote = dataMessage.quote().isPresent() ? JsonQuote.from(dataMessage.quote().get()) : null;
final var payment = dataMessage.payment().isPresent() ? JsonPayment.from(dataMessage.payment().get()) : null;
final var mentions = !dataMessage.mentions().isEmpty() ? dataMessage.mentions()
- .stream()
- .map(JsonMention::from)
- .toList() : null;
+ .stream()
+ .map(JsonMention::from)
+ .toList() : null;
final var previews = !dataMessage.previews().isEmpty() ? dataMessage.previews()
- .stream()
- .map(JsonPreview::from)
- .toList() : null;
+ .stream()
+ .map(JsonPreview::from)
+ .toList() : null;
final var remoteDelete = dataMessage.remoteDeleteId().isPresent()
- ? new JsonRemoteDelete(dataMessage.remoteDeleteId().get())
+ ? new JsonRemoteDelete(dataMessage.remoteDeleteId().get(), dataMessage.remoteDeleteId().get())
: null;
final var attachments = !dataMessage.attachments().isEmpty() ? dataMessage.attachments()
- .stream()
- .map(JsonAttachment::from)
- .toList() : null;
+ .stream()
+ .map(JsonAttachment::from)
+ .toList() : null;
final var sticker = dataMessage.sticker().isPresent() ? JsonSticker.from(dataMessage.sticker().get()) : null;
final var contacts = !dataMessage.sharedContacts().isEmpty() ? dataMessage.sharedContacts()
- .stream()
- .map(JsonSharedContact::from)
- .toList() : null;
+ .stream()
+ .map(JsonSharedContact::from)
+ .toList() : null;
final var pollCreate = dataMessage.pollCreate().map(JsonPollCreate::from).orElse(null);
final var pollVote = dataMessage.pollVote().map(JsonPollVote::from).orElse(null);
final var pollTerminate = dataMessage.pollTerminate().map(JsonPollTerminate::from).orElse(null);
final var textStyles = !dataMessage.textStyles().isEmpty() ? dataMessage.textStyles()
- .stream()
- .map(JsonTextStyle::from)
- .toList() : null;
+ .stream()
+ .map(JsonTextStyle::from)
+ .toList() : null;
final var pinMessage = dataMessage.pinMessage().map(JsonPinMessage::from).orElse(null);
final var unpinMessage = dataMessage.unpinMessage().map(JsonUnpinMessage::from).orElse(null);
final var adminDelete = dataMessage.adminDelete().map(JsonAdminDelete::from).orElse(null);
diff --git a/src/main/java/org/asamk/signal/json/JsonQuote.java b/src/main/java/org/asamk/signal/json/JsonQuote.java
index 146e7562..14cd03f7 100644
--- a/src/main/java/org/asamk/signal/json/JsonQuote.java
+++ b/src/main/java/org/asamk/signal/json/JsonQuote.java
@@ -34,14 +34,14 @@ public record JsonQuote(
: null;
final var attachments = !quote.attachments().isEmpty() ? quote.attachments()
- .stream()
- .map(JsonQuotedAttachment::from)
- .toList() : List.of();
+ .stream()
+ .map(JsonQuotedAttachment::from)
+ .toList() : List.of();
final var textStyles = !quote.textStyles().isEmpty() ? quote.textStyles()
- .stream()
- .map(JsonTextStyle::from)
- .toList() : null;
+ .stream()
+ .map(JsonTextStyle::from)
+ .toList() : null;
return new JsonQuote(id, author, authorNumber, authorUuid, text, mentions, attachments, textStyles);
}
diff --git a/src/main/java/org/asamk/signal/json/JsonReceiveMessageHandler.java b/src/main/java/org/asamk/signal/json/JsonReceiveMessageHandler.java
index ca0dba2b..cb2fa4e1 100644
--- a/src/main/java/org/asamk/signal/json/JsonReceiveMessageHandler.java
+++ b/src/main/java/org/asamk/signal/json/JsonReceiveMessageHandler.java
@@ -23,7 +23,7 @@ public class JsonReceiveMessageHandler implements Manager.ReceiveMessageHandler
@Override
public void handleMessage(MessageEnvelope envelope, Throwable exception) {
final var object = new HashMap();
- object.put("account", m.getSelfNumber());
+ object.put("account", m.getSelfIdentifier());
if (exception != null) {
object.put("exception", JsonError.from(exception));
}
diff --git a/src/main/java/org/asamk/signal/json/JsonRemoteDelete.java b/src/main/java/org/asamk/signal/json/JsonRemoteDelete.java
index b1def8e8..1a870fa9 100644
--- a/src/main/java/org/asamk/signal/json/JsonRemoteDelete.java
+++ b/src/main/java/org/asamk/signal/json/JsonRemoteDelete.java
@@ -3,4 +3,4 @@ package org.asamk.signal.json;
import io.micronaut.jsonschema.JsonSchema;
@JsonSchema(title = "RemoteDelete")
-record JsonRemoteDelete(long timestamp) {}
+record JsonRemoteDelete(long targetTimestamp, @Deprecated long timestamp) {}
diff --git a/src/main/java/org/asamk/signal/json/JsonSendMessageResult.java b/src/main/java/org/asamk/signal/json/JsonSendMessageResult.java
index 79ecd329..3752983e 100644
--- a/src/main/java/org/asamk/signal/json/JsonSendMessageResult.java
+++ b/src/main/java/org/asamk/signal/json/JsonSendMessageResult.java
@@ -27,13 +27,13 @@ public record JsonSendMessageResult(
result.isSuccess()
? Type.SUCCESS
: result.isRateLimitFailure()
- ? Type.RATE_LIMIT_FAILURE
+ ? Type.RATE_LIMIT_FAILURE
: result.isNetworkFailure()
- ? Type.NETWORK_FAILURE
+ ? Type.NETWORK_FAILURE
: result.isUnregisteredFailure()
- ? Type.UNREGISTERED_FAILURE
+ ? Type.UNREGISTERED_FAILURE
: result.isInvalidPreKeyFailure()
- ? Type.INVALID_PRE_KEY_FAILURE
+ ? Type.INVALID_PRE_KEY_FAILURE
: Type.IDENTITY_FAILURE,
result.proofRequiredFailure() != null ? result.proofRequiredFailure().getToken() : null,
rateLimitRetryAfterMilliseconds == null ? null : Math.ceilDiv(rateLimitRetryAfterMilliseconds, 1000L));
diff --git a/src/main/java/org/asamk/signal/json/JsonSharedContact.java b/src/main/java/org/asamk/signal/json/JsonSharedContact.java
index eebb44da..30e3b0c9 100644
--- a/src/main/java/org/asamk/signal/json/JsonSharedContact.java
+++ b/src/main/java/org/asamk/signal/json/JsonSharedContact.java
@@ -31,9 +31,9 @@ public record JsonSharedContact(
: null;
final var address = !contact.address().isEmpty() ? contact.address()
- .stream()
- .map(JsonContactAddress::from)
- .toList() : null;
+ .stream()
+ .map(JsonContactAddress::from)
+ .toList() : null;
final var organization = contact.organization().orElse(null);
diff --git a/src/main/java/org/asamk/signal/json/JsonSyncMessage.java b/src/main/java/org/asamk/signal/json/JsonSyncMessage.java
index e4b1242c..ac87e6ca 100644
--- a/src/main/java/org/asamk/signal/json/JsonSyncMessage.java
+++ b/src/main/java/org/asamk/signal/json/JsonSyncMessage.java
@@ -50,9 +50,9 @@ record JsonSyncMessage(
}
final var readMessages = !syncMessage.read().isEmpty() ? syncMessage.read()
- .stream()
- .map(JsonSyncReadMessage::from)
- .toList() : null;
+ .stream()
+ .map(JsonSyncReadMessage::from)
+ .toList() : null;
final JsonSyncMessageType type;
if (syncMessage.contacts().isPresent()) {
diff --git a/src/main/java/org/asamk/signal/jsonrpc/JsonRpcReader.java b/src/main/java/org/asamk/signal/jsonrpc/JsonRpcReader.java
index 2d1509cf..468637ca 100644
--- a/src/main/java/org/asamk/signal/jsonrpc/JsonRpcReader.java
+++ b/src/main/java/org/asamk/signal/jsonrpc/JsonRpcReader.java
@@ -1,6 +1,7 @@
package org.asamk.signal.jsonrpc;
import com.fasterxml.jackson.core.JsonParseException;
+import com.fasterxml.jackson.core.StreamReadConstraints;
import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -8,6 +9,7 @@ import com.fasterxml.jackson.databind.node.ContainerNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.fasterxml.jackson.databind.node.ValueNode;
+import org.asamk.signal.manager.config.ServiceConfig;
import org.asamk.signal.util.Util;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -24,6 +26,7 @@ import java.util.stream.StreamSupport;
public class JsonRpcReader {
private static final Logger logger = LoggerFactory.getLogger(JsonRpcReader.class);
+ private static final int MAX_JSON_STRING_LENGTH = ((ServiceConfig.MAX_ATTACHMENT_SIZE + 2) / 3) * 4 + 1024;
private final JsonRpcSender jsonRpcSender;
private final ObjectMapper objectMapper;
@@ -34,14 +37,23 @@ public class JsonRpcReader {
this.jsonRpcSender = jsonRpcSender;
this.input = null;
this.lineSupplier = lineSupplier;
- this.objectMapper = Util.createJsonObjectMapper();
+ this.objectMapper = createObjectMapper();
}
public JsonRpcReader(final JsonRpcSender jsonRpcSender, final InputStream input) {
this.jsonRpcSender = jsonRpcSender;
this.input = input;
this.lineSupplier = null;
- this.objectMapper = Util.createJsonObjectMapper();
+ this.objectMapper = createObjectMapper();
+ }
+
+ private static ObjectMapper createObjectMapper() {
+ final var objectMapper = Util.createJsonObjectMapper();
+ objectMapper.getFactory()
+ .setStreamReadConstraints(StreamReadConstraints.builder()
+ .maxStringLength(MAX_JSON_STRING_LENGTH)
+ .build());
+ return objectMapper;
}
public void readMessages(final RequestHandler requestHandler, final Consumer responseHandler) {
diff --git a/src/main/java/org/asamk/signal/jsonrpc/SignalJsonRpcCommandHandler.java b/src/main/java/org/asamk/signal/jsonrpc/SignalJsonRpcCommandHandler.java
index 5ce6c6c1..fcd0f228 100644
--- a/src/main/java/org/asamk/signal/jsonrpc/SignalJsonRpcCommandHandler.java
+++ b/src/main/java/org/asamk/signal/jsonrpc/SignalJsonRpcCommandHandler.java
@@ -8,6 +8,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ContainerNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
+import org.asamk.signal.AccountIdentifier;
import org.asamk.signal.commands.Command;
import org.asamk.signal.commands.JsonRpcMultiCommand;
import org.asamk.signal.commands.JsonRpcRegistrationCommand;
@@ -126,7 +127,7 @@ public class SignalJsonRpcCommandHandler {
private Manager getManagerFromParams(final ContainerNode> params) throws JsonRpcException {
if (params != null && params.hasNonNull("account")) {
- final var manager = c.getManager(params.get("account").asText());
+ final var manager = c.getManager(AccountIdentifier.normalize(params.get("account").asText()));
((ObjectNode) params).remove("account");
if (manager == null) {
throw new JsonRpcException(new JsonRpcResponse.Error(JsonRpcResponse.Error.INVALID_PARAMS,
@@ -140,7 +141,7 @@ public class SignalJsonRpcCommandHandler {
private Pair getRegistrationManagerFromParams(final ContainerNode> params) {
if (params != null && params.has("account")) {
- final var account = params.get("account").asText();
+ final var account = AccountIdentifier.normalize(params.get("account").asText());
((ObjectNode) params).remove("account");
try {
return new Pair<>(account, c.getNewRegistrationManager(account));
diff --git a/src/main/java/org/asamk/signal/jsonrpc/SignalJsonRpcDispatcherHandler.java b/src/main/java/org/asamk/signal/jsonrpc/SignalJsonRpcDispatcherHandler.java
index 166c822a..b959452b 100644
--- a/src/main/java/org/asamk/signal/jsonrpc/SignalJsonRpcDispatcherHandler.java
+++ b/src/main/java/org/asamk/signal/jsonrpc/SignalJsonRpcDispatcherHandler.java
@@ -31,6 +31,7 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Consumer;
import java.util.function.Supplier;
import java.util.stream.Collectors;
@@ -61,17 +62,31 @@ public class SignalJsonRpcDispatcherHandler {
public void handleConnection(final MultiAccountManager c) {
this.commandHandler = new SignalJsonRpcCommandHandler(c, this::getCommand);
+ final Consumer onManagerAddedReceiveHandler = m -> subscribeReceive(m, true);
+ final Consumer onManagerRemovedReceiveHandler = this::unsubscribeReceive;
+ final Consumer onManagerAddedReceiveSubscriptionsHandler = m -> receiveHandlers.forEach((subscriptionId, handlers) -> handlers.add(
+ createReceiveHandler(m, subscriptionId, false)));
+ final Consumer onManagerAddedCallEventSubscriptionsHandler = m -> callEventHandlers.forEach((subscriptionId, handlers) -> handlers.add(
+ createCallEventHandler(m, subscriptionId)));
+
if (!noReceiveOnStart) {
this.subscribeReceive(c.getManagers(), true);
- c.addOnManagerAddedHandler(m -> subscribeReceive(m, true));
- c.addOnManagerRemovedHandler(this::unsubscribeReceive);
+ c.addOnManagerAddedHandler(onManagerAddedReceiveHandler);
+ c.addOnManagerRemovedHandler(onManagerRemovedReceiveHandler);
}
- c.addOnManagerAddedHandler(m -> receiveHandlers.forEach((subscriptionId, handlers) -> handlers.add(
- createReceiveHandler(m, subscriptionId, false))));
- c.addOnManagerAddedHandler(m -> callEventHandlers.forEach((subscriptionId, handlers) -> handlers.add(
- createCallEventHandler(m, subscriptionId))));
+ c.addOnManagerAddedHandler(onManagerAddedReceiveSubscriptionsHandler);
+ c.addOnManagerAddedHandler(onManagerAddedCallEventSubscriptionsHandler);
- handleConnection();
+ try {
+ handleConnection();
+ } finally {
+ if (!noReceiveOnStart) {
+ c.removeOnManagerAddedHandler(onManagerAddedReceiveHandler);
+ c.removeOnManagerRemovedHandler(onManagerRemovedReceiveHandler);
+ }
+ c.removeOnManagerAddedHandler(onManagerAddedReceiveSubscriptionsHandler);
+ c.removeOnManagerAddedHandler(onManagerAddedCallEventSubscriptionsHandler);
+ }
}
public void handleConnection(final Manager m) {
diff --git a/src/main/resources/META-INF/native-image/org.asamk/signal-cli/reachability-metadata.json b/src/main/resources/META-INF/native-image/org.asamk/signal-cli/reachability-metadata.json
index afcd949b..d8b7663a 100644
--- a/src/main/resources/META-INF/native-image/org.asamk/signal-cli/reachability-metadata.json
+++ b/src/main/resources/META-INF/native-image/org.asamk/signal-cli/reachability-metadata.json
@@ -1415,6 +1415,9 @@
}
]
},
+ {
+ "type": "java.math.BigDecimal"
+ },
{
"type": "java.math.BigInteger"
},
@@ -1771,6 +1774,9 @@
{
"type": "kotlin.Any"
},
+ {
+ "type": "kotlin.Array"
+ },
{
"type": "kotlin.Boolean"
},
@@ -1946,42 +1952,7 @@
},
{
"type": "kotlin.reflect.jvm.internal.impl.resolve.scopes.DescriptorKindFilter",
- "allPublicFields": true,
- "fields": [
- {
- "name": "ALL"
- },
- {
- "name": "CALLABLES"
- },
- {
- "name": "CLASSIFIERS"
- },
- {
- "name": "Companion"
- },
- {
- "name": "FUNCTIONS"
- },
- {
- "name": "NON_SINGLETON_CLASSIFIERS"
- },
- {
- "name": "PACKAGES"
- },
- {
- "name": "SINGLETON_CLASSIFIERS"
- },
- {
- "name": "TYPE_ALIASES"
- },
- {
- "name": "VALUES"
- },
- {
- "name": "VARIABLES"
- }
- ]
+ "allPublicFields": true
},
{
"type": "kotlinx.coroutines.AwaitAll"
@@ -2513,6 +2484,10 @@
"type": "org.asamk.signal.commands.ListAccountsCommand$JsonAccount",
"allDeclaredFields": true,
"methods": [
+ {
+ "name": "aci",
+ "parameterTypes": []
+ },
{
"name": "number",
"parameterTypes": []
@@ -5118,6 +5093,15 @@
}
]
},
+ {
+ "type": "org.bouncycastle.jcajce.provider.asymmetric.XMSS$Mappings",
+ "methods": [
+ {
+ "name": "",
+ "parameterTypes": []
+ }
+ ]
+ },
{
"type": "org.bouncycastle.jcajce.provider.asymmetric.edec.SignatureSpi$Ed25519",
"methods": [
@@ -7184,6 +7168,9 @@
"type": "org.signal.libsignal.zkgroup.profiles.ExpiringProfileKeyCredential",
"allDeclaredFields": true
},
+ {
+ "type": "org.signal.libsignal.zkgroup.profiles.ExpiringProfileKeyCredentialResponse"
+ },
{
"type": "org.signal.libsignal.zkgroup.profiles.ProfileKey",
"allDeclaredFields": true
@@ -8321,6 +8308,13 @@
{
"name": "",
"parameterTypes": []
+ },
+ {
+ "name": "",
+ "parameterTypes": [
+ "int",
+ "int"
+ ]
}
]
},
@@ -8463,6 +8457,12 @@
"allDeclaredMethods": true,
"allDeclaredConstructors": true
},
+ {
+ "type": "org.whispersystems.signalservice.api.profiles.SignalServiceProfile$Companion"
+ },
+ {
+ "type": "org.whispersystems.signalservice.api.profiles.SignalServiceProfile$RequestType"
+ },
{
"type": "org.whispersystems.signalservice.api.profiles.SignalServiceProfileWrite",
"allDeclaredFields": true,
@@ -8471,7 +8471,13 @@
},
{
"type": "org.whispersystems.signalservice.api.provisioning.ProvisioningMessage",
- "allDeclaredFields": true
+ "allDeclaredFields": true,
+ "methods": [
+ {
+ "name": "getBody",
+ "parameterTypes": []
+ }
+ ]
},
{
"type": "org.whispersystems.signalservice.api.push.ServiceId",
@@ -8919,6 +8925,9 @@
"allDeclaredMethods": true,
"allDeclaredConstructors": true
},
+ {
+ "type": "org.whispersystems.signalservice.internal.push.AuthCredentials$Companion"
+ },
{
"type": "org.whispersystems.signalservice.internal.push.ByteArrayDeserializerBase64",
"methods": [
@@ -8928,6 +8937,15 @@
}
]
},
+ {
+ "type": "org.whispersystems.signalservice.internal.push.ByteArraySerializerBase64NoPadding",
+ "methods": [
+ {
+ "name": "",
+ "parameterTypes": []
+ }
+ ]
+ },
{
"type": "org.whispersystems.signalservice.internal.push.CdsiAuthResponse",
"allDeclaredFields": true,
@@ -8935,6 +8953,13 @@
{
"name": "",
"parameterTypes": []
+ },
+ {
+ "name": "",
+ "parameterTypes": [
+ "java.lang.String",
+ "java.lang.String"
+ ]
}
]
},
@@ -9096,6 +9121,14 @@
"name": "",
"parameterTypes": []
},
+ {
+ "name": "",
+ "parameterTypes": [
+ "long",
+ "org.signal.libsignal.protocol.kem.KEMPublicKey",
+ "byte[]"
+ ]
+ },
{
"name": "getKeyId",
"parameterTypes": []
@@ -9182,6 +9215,9 @@
"allDeclaredMethods": true,
"allDeclaredConstructors": true
},
+ {
+ "type": "org.whispersystems.signalservice.internal.push.MismatchedDevices$Companion"
+ },
{
"type": "org.whispersystems.signalservice.internal.push.OneTimePreKeyCounts",
"allDeclaredFields": true,
@@ -9583,6 +9619,61 @@
}
]
},
+ {
+ "type": "org.whispersystems.signalservice.internal.push.StickerUploadAttributes",
+ "fields": [
+ {
+ "name": "acl"
+ },
+ {
+ "name": "algorithm"
+ },
+ {
+ "name": "credential"
+ },
+ {
+ "name": "date"
+ },
+ {
+ "name": "id"
+ },
+ {
+ "name": "key"
+ },
+ {
+ "name": "policy"
+ },
+ {
+ "name": "signature"
+ }
+ ],
+ "methods": [
+ {
+ "name": "",
+ "parameterTypes": []
+ }
+ ]
+ },
+ {
+ "type": "org.whispersystems.signalservice.internal.push.StickerUploadAttributesResponse",
+ "fields": [
+ {
+ "name": "manifest"
+ },
+ {
+ "name": "packId"
+ },
+ {
+ "name": "stickers"
+ }
+ ],
+ "methods": [
+ {
+ "name": "",
+ "parameterTypes": []
+ }
+ ]
+ },
{
"type": "org.whispersystems.signalservice.internal.push.SubmitRecaptchaChallengePayload",
"allDeclaredFields": true
diff --git a/src/test/java/org/asamk/signal/AccountIdentifierTest.java b/src/test/java/org/asamk/signal/AccountIdentifierTest.java
new file mode 100644
index 00000000..1f1ddf78
--- /dev/null
+++ b/src/test/java/org/asamk/signal/AccountIdentifierTest.java
@@ -0,0 +1,23 @@
+package org.asamk.signal;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class AccountIdentifierTest {
+
+ @Test
+ void normalizesSignalAndroidAccountKeys() {
+ assertEquals("a6b28482-2e32-83d0-7f23-91360a4c2b91",
+ AccountIdentifier.normalize("A6B284822E3283D07F2391360A4C2B91"));
+ assertEquals("a6b28482-2e32-83d0-7f23-91360a4c2b91",
+ AccountIdentifier.normalize("A6B28482-2E32-83D0-7F23-91360A4C2B91"));
+ }
+
+ @Test
+ void leavesNonAccountIdentifiersUnchanged() {
+ assertEquals("+12025550123", AccountIdentifier.normalize("+12025550123"));
+ assertEquals("not-an-account", AccountIdentifier.normalize("not-an-account"));
+ assertEquals("NOT-AN-ACCOUNT", AccountIdentifier.normalize("NOT-AN-ACCOUNT"));
+ }
+}
diff --git a/src/test/java/org/asamk/signal/commands/SendCommandErrorTest.java b/src/test/java/org/asamk/signal/commands/SendCommandErrorTest.java
new file mode 100644
index 00000000..a2105437
--- /dev/null
+++ b/src/test/java/org/asamk/signal/commands/SendCommandErrorTest.java
@@ -0,0 +1,59 @@
+package org.asamk.signal.commands;
+
+import org.asamk.signal.commands.exceptions.UnexpectedErrorException;
+import org.asamk.signal.manager.Manager;
+import org.asamk.signal.manager.api.AttachmentInvalidException;
+import org.asamk.signal.output.JsonWriter;
+import org.asamk.signal.testutil.ManagerMock;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import java.io.IOException;
+import java.lang.reflect.Proxy;
+import java.net.SocketException;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Stream;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+class SendCommandErrorTest {
+
+ @ParameterizedTest
+ @MethodSource("failures")
+ void uploadAndTransmissionFailuresKeepTheirClassification(final Exception cause) {
+ final var error = send(cause);
+
+ assertEquals("Failed to send message: " + cause.getMessage() + " (" + cause.getClass().getSimpleName() + ")",
+ error.getMessage());
+ assertSame(cause, error.getCause());
+ }
+
+ private static Stream failures() {
+ return Stream.of(new AttachmentInvalidException("inline attachment #2: Connection reset"),
+ new SocketException("Connection reset"),
+ new IOException("Server error: 500"));
+ }
+
+ private static UnexpectedErrorException send(final Exception failure) {
+ final var delegate = ManagerMock.create("+15551234567");
+ final var manager = (Manager) Proxy.newProxyInstance(Manager.class.getClassLoader(),
+ new Class>[]{Manager.class}, (proxy, method, args) -> {
+ if (method.getName().equals("sendMessage")) {
+ throw failure;
+ }
+ return method.invoke(delegate, args);
+ });
+ // Upload and transmission failures must map to the same exception type, so the exit code and the
+ // JSON-RPC error code stay the same.
+ return assertThrows(UnexpectedErrorException.class,
+ () -> new SendCommand().handleCommand(new JsonRpcNamespace(Map.of("noteToSelf",
+ true,
+ "message",
+ "hi",
+ "attachments",
+ List.of("photo.jpg"))), manager, (JsonWriter) ignored -> {}));
+ }
+}
diff --git a/src/test/java/org/asamk/signal/commands/SendCommandParsingTest.java b/src/test/java/org/asamk/signal/commands/SendCommandParsingTest.java
index f56b00e2..e206dbf7 100644
--- a/src/test/java/org/asamk/signal/commands/SendCommandParsingTest.java
+++ b/src/test/java/org/asamk/signal/commands/SendCommandParsingTest.java
@@ -29,42 +29,65 @@ class SendCommandParsingTest {
@Test
void cliPreservesEmptyAndMissingDimensionPositions() throws Exception {
final var parser = ArgumentParsers.newFor("signal-cli", DefaultSettings.VERSION_0_9_0_DEFAULT_SETTINGS)
- .includeArgumentNamesAsKeysInResult(true).build();
+ .includeArgumentNamesAsKeysInResult(true)
+ .build();
new SendCommand().attachToSubparser(parser.addSubparsers().addParser("send"));
- final var message = send(parser.parseArgs(new String[]{"send", "--note-to-self",
- "-a", "photo.jpg", "portrait.mp4", "other.mp4", "--attachment-dimensions", "", "1080x1920"}));
+ final var message = send(parser.parseArgs(new String[]{
+ "send",
+ "--note-to-self",
+ "-a",
+ "photo.jpg",
+ "portrait.mp4",
+ "other.mp4",
+ "--attachment-dimensions",
+ "",
+ "1080x1920"
+ }));
assertEquals(ATTACHMENTS, message.attachments());
- assertEquals(Arrays.asList(null, new Message.AttachmentDimensions(1080, 1920)),
- message.attachmentDimensions());
+ assertEquals(Arrays.asList(null, new Message.AttachmentDimensions(1080, 1920)), message.attachmentDimensions());
}
@Test
void jsonRpcPassesDimensionsIntoTheSentMessage() throws Exception {
final var message = send(namespace(List.of("", "1920x1080")));
assertEquals(ATTACHMENTS, message.attachments());
- assertEquals(Arrays.asList(null, new Message.AttachmentDimensions(1920, 1080)),
- message.attachmentDimensions());
+ assertEquals(Arrays.asList(null, new Message.AttachmentDimensions(1920, 1080)), message.attachmentDimensions());
}
@Test
void cliPreservesBlurHashPositions() throws Exception {
final var parser = ArgumentParsers.newFor("signal-cli", DefaultSettings.VERSION_0_9_0_DEFAULT_SETTINGS)
- .includeArgumentNamesAsKeysInResult(true).build();
+ .includeArgumentNamesAsKeysInResult(true)
+ .build();
new SendCommand().attachToSubparser(parser.addSubparsers().addParser("send"));
- final var message = send(parser.parseArgs(new String[]{"send", "--note-to-self",
- "-a", "photo.jpg", "portrait.mp4", "other.mp4", "--attachment-blurhash", "", BLUR_HASH}));
+ final var message = send(parser.parseArgs(new String[]{
+ "send",
+ "--note-to-self",
+ "-a",
+ "photo.jpg",
+ "portrait.mp4",
+ "other.mp4",
+ "--attachment-blurhash",
+ "",
+ BLUR_HASH
+ }));
assertEquals(List.of("", BLUR_HASH), message.attachmentBlurHashes());
}
@Test
void jsonRpcPassesBlurHashesWithDimensions() throws Exception {
- final var message = send(new JsonRpcNamespace(Map.of("noteToSelf", true, "attachments", ATTACHMENTS,
- "attachmentDimensions", List.of("", "1920x1080"), "attachmentBlurhash", List.of("", BLUR_HASH))));
+ final var message = send(new JsonRpcNamespace(Map.of("noteToSelf",
+ true,
+ "attachments",
+ ATTACHMENTS,
+ "attachmentDimensions",
+ List.of("", "1920x1080"),
+ "attachmentBlurhash",
+ List.of("", BLUR_HASH))));
- assertEquals(Arrays.asList(null, new Message.AttachmentDimensions(1920, 1080)),
- message.attachmentDimensions());
+ assertEquals(Arrays.asList(null, new Message.AttachmentDimensions(1920, 1080)), message.attachmentDimensions());
assertEquals(List.of("", BLUR_HASH), message.attachmentBlurHashes());
}
@@ -91,27 +114,37 @@ class SendCommandParsingTest {
}
private JsonRpcNamespace blurHashNamespace(List blurHashes) {
- return new JsonRpcNamespace(Map.of("noteToSelf", true, "attachments", ATTACHMENTS,
- "attachmentBlurhash", blurHashes));
+ return new JsonRpcNamespace(Map.of("noteToSelf",
+ true,
+ "attachments",
+ ATTACHMENTS,
+ "attachmentBlurhash",
+ blurHashes));
}
private JsonRpcNamespace namespace(List dimensions) {
- return new JsonRpcNamespace(Map.of("noteToSelf", true, "attachments", ATTACHMENTS,
- "attachmentDimensions", dimensions));
+ return new JsonRpcNamespace(Map.of("noteToSelf",
+ true,
+ "attachments",
+ ATTACHMENTS,
+ "attachmentDimensions",
+ dimensions));
}
private Message send(Namespace namespace) throws Exception {
final var captured = new AtomicReference();
final var delegate = ManagerMock.create("+15551234567");
final var manager = (Manager) Proxy.newProxyInstance(Manager.class.getClassLoader(),
- new Class>[]{Manager.class}, (proxy, method, args) -> {
+ new Class>[]{Manager.class},
+ (proxy, method, args) -> {
if (method.getName().equals("sendMessage")) {
captured.set((Message) args[0]);
return new SendMessageResults(1, Map.of());
}
return method.invoke(delegate, args);
});
- new SendCommand().handleCommand(namespace, manager, (JsonWriter) ignored -> {});
+ new SendCommand().handleCommand(namespace, manager, (JsonWriter) ignored -> {
+ });
return captured.get();
}
}
diff --git a/src/test/java/org/asamk/signal/dbus/NumberlessDbusTest.java b/src/test/java/org/asamk/signal/dbus/NumberlessDbusTest.java
new file mode 100644
index 00000000..cf6520f5
--- /dev/null
+++ b/src/test/java/org/asamk/signal/dbus/NumberlessDbusTest.java
@@ -0,0 +1,415 @@
+package org.asamk.signal.dbus;
+
+import org.asamk.Signal;
+import org.asamk.SignalControl;
+import org.asamk.signal.DbusConfig;
+import org.asamk.signal.commands.CommandHandler;
+import org.asamk.signal.commands.ListGroupsCommand;
+import org.asamk.signal.commands.LocalCommand;
+import org.asamk.signal.commands.exceptions.UserErrorException;
+import org.asamk.signal.manager.Manager;
+import org.asamk.signal.manager.api.Group;
+import org.asamk.signal.manager.api.GroupId;
+import org.asamk.signal.manager.api.GroupMember;
+import org.asamk.signal.manager.api.GroupPermission;
+import org.asamk.signal.manager.api.Identity;
+import org.asamk.signal.manager.api.MessageEnvelope;
+import org.asamk.signal.manager.api.Recipient;
+import org.asamk.signal.manager.api.RecipientAddress;
+import org.asamk.signal.manager.api.RecipientIdentifier;
+import org.asamk.signal.manager.api.SendMessageResults;
+import org.asamk.signal.manager.api.TrustLevel;
+import org.asamk.signal.manager.api.TypingAction;
+import org.asamk.signal.manager.internal.MultiAccountManagerImpl;
+import org.freedesktop.dbus.DBusPath;
+import org.freedesktop.dbus.connections.impl.DBusConnection;
+import org.freedesktop.dbus.connections.impl.DBusConnectionBuilder;
+import org.freedesktop.dbus.exceptions.DBusExecutionException;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.NullSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.lang.reflect.InvocationHandler;
+import java.lang.reflect.Proxy;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.stream.Collectors;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+class NumberlessDbusTest {
+
+ private static final String NUMBER = "+12025550123";
+ private static final String ACI = "11111111-1111-4111-8111-111111111111";
+ private static final String OTHER_ACI = "22222222-2222-4222-8222-222222222222";
+ private static final String THIRD_ACI = "33333333-3333-4333-8333-333333333333";
+ private static final String NUMBERLESS_PATH = "/org/asamk/Signal/11111111_1111_4111_8111_111111111111";
+ private static final String NUMBERED_PATH = "/org/asamk/Signal/_12025550123";
+
+ @Test
+ void resolvesNumberlessAndNumberedAccountsWithoutChangingNumberedPaths() {
+ final var numberless = new TestAccount(null, ACI);
+ final var numbered = new TestAccount(NUMBER, OTHER_ACI);
+ final var accounts = new MultiAccountManagerImpl(List.of(numberless.manager, numbered.manager), null);
+ final var control = new DbusSignalControlImpl(accounts, DbusConfig.getObjectPath());
+
+ assertEquals(Set.of(new DBusPath(NUMBERLESS_PATH), new DBusPath(NUMBERED_PATH)),
+ Set.copyOf(control.listAccounts()));
+ assertEquals(NUMBERLESS_PATH, control.getAccount(ACI).getPath());
+ assertEquals(NUMBERED_PATH, control.getAccount(NUMBER).getPath());
+ assertEquals(NUMBERED_PATH, control.getAccount(OTHER_ACI).getPath());
+ assertThrows(SignalControl.Error.Failure.class, () -> control.getAccount(THIRD_ACI));
+ }
+
+ @ParameterizedTest
+ @NullSource
+ @ValueSource(strings = NUMBER)
+ void mapsOptionalPhoneNumbersAndAddressesSelfByAnAvailableIdentifier(final String number) throws Exception {
+ final var account = new TestAccount(number, ACI);
+ final var signal = new DbusSignalImpl(account.manager, null, DbusConfig.getObjectPath(), true);
+ final var client = new DbusManagerImpl(signal, null, "org.asamk.Signal.Test");
+
+ assertEquals(number == null ? "" : number, signal.getSelfNumber());
+ assertEquals(number, client.getSelfNumber());
+ assertEquals(number == null ? ACI : number, client.getSelfIdentifier());
+ checkSelfOperations(client,
+ account,
+ number == null
+ ? new RecipientIdentifier.Uuid(UUID.fromString(ACI))
+ : new RecipientIdentifier.Number(number));
+ }
+
+ // Run with: dbus-run-session -- ./gradlew --no-daemon :test --tests '*NumberlessDbusTest*' --rerun-tasks
+ @Nested
+ @Timeout(20)
+ @EnabledIfEnvironmentVariable(named = "DBUS_SESSION_BUS_ADDRESS", matches = ".+")
+ class SessionBus {
+
+ @Test
+ void exportsMixedAccountsRoutesAciSelectorsAndRemovesOnlyTheClosedAccount() throws Exception {
+ final var numberless = new TestAccount(null, ACI);
+ final var numbered = new TestAccount(NUMBER, OTHER_ACI);
+ final var secondNumberless = new TestAccount(null, THIRD_ACI);
+ final var accounts = new MultiAccountManagerImpl(List.of(numberless.manager,
+ numbered.manager,
+ secondNumberless.manager), null);
+ final var busname = newBusname();
+ try (final var server = new DbusHandler(false,
+ busname,
+ accounts,
+ true); final var connection = DBusConnectionBuilder.forSessionBus().withShared(false).build()) {
+ server.init();
+ final var control = connection.getRemoteObject(busname,
+ DbusConfig.getObjectPath(),
+ SignalControl.class);
+ assertEquals(Set.of(NUMBERLESS_PATH, NUMBERED_PATH, DbusConfig.getObjectPath(THIRD_ACI)),
+ control.listAccounts().stream().map(DBusPath::getPath).collect(Collectors.toSet()));
+ assertEquals(NUMBERED_PATH, control.getAccount(OTHER_ACI).getPath());
+ assertEquals(NUMBERED_PATH, control.getAccount(NUMBER).getPath());
+ assertEquals(NUMBERLESS_PATH, control.getAccount(ACI).getPath());
+ assertEquals(ACI, selectAccount(ACI, connection, busname));
+ assertEquals(OTHER_ACI, selectAccount(OTHER_ACI, connection, busname));
+ assertEquals(OTHER_ACI, selectAccount(NUMBER, connection, busname));
+ assertThrows(UserErrorException.class, () -> selectAccount(null, connection, busname));
+
+ final var client = new DbusMultiAccountManagerImpl(control, connection, busname);
+ assertEquals(3, client.getManagers().size());
+ assertEquals(2, client.getManagers().stream().filter(m -> m.getSelfNumber() == null).count());
+ assertNull(client.getManager(ACI).getSelfNumber());
+ assertEquals(NUMBER, client.getManager(OTHER_ACI).getSelfNumber());
+
+ final var removed = connection.getRemoteObject(busname, NUMBERLESS_PATH, Signal.class);
+ numberless.manager.close();
+ assertEquals(2, control.listAccounts().size());
+ assertThrows(SignalControl.Error.Failure.class, () -> control.getAccount(ACI));
+ assertThrows(DBusExecutionException.class, removed::getSelfACI);
+ assertEquals(THIRD_ACI, client.getManager(THIRD_ACI).getSelfACI());
+ assertEquals(NUMBER, client.getManager(NUMBER).getSelfNumber());
+ }
+ }
+
+ @ParameterizedTest
+ @NullSource
+ @ValueSource(strings = NUMBER)
+ void selectsSingleAccountDaemonsImplicitlyAndByAci(final String number) throws Exception {
+ final var account = new TestAccount(number, ACI);
+ final var busname = newBusname();
+ try (final var server = new DbusHandler(false,
+ busname,
+ account.manager,
+ true); final var connection = DBusConnectionBuilder.forSessionBus().withShared(false).build()) {
+ server.init();
+ assertEquals(ACI, selectAccount(null, connection, busname));
+ assertEquals(ACI, selectAccount(ACI, connection, busname));
+ if (number != null) {
+ assertEquals(ACI, selectAccount(number, connection, busname));
+ }
+ assertThrows(UserErrorException.class, () -> selectAccount(OTHER_ACI, connection, busname));
+ }
+ }
+
+ @Test
+ void preservesAciAddressesInContactsGroupMembersAndIdentities() throws Exception {
+ final var account = new TestAccount(null, ACI);
+ final var contact = new RecipientAddress(UUID.fromString(OTHER_ACI));
+ final var numberedContact = new RecipientAddress(NUMBER);
+ account.recipients = List.of(Recipient.newBuilder().withAddress(contact).build(),
+ Recipient.newBuilder().withAddress(numberedContact).build());
+ account.identities = List.of(new Identity(contact,
+ new byte[]{1},
+ "test",
+ new byte[]{2},
+ TrustLevel.TRUSTED_UNVERIFIED,
+ 123));
+ final var groupId = GroupId.unknownVersion(new byte[32]);
+ account.groups = List.of(new Group(groupId,
+ "Test group",
+ "",
+ null,
+ Set.of(new GroupMember(contact, true, null, null),
+ new GroupMember(numberedContact, false, null, null)),
+ Set.of(contact),
+ Set.of(contact),
+ Set.of(contact),
+ false,
+ 0,
+ GroupPermission.EVERY_MEMBER,
+ GroupPermission.EVERY_MEMBER,
+ GroupPermission.EVERY_MEMBER,
+ true,
+ true,
+ false));
+ final var busname = newBusname();
+ try (final var server = new DbusHandler(false,
+ busname,
+ account.manager,
+ true); final var connection = DBusConnectionBuilder.forSessionBus().withShared(false).build()) {
+ server.init();
+ final var signal = connection.getRemoteObject(busname, DbusConfig.getObjectPath(), Signal.class);
+ assertEquals(List.of(NUMBER), signal.listNumbers());
+ assertEquals(Set.of(OTHER_ACI, NUMBER), Set.copyOf(signal.listRecipientIdentifiers()));
+ try (final var client = new DbusManagerImpl(signal, connection, busname)) {
+ final var contacts = client.getRecipients(false,
+ Optional.empty(),
+ Set.of(new RecipientIdentifier.Uuid(UUID.fromString(OTHER_ACI))),
+ Optional.empty());
+ assertEquals(1, contacts.size());
+ assertEquals(contact, contacts.getFirst().getAddress());
+ assertEquals(contact, client.getIdentities().getFirst().recipient());
+ final var group = client.getGroups().getFirst();
+ assertEquals(Set.of(contact, numberedContact),
+ group.members().stream().map(GroupMember::recipientAddress).collect(Collectors.toSet()));
+ assertEquals(Set.of(contact), group.pendingMembers());
+ assertEquals(Set.of(contact), group.requestingMembers());
+ assertEquals(Set.of(contact), group.bannedMembers());
+ }
+ }
+ }
+
+ @Test
+ void sendsAndReceivesNumberlessDirectGroupAndSelfMessagesOverDbus() throws Exception {
+ final var account = new TestAccount(null, ACI);
+ final var accounts = new MultiAccountManagerImpl(List.of(account.manager), null);
+ final var busname = newBusname();
+ try (final var server = new DbusHandler(false,
+ busname,
+ accounts,
+ true); final var connection = DBusConnectionBuilder.forSessionBus().withShared(false).build()) {
+ server.init();
+ assertEquals(ACI, selectAccount(null, connection, busname));
+ final var signal = connection.getRemoteObject(busname, NUMBERLESS_PATH, Signal.class);
+ assertEquals("", signal.getSelfNumber());
+ final var groupId = GroupId.unknownVersion(new byte[32]);
+ assertEquals(42, signal.sendMessage("Direct", List.of(), OTHER_ACI));
+ assertEquals(42, signal.sendGroupMessage("Group", List.of(), groupId.serialize()));
+ assertEquals(42, signal.sendNoteToSelfMessage("Self", List.of()));
+ assertEquals(Set.of(new RecipientIdentifier.Uuid(UUID.fromString(OTHER_ACI))),
+ account.calls.get(0).arguments().get(1));
+ assertEquals(Set.of(new RecipientIdentifier.Group(groupId)), account.calls.get(1).arguments().get(1));
+ assertEquals(Set.of(RecipientIdentifier.NoteToSelf.INSTANCE), account.calls.get(2).arguments().get(1));
+ assertThrows(Signal.Error.InvalidNumber.class,
+ () -> signal.sendMessage("Invalid local number", List.of(), "2025550124"));
+
+ account.calls.clear();
+ try (final var client = new DbusManagerImpl(signal, connection, busname)) {
+ checkSelfOperations(client, account, new RecipientIdentifier.Uuid(UUID.fromString(ACI)));
+ final var received = new LinkedBlockingQueue();
+ client.addReceiveHandler((envelope, error) -> received.add(envelope));
+ for (final var group : List.of(Optional.empty(), Optional.of(groupId))) {
+ account.emit(messageEnvelope(group));
+ final var envelope = received.poll(5, TimeUnit.SECONDS);
+ assertNotNull(envelope);
+ assertEquals(OTHER_ACI, envelope.sourceAddress().orElseThrow().aci().orElseThrow());
+ assertEquals(Optional.empty(), envelope.sourceAddress().orElseThrow().number());
+ assertEquals("Incoming", envelope.data().orElseThrow().body().orElseThrow());
+ assertEquals(Optional.of(ACI),
+ envelope.data().orElseThrow().mentions().getFirst().recipient().aci());
+ assertEquals(group,
+ envelope.data()
+ .orElseThrow()
+ .groupContext()
+ .map(MessageEnvelope.Data.GroupContext::groupId));
+ }
+ }
+ assertEquals(0, account.receivers.size());
+ }
+ }
+ }
+
+ private static void checkSelfOperations(
+ final Manager client,
+ final TestAccount account,
+ final RecipientIdentifier.Single self
+ ) throws Exception {
+ final Set recipients = Set.of(RecipientIdentifier.NoteToSelf.INSTANCE);
+ client.sendTypingMessage(TypingAction.START, recipients);
+ client.sendRemoteDeleteMessage(123, recipients);
+ client.sendMessageReaction("\uD83D\uDC4D", false, self, 123, recipients, false, false);
+ assertEquals(List.of("sendTypingMessage", "sendRemoteDeleteMessage", "sendMessageReaction"),
+ account.calls.stream().map(Call::name).toList());
+ assertEquals(Set.of(self), account.calls.get(0).arguments().get(1));
+ assertEquals(Set.of(self), account.calls.get(1).arguments().get(1));
+ assertEquals(Set.of(self), account.calls.get(2).arguments().get(4));
+ }
+
+ private static String selectAccount(
+ final String account,
+ final DBusConnection connection,
+ final String busname
+ ) throws Exception {
+ final var selected = new AtomicReference();
+ final var handler = new CommandHandler(null, null) {
+ @Override
+ public void handleLocalCommand(final LocalCommand command, final Manager manager) {
+ selected.set(manager.getSelfACI());
+ }
+ };
+ DbusCommandHandler.handleCommand(new ListGroupsCommand(), account, connection, busname, handler);
+ return selected.get();
+ }
+
+ private static String newBusname() {
+ return "org.asamk.Signal.Test" + UUID.randomUUID().toString().replace("-", "");
+ }
+
+ private static MessageEnvelope messageEnvelope(final Optional groupId) {
+ final var data = new MessageEnvelope.Data(123,
+ groupId.map(id -> new MessageEnvelope.Data.GroupContext(id, false, 0)),
+ Optional.empty(),
+ Optional.empty(),
+ Optional.of("Incoming"),
+ 0,
+ false,
+ false,
+ false,
+ false,
+ false,
+ Optional.empty(),
+ Optional.empty(),
+ Optional.empty(),
+ List.of(),
+ Optional.empty(),
+ Optional.empty(),
+ List.of(),
+ Optional.empty(),
+ Optional.empty(),
+ Optional.empty(),
+ List.of(new MessageEnvelope.Data.Mention(new RecipientAddress(UUID.fromString(ACI)), 0, 1)),
+ List.of(),
+ List.of(),
+ Optional.empty(),
+ Optional.empty(),
+ Optional.empty());
+ return new MessageEnvelope(Optional.of(new RecipientAddress(UUID.fromString(OTHER_ACI))),
+ 1,
+ 123,
+ 0,
+ 0,
+ false,
+ Optional.empty(),
+ Optional.empty(),
+ Optional.of(data),
+ Optional.empty(),
+ Optional.empty(),
+ Optional.empty(),
+ Optional.empty());
+ }
+
+ private record Call(String name, List