Add group story support to core library layer

Extend Manager.sendStory() with an optional GroupId parameter and add
SendHelper.sendGroupStoryMessage() for endorsement-aware group story
delivery, laying the groundwork for group story support (task 1 of 4).
The existing My Story code path is unchanged.
This commit is contained in:
Tony Cebzanov 2026-07-09 10:23:12 -04:00
parent d6b321dc19
commit ad34477700
4 changed files with 192 additions and 4 deletions

View File

@ -0,0 +1,64 @@
# Task 1 Report: Core Library Layer — Group Story Support
## Changes
### `lib/src/main/java/org/asamk/signal/manager/Manager.java`
- `sendStory(String, boolean)``sendStory(String attachment, boolean allowsReplies, Optional<GroupId> groupId)`.
- Throws clause extended with `GroupNotFoundException, NotAGroupMemberException`.
- All required imports (`Optional`, `GroupId`, `GroupNotFoundException`, `NotAGroupMemberException`) were already present in the file, so no import changes were needed.
- Updated the javadoc above the method to describe the new `groupId` parameter.
### `lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java`
- `sendStory()` now takes the third `Optional<GroupId> groupId` parameter and throws `GroupNotFoundException, NotAGroupMemberException` in addition to the existing exceptions.
- MIME validation runs first (shared by both paths); if `groupId.isPresent()`, control is handed off to a new private `sendGroupStory(String attachment, boolean allowsReplies, GroupId groupId)` method before any attachment upload happens (fail-fast).
- The pre-existing "My Story" branch (`groupId.isEmpty()`) is untouched — same code, same order of operations.
- New private `sendGroupStory` method:
1. Resolves the group via `context.getGroupHelper().getGroup(groupId)`; throws `GroupNotFoundException` if null.
2. Checks `groupInfo.isMember(account.getSelfRecipientId())`; throws `NotAGroupMemberException` if false.
3. Requires a V2 group via pattern-match cast to `GroupInfoV2`; throws `IOException("Stories are only supported for V2 groups")` otherwise.
4. Uploads the attachment, builds a `SignalServiceGroupV2` context (master key + revision, revision 0 if `getGroup()` is null), builds the `SignalServiceStoryMessage.forFileAttachment(...)` with the group context.
5. Uses `getNextMessageTimestamp()` for the timestamp (not `System.currentTimeMillis()`).
6. Calls `context.getSendHelper().sendGroupStoryMessage(storyMessage, timestamp, groupInfoV2, allowsReplies)`.
7. Builds sync transcript recipients using `groupInfoV2.getDistributionId().asUuid().toString()` (not `DistributionId.MY_STORY`).
8. Sends the sync message via `dependencies.getMessageSender().sendStorySyncMessage(...)`, wrapping `UntrustedIdentityException` in `IOException`, same as the My Story path.
9. Returns `SendMessageResults` built the same way as the My Story path.
- Added imports: `org.asamk.signal.manager.storage.groups.GroupInfoV2` and `org.whispersystems.signalservice.api.messages.SignalServiceGroupV2`.
### `lib/src/main/java/org/asamk/signal/manager/helper/SendHelper.java`
- New public method `sendGroupStoryMessage(SignalServiceStoryMessage storyMessage, long timestamp, GroupInfoV2 groupInfo, boolean allowsReplies) throws IOException`, inserted right after the existing `sendStoryMessage` (My Story) method.
- Flow follows the brief exactly:
1. `groupInfo.getMembersWithout(account.getSelfRecipientId())` for recipients, converted to `List.copyOf(...)`.
2. Resolves addresses via `context.getRecipientHelper()::resolveSignalServiceAddress` and unidentified access via `context.getUnidentifiedAccessHelper().getAccessFor(recipientIds)`.
3. Calls the existing private `getGroupSendEndorsements(groupInfo)`; throws `IOException("Group send endorsements unavailable; try again after group state refreshes")` if the result is null.
4. Derives `GroupSecretParams.deriveFromMasterKey(groupInfo.getMasterKey())` and fetches the sender certificate via `context.getUnidentifiedAccessHelper().getSenderCertificateFor(null)`.
5. Builds `GroupSendEndorsements` with an `ACI`-keyed map derived from the resolved addresses.
6. Builds `SignalServiceStoryMessageRecipient`s using `groupInfo.getDistributionId().asUuid().toString()`.
7. Calls `messageSender.sendGroupStory(groupInfo.getDistributionId(), Optional.of(groupInfo.getMasterKey().serialize()), addresses, unidentifiedAccesses, groupSendEndorsements, false, storyMessage, timestamp, storyMessageRecipients, null)`, catching `UntrustedIdentityException | InvalidKeyException | NoSessionException | InvalidRegistrationIdException` and rethrowing as `IOException`, mirroring `sendStoryMessage`'s existing catch clause.
8. Calls `handleSendMessageResult(r)` for each result and returns the list.
- No new imports were required — `GroupInfoV2`, `GroupSecretParams`, `ACI`, `GroupSendEndorsements`, and `UnidentifiedAccess` were already imported in this file (used by the existing group-message sending code).
## Build output
```
$ export JAVA_HOME=/opt/homebrew/opt/openjdk@25/libexec/openjdk.jdk/Contents/Home
$ ./gradlew :lib:compileJava 2>&1 | tail -10
> Task :buildSrc:checkKotlinGradlePluginConfigurationErrors SKIPPED
> Task :buildSrc:compileKotlin UP-TO-DATE
> Task :buildSrc:compileJava NO-SOURCE
> Task :buildSrc:compileGroovy NO-SOURCE
> Task :buildSrc:pluginDescriptors UP-TO-DATE
> Task :buildSrc:processResources UP-TO-DATE
> Task :buildSrc:classes UP-TO-DATE
> Task :buildSrc:jar UP-TO-DATE
> Task :libsignal-cli:compileJava
BUILD SUCCESSFUL in 1s
```
Note: the Gradle project for the `lib/` source tree is actually named `:libsignal-cli` (see `settings.gradle.kts`: `project(":libsignal-cli").projectDir = file("lib")`). Gradle's task-selector prefix matching resolved `:lib:compileJava` to `:libsignal-cli:compileJava`, which compiled successfully — confirming the lib module (including these changes) compiles cleanly on its own, without the command layer.
As expected/documented in the brief, the command layer (`SendStoryCommand`, `DbusManagerImpl`, `StubManager`) was intentionally left untouched and will fail to compile against the new 3-parameter `sendStory` signature until Tasks 2/3 update those call sites. This was not exercised here since only `:lib:compileJava` was run.
## Concerns
None. The implementation follows the brief's step-by-step spec verbatim, reuses the exact patterns already present in the "My Story" code paths (`sendStory` in ManagerImpl, `sendStoryMessage`/`getGroupSendEndorsements`/`GroupSendEndorsements` construction in SendHelper), and required no new imports in SendHelper since all needed types were already imported for the pre-existing sender-key group-message code path.

