From f97cbab1fe963d25de3068b5d0543df693ba51a8 Mon Sep 17 00:00:00 2001 From: Dmytro L Date: Mon, 14 Sep 2026 18:17:24 +0300 Subject: [PATCH] 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. --- CHANGELOG.md | 4 + .../org/asamk/signal/manager/api/Message.java | 12 +++ .../manager/helper/AttachmentHelper.java | 19 ++--- .../signal/manager/internal/ManagerImpl.java | 2 +- .../signal/manager/util/AttachmentUtils.java | 8 +- .../manager/util/AttachmentUtilsTest.java | 14 ++++ man/signal-cli.1.adoc | 7 ++ .../asamk/signal/commands/SendCommand.java | 36 +++++++++ .../org/asamk/signal/dbus/DbusSignalImpl.java | 3 + .../commands/SendCommandParsingTest.java | 81 +++++++++++++++++++ 10 files changed, 173 insertions(+), 13 deletions(-) create mode 100644 src/test/java/org/asamk/signal/commands/SendCommandParsingTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index b5a4c0ce..3b4794af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/lib/src/main/java/org/asamk/signal/manager/api/Message.java b/lib/src/main/java/org/asamk/signal/manager/api/Message.java index b9d60b00..a37ced94 100644 --- a/lib/src/main/java/org/asamk/signal/manager/api/Message.java +++ b/lib/src/main/java/org/asamk/signal/manager/api/Message.java @@ -6,6 +6,7 @@ import java.util.Optional; public record Message( String messageText, List attachments, + List attachmentDimensions, boolean viewOnce, boolean voiceNote, List 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( diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/AttachmentHelper.java b/lib/src/main/java/org/asamk/signal/manager/helper/AttachmentHelper.java index 1fefcb24..0968aa48 100644 --- a/lib/src/main/java/org/asamk/signal/manager/helper/AttachmentHelper.java +++ b/lib/src/main/java/org/asamk/signal/manager/helper/AttachmentHelper.java @@ -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 uploadAttachments( final List attachments, + final List 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 uploadAttachments(final List attachments) throws AttachmentInvalidException, IOException { - return uploadAttachments(attachments, false); - } - private List createAttachmentStreams( List attachments, + List dimensions, boolean voiceNote ) throws AttachmentInvalidException, IOException { if (attachments == null) { return null; } final var signalServiceAttachments = new ArrayList(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); } diff --git a/lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java b/lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java index 173faf36..6a4ec67c 100644 --- a/lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java +++ b/lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java @@ -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); diff --git a/lib/src/main/java/org/asamk/signal/manager/util/AttachmentUtils.java b/lib/src/main/java/org/asamk/signal/manager/util/AttachmentUtils.java index 74693ac3..b0211bec 100644 --- a/lib/src/main/java/org/asamk/signal/manager/util/AttachmentUtils.java +++ b/lib/src/main/java/org/asamk/signal/manager/util/AttachmentUtils.java @@ -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 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 name, ResumableUploadSpec resumableUploadSpec ) throws ResumeLocationInvalidException, IOException { - return createAttachmentStream(streamDetails, name, false, resumableUploadSpec); + return createAttachmentStream(streamDetails, name, false, AttachmentDimensions.UNKNOWN, resumableUploadSpec); } /** diff --git a/lib/src/test/java/org/asamk/signal/manager/util/AttachmentUtilsTest.java b/lib/src/test/java/org/asamk/signal/manager/util/AttachmentUtilsTest.java index dfc393cb..332debab 100644 --- a/lib/src/test/java/org/asamk/signal/manager/util/AttachmentUtilsTest.java +++ b/lib/src/test/java/org/asamk/signal/manager/util/AttachmentUtilsTest.java @@ -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(); diff --git a/man/signal-cli.1.adoc b/man/signal-cli.1.adoc index 6fcdfee1..40606d4a 100644 --- a/man/signal-cli.1.adoc +++ b/man/signal-cli.1.adoc @@ -344,6 +344,13 @@ Data URI encoded attachments must follow the RFC 2397. Additionally a file name can be added: e.g.: `data:;filename=;base64,` +*--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. diff --git a/src/main/java/org/asamk/signal/commands/SendCommand.java b/src/main/java/org/asamk/signal/commands/SendCommand.java index c6ef5077..31afb12e 100644 --- a/src/main/java/org/asamk/signal/commands/SendCommand.java +++ b/src/main/java/org/asamk/signal/commands/SendCommand.java @@ -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.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 parseAttachmentDimensions( + List 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(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 parseMentions( final String selfNumber, final List mentionStrings diff --git a/src/main/java/org/asamk/signal/dbus/DbusSignalImpl.java b/src/main/java/org/asamk/signal/dbus/DbusSignalImpl.java index 1d53fe7d..6f520572 100644 --- a/src/main/java/org/asamk/signal/dbus/DbusSignalImpl.java +++ b/src/main/java/org/asamk/signal/dbus/DbusSignalImpl.java @@ -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(), diff --git a/src/test/java/org/asamk/signal/commands/SendCommandParsingTest.java b/src/test/java/org/asamk/signal/commands/SendCommandParsingTest.java new file mode 100644 index 00000000..3bfee70c --- /dev/null +++ b/src/test/java/org/asamk/signal/commands/SendCommandParsingTest.java @@ -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 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 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) -> { + 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(); + } +}