From c146224493ad41a6f6ce14e17d75c590564b5c8f Mon Sep 17 00:00:00 2001 From: Dmytro Lomako Date: Thu, 17 Sep 2026 17:58:40 +0300 Subject: [PATCH] Add --attachment-dimensions and --attachment-blurhash to send (#2120) * 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. * Accept caller-supplied attachment BlurHashes Forward positional BlurHashes supplied by the caller alongside attachment dimensions. Reject BlurHashes whose length doesn't match their size digit. * Skip image probing when dimensions are supplied Caller-supplied dimensions replace the probed values, so don't buffer and decode the image to measure it. --- CHANGELOG.md | 4 + .../org/asamk/signal/manager/api/Message.java | 4 + .../manager/helper/AttachmentHelper.java | 22 +++- .../signal/manager/internal/ManagerImpl.java | 5 +- .../signal/manager/util/AttachmentUtils.java | 10 +- .../manager/util/AttachmentUtilsTest.java | 29 +++++ man/signal-cli.1.adoc | 9 ++ .../asamk/signal/commands/SendCommand.java | 57 +++++++++ .../org/asamk/signal/dbus/DbusSignalImpl.java | 6 + .../commands/SendCommandParsingTest.java | 117 ++++++++++++++++++ 10 files changed, 254 insertions(+), 9 deletions(-) create mode 100644 src/test/java/org/asamk/signal/commands/SendCommandParsingTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index b5a4c0ce..b091909f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- `send --attachment-dimensions` and `--attachment-blurhash` to set the placeholder shown before an attachment is downloaded + ## [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..ccb7cba8 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,8 @@ import java.util.Optional; public record Message( String messageText, List attachments, + List attachmentDimensions, + List attachmentBlurHashes, boolean viewOnce, boolean voiceNote, List mentions, @@ -17,6 +19,8 @@ public record Message( boolean urgent ) { + public record AttachmentDimensions(int width, int height) {} + 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..85ea14a4 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,11 @@ public class AttachmentHelper { public List uploadAttachments( final List attachments, + final List dimensions, + final List blurHashes, boolean voiceNote ) throws AttachmentInvalidException, IOException { - final var attachmentStreams = createAttachmentStreams(attachments, voiceNote); + final var attachmentStreams = createAttachmentStreams(attachments, dimensions, blurHashes, voiceNote); try { // Upload attachments here, so we only upload once even for multiple recipients @@ -71,26 +74,31 @@ public class AttachmentHelper { } public List uploadAttachments(final List attachments) throws AttachmentInvalidException, IOException { - return uploadAttachments(attachments, false); + return uploadAttachments(attachments, List.of(), List.of(), false); } private List createAttachmentStreams( List attachments, + List dimensions, + List blurHashes, 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) : null; + final var blurHash = i < blurHashes.size() && !blurHashes.get(i).isEmpty() ? blurHashes.get(i) : null; + signalServiceAttachments.add(getAttachmentStream(attachments.get(i), size, blurHash, voiceNote)); } return signalServiceAttachments; } private SignalServiceAttachmentStream getAttachmentStream( final String attachment, + final AttachmentDimensions dimensions, + final String blurHash, final boolean voiceNote ) throws AttachmentInvalidException { try { @@ -116,6 +124,8 @@ public class AttachmentHelper { return AttachmentUtils.createAttachmentStream(streamDetails, streamDetailsAndFileName.second(), voiceNote, + dimensions, + blurHash, uploadSpec); } catch (IOException e) { throw new AttachmentInvalidException(attachment, e); @@ -130,7 +140,7 @@ public class AttachmentHelper { } public SignalServiceAttachmentPointer uploadAttachment(String attachment) throws IOException, AttachmentInvalidException { - final var attachmentStream = getAttachmentStream(attachment, false); + final var attachmentStream = getAttachmentStream(attachment, null, null, 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..1b0ed529 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,10 @@ 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.attachmentBlurHashes(), + 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..8d2329b8 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,16 +28,21 @@ public class AttachmentUtils { StreamDetails streamDetails, Optional name, boolean voiceNote, + AttachmentDimensions dimensions, + String blurHash, ResumableUploadSpec resumableUploadSpec ) throws ResumeLocationInvalidException, IOException { final var uploadTimestamp = System.currentTimeMillis(); - final var probedStream = probeImageDimensions(streamDetails); + final var probedStream = dimensions != null + ? new ProbedStream(streamDetails.getStream(), dimensions.width(), dimensions.height()) + : probeImageDimensions(streamDetails); return SignalServiceAttachmentStream.newStreamBuilder() .withStream(probedStream.inputStream()) .withContentType(streamDetails.getContentType()) .withLength(streamDetails.getLength()) .withFileName(name.orElse(null)) .withVoiceNote(voiceNote) + .withBlurHash(blurHash) .withWidth(probedStream.width()) .withHeight(probedStream.height()) .withUploadTimestamp(uploadTimestamp) @@ -50,7 +56,7 @@ public class AttachmentUtils { Optional name, ResumableUploadSpec resumableUploadSpec ) throws ResumeLocationInvalidException, IOException { - return createAttachmentStream(streamDetails, name, false, resumableUploadSpec); + return createAttachmentStream(streamDetails, name, false, null, null, 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..123f04a6 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,34 @@ class AttachmentUtilsTest { assertArrayEquals(bytes, attachment.getInputStream().readAllBytes()); } + @Test + public void createAttachmentStream_setsSuppliedDimensionsAndBlurHash() throws Exception { + final var bytes = "opaque video bytes".getBytes(); + final var blurHash = "LEHV6nWB2yk8pyo0adR*.7kCMdnj"; + 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), blurHash, null); + + assertEquals(1080, attachment.getWidth()); + assertEquals(1920, attachment.getHeight()); + assertEquals(Optional.of(blurHash), attachment.getBlurHash()); + assertArrayEquals(bytes, attachment.getInputStream().readAllBytes()); + } + + @Test + public void createAttachmentStream_skipsProbingWhenDimensionsSupplied() throws Exception { + final var imageBytes = pngBytes(37, 21); + final var stream = new ByteArrayInputStream(imageBytes); + final var details = new StreamDetails(stream, "image/png", imageBytes.length); + final var attachment = AttachmentUtils.createAttachmentStream(details, + Optional.of("meme.png"), false, new AttachmentDimensions(100, 200), null, null); + + assertEquals(imageBytes.length, stream.available()); + assertEquals(100, attachment.getWidth()); + assertEquals(200, attachment.getHeight()); + assertArrayEquals(imageBytes, 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..26fac479 100644 --- a/man/signal-cli.1.adoc +++ b/man/signal-cli.1.adoc @@ -344,6 +344,15 @@ 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 the displayed dimensions (after rotation) of the attachment at the same position in `--attachment`. +Use an empty string to skip one; image dimensions are otherwise detected automatically. + +*--attachment-blurhash* [BLURHASH [BLURHASH ...]]:: +Specify a BlurHash (https://blurha.sh) for the attachment at the same position in `--attachment`, which clients show while it's downloading. +Use an empty string to skip one. +e.g.: `--attachment a.jpg clip.mp4 --attachment-dimensions '' 1080x1920 --attachment-blurhash '' 'LEHV6nWB2yk8pyo0adR*.7kCMdnj'` + *--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..dc6be9a4 100644 --- a/src/main/java/org/asamk/signal/commands/SendCommand.java +++ b/src/main/java/org/asamk/signal/commands/SendCommand.java @@ -36,6 +36,7 @@ import static org.asamk.signal.util.SendMessageResultUtils.outputResult; public class SendCommand implements JsonRpcLocalCommand { private static final Logger logger = LoggerFactory.getLogger(SendCommand.class); + private static final String BLURHASH_DIGITS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~"; @Override public String getName() { @@ -112,6 +113,13 @@ 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."); + subparser.addArgument("--attachment-blurhash") + .nargs("*") + .help("Specify a BlurHash (https://blurha.sh) for each attachment in order, which clients show " + + "while it's downloading. Use an empty string to skip one."); } @Override @@ -175,6 +183,13 @@ 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 dimensionStrings = ns.getList("attachment-dimensions"); + final var attachmentDimensions = dimensionStrings == null + ? List.of() + : parseAttachmentDimensions(dimensionStrings); + final var blurHashes = ns.getList("attachment-blurhash"); + final var attachmentBlurHashes = blurHashes == null ? List.of() : parseAttachmentBlurHashes(blurHashes); + final var selfNumber = m.getSelfNumber(); final var mentionStrings = ns.getList("mention"); @@ -249,6 +264,8 @@ public class SendCommand implements JsonRpcLocalCommand { try { final var message = new Message(messageText, attachments, + attachmentDimensions, + attachmentBlurHashes, viewOnce, voiceNote, mentions, @@ -280,6 +297,46 @@ public class SendCommand implements JsonRpcLocalCommand { } } + private List parseAttachmentDimensions( + final List dimensionStrings + ) throws UserErrorException { + final var dimensionPattern = Pattern.compile("([1-9]\\d*)x([1-9]\\d*)"); + final var dimensions = new ArrayList(); + for (final var dimension : dimensionStrings) { + if (dimension.isEmpty()) { + dimensions.add(null); + continue; + } + final var matcher = dimensionPattern.matcher(dimension); + if (!matcher.matches()) { + throw new UserErrorException("Invalid attachment dimensions syntax (" + + dimension + + ") expected 'WIDTHxHEIGHT'"); + } + dimensions.add(new Message.AttachmentDimensions(Integer.parseInt(matcher.group(1)), + Integer.parseInt(matcher.group(2)))); + } + return dimensions; + } + + private List parseAttachmentBlurHashes(final List blurHashes) throws UserErrorException { + for (final var blurHash : blurHashes) { + if (!blurHash.isEmpty() && !isValidBlurHash(blurHash)) { + throw new UserErrorException("Invalid attachment BlurHash (" + blurHash + ")"); + } + } + return blurHashes; + } + + // Same check the BlurHash decoders make: the first digit fixes the length. + private static boolean isValidBlurHash(final String blurHash) { + if (blurHash.length() < 6) { + return false; + } + final var sizeFlag = BLURHASH_DIGITS.indexOf(blurHash.charAt(0)); + return blurHash.length() == 4 + 2 * (sizeFlag % 9 + 1) * (sizeFlag / 9 + 1); + } + 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..30d7e4a0 100644 --- a/src/main/java/org/asamk/signal/dbus/DbusSignalImpl.java +++ b/src/main/java/org/asamk/signal/dbus/DbusSignalImpl.java @@ -242,6 +242,8 @@ public class DbusSignalImpl implements Signal, AutoCloseable { try { final var message = new Message(messageText, attachments, + List.of(), + List.of(), false, false, List.of(), @@ -409,6 +411,8 @@ public class DbusSignalImpl implements Signal, AutoCloseable { try { final var message = new Message(messageText, attachments, + List.of(), + List.of(), false, false, List.of(), @@ -456,6 +460,8 @@ public class DbusSignalImpl implements Signal, AutoCloseable { try { final var message = new Message(messageText, attachments, + List.of(), + 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..f56b00e2 --- /dev/null +++ b/src/test/java/org/asamk/signal/commands/SendCommandParsingTest.java @@ -0,0 +1,117 @@ +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.Arrays; +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"); + private static final String BLUR_HASH = "LEHV6nWB2yk8pyo0adR*.7kCMdnj"; + + @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(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()); + } + + @Test + void cliPreservesBlurHashPositions() 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-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)))); + + assertEquals(Arrays.asList(null, new Message.AttachmentDimensions(1920, 1080)), + message.attachmentDimensions()); + assertEquals(List.of("", BLUR_HASH), message.attachmentBlurHashes()); + } + + @Test + void omittedDimensionsKeepExistingBehavior() throws Exception { + final var message = send(new JsonRpcNamespace(Map.of("noteToSelf", true, "attachments", ATTACHMENTS))); + assertEquals(List.of(), message.attachmentDimensions()); + assertEquals(List.of(), message.attachmentBlurHashes()); + } + + @Test + void rejectsMalformedDimensionsBeforeSending() { + for (final var value : List.of("0x1080", "1920x0", "-1x2", "1.5x2", "1920")) { + assertThrows(UserErrorException.class, () -> send(namespace(List.of(value))), value); + } + } + + @Test + void rejectsMalformedBlurHashesBeforeSending() { + // Wrong length for its size digit, an unknown size digit, too short. + for (final var value : List.of(BLUR_HASH + "0", "!" + BLUR_HASH.substring(1), "00000")) { + assertThrows(UserErrorException.class, () -> send(blurHashNamespace(List.of(value))), value); + } + } + + private JsonRpcNamespace blurHashNamespace(List 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)); + } + + 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(); + } +}