# Changelog
## [Unreleased]
+### Added
+- Accept group invitation with `updateGroup -g GROUP_ID`
+- Decline group invitation with `quitGroup -g GROUP_ID`
+
### Fixed
- Include group ids for v2 groups in json output
=== updateGroup
Create or update a group.
+If the user is a pending member, this command will accept the group invitation.
*-g* GROUP, *--group* GROUP::
Specify the recipient group ID in base64 encoding.
=== quitGroup
Send a quit group message to all group members and remove self from member list.
+If the user is a pending member, this command will decline the group invitation.
*-g* GROUP, *--group* GROUP::
Specify the recipient group ID in base64 encoding.
Update the name and avatar image visible by message recipients for the current users.
The profile is stored encrypted on the Signal servers.
-The decryption key is sent with every outgoing messages (excluding group messages).
+The decryption key is sent with every outgoing messages to contacts.
*--name*::
New name visible by message recipients.
contact.profileKey = null;
account.getProfileStore().storeProfileKey(contact.getAddress(), profileKey);
}
+ // Ensure our profile key is stored in profile store
+ account.getProfileStore().storeProfileKey(getSelfAddress(), account.getProfileKey());
}
public void checkAccountState() throws IOException {
return g;
}
+ private GroupInfo getGroupForUpdating(byte[] groupId) throws GroupNotFoundException, NotAGroupMemberException {
+ GroupInfo g = account.getGroupStore().getGroup(groupId);
+ if (g == null) {
+ throw new GroupNotFoundException(groupId);
+ }
+ if (!g.isMember(account.getSelfAddress()) && !g.isPendingMember(account.getSelfAddress())) {
+ throw new NotAGroupMemberException(groupId, g.getTitle());
+ }
+ return g;
+ }
+
public List<GroupInfo> getGroups() {
return account.getGroupStore().getGroups();
}
SignalServiceDataMessage.Builder messageBuilder;
- final GroupInfo g = getGroupForSending(groupId);
+ final GroupInfo g = getGroupForUpdating(groupId);
if (g instanceof GroupInfoV1) {
GroupInfoV1 groupInfoV1 = (GroupInfoV1) g;
SignalServiceGroup group = SignalServiceGroup.newBuilder(SignalServiceGroup.Type.QUIT)
g = gv2;
}
} else {
- GroupInfo group = getGroupForSending(groupId);
+ GroupInfo group = getGroupForUpdating(groupId);
if (group instanceof GroupInfoV2) {
- Pair<DecryptedGroup, GroupChange> groupGroupChangePair = null;
+ final GroupInfoV2 groupInfoV2 = (GroupInfoV2) group;
+
+ Pair<Long, List<SendMessageResult>> result = null;
+ if (groupInfoV2.isPendingMember(getSelfAddress())) {
+ Pair<DecryptedGroup, GroupChange> groupGroupChangePair = groupHelper.acceptInvite(groupInfoV2);
+ result = sendUpdateGroupMessage(groupInfoV2,
+ groupGroupChangePair.first(),
+ groupGroupChangePair.second());
+ }
+
if (members != null) {
final Set<SignalServiceAddress> newMembers = new HashSet<>(members);
newMembers.removeAll(group.getMembers());
if (newMembers.size() > 0) {
- groupGroupChangePair = groupHelper.updateGroupV2((GroupInfoV2) group, newMembers);
+ Pair<DecryptedGroup, GroupChange> groupGroupChangePair = groupHelper.updateGroupV2(groupInfoV2,
+ newMembers);
+ result = sendUpdateGroupMessage(groupInfoV2,
+ groupGroupChangePair.first(),
+ groupGroupChangePair.second());
}
}
- if (groupGroupChangePair == null || name != null || avatarFile != null) {
- if (groupGroupChangePair != null) {
- ((GroupInfoV2) group).setGroup(groupGroupChangePair.first());
- messageBuilder = getGroupUpdateMessageBuilder((GroupInfoV2) group,
- groupGroupChangePair.second().toByteArray());
- sendMessage(messageBuilder, group.getMembersWithout(account.getSelfAddress()));
- }
-
- groupGroupChangePair = groupHelper.updateGroupV2((GroupInfoV2) group, name, avatarFile);
+ if (result == null || name != null || avatarFile != null) {
+ Pair<DecryptedGroup, GroupChange> groupGroupChangePair = groupHelper.updateGroupV2(groupInfoV2,
+ name,
+ avatarFile);
+ result = sendUpdateGroupMessage(groupInfoV2,
+ groupGroupChangePair.first(),
+ groupGroupChangePair.second());
}
- ((GroupInfoV2) group).setGroup(groupGroupChangePair.first());
- messageBuilder = getGroupUpdateMessageBuilder((GroupInfoV2) group,
- groupGroupChangePair.second().toByteArray());
- g = group;
+ return new Pair<>(group.groupId, result.second());
} else {
GroupInfoV1 gv1 = (GroupInfoV1) group;
updateGroupV1(gv1, name, members, avatarFile);
account.getGroupStore().updateGroup(g);
final Pair<Long, List<SendMessageResult>> result = sendMessage(messageBuilder,
- g.getMembersWithout(account.getSelfAddress()));
+ g.getMembersIncludingPendingWithout(account.getSelfAddress()));
return new Pair<>(g.groupId, result.second());
}
+ private Pair<Long, List<SendMessageResult>> sendUpdateGroupMessage(
+ GroupInfoV2 group, DecryptedGroup newDecryptedGroup, GroupChange groupChange
+ ) throws IOException {
+ group.setGroup(newDecryptedGroup);
+ final SignalServiceDataMessage.Builder messageBuilder = getGroupUpdateMessageBuilder(group,
+ groupChange.toByteArray());
+ account.getGroupStore().updateGroup(group);
+ return sendMessage(messageBuilder, group.getMembersIncludingPendingWithout(account.getSelfAddress()));
+ }
+
private void updateGroupV1(
final GroupInfoV1 g,
final String name,
group = groupHelper.getUpdatedDecryptedGroup(groupInfoV2.getGroup(),
groupContext.getSignedGroupChange(),
groupMasterKey);
+ if (group != null) {
+ storeProfileKeysFromMembers(group);
+ }
}
if (group == null) {
group = getDecryptedGroup(groupSecretParams);
try {
final GroupsV2AuthorizationString groupsV2AuthorizationString = getGroupAuthForToday(groupSecretParams);
DecryptedGroup group = groupsV2Api.getGroup(groupSecretParams, groupsV2AuthorizationString);
- for (DecryptedMember member : group.getMembersList()) {
- final SignalServiceAddress address = resolveSignalServiceAddress(new SignalServiceAddress(UuidUtil.parseOrThrow(
- member.getUuid().toByteArray()), null));
- try {
- account.getProfileStore()
- .storeProfileKey(address, new ProfileKey(member.getProfileKey().toByteArray()));
- } catch (InvalidInputException ignored) {
- }
- }
+ storeProfileKeysFromMembers(group);
return group;
} catch (IOException | VerificationFailedException | InvalidGroupStateException e) {
System.err.println("Failed to retrieve Group V2 info, ignoring ...");
}
}
+ private void storeProfileKeysFromMembers(final DecryptedGroup group) {
+ for (DecryptedMember member : group.getMembersList()) {
+ final SignalServiceAddress address = resolveSignalServiceAddress(new SignalServiceAddress(UuidUtil.parseOrThrow(
+ member.getUuid().toByteArray()), null));
+ try {
+ account.getProfileStore()
+ .storeProfileKey(address, new ProfileKey(member.getProfileKey().toByteArray()));
+ } catch (InvalidInputException ignored) {
+ }
+ }
+ }
+
private void retryFailedReceivedMessages(
ReceiveMessageHandler handler, boolean ignoreAttachments
) {
return null;
}
- final int noUuidCapability = members.stream()
- .filter(address -> !address.getUuid().isPresent())
- .collect(Collectors.toUnmodifiableSet())
- .size();
- if (noUuidCapability > 0) {
- System.err.println("Cannot create a V2 group as " + noUuidCapability + " members don't have a UUID.");
- return null;
- }
-
- final int noGv2Capability = members.stream()
- .map(profileProvider::getProfile)
- .filter(profile -> !profile.getCapabilities().gv2)
- .collect(Collectors.toUnmodifiableSet())
- .size();
- if (noGv2Capability > 0) {
- System.err.println("Cannot create a V2 group as " + noGv2Capability + " members don't support Groups V2.");
- return null;
- }
+ if (!areMembersValid(members)) return null;
GroupCandidate self = new GroupCandidate(selfAddressProvider.getSelfAddress().getUuid().orNull(),
Optional.fromNullable(profileKeyCredential));
0);
}
+ private boolean areMembersValid(final Collection<SignalServiceAddress> members) {
+ final int noUuidCapability = members.stream()
+ .filter(address -> !address.getUuid().isPresent())
+ .collect(Collectors.toUnmodifiableSet())
+ .size();
+ if (noUuidCapability > 0) {
+ System.err.println("Cannot create a V2 group as " + noUuidCapability + " members don't have a UUID.");
+ return false;
+ }
+
+ final int noGv2Capability = members.stream()
+ .map(profileProvider::getProfile)
+ .filter(profile -> profile != null && !profile.getCapabilities().gv2)
+ .collect(Collectors.toUnmodifiableSet())
+ .size();
+ if (noGv2Capability > 0) {
+ System.err.println("Cannot create a V2 group as " + noGv2Capability + " members don't support Groups V2.");
+ return false;
+ }
+
+ return true;
+ }
+
public Pair<DecryptedGroup, GroupChange> updateGroupV2(
GroupInfoV2 groupInfoV2, String name, String avatarFile
) throws IOException {
final GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
GroupsV2Operations.GroupOperations groupOperations = groupsV2Operations.forGroup(groupSecretParams);
+ if (!areMembersValid(newMembers)) return null;
+
Set<GroupCandidate> candidates = newMembers.stream()
.map(member -> new GroupCandidate(member.getUuid().get(),
Optional.fromNullable(profileKeyCredentialProvider.getProfileKeyCredential(member))))
}
}
+ public Pair<DecryptedGroup, GroupChange> acceptInvite(GroupInfoV2 groupInfoV2) throws IOException {
+ final GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupInfoV2.getMasterKey());
+ final GroupsV2Operations.GroupOperations groupOperations = groupsV2Operations.forGroup(groupSecretParams);
+
+ final SignalServiceAddress selfAddress = this.selfAddressProvider.getSelfAddress();
+ final ProfileKeyCredential profileKeyCredential = profileKeyCredentialProvider.getProfileKeyCredential(
+ selfAddress);
+ if (profileKeyCredential == null) {
+ throw new IOException("Cannot join a V2 group as self does not have a versioned profile");
+ }
+
+ final GroupChange.Actions.Builder change = groupOperations.createAcceptInviteChange(profileKeyCredential);
+
+ final Optional<UUID> uuid = selfAddress.getUuid();
+ if (uuid.isPresent()) {
+ change.setSourceUuid(UuidUtil.toByteString(uuid.get()));
+ }
+
+ return commitChange(groupInfoV2, change);
+ }
+
public Pair<DecryptedGroup, GroupChange> revokeInvites(
GroupInfoV2 groupInfoV2, Set<DecryptedPendingMember> pendingMembers
) throws IOException {
import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.signalservice.api.SignalServiceMessagePipe;
import org.whispersystems.signalservice.api.SignalServiceMessageReceiver;
-import org.whispersystems.signalservice.api.crypto.InvalidCiphertextException;
-import org.whispersystems.signalservice.api.crypto.ProfileCipher;
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess;
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair;
import org.whispersystems.signalservice.api.profiles.ProfileAndCredential;
import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException;
import org.whispersystems.signalservice.internal.util.concurrent.CascadingFuture;
import org.whispersystems.signalservice.internal.util.concurrent.ListenableFuture;
-import org.whispersystems.util.Base64;
import java.io.IOException;
import java.util.Arrays;
}
}
- public String decryptName(
- ProfileKey profileKey, String encryptedName
- ) throws InvalidCiphertextException, IOException {
- if (encryptedName == null) {
- return null;
- }
-
- ProfileCipher profileCipher = new ProfileCipher(profileKey);
- return new String(profileCipher.decryptName(Base64.decode(encryptedName)));
- }
-
private ListenableFuture<ProfileAndCredential> getPipeRetrievalFuture(
SignalServiceAddress address,
Optional<ProfileKey> profileKey,
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
-import java.util.HashSet;
import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
public abstract class GroupInfo {
@JsonIgnore
public Set<SignalServiceAddress> getMembersWithout(SignalServiceAddress address) {
- Set<SignalServiceAddress> members = new HashSet<>();
+ return getMembers().stream().filter(member -> !member.matches(address)).collect(Collectors.toSet());
+ }
+
+ @JsonIgnore
+ public Set<SignalServiceAddress> getMembersIncludingPendingWithout(SignalServiceAddress address) {
+ return Stream.concat(getMembers().stream(), getPendingMembers().stream())
+ .filter(member -> !member.matches(address))
+ .collect(Collectors.toSet());
+ }
+
+ @JsonIgnore
+ public boolean isMember(SignalServiceAddress address) {
for (SignalServiceAddress member : getMembers()) {
- if (!member.matches(address)) {
- members.add(member);
+ if (member.matches(address)) {
+ return true;
}
}
- return members;
+ return false;
}
@JsonIgnore
- public boolean isMember(SignalServiceAddress address) {
- for (SignalServiceAddress member : getMembers()) {
+ public boolean isPendingMember(SignalServiceAddress address) {
+ for (SignalServiceAddress member : getPendingMembers()) {
if (member.matches(address)) {
return true;
}