View File

@ -214,12 +214,17 @@ public interface Manager extends Closeable {
) throws IOException, AttachmentInvalidException, NotAGroupMemberException, GroupNotFoundException, GroupSendingNotAllowedException, UnregisteredRecipientException, InvalidStickerException;
/**
* Post a file attachment story to "My Story".
* Post a file attachment story to "My Story" or to a group.
*
* @param attachment path to the file to upload and post as a story
* @param allowsReplies whether other users are allowed to reply to this story
* @param groupId if present, post the story to this group instead of "My Story"
*/
SendMessageResults sendStory(String attachment, boolean allowsReplies) throws IOException, AttachmentInvalidException;
SendMessageResults sendStory(
String attachment,
boolean allowsReplies,
Optional<GroupId> groupId
) throws IOException, AttachmentInvalidException, GroupNotFoundException, NotAGroupMemberException;
SendMessageResults sendRemoteDeleteMessage(
long targetSentTimestamp,

View File

@ -379,6 +379,67 @@ public class SendHelper {
return results;
}
/**
* Send a story message (file attachment) to a group.
*/
public List<SendMessageResult> sendGroupStoryMessage(
SignalServiceStoryMessage storyMessage,
long timestamp,
GroupInfoV2 groupInfo,
boolean allowsReplies
) throws IOException {
final var messageSender = dependencies.getMessageSender();
final var recipientIds = groupInfo.getMembersWithout(account.getSelfRecipientId());
final var recipientIdList = List.copyOf(recipientIds);
final var addressesMap = recipientIdList.stream()
.collect(Collectors.toMap(id -> id, context.getRecipientHelper()::resolveSignalServiceAddress));
final var unidentifiedAccessesMap = context.getUnidentifiedAccessHelper().getAccessFor(recipientIds);
final var groupSendEndorsementsResult = getGroupSendEndorsements(groupInfo);
if (groupSendEndorsementsResult == null) {
throw new IOException("Group send endorsements unavailable; try again after group state refreshes");
}
final var groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfo.getMasterKey());
final var senderCertificate = context.getUnidentifiedAccessHelper().getSenderCertificateFor(null);
final var groupSendEndorsements = new GroupSendEndorsements(groupSendEndorsementsResult.first(),
recipientIdList.stream()
.collect(Collectors.toMap(id -> (ACI) addressesMap.get(id).getServiceId(),
groupSendEndorsementsResult.second()::get)),
senderCertificate,
groupSecretParams);
final var addresses = recipientIdList.stream().map(addressesMap::get).toList();
final var unidentifiedAccesses = recipientIdList.stream().map(unidentifiedAccessesMap::get).toList();
final var storyMessageRecipients = recipientIdList.stream()
.map(id -> new SignalServiceStoryMessageRecipient(addressesMap.get(id),
List.of(groupInfo.getDistributionId().asUuid().toString()),
allowsReplies))
.collect(Collectors.toSet());
final List<SendMessageResult> results;
try {
results = messageSender.sendGroupStory(groupInfo.getDistributionId(),
Optional.of(groupInfo.getMasterKey().serialize()),
addresses,
unidentifiedAccesses,
groupSendEndorsements,
false,
storyMessage,
timestamp,
storyMessageRecipients,
null);
} catch (UntrustedIdentityException | InvalidKeyException | NoSessionException | InvalidRegistrationIdException e) {
throw new IOException(e);
}
for (var r : results) {
handleSendMessageResult(r);
}
return results;
}
private List<SendMessageResult> sendAsGroupMessage(
final SignalServiceDataMessage.Builder messageBuilder,
final GroupInfo g,

View File

@ -83,6 +83,7 @@ import org.asamk.signal.manager.storage.AttachmentStore;
import org.asamk.signal.manager.storage.AvatarStore;
import org.asamk.signal.manager.storage.SignalAccount;
import org.asamk.signal.manager.storage.groups.GroupInfo;
import org.asamk.signal.manager.storage.groups.GroupInfoV2;
import org.asamk.signal.manager.storage.identities.IdentityInfo;
import org.asamk.signal.manager.storage.recipients.RecipientAddress;
import org.asamk.signal.manager.storage.recipients.RecipientId;
@ -107,6 +108,7 @@ import org.slf4j.LoggerFactory;
import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage;
import org.whispersystems.signalservice.api.messages.SignalServiceGroupV2;
import org.whispersystems.signalservice.api.messages.SignalServicePreview;
import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage;
import org.whispersystems.signalservice.api.messages.SignalServiceStoryMessage;
@ -833,8 +835,9 @@ public class ManagerImpl implements Manager {
@Override
public SendMessageResults sendStory(
String attachment,
boolean allowsReplies
) throws IOException, AttachmentInvalidException {
boolean allowsReplies,
Optional<GroupId> groupId
) throws IOException, AttachmentInvalidException, GroupNotFoundException, NotAGroupMemberException {
final var file = new File(attachment);
final var mimeType = MimeUtils.getFileMimeType(file);
if (mimeType.isEmpty() || (!mimeType.get().startsWith("image/") && !mimeType.get().startsWith("video/"))) {
@ -842,6 +845,10 @@ public class ManagerImpl implements Manager {
new IOException("Stories only support image and video attachments"));
}
if (groupId.isPresent()) {
return sendGroupStory(attachment, allowsReplies, groupId.get());
}
final var recipients = account.getRecipientStore()
.getRecipients(true, Optional.of(false), Set.of(), Optional.empty());
final var recipientIds = recipients.stream()
@ -886,6 +893,57 @@ public class ManagerImpl implements Manager {
return new SendMessageResults(timestamp, results);
}
private SendMessageResults sendGroupStory(
String attachment,
boolean allowsReplies,
GroupId groupId
) throws IOException, AttachmentInvalidException, GroupNotFoundException, NotAGroupMemberException {
final var groupInfo = context.getGroupHelper().getGroup(groupId);
if (groupInfo == null) {
throw new GroupNotFoundException(groupId);
}
if (!groupInfo.isMember(account.getSelfRecipientId())) {
throw new NotAGroupMemberException(groupId, groupInfo.getTitle());
}
if (!(groupInfo instanceof GroupInfoV2 groupInfoV2)) {
throw new IOException("Stories are only supported for V2 groups");
}
final var uploadedAttachment = context.getAttachmentHelper().uploadAttachment(attachment);
final var groupContext = SignalServiceGroupV2.newBuilder(groupInfoV2.getMasterKey())
.withRevision(groupInfoV2.getGroup() == null ? 0 : groupInfoV2.getGroup().revision)
.build();
final var storyMessage = SignalServiceStoryMessage.forFileAttachment(account.getProfileKey().serialize(),
groupContext,
uploadedAttachment,
allowsReplies,
List.of());
final var timestamp = getNextMessageTimestamp();
final var sendResults = context.getSendHelper()
.sendGroupStoryMessage(storyMessage, timestamp, groupInfoV2, allowsReplies);
final var storyMessageRecipients = sendResults.stream()
.filter(org.whispersystems.signalservice.api.messages.SendMessageResult::isSuccess)
.map(r -> new SignalServiceStoryMessageRecipient(r.getAddress(),
List.of(groupInfoV2.getDistributionId().asUuid().toString()),
allowsReplies))
.collect(Collectors.toSet());
try {
dependencies.getMessageSender().sendStorySyncMessage(storyMessage, timestamp, false, storyMessageRecipients);
} catch (UntrustedIdentityException e) {
throw new IOException(e);
}
final var results = new HashMap<RecipientIdentifier, List<SendMessageResult>>();
for (final var sendResult : sendResults) {
final var result = toSendMessageResult(sendResult);
results.put(RecipientIdentifier.Single.fromAddress(result.address()), List.of(result));
}
return new SendMessageResults(timestamp, results);
}
private void applyMessage(
final SignalServiceDataMessage.Builder messageBuilder,
final Message message