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.
This commit is contained in:
Dmytro Lomako 2026-09-17 17:58:40 +03:00 committed by GitHub
parent a255a7fecf
commit c146224493
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 254 additions and 9 deletions

View File

@ -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

View File

@ -6,6 +6,8 @@ import java.util.Optional;
public record Message(
String messageText,
List<String> attachments,
List<AttachmentDimensions> attachmentDimensions,
List<String> attachmentBlurHashes,
boolean viewOnce,
boolean voiceNote,
List<Mention> 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(

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,11 @@ public class AttachmentHelper {
public List<SignalServiceAttachment> uploadAttachments(
final List<String> attachments,
final List<AttachmentDimensions> dimensions,
final List<String> 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<SignalServiceAttachment> uploadAttachments(final List<String> attachments) throws AttachmentInvalidException, IOException {
return uploadAttachments(attachments, false);
return uploadAttachments(attachments, List.of(), List.of(), false);
}
private List<SignalServiceAttachmentStream> createAttachmentStreams(
List<String> attachments,
List<AttachmentDimensions> dimensions,
List<String> blurHashes,
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) : 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);
}

View File

@ -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);

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,16 +28,21 @@ public class AttachmentUtils {
StreamDetails streamDetails,
Optional<String> 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<String> name,
ResumableUploadSpec resumableUploadSpec
) throws ResumeLocationInvalidException, IOException {
return createAttachmentStream(streamDetails, name, false, resumableUploadSpec);
return createAttachmentStream(streamDetails, name, false, null, null, 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,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();

View File

@ -344,6 +344,15 @@ 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 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.

View File

@ -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.<String>getList("attachment-dimensions");
final var attachmentDimensions = dimensionStrings == null
? List.<Message.AttachmentDimensions>of()
: parseAttachmentDimensions(dimensionStrings);
final var blurHashes = ns.<String>getList("attachment-blurhash");
final var attachmentBlurHashes = blurHashes == null ? List.<String>of() : parseAttachmentBlurHashes(blurHashes);
final var selfNumber = m.getSelfNumber();
final var mentionStrings = ns.<String>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<Message.AttachmentDimensions> parseAttachmentDimensions(
final List<String> dimensionStrings
) throws UserErrorException {
final var dimensionPattern = Pattern.compile("([1-9]\\d*)x([1-9]\\d*)");
final var dimensions = new ArrayList<Message.AttachmentDimensions>();
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<String> parseAttachmentBlurHashes(final List<String> 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<Message.Mention> parseMentions(
final String selfNumber,
final List<String> mentionStrings

View File

@ -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(),

View File

@ -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<String> 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<String> blurHashes) {
return new JsonRpcNamespace(Map.of("noteToSelf", true, "attachments", ATTACHMENTS,
"attachmentBlurhash", blurHashes));
}
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();
}
}