Accept caller-supplied attachment dimensions

Forward positional WIDTHxHEIGHT metadata supplied by the caller. Validate dimensions before sending and retain automatic image detection for omitted entries.
This commit is contained in:
Dmytro L 2026-09-14 18:17:24 +03:00
parent a255a7fecf
commit f97cbab1fe
10 changed files with 173 additions and 13 deletions

View File

@ -2,6 +2,10 @@
## [Unreleased]
### Added
- `send --attachment-dimensions` to supply displayed attachment dimensions for correctly sized video placeholders
## [0.14.8] - 2026-09-10
### Added

View File

@ -6,6 +6,7 @@ import java.util.Optional;
public record Message(
String messageText,
List<String> attachments,
List<AttachmentDimensions> attachmentDimensions,
boolean viewOnce,
boolean voiceNote,
List<Mention> mentions,
@ -17,6 +18,17 @@ public record Message(
boolean urgent
) {
public record AttachmentDimensions(int width, int height) {
public static final AttachmentDimensions UNKNOWN = new AttachmentDimensions(0, 0);
public AttachmentDimensions {
if (width < 0 || height < 0 || (width == 0) != (height == 0)) {
throw new IllegalArgumentException("Dimensions must both be positive or both zero");
}
}
}
public record Mention(RecipientIdentifier.Single recipient, int start, int length) {}
public record Quote(

View File

@ -1,6 +1,7 @@
package org.asamk.signal.manager.helper;
import org.asamk.signal.manager.api.AttachmentInvalidException;
import org.asamk.signal.manager.api.Message.AttachmentDimensions;
import org.asamk.signal.manager.config.ServiceConfig;
import org.asamk.signal.manager.internal.SignalDependencies;
import org.asamk.signal.manager.storage.AttachmentStore;
@ -52,9 +53,10 @@ public class AttachmentHelper {
public List<SignalServiceAttachment> uploadAttachments(
final List<String> attachments,
final List<AttachmentDimensions> dimensions,
boolean voiceNote
) throws AttachmentInvalidException, IOException {
final var attachmentStreams = createAttachmentStreams(attachments, voiceNote);
final var attachmentStreams = createAttachmentStreams(attachments, dimensions, voiceNote);
try {
// Upload attachments here, so we only upload once even for multiple recipients
@ -70,27 +72,25 @@ public class AttachmentHelper {
}
}
public List<SignalServiceAttachment> uploadAttachments(final List<String> attachments) throws AttachmentInvalidException, IOException {
return uploadAttachments(attachments, false);
}
private List<SignalServiceAttachmentStream> createAttachmentStreams(
List<String> attachments,
List<AttachmentDimensions> dimensions,
boolean voiceNote
) throws AttachmentInvalidException, IOException {
if (attachments == null) {
return null;
}
final var signalServiceAttachments = new ArrayList<SignalServiceAttachmentStream>(attachments.size());
for (var attachment : attachments) {
final var attachmentStream = getAttachmentStream(attachment, voiceNote);
signalServiceAttachments.add(attachmentStream);
for (var i = 0; i < attachments.size(); i++) {
final var size = i < dimensions.size() ? dimensions.get(i) : AttachmentDimensions.UNKNOWN;
signalServiceAttachments.add(getAttachmentStream(attachments.get(i), size, voiceNote));
}
return signalServiceAttachments;
}
private SignalServiceAttachmentStream getAttachmentStream(
final String attachment,
final AttachmentDimensions dimensions,
final boolean voiceNote
) throws AttachmentInvalidException {
try {
@ -116,6 +116,7 @@ public class AttachmentHelper {
return AttachmentUtils.createAttachmentStream(streamDetails,
streamDetailsAndFileName.second(),
voiceNote,
dimensions,
uploadSpec);
} catch (IOException e) {
throw new AttachmentInvalidException(attachment, e);
@ -130,7 +131,7 @@ public class AttachmentHelper {
}
public SignalServiceAttachmentPointer uploadAttachment(String attachment) throws IOException, AttachmentInvalidException {
final var attachmentStream = getAttachmentStream(attachment, false);
final var attachmentStream = getAttachmentStream(attachment, AttachmentDimensions.UNKNOWN, false);
return uploadAttachment(attachmentStream);
}

View File

@ -970,7 +970,7 @@ public class ManagerImpl implements Manager {
}
if (!message.attachments().isEmpty()) {
final var uploadedAttachments = context.getAttachmentHelper()
.uploadAttachments(message.attachments(), message.voiceNote());
.uploadAttachments(message.attachments(), message.attachmentDimensions(), message.voiceNote());
if (!additionalAttachments.isEmpty()) {
additionalAttachments.addAll(uploadedAttachments);
messageBuilder.withAttachments(additionalAttachments);

View File

@ -1,5 +1,6 @@
package org.asamk.signal.manager.util;
import org.asamk.signal.manager.api.Message.AttachmentDimensions;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentStream;
@ -27,6 +28,7 @@ public class AttachmentUtils {
StreamDetails streamDetails,
Optional<String> name,
boolean voiceNote,
AttachmentDimensions dimensions,
ResumableUploadSpec resumableUploadSpec
) throws ResumeLocationInvalidException, IOException {
final var uploadTimestamp = System.currentTimeMillis();
@ -37,8 +39,8 @@ public class AttachmentUtils {
.withLength(streamDetails.getLength())
.withFileName(name.orElse(null))
.withVoiceNote(voiceNote)
.withWidth(probedStream.width())
.withHeight(probedStream.height())
.withWidth(dimensions.width() > 0 ? dimensions.width() : probedStream.width())
.withHeight(dimensions.height() > 0 ? dimensions.height() : probedStream.height())
.withUploadTimestamp(uploadTimestamp)
.withResumableUploadSpec(resumableUploadSpec)
.withUuid(UUID.randomUUID())
@ -50,7 +52,7 @@ public class AttachmentUtils {
Optional<String> name,
ResumableUploadSpec resumableUploadSpec
) throws ResumeLocationInvalidException, IOException {
return createAttachmentStream(streamDetails, name, false, resumableUploadSpec);
return createAttachmentStream(streamDetails, name, false, AttachmentDimensions.UNKNOWN, resumableUploadSpec);
}
/**

View File

@ -1,5 +1,6 @@
package org.asamk.signal.manager.util;
import org.asamk.signal.manager.api.Message.AttachmentDimensions;
import org.junit.jupiter.api.Test;
import org.whispersystems.signalservice.api.util.StreamDetails;
@ -43,6 +44,19 @@ class AttachmentUtilsTest {
assertArrayEquals(bytes, attachment.getInputStream().readAllBytes());
}
@Test
public void createAttachmentStream_setsSuppliedVideoDimensionsWithoutReadingVideo() throws Exception {
final var bytes = "opaque video bytes".getBytes();
final var details = new StreamDetails(new ByteArrayInputStream(bytes), "video/mp4", bytes.length);
final var attachment = AttachmentUtils.createAttachmentStream(details,
Optional.of("clip.mp4"), false, new AttachmentDimensions(1080, 1920), null);
assertEquals(1080, attachment.getWidth());
assertEquals(1920, attachment.getHeight());
assertEquals(Optional.empty(), attachment.getBlurHash());
assertArrayEquals(bytes, attachment.getInputStream().readAllBytes());
}
private static byte[] pngBytes(final int width, final int height) throws Exception {
final var image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
final var out = new ByteArrayOutputStream();

View File

@ -344,6 +344,13 @@ Data URI encoded attachments must follow the RFC 2397.
Additionally a file name can be added:
e.g.: `data:<MIME-TYPE>;filename=<FILENAME>;base64,<BASE64 ENCODED DATA>`
*--attachment-dimensions* [WIDTHxHEIGHT [WIDTHxHEIGHT ...]]::
Specify positive integer display dimensions for the attachment at the same position in `--attachment`.
Use dimensions after applying video rotation. An empty string or omitted trailing entry keeps automatic image dimension detection; video dimensions are not detected.
Extra entries or malformed dimensions are rejected.
JSON-RPC uses `attachmentDimensions`, e.g. `["", "1080x1920"]`.
e.g.: `--attachment a.jpg clip.mp4 --attachment-dimensions '' 1080x1920`
*--view-once*::
Send the message as a view once message.
A conformant client will only allow the receiver to view the message once.

View File

@ -112,6 +112,9 @@ public class SendCommand implements JsonRpcLocalCommand {
subparser.addArgument("--voice-note")
.action(Arguments.storeTrue())
.help("Mark audio attachments as voice notes. Voice notes are displayed inline in Signal clients.");
subparser.addArgument("--attachment-dimensions")
.nargs("*")
.help("Specify displayed WIDTHxHEIGHT for each attachment in order. Use an empty string to skip one.");
}
@Override
@ -175,6 +178,9 @@ public class SendCommand implements JsonRpcLocalCommand {
final var viewOnce = Boolean.TRUE.equals(ns.getBoolean("view-once"));
final var voiceNote = Boolean.TRUE.equals(ns.getBoolean("voice-note"));
final var attachmentDimensions = parseAttachmentDimensions(ns.getList("attachment-dimensions"),
attachments.size());
final var selfNumber = m.getSelfNumber();
final var mentionStrings = ns.<String>getList("mention");
@ -249,6 +255,7 @@ public class SendCommand implements JsonRpcLocalCommand {
try {
final var message = new Message(messageText,
attachments,
attachmentDimensions,
viewOnce,
voiceNote,
mentions,
@ -280,6 +287,35 @@ public class SendCommand implements JsonRpcLocalCommand {
}
}
private List<Message.AttachmentDimensions> parseAttachmentDimensions(
List<String> values,
int attachmentCount
) throws UserErrorException {
if (values == null) {
return List.of();
}
if (values.size() > attachmentCount) {
throw new UserErrorException("More attachment dimensions than attachments");
}
final var dimensions = new ArrayList<Message.AttachmentDimensions>(values.size());
for (final var value : values) {
if ("".equals(value)) {
dimensions.add(Message.AttachmentDimensions.UNKNOWN);
continue;
}
if (value == null || !value.matches("[1-9][0-9]*x[1-9][0-9]*")) {
throw new UserErrorException("Attachment dimensions must be positive WIDTHxHEIGHT or an empty string");
}
final var parts = value.split("x");
try {
dimensions.add(new Message.AttachmentDimensions(Integer.parseInt(parts[0]), Integer.parseInt(parts[1])));
} catch (IllegalArgumentException e) {
throw new UserErrorException("Attachment dimensions exceed the supported integer range");
}
}
return dimensions;
}
private List<Message.Mention> parseMentions(
final String selfNumber,
final List<String> mentionStrings

View File

@ -242,6 +242,7 @@ public class DbusSignalImpl implements Signal, AutoCloseable {
try {
final var message = new Message(messageText,
attachments,
List.of(),
false,
false,
List.of(),
@ -409,6 +410,7 @@ public class DbusSignalImpl implements Signal, AutoCloseable {
try {
final var message = new Message(messageText,
attachments,
List.of(),
false,
false,
List.of(),
@ -456,6 +458,7 @@ public class DbusSignalImpl implements Signal, AutoCloseable {
try {
final var message = new Message(messageText,
attachments,
List.of(),
false,
false,
List.of(),

View File

@ -0,0 +1,81 @@
package org.asamk.signal.commands;
import net.sourceforge.argparse4j.ArgumentParsers;
import net.sourceforge.argparse4j.DefaultSettings;
import net.sourceforge.argparse4j.inf.Namespace;
import org.asamk.signal.commands.exceptions.UserErrorException;
import org.asamk.signal.manager.Manager;
import org.asamk.signal.manager.api.Message;
import org.asamk.signal.manager.api.SendMessageResults;
import org.asamk.signal.output.JsonWriter;
import org.asamk.signal.testutil.ManagerMock;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Proxy;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
class SendCommandParsingTest {
private static final List<String> ATTACHMENTS = List.of("photo.jpg", "portrait.mp4", "other.mp4");
@Test
void cliPreservesEmptyAndMissingDimensionPositions() throws Exception {
final var parser = ArgumentParsers.newFor("signal-cli", DefaultSettings.VERSION_0_9_0_DEFAULT_SETTINGS)
.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"}));
assertEquals(ATTACHMENTS, message.attachments());
assertEquals(List.of(Message.AttachmentDimensions.UNKNOWN, 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(List.of(Message.AttachmentDimensions.UNKNOWN, new Message.AttachmentDimensions(1920, 1080)),
message.attachmentDimensions());
}
@Test
void omittedDimensionsKeepExistingBehavior() throws Exception {
assertEquals(List.of(), send(new JsonRpcNamespace(Map.of("noteToSelf", true,
"attachments", ATTACHMENTS))).attachmentDimensions());
}
@Test
void rejectsMalformedOrExcessDimensionsBeforeSending() {
for (final var value : List.of("0x1080", "1920x0", "-1x2", "1.5x2", "1920", "2147483648x1")) {
assertThrows(UserErrorException.class, () -> send(namespace(List.of(value))), value);
}
assertThrows(UserErrorException.class, () -> send(namespace(List.of("", "", "", "1x1"))));
}
private JsonRpcNamespace namespace(List<String> dimensions) {
return new JsonRpcNamespace(Map.of("noteToSelf", true, "attachments", ATTACHMENTS,
"attachmentDimensions", dimensions));
}
private Message send(Namespace namespace) throws Exception {
final var captured = new AtomicReference<Message>();
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")) {
captured.set((Message) args[0]);
return new SendMessageResults(1, Map.of());
}
return method.invoke(delegate, args);
});
new SendCommand().handleCommand(namespace, manager, (JsonWriter) ignored -> {});
return captured.get();
}
}