Group Manager V2 operations.

master
Alan Evans 2020-05-05 12:13:53 -03:00 committed by Alex Hart
parent 48a693793f
commit 86f0456e8c
36 changed files with 1133 additions and 298 deletions

View File

@ -7,6 +7,7 @@ import androidx.annotation.Nullable;
import org.signal.zkgroup.InvalidInputException;
import org.signal.zkgroup.profiles.ProfileKey;
import org.signal.zkgroup.profiles.ProfileKeyCredential;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.Util;
@ -53,6 +54,18 @@ public final class ProfileKeyUtil {
return null;
}
public static @Nullable ProfileKeyCredential profileKeyCredentialOrNull(@Nullable byte[] profileKeyCredential) {
if (profileKeyCredential != null) {
try {
return new ProfileKeyCredential(profileKeyCredential);
} catch (InvalidInputException e) {
Log.w(TAG, String.format(Locale.US, "Seen non-null profile key credential of wrong length %d", profileKeyCredential.length), e);
}
}
return null;
}
public static @NonNull ProfileKey profileKeyOrThrow(@NonNull byte[] profileKey) {
try {
return new ProfileKey(profileKey);
@ -69,6 +82,10 @@ public final class ProfileKeyUtil {
return Optional.of(profileKeyOrThrow(profileKey));
}
public static @NonNull Optional<ProfileKeyCredential> profileKeyCredentialOptional(@Nullable byte[] profileKey) {
return Optional.fromNullable(profileKeyCredentialOrNull(profileKey));
}
public static @NonNull ProfileKey createNew() {
try {
return new ProfileKey(Util.getSecretBytes(32));

View File

@ -7,13 +7,14 @@ import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.BuildConfig;
import org.thoughtcrime.securesms.IncomingMessageProcessor;
import org.thoughtcrime.securesms.gcm.MessageRetriever;
import org.thoughtcrime.securesms.groups.GroupsV2AuthorizationMemoryValueCache;
import org.thoughtcrime.securesms.groups.v2.processing.GroupsV2StateProcessor;
import org.thoughtcrime.securesms.jobmanager.JobManager;
import org.thoughtcrime.securesms.keyvalue.KeyValueStore;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.megaphone.MegaphoneRepository;
import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess;
import org.thoughtcrime.securesms.recipients.LiveRecipientCache;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.service.IncomingMessageObserver;
import org.thoughtcrime.securesms.util.EarlyMessageCache;
import org.thoughtcrime.securesms.util.FeatureFlags;
@ -24,8 +25,7 @@ import org.whispersystems.signalservice.api.KeyBackupService;
import org.whispersystems.signalservice.api.SignalServiceAccountManager;
import org.whispersystems.signalservice.api.SignalServiceMessageReceiver;
import org.whispersystems.signalservice.api.SignalServiceMessageSender;
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Api;
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Authorization;
import org.thoughtcrime.securesms.groups.GroupsV2Authorization;
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations;
/**
@ -84,7 +84,8 @@ public class ApplicationDependencies {
assertInitialization();
if (groupsV2Authorization == null) {
groupsV2Authorization = getSignalServiceAccountManager().createGroupsV2Authorization(Recipient.self().getUuid().get());
GroupsV2Authorization.ValueCache authCache = new GroupsV2AuthorizationMemoryValueCache(SignalStore.groupsV2AuthorizationCache());
groupsV2Authorization = new GroupsV2Authorization(getSignalServiceAccountManager().getGroupsV2Api(), authCache);
}
return groupsV2Authorization;

View File

@ -11,9 +11,12 @@ import org.signal.zkgroup.VerificationFailedException;
import org.signal.zkgroup.groups.UuidCiphertext;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.GroupDatabase;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.profiles.AvatarHelper;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.BitmapUtil;
import org.thoughtcrime.securesms.util.Util;
import org.whispersystems.signalservice.api.groupsv2.InvalidGroupStateException;
import org.whispersystems.signalservice.api.util.InvalidNumberException;
@ -25,6 +28,8 @@ import java.util.Set;
public final class GroupManager {
private static final String TAG = Log.tag(GroupManager.class);
public static @NonNull GroupActionResult createGroup(@NonNull Context context,
@NonNull Set<Recipient> members,
@Nullable Bitmap avatar,
@ -87,13 +92,44 @@ public final class GroupManager {
}
}
@WorkerThread
public static void setMemberAdmin(@NonNull Context context,
@NonNull GroupId.V2 groupId,
@NonNull RecipientId recipientId,
boolean admin)
throws GroupChangeBusyException, GroupChangeFailedException, GroupInsufficientRightsException, GroupNotAMemberException, IOException
{
try (GroupManagerV2.GroupEditor editor = new GroupManagerV2(context).edit(groupId.requireV2())) {
editor.setMemberAdmin(recipientId, admin);
}
}
@WorkerThread
public static void updateSelfProfileKeyInGroup(@NonNull Context context, @NonNull GroupId.V2 groupId)
throws IOException, GroupChangeBusyException, GroupInsufficientRightsException, GroupNotAMemberException, GroupChangeFailedException
{
try (GroupManagerV2.GroupEditor editor = new GroupManagerV2(context).edit(groupId.requireV2())) {
editor.updateSelfProfileKeyInGroup();
}
}
@WorkerThread
public static void acceptInvite(@NonNull Context context, @NonNull GroupId.V2 groupId)
throws GroupChangeBusyException, GroupChangeFailedException, GroupNotAMemberException, GroupInsufficientRightsException, IOException
{
try (GroupManagerV2.GroupEditor editor = new GroupManagerV2(context).edit(groupId.requireV2())) {
editor.acceptInvite();
}
}
@WorkerThread
public static void updateGroupTimer(@NonNull Context context, @NonNull GroupId.Push groupId, int expirationTime)
throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException, GroupChangeBusyException
{
if (groupId.isV2()) {
new GroupManagerV2(context).edit(groupId.requireV2())
.updateGroupTimer(expirationTime);
try (GroupManagerV2.GroupEditor editor = new GroupManagerV2(context).edit(groupId.requireV2())) {
editor.updateGroupTimer(expirationTime);
}
} else {
GroupManagerV1.updateGroupTimer(context, groupId.requireV1(), expirationTime);
}
@ -103,27 +139,53 @@ public final class GroupManager {
public static void cancelInvites(@NonNull Context context,
@NonNull GroupId.V2 groupId,
@NonNull Collection<UuidCiphertext> uuidCipherTexts)
throws InvalidGroupStateException, VerificationFailedException, IOException
throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException, GroupChangeBusyException
{
throw new AssertionError("NYI"); // TODO: GV2 allow invite cancellation
try (GroupManagerV2.GroupEditor editor = new GroupManagerV2(context).edit(groupId.requireV2())) {
editor.cancelInvites(uuidCipherTexts);
}
}
@WorkerThread
public static void applyMembershipAdditionRightsChange(@NonNull Context context,
@NonNull GroupId.V2 groupId,
@NonNull GroupAccessControl newRights)
throws GroupChangeFailedException, GroupInsufficientRightsException
throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException, GroupChangeBusyException
{
throw new GroupChangeFailedException(new AssertionError("NYI")); // TODO: GV2 allow membership addition rights change
try (GroupManagerV2.GroupEditor editor = new GroupManagerV2(context).edit(groupId.requireV2())) {
editor.updateMembershipRights(newRights);
}
}
@WorkerThread
public static void applyAttributesRightsChange(@NonNull Context context,
@NonNull GroupId.V2 groupId,
@NonNull GroupAccessControl newRights)
throws GroupChangeFailedException, GroupInsufficientRightsException
throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException, GroupChangeBusyException
{
throw new GroupChangeFailedException(new AssertionError("NYI")); // TODO: GV2 allow attributes rights change
try (GroupManagerV2.GroupEditor editor = new GroupManagerV2(context).edit(groupId.requireV2())) {
editor.updateAttributesRights(newRights);
}
}
public static void addMembers(@NonNull Context context,
@NonNull GroupId.Push groupId,
@NonNull Collection<RecipientId> newMembers)
throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException, GroupChangeBusyException, MembershipNotSuitableForV2Exception
{
if (groupId.isV2()) {
try (GroupManagerV2.GroupEditor editor = new GroupManagerV2(context).edit(groupId.requireV2())) {
editor.addMembers(newMembers);
}
} else {
GroupDatabase.GroupRecord groupRecord = DatabaseFactory.getGroupDatabase(context).requireGroup(groupId);
List<RecipientId> members = groupRecord.getMembers();
byte[] avatar = Util.readFully(AvatarHelper.getAvatar(context, groupRecord.getRecipientId()));
Set<RecipientId> addresses = new HashSet<>(members);
addresses.addAll(newMembers);
GroupManagerV1.updateGroup(context, groupId, addresses, avatar, groupRecord.getTitle());
}
}
public static class GroupActionResult {

View File

@ -34,7 +34,6 @@ import org.thoughtcrime.securesms.util.BitmapUtil;
import org.thoughtcrime.securesms.util.GroupUtil;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.signalservice.api.util.InvalidNumberException;
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.GroupContext;
import java.io.ByteArrayInputStream;
@ -89,7 +88,6 @@ final class GroupManagerV1 {
@NonNull Set<RecipientId> memberAddresses,
@Nullable byte[] avatarBytes,
@Nullable String name)
throws InvalidNumberException
{
final GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context);
final RecipientId groupRecipientId = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(groupId);

View File

@ -6,49 +6,66 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import org.signal.storageservice.protos.groups.AccessControl;
import org.signal.storageservice.protos.groups.GroupChange;
import org.signal.storageservice.protos.groups.Member;
import org.signal.storageservice.protos.groups.local.DecryptedGroup;
import org.signal.storageservice.protos.groups.local.DecryptedGroupChange;
import org.signal.storageservice.protos.groups.local.DecryptedMember;
import org.signal.zkgroup.VerificationFailedException;
import org.signal.zkgroup.groups.GroupMasterKey;
import org.signal.zkgroup.groups.GroupSecretParams;
import org.signal.zkgroup.groups.UuidCiphertext;
import org.signal.zkgroup.profiles.ProfileKey;
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.GroupDatabase;
import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.groups.v2.GroupCandidateHelper;
import org.thoughtcrime.securesms.groups.v2.processing.GroupsV2StateProcessor;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.mms.OutgoingGroupMediaMessage;
import org.thoughtcrime.securesms.profiles.AvatarHelper;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.sms.MessageSender;
import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil;
import org.whispersystems.signalservice.api.groupsv2.GroupCandidate;
import org.whispersystems.signalservice.api.groupsv2.GroupChangeUtil;
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Api;
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Authorization;
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations;
import org.whispersystems.signalservice.api.groupsv2.InvalidGroupStateException;
import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedException;
import org.whispersystems.signalservice.api.push.exceptions.ConflictException;
import org.whispersystems.signalservice.api.util.UuidUtil;
import org.whispersystems.signalservice.internal.push.exceptions.GroupPatchNotAcceptedException;
import org.whispersystems.signalservice.internal.push.exceptions.NotInGroupException;
import java.io.ByteArrayInputStream;
import java.io.Closeable;
import java.io.IOException;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
final class GroupManagerV2 {
private static final String TAG = Log.tag(GroupManagerV2.class);
private final Context context;
private final GroupDatabase groupDatabase;
private final GroupsV2Api groupsV2Api;
private final GroupsV2Operations groupsV2Operations;
private final GroupsV2Authorization authorization;
private final GroupsV2StateProcessor groupsV2StateProcessor;
private final UUID selfUuid;
private final Context context;
private final GroupDatabase groupDatabase;
private final GroupsV2Api groupsV2Api;
private final GroupsV2Operations groupsV2Operations;
private final GroupsV2Authorization authorization;
private final GroupsV2StateProcessor groupsV2StateProcessor;
private final UUID selfUuid;
private final GroupCandidateHelper groupCandidateHelper;
private final GroupsV2CapabilityChecker capabilityChecker;
GroupManagerV2(@NonNull Context context) {
this.context = context;
@ -58,6 +75,13 @@ final class GroupManagerV2 {
this.authorization = ApplicationDependencies.getGroupsV2Authorization();
this.groupsV2StateProcessor = ApplicationDependencies.getGroupsV2StateProcessor();
this.selfUuid = Recipient.self().getUuid().get();
this.groupCandidateHelper = new GroupCandidateHelper(context);
this.capabilityChecker = new GroupsV2CapabilityChecker();
}
@WorkerThread
GroupCreator create() throws GroupChangeBusyException {
return new GroupCreator(GroupsV2ProcessingLock.acquireGroupProcessingLock());
}
@WorkerThread
@ -65,6 +89,68 @@ final class GroupManagerV2 {
return new GroupEditor(groupId, GroupsV2ProcessingLock.acquireGroupProcessingLock());
}
class GroupCreator implements Closeable {
private final Closeable lock;
GroupCreator(@NonNull Closeable lock) {
this.lock = lock;
}
@WorkerThread
@NonNull GroupManager.GroupActionResult createGroup(@NonNull Collection<RecipientId> members,
@Nullable String name,
@Nullable byte[] avatar)
throws GroupChangeFailedException, IOException, MembershipNotSuitableForV2Exception
{
if (!capabilityChecker.allAndSelfSupportGroupsV2AndUuid(members)) {
throw new MembershipNotSuitableForV2Exception("At least one potential new member does not support GV2 or UUID capabilities");
}
GroupCandidate self = groupCandidateHelper.recipientIdToCandidate(Recipient.self().getId());
Set<GroupCandidate> candidates = new HashSet<>(groupCandidateHelper.recipientIdsToCandidates(members));
if (!self.hasProfileKeyCredential()) {
Log.w(TAG, "Cannot create a V2 group as self does not have a versioned profile");
throw new MembershipNotSuitableForV2Exception("Cannot create a V2 group as self does not have a versioned profile");
}
GroupsV2Operations.NewGroup newGroup = groupsV2Operations.createNewGroup(name,
Optional.fromNullable(avatar),
self,
candidates);
GroupSecretParams groupSecretParams = newGroup.getGroupSecretParams();
GroupMasterKey masterKey = groupSecretParams.getMasterKey();
try {
groupsV2Api.putNewGroup(newGroup, authorization.getAuthorizationForToday(Recipient.self().requireUuid(), groupSecretParams));
DecryptedGroup decryptedGroup = groupsV2Api.getGroup(groupSecretParams, ApplicationDependencies.getGroupsV2Authorization().getAuthorizationForToday(Recipient.self().requireUuid(), groupSecretParams));
if (decryptedGroup == null) {
throw new GroupChangeFailedException();
}
GroupId.V2 groupId = groupDatabase.create(masterKey, decryptedGroup);
RecipientId groupRecipientId = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(groupId);
Recipient groupRecipient = Recipient.resolved(groupRecipientId);
AvatarHelper.setAvatar(context, groupRecipientId, avatar != null ? new ByteArrayInputStream(avatar) : null);
groupDatabase.onAvatarUpdated(groupId, avatar != null);
DatabaseFactory.getRecipientDatabase(context).setProfileSharing(groupRecipient.getId(), true);
return sendGroupUpdate(masterKey, decryptedGroup, null);
} catch (VerificationFailedException | InvalidGroupStateException e) {
throw new GroupChangeFailedException(e);
}
}
@Override
public void close() throws IOException {
lock.close();
}
}
class GroupEditor implements Closeable {
private final Closeable lock;
@ -84,6 +170,18 @@ final class GroupManagerV2 {
this.groupOperations = groupsV2Operations.forGroup(groupSecretParams);
}
@WorkerThread
@NonNull GroupManager.GroupActionResult addMembers(@NonNull Collection<RecipientId> newMembers)
throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException, MembershipNotSuitableForV2Exception
{
if (!capabilityChecker.allSupportGroupsV2AndUuid(newMembers)) {
throw new MembershipNotSuitableForV2Exception("At least one potential new member does not support GV2 or UUID capabilities");
}
Set<GroupCandidate> groupCandidates = groupCandidateHelper.recipientIdsToCandidates(new HashSet<>(newMembers));
return commitChangeWithConflictResolution(groupOperations.createModifyGroupMembershipChange(groupCandidates, selfUuid));
}
@WorkerThread
@NonNull GroupManager.GroupActionResult updateGroupTimer(int expirationTime)
throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException
@ -91,6 +189,113 @@ final class GroupManagerV2 {
return commitChangeWithConflictResolution(groupOperations.createModifyGroupTimerChange(expirationTime));
}
@WorkerThread
@NonNull GroupManager.GroupActionResult updateAttributesRights(@NonNull GroupAccessControl newRights)
throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException
{
return commitChangeWithConflictResolution(groupOperations.createChangeAttributesRights(rightsToAccessControl(newRights)));
}
@WorkerThread
@NonNull GroupManager.GroupActionResult updateMembershipRights(@NonNull GroupAccessControl newRights)
throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException
{
return commitChangeWithConflictResolution(groupOperations.createChangeMembershipRights(rightsToAccessControl(newRights)));
}
@WorkerThread
@NonNull GroupManager.GroupActionResult updateGroupTitleAndAvatar(@Nullable String title, @Nullable byte[] avatarBytes)
throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException
{
try {
GroupChange.Actions.Builder change = groupOperations.createModifyGroupTitleAndMembershipChange(Optional.fromNullable(title), Collections.emptySet(), Collections.emptySet());
if (avatarBytes != null) {
String cdnKey = groupsV2Api.uploadAvatar(avatarBytes, groupSecretParams, authorization.getAuthorizationForToday(selfUuid, groupSecretParams));
change.setModifyAvatar(GroupChange.Actions.ModifyAvatarAction.newBuilder()
.setAvatar(cdnKey));
}
GroupManager.GroupActionResult groupActionResult = commitChangeWithConflictResolution(change);
if (avatarBytes != null) {
AvatarHelper.setAvatar(context, Recipient.externalGroup(context, groupId).getId(), new ByteArrayInputStream(avatarBytes));
groupDatabase.onAvatarUpdated(groupId, true);
}
return groupActionResult;
} catch (VerificationFailedException e) {
throw new GroupChangeFailedException(e);
}
}
@WorkerThread
@NonNull GroupManager.GroupActionResult cancelInvites(@NonNull Collection<UuidCiphertext> uuidCipherTexts)
throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException
{
return commitChangeWithConflictResolution(groupOperations.createRemoveInvitationChange(new HashSet<>(uuidCipherTexts)));
}
@WorkerThread
@NonNull GroupManager.GroupActionResult setMemberAdmin(@NonNull RecipientId recipientId,
boolean admin)
throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException
{
Recipient recipient = Recipient.resolved(recipientId);
return commitChangeWithConflictResolution(groupOperations.createChangeMemberRole(recipient.getUuid().get(), admin ? Member.Role.ADMINISTRATOR : Member.Role.DEFAULT));
}
@WorkerThread
@Nullable GroupManager.GroupActionResult updateSelfProfileKeyInGroup()
throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException
{
ProfileKey profileKey = ProfileKeyUtil.getSelfProfileKey();
DecryptedGroup group = groupDatabase.requireGroup(groupId).requireV2GroupProperties().getDecryptedGroup();
Optional<DecryptedMember> selfInGroup = DecryptedGroupUtil.findMemberByUuid(group.getMembersList(), selfUuid);
if (!selfInGroup.isPresent()) {
Log.w(TAG, "Self not in group");
return null;
}
if (Arrays.equals(profileKey.serialize(), selfInGroup.get().getProfileKey().toByteArray())) {
Log.i(TAG, "Own Profile Key is already up to date in group " + groupId);
return null;
}
GroupCandidate groupCandidate = groupCandidateHelper.recipientIdToCandidate(Recipient.self().getId());
if (!groupCandidate.hasProfileKeyCredential()) {
Log.w(TAG, "No credential available");
return null;
}
return commitChangeWithConflictResolution(groupOperations.createUpdateProfileKeyCredentialChange(groupCandidate.getProfileKeyCredential().get()));
}
@WorkerThread
@Nullable GroupManager.GroupActionResult acceptInvite()
throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException
{
DecryptedGroup group = groupDatabase.requireGroup(groupId).requireV2GroupProperties().getDecryptedGroup();
Optional<DecryptedMember> selfInGroup = DecryptedGroupUtil.findMemberByUuid(group.getMembersList(), Recipient.self().getUuid().get());
if (selfInGroup.isPresent()) {
Log.w(TAG, "Self already in group");
return null;
}
GroupCandidate groupCandidate = groupCandidateHelper.recipientIdToCandidate(Recipient.self().getId());
if (!groupCandidate.hasProfileKeyCredential()) {
Log.w(TAG, "No credential available");
return null;
}
return commitChangeWithConflictResolution(groupOperations.createAcceptInviteChange(groupCandidate.getProfileKeyCredential().get()));
}
@WorkerThread
void updateLocalToServerVersion(int version)
throws IOException, GroupNotAMemberException
{
@ -98,7 +303,7 @@ final class GroupManagerV2 {
.updateLocalGroupToRevision(version, System.currentTimeMillis());
}
private GroupManager.GroupActionResult commitChangeWithConflictResolution(GroupChange.Actions.Builder change)
private @NonNull GroupManager.GroupActionResult commitChangeWithConflictResolution(@NonNull GroupChange.Actions.Builder change)
throws GroupChangeFailedException, GroupNotAMemberException, GroupInsufficientRightsException, IOException
{
change.setSourceUuid(UuidUtil.toByteString(Recipient.self().getUuid().get()));
@ -106,22 +311,19 @@ final class GroupManagerV2 {
for (int attempt = 0; attempt < 5; attempt++) {
try {
return commitChange(change);
} catch (GroupPatchNotAcceptedException e) {
throw new GroupChangeFailedException(e);
} catch (ConflictException e) {
Log.w(TAG, "Conflict on group");
GroupsV2StateProcessor.GroupUpdateResult groupUpdateResult = groupsV2StateProcessor.forGroup(groupMasterKey)
.updateLocalGroupToRevision(GroupsV2StateProcessor.LATEST, System.currentTimeMillis());
Log.w(TAG, "Invalid group patch or conflict", e);
if (groupUpdateResult.getGroupState() != GroupsV2StateProcessor.GroupState.GROUP_UPDATED || groupUpdateResult.getLatestServer() == null) {
throw new GroupChangeFailedException();
}
change = resolveConflict(change);
Log.w(TAG, "Group has been updated");
try {
change = GroupChangeUtil.resolveConflict(groupUpdateResult.getLatestServer(),
groupOperations.decryptChange(change.build(), selfUuid),
change.build());
} catch (VerificationFailedException | InvalidGroupStateException ex) {
throw new GroupChangeFailedException(ex);
if (GroupChangeUtil.changeIsEmpty(change.build())) {
Log.i(TAG, "Change is empty after conflict resolution");
Recipient groupRecipient = Recipient.externalGroup(context, groupId);
long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(groupRecipient);
return new GroupManager.GroupActionResult(groupRecipient, threadId);
}
}
}
@ -129,7 +331,29 @@ final class GroupManagerV2 {
throw new GroupChangeFailedException("Unable to apply change to group after conflicts");
}
private GroupManager.GroupActionResult commitChange(GroupChange.Actions.Builder change)
private GroupChange.Actions.Builder resolveConflict(@NonNull GroupChange.Actions.Builder change)
throws IOException, GroupNotAMemberException, GroupChangeFailedException
{
GroupsV2StateProcessor.GroupUpdateResult groupUpdateResult = groupsV2StateProcessor.forGroup(groupMasterKey)
.updateLocalGroupToRevision(GroupsV2StateProcessor.LATEST, System.currentTimeMillis());
if (groupUpdateResult.getGroupState() != GroupsV2StateProcessor.GroupState.GROUP_UPDATED || groupUpdateResult.getLatestServer() == null) {
throw new GroupChangeFailedException();
}
Log.w(TAG, "Group has been updated");
try {
GroupChange.Actions changeActions = change.build();
return GroupChangeUtil.resolveConflict(groupUpdateResult.getLatestServer(),
groupOperations.decryptChange(changeActions, selfUuid),
changeActions);
} catch (VerificationFailedException | InvalidGroupStateException ex) {
throw new GroupChangeFailedException(ex);
}
}
private GroupManager.GroupActionResult commitChange(@NonNull GroupChange.Actions.Builder change)
throws GroupNotAMemberException, GroupChangeFailedException, IOException, GroupInsufficientRightsException
{
final GroupDatabase.GroupRecord groupRecord = groupDatabase.requireGroup(groupId);
@ -157,7 +381,7 @@ final class GroupManagerV2 {
throws GroupNotAMemberException, GroupChangeFailedException, IOException, GroupInsufficientRightsException
{
try {
groupsV2Api.patchGroup(change, groupSecretParams, authorization);
groupsV2Api.patchGroup(change, groupSecretParams, authorization.getAuthorizationForToday(selfUuid, groupSecretParams));
} catch (NotInGroupException e) {
Log.w(TAG, e);
throw new GroupNotAMemberException(e);
@ -170,31 +394,42 @@ final class GroupManagerV2 {
}
}
private @NonNull GroupManager.GroupActionResult sendGroupUpdate(@NonNull GroupMasterKey masterKey,
@NonNull DecryptedGroup decryptedGroup,
@Nullable DecryptedGroupChange plainGroupChange)
{
RecipientId groupRecipientId = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(groupId);
Recipient groupRecipient = Recipient.resolved(groupRecipientId);
DecryptedGroupV2Context decryptedGroupV2Context = GroupProtoUtil.createDecryptedGroupV2Context(masterKey, decryptedGroup, plainGroupChange);
OutgoingGroupMediaMessage outgoingMessage = new OutgoingGroupMediaMessage(groupRecipient,
decryptedGroupV2Context,
null,
System.currentTimeMillis(),
0,
false,
null,
Collections.emptyList(),
Collections.emptyList());
long threadId = MessageSender.send(context, outgoingMessage, -1, false, null);
return new GroupManager.GroupActionResult(groupRecipient, threadId);
}
@Override
public void close() throws IOException {
lock.close();
}
}
private @NonNull GroupManager.GroupActionResult sendGroupUpdate(@NonNull GroupMasterKey masterKey,
@NonNull DecryptedGroup decryptedGroup,
@Nullable DecryptedGroupChange plainGroupChange)
{
GroupId.V2 groupId = GroupId.v2(masterKey);
Recipient groupRecipient = Recipient.externalGroup(context, groupId);
DecryptedGroupV2Context decryptedGroupV2Context = GroupProtoUtil.createDecryptedGroupV2Context(masterKey, decryptedGroup, plainGroupChange);
OutgoingGroupMediaMessage outgoingMessage = new OutgoingGroupMediaMessage(groupRecipient,
decryptedGroupV2Context,
null,
System.currentTimeMillis(),
0,
false,
null,
Collections.emptyList(),
Collections.emptyList());
long threadId = MessageSender.send(context, outgoingMessage, -1, false, null);
return new GroupManager.GroupActionResult(groupRecipient, threadId);
}
private static @NonNull AccessControl.AccessRequired rightsToAccessControl(@NonNull GroupAccessControl rights) {
switch (rights){
case ALL_MEMBERS:
return AccessControl.AccessRequired.MEMBER;
case ONLY_ADMINS:
return AccessControl.AccessRequired.ADMINISTRATOR;
default:
throw new AssertionError();
}
}
}

View File

@ -0,0 +1,84 @@
package org.thoughtcrime.securesms.groups;
import androidx.annotation.NonNull;
import org.signal.zkgroup.VerificationFailedException;
import org.signal.zkgroup.auth.AuthCredentialResponse;
import org.signal.zkgroup.groups.GroupSecretParams;
import org.thoughtcrime.securesms.logging.Log;
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Api;
import org.whispersystems.signalservice.api.groupsv2.GroupsV2AuthorizationString;
import org.whispersystems.signalservice.api.groupsv2.NoCredentialForRedemptionTimeException;
import java.io.IOException;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
public final class GroupsV2Authorization {
private static final String TAG = Log.tag(GroupsV2Authorization.class);
private final ValueCache cache;
private final GroupsV2Api groupsV2Api;
public GroupsV2Authorization(@NonNull GroupsV2Api groupsV2Api, @NonNull ValueCache cache) {
this.groupsV2Api = groupsV2Api;
this.cache = cache;
}
public GroupsV2AuthorizationString getAuthorizationForToday(@NonNull UUID self,
@NonNull GroupSecretParams groupSecretParams)
throws IOException, VerificationFailedException
{
final int today = currentTimeDays();
Map<Integer, AuthCredentialResponse> credentials = cache.read();
try {
return getAuthorization(self, groupSecretParams, credentials, today);
} catch (NoCredentialForRedemptionTimeException e) {
Log.i(TAG, "Auth out of date, will update auth and try again");
cache.clear();
}
Log.i(TAG, "Getting new auth credential responses");
credentials = groupsV2Api.getCredentials(today);
cache.write(credentials);
try {
return getAuthorization(self, groupSecretParams, credentials, today);
} catch (NoCredentialForRedemptionTimeException e) {
Log.w(TAG, "The credentials returned did not include the day requested");
throw new IOException("Failed to get credentials");
}
}
private static int currentTimeDays() {
return (int) TimeUnit.MILLISECONDS.toDays(System.currentTimeMillis());
}
private GroupsV2AuthorizationString getAuthorization(UUID self,
GroupSecretParams groupSecretParams,
Map<Integer, AuthCredentialResponse> credentials,
int today)
throws NoCredentialForRedemptionTimeException, VerificationFailedException
{
AuthCredentialResponse authCredentialResponse = credentials.get(today);
if (authCredentialResponse == null) {
throw new NoCredentialForRedemptionTimeException();
}
return groupsV2Api.getGroupsV2AuthorizationString(self, today, groupSecretParams, authCredentialResponse);
}
public interface ValueCache {
void clear();
@NonNull Map<Integer, AuthCredentialResponse> read();
void write(@NonNull Map<Integer, AuthCredentialResponse> values);
}
}

View File

@ -0,0 +1,43 @@
package org.thoughtcrime.securesms.groups;
import androidx.annotation.NonNull;
import org.signal.zkgroup.auth.AuthCredentialResponse;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
public final class GroupsV2AuthorizationMemoryValueCache implements GroupsV2Authorization.ValueCache {
private final GroupsV2Authorization.ValueCache inner;
private Map<Integer, AuthCredentialResponse> values;
public GroupsV2AuthorizationMemoryValueCache(@NonNull GroupsV2Authorization.ValueCache inner) {
this.inner = inner;
}
@Override
public synchronized void clear() {
inner.clear();
values = null;
}
@Override
public @NonNull synchronized Map<Integer, AuthCredentialResponse> read() {
Map<Integer, AuthCredentialResponse> map = values;
if (map == null) {
map = inner.read();
values = map;
}
return map;
}
@Override
public synchronized void write(@NonNull Map<Integer, AuthCredentialResponse> values) {
inner.write(values);
this.values = Collections.unmodifiableMap(new HashMap<>(values));
}
}

View File

@ -0,0 +1,76 @@
package org.thoughtcrime.securesms.groups;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.annotation.WorkerThread;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import java.io.IOException;
import java.util.Collection;
import java.util.HashSet;
import java.util.concurrent.TimeUnit;
final class GroupsV2CapabilityChecker {
private static final String TAG = Log.tag(GroupsV2CapabilityChecker.class);
GroupsV2CapabilityChecker() {
}
@WorkerThread
boolean allAndSelfSupportGroupsV2AndUuid(@NonNull Collection<RecipientId> recipientIds)
throws IOException
{
HashSet<RecipientId> recipientIdsSet = new HashSet<>(recipientIds);
recipientIdsSet.add(Recipient.self().getId());
return allSupportGroupsV2AndUuid(recipientIdsSet);
}
@WorkerThread
boolean allSupportGroupsV2AndUuid(@NonNull Collection<RecipientId> recipientIds)
throws IOException
{
final HashSet<RecipientId> recipientIdsSet = new HashSet<>(recipientIds);
for (RecipientId recipientId : recipientIdsSet) {
Recipient member = Recipient.resolved(recipientId);
Recipient.Capability gv2Capability = member.getGroupsV2Capability();
Recipient.Capability uuidCapability = member.getUuidCapability();
if (gv2Capability == Recipient.Capability.UNKNOWN || uuidCapability == Recipient.Capability.UNKNOWN) {
if (!ApplicationDependencies.getJobManager().runSynchronously(RetrieveProfileJob.forRecipient(member), TimeUnit.SECONDS.toMillis(1000)).isPresent()) {
throw new IOException("Recipient capability was not retrieved in time");
}
}
if (gv2Capability != Recipient.Capability.SUPPORTED) {
Log.i(TAG, "At least one recipient does not support GV2, capability was " + gv2Capability);
return false;
}
if (uuidCapability != Recipient.Capability.SUPPORTED) {
Log.i(TAG, "At least one recipient does not support UUID, capability was " + uuidCapability);
return false;
}
}
for (RecipientId recipientId : recipientIdsSet) {
Recipient member = Recipient.resolved(recipientId);
if (!member.hasUuid()) {
Log.i(TAG, "At least one recipient did not have a UUID known to us");
return false;
}
}
return true;
}
}

View File

@ -91,31 +91,41 @@ public final class LiveGroup {
}
public LiveData<Boolean> selfCanEditGroupAttributes() {
return LiveDataUtil.combineLatest(isSelfAdmin(),
getAttributesAccessControl(),
(admin, rights) -> {
switch (rights) {
case ALL_MEMBERS:
return true;
case ONLY_ADMINS:
return admin;
default:
throw new AssertionError();
}
}
);
return LiveDataUtil.combineLatest(isSelfAdmin(), getAttributesAccessControl(), this::applyAccessControl);
}
public LiveData<Boolean> selfCanAddMembers() {
return LiveDataUtil.combineLatest(isSelfAdmin(), getMembershipAdditionAccessControl(), this::applyAccessControl);
}
/**
* A string representing the count of full members and pending members if > 0.
*/
public LiveData<String> getMembershipCountDescription(@NonNull Resources resources) {
return LiveDataUtil.combineLatest(getFullMembers(),
getPendingMemberCount(),
(fullMembers, invitedCount) -> getMembershipDescription(resources, invitedCount, fullMembers.size()));
}
/**
* A string representing the count of full members.
*/
public LiveData<String> getFullMembershipCountDescription(@NonNull Resources resources) {
return Transformations.map(getFullMembers(), fullMembers -> getMembershipDescription(resources, 0, fullMembers.size()));
}
private static String getMembershipDescription(@NonNull Resources resources, int invitedCount, int fullMemberCount) {
return invitedCount > 0 ? resources.getQuantityString(R.plurals.MessageRequestProfileView_members_and_invited, fullMemberCount,
fullMemberCount, invitedCount)
: resources.getQuantityString(R.plurals.MessageRequestProfileView_members, fullMemberCount,
fullMemberCount);
}
private boolean applyAccessControl(boolean isAdmin, @NonNull GroupAccessControl rights) {
switch (rights) {
case ALL_MEMBERS: return true;
case ONLY_ADMINS: return isAdmin;
default: throw new AssertionError();
}
}
}

View File

@ -0,0 +1,7 @@
package org.thoughtcrime.securesms.groups;
public final class MembershipNotSuitableForV2Exception extends Exception {
public MembershipNotSuitableForV2Exception(String message) {
super(message);
}
}

View File

@ -23,11 +23,14 @@ import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentActivity;
import androidx.lifecycle.ViewModelProviders;
import org.thoughtcrime.securesms.ContactSelectionListFragment;
import org.thoughtcrime.securesms.MediaPreviewActivity;
import org.thoughtcrime.securesms.MuteDialog;
import org.thoughtcrime.securesms.PushContactSelectionActivity;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.AvatarImageView;
import org.thoughtcrime.securesms.components.ThreadPhotoRailView;
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader;
import org.thoughtcrime.securesms.groups.GroupId;
import org.thoughtcrime.securesms.groups.ui.GroupMemberListView;
import org.thoughtcrime.securesms.groups.ui.LeaveGroupDialog;
@ -39,9 +42,11 @@ import org.thoughtcrime.securesms.mediaoverview.MediaOverviewActivity;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.notifications.NotificationChannels;
import org.thoughtcrime.securesms.profiles.edit.EditProfileActivity;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.recipients.ui.bottomsheet.RecipientBottomSheetDialogFragment;
import org.thoughtcrime.securesms.util.DateUtils;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
@ -51,12 +56,14 @@ public class ManageGroupFragment extends Fragment {
private static final String TAG = Log.tag(ManageGroupFragment.class);
private static final int RETURN_FROM_MEDIA = 33114;
private static final int PICK_CONTACT = 61341;
private ManageGroupViewModel viewModel;
private GroupMemberListView groupMemberList;
private View listPending;
private TextView groupTitle;
private TextView memberCount;
private TextView memberCountUnderAvatar;
private TextView memberCountAboveList;
private AvatarImageView avatar;
private ThreadPhotoRailView threadPhotoRailView;
private View groupMediaCard;
@ -69,6 +76,7 @@ public class ManageGroupFragment extends Fragment {
private Button disappearingMessages;
private Button blockGroup;
private Button leaveGroup;
private Button addMembers;
private Switch muteNotificationsSwitch;
private TextView muteNotificationsUntilLabel;
private TextView customNotificationsButton;
@ -99,7 +107,8 @@ public class ManageGroupFragment extends Fragment {
avatar = view.findViewById(R.id.group_avatar);
groupTitle = view.findViewById(R.id.group_title);
memberCount = view.findViewById(R.id.member_count);
memberCountUnderAvatar = view.findViewById(R.id.member_count);
memberCountAboveList = view.findViewById(R.id.member_count_2);
groupMemberList = view.findViewById(R.id.group_members);
listPending = view.findViewById(R.id.listPending);
threadPhotoRailView = view.findViewById(R.id.recent_photos);
@ -112,6 +121,7 @@ public class ManageGroupFragment extends Fragment {
disappearingMessages = view.findViewById(R.id.disappearing_messages);
blockGroup = view.findViewById(R.id.blockGroup);
leaveGroup = view.findViewById(R.id.leaveGroup);
addMembers = view.findViewById(R.id.add_members);
muteNotificationsUntilLabel = view.findViewById(R.id.group_mute_notifications_until);
muteNotificationsSwitch = view.findViewById(R.id.group_mute_notifications_switch);
customNotificationsButton = view.findViewById(R.id.group_custom_notifications_button);
@ -147,12 +157,13 @@ public class ManageGroupFragment extends Fragment {
});
viewModel.getTitle().observe(getViewLifecycleOwner(), groupTitle::setText);
viewModel.getMemberCountSummary().observe(getViewLifecycleOwner(), memberCount::setText);
viewModel.getMemberCountSummary().observe(getViewLifecycleOwner(), memberCountUnderAvatar::setText);
viewModel.getFullMemberCountSummary().observe(getViewLifecycleOwner(), memberCountAboveList::setText);
viewModel.getGroupRecipient().observe(getViewLifecycleOwner(), avatar::setRecipient);
viewModel.getGroupViewState().observe(getViewLifecycleOwner(), vs -> {
if (vs == null) return;
photoRailLabel.setOnClickListener(v -> startActivity(MediaOverviewActivity.forThread(context, vs.getThreadId())));
avatar.setRecipient(vs.getGroupRecipient());
setMediaCursorFactory(vs.getMediaCursorFactory());
@ -177,6 +188,12 @@ public class ManageGroupFragment extends Fragment {
disappearingMessages.setOnClickListener(v -> viewModel.handleExpirationSelection());
blockGroup.setOnClickListener(v -> viewModel.blockAndLeave(requireActivity()));
addMembers.setOnClickListener(v -> {
Intent intent = new Intent(requireActivity(), PushContactSelectionActivity.class);
intent.putExtra(ContactSelectionListFragment.DISPLAY_MODE, ContactsCursorLoader.DisplayMode.FLAG_PUSH);
startActivityForResult(intent, PICK_CONTACT);
});
viewModel.getMembershipRights().observe(getViewLifecycleOwner(), r -> {
if (r != null) {
editGroupMembershipValue.setText(r.getString());
@ -199,6 +216,7 @@ public class ManageGroupFragment extends Fragment {
});
viewModel.getCanEditGroupAttributes().observe(getViewLifecycleOwner(), canEdit -> disappearingMessages.setEnabled(canEdit));
viewModel.getCanAddMembers().observe(getViewLifecycleOwner(), canEdit -> addMembers.setVisibility(canEdit ? View.VISIBLE : View.GONE));
groupMemberList.setRecipientClickListener(recipient -> RecipientBottomSheetDialogFragment.create(recipient.getId(), groupId).show(requireFragmentManager(), "BOTTOM"));
@ -290,6 +308,9 @@ public class ManageGroupFragment extends Fragment {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode == RETURN_FROM_MEDIA) {
applyMediaCursorFactory();
} else if (requestCode == PICK_CONTACT) {
List<RecipientId> selected = data.getParcelableArrayListExtra(PushContactSelectionActivity.KEY_SELECTED_RECIPIENTS);
viewModel.onAddMembers(selected);
}
}
}

View File

@ -17,6 +17,7 @@ import org.thoughtcrime.securesms.groups.GroupId;
import org.thoughtcrime.securesms.groups.GroupInsufficientRightsException;
import org.thoughtcrime.securesms.groups.GroupManager;
import org.thoughtcrime.securesms.groups.GroupNotAMemberException;
import org.thoughtcrime.securesms.groups.MembershipNotSuitableForV2Exception;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
@ -24,6 +25,7 @@ import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
import java.io.IOException;
import java.util.List;
import java.util.concurrent.ExecutorService;
final class ManageGroupRepository {
@ -31,12 +33,10 @@ final class ManageGroupRepository {
private static final String TAG = Log.tag(ManageGroupRepository.class);
private final Context context;
private final GroupId groupId;
private final ExecutorService executor;
private final GroupId.Push groupId;
ManageGroupRepository(@NonNull Context context, @NonNull GroupId groupId) {
ManageGroupRepository(@NonNull Context context, @NonNull GroupId.Push groupId) {
this.context = context;
this.executor = SignalExecutors.BOUNDED;
this.groupId = groupId;
}
@ -45,7 +45,7 @@ final class ManageGroupRepository {
}
void getGroupState(@NonNull Consumer<GroupStateResult> onGroupStateLoaded) {
executor.execute(() -> onGroupStateLoaded.accept(getGroupState()));
SignalExecutors.BOUNDED.execute(() -> onGroupStateLoaded.accept(getGroupState()));
}
@WorkerThread
@ -58,7 +58,7 @@ final class ManageGroupRepository {
}
void setExpiration(int newExpirationTime, @NonNull Error error) {
SignalExecutors.BOUNDED.execute(() -> {
SignalExecutors.UNBOUNDED.execute(() -> {
try {
GroupManager.updateGroupTimer(context, groupId.requirePush(), newExpirationTime);
} catch (GroupInsufficientRightsException e) {
@ -75,13 +75,13 @@ final class ManageGroupRepository {
}
void applyMembershipRightsChange(@NonNull GroupAccessControl newRights, @NonNull Error error) {
SignalExecutors.BOUNDED.execute(() -> {
SignalExecutors.UNBOUNDED.execute(() -> {
try {
GroupManager.applyMembershipAdditionRightsChange(context, groupId.requireV2(), newRights);
} catch (GroupInsufficientRightsException e) {
} catch (GroupInsufficientRightsException | GroupNotAMemberException e) {
Log.w(TAG, e);
error.onError(FailureReason.NO_RIGHTS);
} catch (GroupChangeFailedException e) {
} catch (GroupChangeFailedException | GroupChangeBusyException | IOException e) {
Log.w(TAG, e);
error.onError(FailureReason.OTHER);
}
@ -89,13 +89,13 @@ final class ManageGroupRepository {
}
void applyAttributesRightsChange(@NonNull GroupAccessControl newRights, @NonNull Error error) {
SignalExecutors.BOUNDED.execute(() -> {
SignalExecutors.UNBOUNDED.execute(() -> {
try {
GroupManager.applyAttributesRightsChange(context, groupId.requireV2(), newRights);
} catch (GroupInsufficientRightsException e) {
} catch (GroupInsufficientRightsException | GroupNotAMemberException e) {
Log.w(TAG, e);
error.onError(FailureReason.NO_RIGHTS);
} catch (GroupChangeFailedException e) {
} catch (GroupChangeFailedException | GroupChangeBusyException | IOException e) {
Log.w(TAG, e);
error.onError(FailureReason.OTHER);
}
@ -108,13 +108,30 @@ final class ManageGroupRepository {
recipientCallback::accept);
}
public void setMuteUntil(long until) {
void setMuteUntil(long until) {
SignalExecutors.BOUNDED.execute(() -> {
RecipientId recipientId = Recipient.externalGroup(context, groupId).getId();
DatabaseFactory.getRecipientDatabase(context).setMuted(recipientId, until);
});
}
void addMembers(@NonNull List<RecipientId> selected, @NonNull Error error) {
SignalExecutors.UNBOUNDED.execute(() -> {
try {
GroupManager.addMembers(context, groupId, selected);
} catch (GroupInsufficientRightsException | GroupNotAMemberException e) {
Log.w(TAG, e);
error.onError(FailureReason.NO_RIGHTS);
} catch (GroupChangeFailedException | GroupChangeBusyException | IOException e) {
Log.w(TAG, e);
error.onError(FailureReason.OTHER);
} catch (MembershipNotSuitableForV2Exception e) {
Log.w(TAG, e);
error.onError(FailureReason.NOT_CAPABLE);
}
});
}
static final class GroupStateResult {
private final long threadId;
@ -138,6 +155,7 @@ final class ManageGroupRepository {
public enum FailureReason {
NO_RIGHTS(R.string.ManageGroupActivity_you_dont_have_the_rights_to_do_this),
NOT_CAPABLE(R.string.ManageGroupActivity_not_capable),
NOT_A_MEMBER(R.string.ManageGroupActivity_youre_not_a_member_of_the_group),
OTHER(R.string.ManageGroupActivity_failed_to_update_the_group);

View File

@ -1,6 +1,7 @@
package org.thoughtcrime.securesms.groups.ui.managegroup;
import android.content.Context;
import android.content.Intent;
import android.database.Cursor;
import android.widget.Toast;
@ -14,7 +15,11 @@ import androidx.lifecycle.ViewModel;
import androidx.lifecycle.ViewModelProvider;
import org.thoughtcrime.securesms.BlockUnblockDialog;
import org.thoughtcrime.securesms.ContactSelectionListFragment;
import org.thoughtcrime.securesms.ExpirationDialog;
import org.thoughtcrime.securesms.GroupCreateActivity;
import org.thoughtcrime.securesms.PushContactSelectionActivity;
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader;
import org.thoughtcrime.securesms.database.MediaDatabase;
import org.thoughtcrime.securesms.database.loaders.MediaLoader;
import org.thoughtcrime.securesms.database.loaders.ThreadMediaLoader;
@ -23,6 +28,7 @@ import org.thoughtcrime.securesms.groups.GroupId;
import org.thoughtcrime.securesms.groups.LiveGroup;
import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.recipients.RecipientUtil;
import org.thoughtcrime.securesms.util.ExpirationUtil;
import org.thoughtcrime.securesms.util.Util;
@ -36,12 +42,15 @@ public class ManageGroupViewModel extends ViewModel {
private final LiveData<String> title;
private final LiveData<Boolean> isAdmin;
private final LiveData<Boolean> canEditGroupAttributes;
private final LiveData<Boolean> canAddMembers;
private final LiveData<List<GroupMemberEntry.FullMember>> members;
private final LiveData<Integer> pendingMemberCount;
private final LiveData<String> disappearingMessageTimer;
private final LiveData<String> memberCountSummary;
private final LiveData<String> fullMemberCountSummary;
private final LiveData<GroupAccessControl> editMembershipRights;
private final LiveData<GroupAccessControl> editGroupAttributesRights;
private final LiveData<Recipient> groupRecipient;
private final MutableLiveData<GroupViewState> groupViewState = new MutableLiveData<>(null);
private final LiveData<MuteState> muteState;
private final LiveData<Boolean> hasCustomNotifications;
@ -59,13 +68,16 @@ public class ManageGroupViewModel extends ViewModel {
this.members = liveGroup.getFullMembers();
this.pendingMemberCount = liveGroup.getPendingMemberCount();
this.memberCountSummary = liveGroup.getMembershipCountDescription(context.getResources());
this.fullMemberCountSummary = liveGroup.getFullMembershipCountDescription(context.getResources());
this.editMembershipRights = liveGroup.getMembershipAdditionAccessControl();
this.editGroupAttributesRights = liveGroup.getAttributesAccessControl();
this.disappearingMessageTimer = Transformations.map(liveGroup.getExpireMessages(), expiration -> ExpirationUtil.getExpirationDisplayValue(context, expiration));
this.canEditGroupAttributes = liveGroup.selfCanEditGroupAttributes();
this.muteState = Transformations.map(liveGroup.getGroupRecipient(),
this.canAddMembers = liveGroup.selfCanAddMembers();
this.groupRecipient = liveGroup.getGroupRecipient();
this.muteState = Transformations.map(this.groupRecipient,
recipient -> new MuteState(recipient.getMuteUntil(), recipient.isMuted()));
this.hasCustomNotifications = Transformations.map(liveGroup.getGroupRecipient(),
this.hasCustomNotifications = Transformations.map(this.groupRecipient,
recipient -> recipient.getNotificationChannel() != null);
}
@ -88,6 +100,14 @@ public class ManageGroupViewModel extends ViewModel {
return memberCountSummary;
}
LiveData<String> getFullMemberCountSummary() {
return fullMemberCountSummary;
}
public LiveData<Recipient> getGroupRecipient() {
return groupRecipient;
}
LiveData<GroupViewState> getGroupViewState() {
return groupViewState;
}
@ -116,6 +136,10 @@ public class ManageGroupViewModel extends ViewModel {
return canEditGroupAttributes;
}
LiveData<Boolean> getCanAddMembers() {
return canAddMembers;
}
LiveData<String> getDisappearingMessageTimer() {
return disappearingMessageTimer;
}
@ -144,6 +168,10 @@ public class ManageGroupViewModel extends ViewModel {
() -> RecipientUtil.block(context, recipient)));
}
void onAddMembers(List<RecipientId> selected) {
manageGroupRepository.addMembers(selected, this::showErrorToast);
}
void setMuteUntil(long muteUntil) {
manageGroupRepository.setMuteUntil(muteUntil);
}
@ -154,7 +182,7 @@ public class ManageGroupViewModel extends ViewModel {
@WorkerThread
private void showErrorToast(@NonNull ManageGroupRepository.FailureReason e) {
Util.runOnMain(() -> Toast.makeText(context, e.getToastMessage(), Toast.LENGTH_SHORT).show());
Util.runOnMain(() -> Toast.makeText(context, e.getToastMessage(), Toast.LENGTH_LONG).show());
}
static final class GroupViewState {

View File

@ -12,24 +12,25 @@ import com.google.protobuf.ByteString;
import org.signal.storageservice.protos.groups.local.DecryptedGroup;
import org.signal.storageservice.protos.groups.local.DecryptedPendingMember;
import org.signal.zkgroup.InvalidInputException;
import org.signal.zkgroup.VerificationFailedException;
import org.signal.zkgroup.groups.UuidCiphertext;
import org.signal.zkgroup.util.UUIDUtil;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.GroupDatabase;
import org.thoughtcrime.securesms.groups.GroupChangeBusyException;
import org.thoughtcrime.securesms.groups.GroupChangeFailedException;
import org.thoughtcrime.securesms.groups.GroupId;
import org.thoughtcrime.securesms.groups.GroupInsufficientRightsException;
import org.thoughtcrime.securesms.groups.GroupManager;
import org.thoughtcrime.securesms.groups.GroupNotAMemberException;
import org.thoughtcrime.securesms.groups.GroupProtoUtil;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
import org.whispersystems.signalservice.api.groupsv2.InvalidGroupStateException;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.Executor;
/**
@ -104,7 +105,7 @@ final class PendingMemberRepository {
try {
GroupManager.cancelInvites(context, groupId, uuidCipherTexts);
return true;
} catch (InvalidGroupStateException | VerificationFailedException | IOException e) {
} catch (GroupChangeFailedException | GroupInsufficientRightsException | IOException | GroupNotAMemberException | GroupChangeBusyException e) {
Log.w(TAG, e);
return false;
}

View File

@ -0,0 +1,92 @@
package org.thoughtcrime.securesms.groups.v2;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.annotation.WorkerThread;
import org.signal.zkgroup.VerificationFailedException;
import org.signal.zkgroup.profiles.ProfileKey;
import org.signal.zkgroup.profiles.ProfileKeyCredential;
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.RecipientDatabase;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.signalservice.api.SignalServiceAccountManager;
import org.whispersystems.signalservice.api.groupsv2.GroupCandidate;
import java.io.IOException;
import java.util.Collection;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
public final class GroupCandidateHelper {
private final SignalServiceAccountManager signalServiceAccountManager;
private final RecipientDatabase recipientDatabase;
public GroupCandidateHelper(@NonNull Context context) {
signalServiceAccountManager = ApplicationDependencies.getSignalServiceAccountManager();
recipientDatabase = DatabaseFactory.getRecipientDatabase(context);
}
private static final String TAG = Log.tag(GroupCandidateHelper.class);
/**
* Given a recipient will create a {@link GroupCandidate} which may or may not have a profile key credential.
* <p>
* It will try to find missing profile key credentials from the server and persist locally.
*/
@WorkerThread
public @NonNull GroupCandidate recipientIdToCandidate(@NonNull RecipientId recipientId)
throws IOException
{
final Recipient recipient = Recipient.resolved(recipientId);
UUID uuid = recipient.getUuid().orNull();
if (uuid == null) {
throw new AssertionError("Non UUID members should have need detected by now");
}
Optional<ProfileKeyCredential> profileKeyCredential = ProfileKeyUtil.profileKeyCredentialOptional(recipient.getProfileKeyCredential());
GroupCandidate candidate = new GroupCandidate(uuid, profileKeyCredential);
if (!candidate.hasProfileKeyCredential()) {
ProfileKey profileKey = ProfileKeyUtil.profileKeyOrNull(recipient.getProfileKey());
if (profileKey != null) {
try {
Optional<ProfileKeyCredential> profileKeyCredentialOptional = signalServiceAccountManager.resolveProfileKeyCredential(uuid, profileKey);
if (profileKeyCredentialOptional.isPresent()) {
candidate = candidate.withProfileKeyCredential(profileKeyCredentialOptional.get());
recipientDatabase.setProfileKeyCredential(recipient.getId(), profileKey, profileKeyCredentialOptional.get());
}
} catch (VerificationFailedException e) {
Log.w(TAG, e);
throw new IOException(e);
}
}
}
return candidate;
}
@WorkerThread
public @NonNull Set<GroupCandidate> recipientIdsToCandidates(@NonNull Collection<RecipientId> recipientIds)
throws IOException
{
Set<GroupCandidate> result = new HashSet<>(recipientIds.size());
for (RecipientId recipientId : recipientIds) {
result.add(recipientIdToCandidate(recipientId));
}
return result;
}
}

View File

@ -19,6 +19,7 @@ import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.groups.GroupId;
import org.thoughtcrime.securesms.groups.GroupNotAMemberException;
import org.thoughtcrime.securesms.groups.GroupProtoUtil;
import org.thoughtcrime.securesms.groups.GroupsV2Authorization;
import org.thoughtcrime.securesms.groups.v2.ProfileKeySet;
import org.thoughtcrime.securesms.jobmanager.JobManager;
import org.thoughtcrime.securesms.jobs.AvatarGroupsV2DownloadJob;
@ -31,7 +32,6 @@ import org.thoughtcrime.securesms.recipients.RecipientId;
import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupHistoryEntry;
import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil;
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Api;
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Authorization;
import org.whispersystems.signalservice.api.groupsv2.InvalidGroupStateException;
import org.whispersystems.signalservice.internal.push.exceptions.NotInGroupException;
@ -217,7 +217,7 @@ public final class GroupsV2StateProcessor {
.orNull();
try {
latestServerGroup = groupsV2Api.getGroup(groupSecretParams, groupsV2Authorization);
latestServerGroup = groupsV2Api.getGroup(groupSecretParams, groupsV2Authorization.getAuthorizationForToday(selfUuid, groupSecretParams));
} catch (NotInGroupException e) {
throw new GroupNotAMemberException(e);
} catch (VerificationFailedException | InvalidGroupStateException e) {
@ -238,7 +238,7 @@ public final class GroupsV2StateProcessor {
private List<GroupLogEntry> getFullMemberHistory(@NonNull UUID selfUuid, int logsNeededFrom) throws IOException {
try {
Collection<DecryptedGroupHistoryEntry> groupStatesFromRevision = groupsV2Api.getGroupHistory(groupSecretParams, logsNeededFrom, groupsV2Authorization);
Collection<DecryptedGroupHistoryEntry> groupStatesFromRevision = groupsV2Api.getGroupHistory(groupSecretParams, logsNeededFrom, groupsV2Authorization.getAuthorizationForToday(selfUuid, groupSecretParams));
ArrayList<GroupLogEntry> history = new ArrayList<>(groupStatesFromRevision.size());
for (DecryptedGroupHistoryEntry entry : groupStatesFromRevision) {

View File

@ -0,0 +1,82 @@
package org.thoughtcrime.securesms.keyvalue;
import androidx.annotation.NonNull;
import com.google.protobuf.ByteString;
import com.google.protobuf.InvalidProtocolBufferException;
import org.signal.zkgroup.InvalidInputException;
import org.signal.zkgroup.auth.AuthCredentialResponse;
import org.thoughtcrime.securesms.database.model.databaseprotos.TemporalAuthCredentialResponse;
import org.thoughtcrime.securesms.database.model.databaseprotos.TemporalAuthCredentialResponses;
import org.thoughtcrime.securesms.groups.GroupsV2Authorization;
import org.thoughtcrime.securesms.logging.Log;
import java.util.Collections;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
public final class GroupsV2AuthorizationSignalStoreCache implements GroupsV2Authorization.ValueCache {
private static final String TAG = Log.tag(GroupsV2AuthorizationSignalStoreCache.class);
private static final String KEY = "gv2:auth_token_cache";
private final KeyValueStore store;
GroupsV2AuthorizationSignalStoreCache(KeyValueStore store) {
this.store = store;
}
@Override
public void clear() {
store.beginWrite()
.remove(KEY)
.commit();
Log.i(TAG, "Cleared local response cache");
}
@Override
public @NonNull Map<Integer, AuthCredentialResponse> read() {
byte[] credentialBlob = store.getBlob(KEY, null);
if (credentialBlob == null) {
Log.i(TAG, "No credentials responses are cached locally");
return Collections.emptyMap();
}
try {
TemporalAuthCredentialResponses temporalCredentials = TemporalAuthCredentialResponses.parseFrom(credentialBlob);
HashMap<Integer, AuthCredentialResponse> result = new HashMap<>(temporalCredentials.getCredentialResponseCount());
for (TemporalAuthCredentialResponse credential : temporalCredentials.getCredentialResponseList()) {
result.put(credential.getDate(), new AuthCredentialResponse(credential.getAuthCredentialResponse().toByteArray()));
}
Log.i(TAG, String.format(Locale.US, "Loaded %d credentials from local storage", result.size()));
return result;
} catch (InvalidProtocolBufferException | InvalidInputException e) {
throw new AssertionError(e);
}
}
@Override
public void write(@NonNull Map<Integer, AuthCredentialResponse> values) {
TemporalAuthCredentialResponses.Builder builder = TemporalAuthCredentialResponses.newBuilder();
for (Map.Entry<Integer, AuthCredentialResponse> entry : values.entrySet()) {
builder.addCredentialResponse(TemporalAuthCredentialResponse.newBuilder()
.setDate(entry.getKey())
.setAuthCredentialResponse(ByteString.copyFrom(entry.getValue().serialize())));
}
store.beginWrite()
.putBlob(KEY, builder.build().toByteArray())
.commit();
Log.i(TAG, String.format(Locale.US, "Written %d credentials to local storage", values.size()));
}
}

View File

@ -40,6 +40,10 @@ public final class SignalStore {
return new StorageServiceValues(getStore());
}
public static @NonNull GroupsV2AuthorizationSignalStoreCache groupsV2AuthorizationCache() {
return new GroupsV2AuthorizationSignalStoreCache(getStore());
}
public static long getLastPrekeyRefreshTime() {
return getStore().getLong(LAST_PREKEY_REFRESH_TIME, 0);
}

View File

@ -731,6 +731,10 @@ public class Recipient {
return groupsV2Capability;
}
public Capability getUuidCapability() {
return uuidCapability;
}
public @Nullable byte[] getProfileKey() {
return profileKey;
}

View File

@ -32,3 +32,12 @@ message DecryptedGroupV2Context {
DecryptedGroupChange change = 2;
DecryptedGroup groupState = 3;
}
message TemporalAuthCredentialResponse {
int32 date = 1;
bytes authCredentialResponse = 2;
}
message TemporalAuthCredentialResponses {
repeated TemporalAuthCredentialResponse credentialResponse = 1;
}

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="oval">
<solid android:color="@color/core_grey_80" />
</shape>
</item>
<item
android:bottom="12dp"
android:drawable="@drawable/ic_plus_24_ultramarine"
android:left="12dp"
android:right="12dp"
android:top="12dp" />
</layer-list>

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="oval">
<solid android:color="@color/core_grey_02" />
</shape>
</item>
<item
android:bottom="12dp"
android:drawable="@drawable/ic_plus_24_ultramarine"
android:left="12dp"
android:right="12dp"
android:top="12dp" />
</layer-list>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@color/core_ultramarine"
android:pathData="M21,11.25l-8.25,0l0,-8.25l-1.5,0l0,8.25l-8.25,0l0,1.5l8.25,0l0,8.25l1.5,0l0,-8.25l8.25,0l0,-1.5z" />
</vector>

View File

@ -160,7 +160,7 @@
android:id="@+id/group_custom_notifications_controls"
android:layout_width="0dp"
android:layout_height="0dp"
app:constraint_referenced_ids="group_custom_notifications,group_custom_notifications_button"/>
app:constraint_referenced_ids="group_custom_notifications,group_custom_notifications_button" />
</androidx.constraintlayout.widget.ConstraintLayout>
@ -268,12 +268,40 @@
app:cardBackgroundColor="?android:attr/windowBackground"
app:layout_constraintTop_toBottomOf="@id/group_access_control_card">
<org.thoughtcrime.securesms.groups.ui.GroupMemberListView
android:id="@+id/group_members"
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
app:maxHeight="280dp"
tools:listitem="@layout/group_recipient_list_item" />
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/member_count_2"
style="@style/TextAppearance.Signal.Body2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="12dp"
tools:text="12 members" />
<Button
android:id="@+id/add_members"
style="@style/Widget.Signal.Button.TextButton.Drawable.Ultramarine"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:drawableStart="?attr/manage_group_add_members_icon"
android:drawablePadding="8dp"
android:text="@string/ManageGroupActivity_add_members"
android:visibility="gone"
tools:visibility="visible" />
<org.thoughtcrime.securesms.groups.ui.GroupMemberListView
android:id="@+id/group_members"
android:layout_width="match_parent"
android:layout_height="0dp"
app:maxHeight="280dp"
tools:listitem="@layout/group_recipient_list_item" />
</LinearLayout>
</androidx.cardview.widget.CardView>

View File

@ -227,6 +227,7 @@
</declare-styleable>
<attr name="group_members_dialog_icon" format="reference"/>
<attr name="manage_group_add_members_icon" format="reference"/>
<attr name="lockscreen_watermark" format="reference" />
<attr name="recipient_preference_blocked" format="color"/>

View File

@ -499,8 +499,10 @@
<string name="ManageGroupActivity_until_s">Until %1$s</string>
<string name="ManageGroupActivity_off">Off</string>
<string name="ManageGroupActivity_on">On</string>
<string name="ManageGroupActivity_add_members">Add members</string>
<string name="ManageGroupActivity_you_dont_have_the_rights_to_do_this">You don\'t have the rights to do this</string>
<string name="ManageGroupActivity_not_capable">Someone you added does not support new groups and needs to update Signal</string>
<string name="ManageGroupActivity_failed_to_update_the_group">Failed to update the group</string>
<string name="ManageGroupActivity_youre_not_a_member_of_the_group">You\'re not a member of the group</string>

View File

@ -432,6 +432,10 @@
<item name="android:textColor">@color/ultramarine_text_button</item>
</style>
<style name="Widget.Signal.Button.TextButton.Drawable.Ultramarine" >
<item name="android:textColor">@color/ultramarine_text_button</item>
</style>
<style name="Widget.Signal.Button.CalleeDialog" parent="Widget.AppCompat.Button">
<item name="android:textColor">@color/core_ultramarine</item>
<item name="android:background">@drawable/callee_dialog_button_background</item>

View File

@ -405,6 +405,7 @@
<item name="quote_dismiss_button_tint">@color/core_grey_70</item>
<item name="group_members_dialog_icon">@drawable/ic_group_outline_24</item>
<item name="manage_group_add_members_icon">@drawable/ic_add_members_circle_light</item>
<item name="preferenceTheme">@style/PreferenceThemeOverlay.Fix</item>
<item name="search_toolbar_background">@color/white</item>
@ -689,6 +690,7 @@
<item name="quote_dismiss_button_tint">@color/core_white</item>
<item name="group_members_dialog_icon">@drawable/ic_group_solid_24</item>
<item name="manage_group_add_members_icon">@drawable/ic_add_members_circle_dark</item>
<item name="preferenceTheme">@style/PreferenceThemeOverlay.Fix</item>
<item name="search_toolbar_background">@color/core_grey_95</item>
<item name="search_background">@color/black</item>

View File

@ -26,9 +26,7 @@ import org.whispersystems.signalservice.api.crypto.ProfileCipher;
import org.whispersystems.signalservice.api.crypto.ProfileCipherOutputStream;
import org.whispersystems.signalservice.api.groupsv2.ClientZkOperations;
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Api;
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Authorization;
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations;
import org.whispersystems.signalservice.api.kbs.HashedPin;
import org.whispersystems.signalservice.api.kbs.MasterKey;
import org.whispersystems.signalservice.api.messages.calls.TurnServerInfo;
import org.whispersystems.signalservice.api.messages.multidevice.DeviceInfo;
@ -758,8 +756,4 @@ public class SignalServiceAccountManager {
public GroupsV2Api getGroupsV2Api() {
return new GroupsV2Api(pushServiceSocket, groupsV2Operations);
}
public GroupsV2Authorization createGroupsV2Authorization(UUID self) {
return new GroupsV2Authorization(self, pushServiceSocket, groupsV2Operations.getAuthOperations());
}
}

View File

@ -39,8 +39,8 @@ public final class GroupCandidate {
return profileKeyCredential.isPresent();
}
public GroupCandidate withProfileKeyCredential(Optional<ProfileKeyCredential> profileKeyCredential) {
return new GroupCandidate(uuid, profileKeyCredential);
public GroupCandidate withProfileKeyCredential(ProfileKeyCredential profileKeyCredential) {
return new GroupCandidate(uuid, Optional.of(profileKeyCredential));
}
public static List<UUID> toUuidList(Collection<GroupCandidate> candidates) {

View File

@ -9,14 +9,22 @@ import org.signal.storageservice.protos.groups.GroupChange;
import org.signal.storageservice.protos.groups.GroupChanges;
import org.signal.storageservice.protos.groups.local.DecryptedGroup;
import org.signal.storageservice.protos.groups.local.DecryptedGroupChange;
import org.signal.zkgroup.InvalidInputException;
import org.signal.zkgroup.VerificationFailedException;
import org.signal.zkgroup.auth.AuthCredential;
import org.signal.zkgroup.auth.AuthCredentialPresentation;
import org.signal.zkgroup.auth.AuthCredentialResponse;
import org.signal.zkgroup.auth.ClientZkAuthOperations;
import org.signal.zkgroup.groups.ClientZkGroupCipher;
import org.signal.zkgroup.groups.GroupSecretParams;
import org.whispersystems.signalservice.internal.push.PushServiceSocket;
import java.io.IOException;
import java.security.SecureRandom;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.UUID;
public final class GroupsV2Api {
@ -28,9 +36,34 @@ public final class GroupsV2Api {
this.groupsOperations = groupsOperations;
}
/**
* Provides 7 days of credentials, which you should cache.
*/
public HashMap<Integer, AuthCredentialResponse> getCredentials(int today)
throws IOException
{
return parseCredentialResponse(socket.retrieveGroupsV2Credentials(today));
}
/**
* Create an auth token from a credential response.
*/
public GroupsV2AuthorizationString getGroupsV2AuthorizationString(UUID self,
int today,
GroupSecretParams groupSecretParams,
AuthCredentialResponse authCredentialResponse)
throws VerificationFailedException
{
ClientZkAuthOperations authOperations = groupsOperations.getAuthOperations();
AuthCredential authCredential = authOperations.receiveAuthCredential(self, today, authCredentialResponse);
AuthCredentialPresentation authCredentialPresentation = authOperations.createAuthCredentialPresentation(new SecureRandom(), groupSecretParams, authCredential);
return new GroupsV2AuthorizationString(groupSecretParams, authCredentialPresentation);
}
public void putNewGroup(GroupsV2Operations.NewGroup newGroup,
GroupsV2Authorization authorization)
throws IOException, VerificationFailedException
GroupsV2AuthorizationString authorization)
throws IOException
{
Group group = newGroup.getNewGroupMessage();
@ -42,14 +75,14 @@ public final class GroupsV2Api {
.build();
}
socket.putNewGroupsV2Group(group, authorization.getAuthorizationForToday(newGroup.getGroupSecretParams()));
socket.putNewGroupsV2Group(group, authorization);
}
public DecryptedGroup getGroup(GroupSecretParams groupSecretParams,
GroupsV2Authorization authorization)
GroupsV2AuthorizationString authorization)
throws IOException, InvalidGroupStateException, VerificationFailedException
{
Group group = socket.getGroupsV2Group(authorization.getAuthorizationForToday(groupSecretParams));
Group group = socket.getGroupsV2Group(authorization);
return groupsOperations.forGroup(groupSecretParams)
.decryptGroup(group);
@ -57,10 +90,10 @@ public final class GroupsV2Api {
public List<DecryptedGroupHistoryEntry> getGroupHistory(GroupSecretParams groupSecretParams,
int fromRevision,
GroupsV2Authorization authorization)
GroupsV2AuthorizationString authorization)
throws IOException, InvalidGroupStateException, VerificationFailedException
{
GroupChanges group = socket.getGroupsV2GroupHistory(fromRevision, authorization.getAuthorizationForToday(groupSecretParams));
GroupChanges group = socket.getGroupsV2GroupHistory(fromRevision, authorization);
List<GroupChanges.GroupChangeState> changesList = group.getGroupChangesList();
ArrayList<DecryptedGroupHistoryEntry> result = new ArrayList<>(changesList.size());
@ -82,10 +115,10 @@ public final class GroupsV2Api {
public String uploadAvatar(byte[] avatar,
GroupSecretParams groupSecretParams,
GroupsV2Authorization authorization)
throws IOException, VerificationFailedException
GroupsV2AuthorizationString authorization)
throws IOException
{
AvatarUploadAttributes form = socket.getGroupsV2AvatarUploadForm(authorization.getAuthorizationForToday(groupSecretParams));
AvatarUploadAttributes form = socket.getGroupsV2AvatarUploadForm(authorization.toString());
byte[] cipherText;
try {
@ -101,13 +134,31 @@ public final class GroupsV2Api {
public DecryptedGroupChange patchGroup(GroupChange.Actions groupChange,
GroupSecretParams groupSecretParams,
GroupsV2Authorization authorization)
GroupsV2AuthorizationString authorization)
throws IOException, VerificationFailedException, InvalidGroupStateException
{
String authorizationBasic = authorization.getAuthorizationForToday(groupSecretParams);
GroupChange groupChanges = socket.patchGroupsV2Group(groupChange, authorizationBasic);
GroupChange groupChanges = socket.patchGroupsV2Group(groupChange, authorization.toString());
return groupsOperations.forGroup(groupSecretParams)
.decryptChange(groupChanges, true);
}
private static HashMap<Integer, AuthCredentialResponse> parseCredentialResponse(CredentialResponse credentialResponse)
throws IOException
{
HashMap<Integer, AuthCredentialResponse> credentials = new HashMap<>();
for (TemporalCredential credential : credentialResponse.getCredentials()) {
AuthCredentialResponse authCredentialResponse;
try {
authCredentialResponse = new AuthCredentialResponse(credential.getCredential());
} catch (InvalidInputException e) {
throw new IOException(e);
}
credentials.put(credential.getRedemptionTime(), authCredentialResponse);
}
return credentials;
}
}

View File

@ -1,146 +0,0 @@
package org.whispersystems.signalservice.api.groupsv2;
import org.signal.zkgroup.InvalidInputException;
import org.signal.zkgroup.VerificationFailedException;
import org.signal.zkgroup.auth.AuthCredential;
import org.signal.zkgroup.auth.AuthCredentialPresentation;
import org.signal.zkgroup.auth.AuthCredentialResponse;
import org.signal.zkgroup.auth.ClientZkAuthOperations;
import org.signal.zkgroup.groups.GroupSecretParams;
import org.whispersystems.libsignal.logging.Log;
import org.whispersystems.signalservice.internal.push.PushServiceSocket;
import org.whispersystems.signalservice.internal.util.Hex;
import java.io.IOException;
import java.security.SecureRandom;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import okhttp3.Credentials;
public final class GroupsV2Authorization {
private static final String TAG = GroupsV2Authorization.class.getSimpleName();
private final UUID self;
private final PushServiceSocket socket;
private final ClientZkAuthOperations authOperations;
private AuthorizationFactory currentFactory;
public GroupsV2Authorization(UUID self, PushServiceSocket socket, ClientZkAuthOperations authOperations) {
this.self = self;
this.socket = socket;
this.authOperations = authOperations;
}
String getAuthorizationForToday(GroupSecretParams groupSecretParams)
throws IOException, VerificationFailedException
{
final int today = AuthorizationFactory.currentTimeDays();
final AuthorizationFactory currentFactory = getCurrentFactory();
if (currentFactory != null) {
try {
return currentFactory.getAuthorization(groupSecretParams, today);
} catch (NoCredentialForRedemptionTimeException e) {
Log.i(TAG, "Auth out of date, will update auth and try again");
}
}
Log.i(TAG, "Getting new auth tokens");
setCurrentFactory(createFactory(socket.retrieveGroupsV2Credentials(today)));
try {
return getCurrentFactory().getAuthorization(groupSecretParams, today);
} catch (NoCredentialForRedemptionTimeException e) {
Log.w(TAG, "The credentials returned did not include the day requested");
throw new IOException("Failed to get credentials");
}
}
private AuthorizationFactory createFactory(CredentialResponse credentialResponse)
throws IOException, VerificationFailedException
{
HashMap<Integer, AuthCredentialResponse> credentials = new HashMap<>();
for (TemporalCredential credential : credentialResponse.getCredentials()) {
AuthCredentialResponse authCredentialResponse;
try {
authCredentialResponse = new AuthCredentialResponse(credential.getCredential());
} catch (InvalidInputException e) {
throw new IOException(e);
}
credentials.put(credential.getRedemptionTime(), authCredentialResponse);
}
return new AuthorizationFactory(self, authOperations, credentials);
}
private synchronized AuthorizationFactory getCurrentFactory() {
return currentFactory;
}
private synchronized void setCurrentFactory(AuthorizationFactory currentFactory) {
this.currentFactory = currentFactory;
}
private static class AuthorizationFactory {
private final SecureRandom random;
private final ClientZkAuthOperations clientZkAuthOperations;
private final Map<Integer, AuthCredential> credentials;
AuthorizationFactory(UUID self,
ClientZkAuthOperations clientZkAuthOperations,
Map<Integer, AuthCredentialResponse> credentialResponseMap)
throws VerificationFailedException
{
this.random = new SecureRandom();
this.clientZkAuthOperations = clientZkAuthOperations;
this.credentials = verifyCredentials(self, clientZkAuthOperations, credentialResponseMap);
}
static int currentTimeDays() {
return (int) TimeUnit.MILLISECONDS.toDays(System.currentTimeMillis());
}
String getAuthorization(GroupSecretParams groupSecretParams, int redemptionTime)
throws NoCredentialForRedemptionTimeException
{
AuthCredential authCredential = credentials.get(redemptionTime);
if (authCredential == null) {
throw new NoCredentialForRedemptionTimeException();
}
AuthCredentialPresentation authCredentialPresentation = clientZkAuthOperations.createAuthCredentialPresentation(random, groupSecretParams, authCredential);
String username = Hex.toStringCondensed(groupSecretParams.getPublicParams().serialize());
String password = Hex.toStringCondensed(authCredentialPresentation.serialize());
return Credentials.basic(username, password);
}
private static Map<Integer, AuthCredential> verifyCredentials(UUID self,
ClientZkAuthOperations clientZkAuthOperations,
Map<Integer, AuthCredentialResponse> credentialResponseMap)
throws VerificationFailedException
{
Map<Integer, AuthCredential> credentials = new HashMap<>(credentialResponseMap.size());
for (Map.Entry<Integer, AuthCredentialResponse> entry : credentialResponseMap.entrySet()) {
int redemptionTime = entry.getKey();
AuthCredentialResponse authCredentialResponse = entry.getValue();
AuthCredential authCredential = clientZkAuthOperations.receiveAuthCredential(self, redemptionTime, authCredentialResponse);
credentials.put(redemptionTime, authCredential);
}
return credentials;
}
}
}

View File

@ -0,0 +1,24 @@
package org.whispersystems.signalservice.api.groupsv2;
import org.signal.zkgroup.auth.AuthCredentialPresentation;
import org.signal.zkgroup.groups.GroupSecretParams;
import org.whispersystems.signalservice.internal.util.Hex;
import okhttp3.Credentials;
public final class GroupsV2AuthorizationString {
private final String authString;
GroupsV2AuthorizationString(GroupSecretParams groupSecretParams, AuthCredentialPresentation authCredentialPresentation) {
String username = Hex.toStringCondensed(groupSecretParams.getPublicParams().serialize());
String password = Hex.toStringCondensed(authCredentialPresentation.serialize());
authString = Credentials.basic(username, password);
}
@Override
public String toString() {
return authString;
}
}

View File

@ -170,6 +170,28 @@ public final class GroupsV2Operations {
return actions;
}
public GroupChange.Actions.Builder createModifyGroupMembershipChange(Set<GroupCandidate> membersToAdd, UUID selfUuid) {
final GroupOperations groupOperations = forGroup(groupSecretParams);
GroupChange.Actions.Builder actions = GroupChange.Actions.newBuilder();
for (GroupCandidate credential : membersToAdd) {
Member.Role newMemberRole = Member.Role.DEFAULT;
ProfileKeyCredential profileKeyCredential = credential.getProfileKeyCredential().orNull();
if (profileKeyCredential != null) {
actions.addAddMembers(GroupChange.Actions.AddMemberAction.newBuilder()
.setAdded(groupOperations.member(profileKeyCredential, newMemberRole)));
} else {
actions.addAddPendingMembers(GroupChange.Actions.AddPendingMemberAction.newBuilder()
.setAdded(groupOperations.invitee(credential.getUuid(), newMemberRole)
.setAddedByUserId(encryptUuid(selfUuid))));
}
}
return actions;
}
public GroupChange.Actions.Builder createModifyGroupTimerChange(int timerDurationSeconds) {
return GroupChange.Actions
.newBuilder()
@ -233,7 +255,11 @@ public final class GroupsV2Operations {
List<DecryptedPendingMember> decryptedPendingMembers = new ArrayList<>(pendingMembersList.size());
for (Member member : membersList) {
decryptedMembers.add(decryptMember(member));
try {
decryptedMembers.add(decryptMember(member).build());
} catch (InvalidInputException e) {
throw new InvalidGroupStateException(e);
}
}
for (PendingMember member : pendingMembersList) {
@ -289,12 +315,11 @@ public final class GroupsV2Operations {
// Field 3
for (GroupChange.Actions.AddMemberAction addMemberAction : actions.getAddMembersList()) {
UUID uuid = decryptUuid(addMemberAction.getAdded().getUserId());
builder.addNewMembers(DecryptedMember.newBuilder()
.setJoinedAtVersion(actions.getVersion())
.setRole(addMemberAction.getAdded().getRole())
.setUuid(UuidUtil.toByteString(uuid))
.setProfileKey(decryptProfileKeyToByteString(addMemberAction.getAdded().getProfileKey(), uuid)));
try {
builder.addNewMembers(decryptMember(addMemberAction.getAdded()).setJoinedAtVersion(actions.getVersion()));
} catch (InvalidInputException e) {
throw new InvalidGroupStateException(e);
}
}
// Field 4
@ -327,7 +352,7 @@ public final class GroupsV2Operations {
}
// Field 7
for (GroupChange.Actions.AddPendingMemberAction addPendingMemberAction:actions.getAddPendingMembersList()) {
for (GroupChange.Actions.AddPendingMemberAction addPendingMemberAction : actions.getAddPendingMembersList()) {
PendingMember added = addPendingMemberAction.getAdded();
Member member = added.getMember();
ByteString uuidCipherText = member.getUserId();
@ -397,18 +422,28 @@ public final class GroupsV2Operations {
return builder.build();
}
private DecryptedMember decryptMember(Member member)
throws InvalidGroupStateException, VerificationFailedException
private DecryptedMember.Builder decryptMember(Member member)
throws InvalidGroupStateException, VerificationFailedException, InvalidInputException
{
ByteString userId = member.getUserId();
UUID uuid = decryptUuid(userId);
if (member.getPresentation().isEmpty()) {
UUID uuid = decryptUuid(member.getUserId());
return DecryptedMember.newBuilder()
.setUuid(UuidUtil.toByteString(uuid))
.setJoinedAtVersion(member.getJoinedAtVersion())
.setProfileKey(decryptProfileKeyToByteString(member.getProfileKey(), uuid))
.setRole(member.getRole())
.build();
return DecryptedMember.newBuilder()
.setUuid(UuidUtil.toByteString(uuid))
.setJoinedAtVersion(member.getJoinedAtVersion())
.setProfileKey(decryptProfileKeyToByteString(member.getProfileKey(), uuid))
.setRole(member.getRole());
} else {
ProfileKeyCredentialPresentation profileKeyCredentialPresentation = new ProfileKeyCredentialPresentation(member.getPresentation().toByteArray());
UUID uuid = clientZkGroupCipher.decryptUuid(profileKeyCredentialPresentation.getUuidCiphertext());
ProfileKey profileKey = clientZkGroupCipher.decryptProfileKey(profileKeyCredentialPresentation.getProfileKeyCiphertext(), uuid);
return DecryptedMember.newBuilder()
.setUuid(UuidUtil.toByteString(uuid))
.setJoinedAtVersion(member.getJoinedAtVersion())
.setProfileKey(ByteString.copyFrom(profileKey.serialize()))
.setRole(member.getRole());
}
}
private DecryptedPendingMember decryptMember(PendingMember member)

View File

@ -34,6 +34,7 @@ import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.signalservice.FeatureFlags;
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess;
import org.whispersystems.signalservice.api.groupsv2.CredentialResponse;
import org.whispersystems.signalservice.api.groupsv2.GroupsV2AuthorizationString;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment.ProgressListener;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId;
import org.whispersystems.signalservice.api.messages.calls.TurnServerInfo;
@ -72,6 +73,7 @@ import org.whispersystems.signalservice.internal.contacts.entities.DiscoveryResp
import org.whispersystems.signalservice.internal.contacts.entities.KeyBackupRequest;
import org.whispersystems.signalservice.internal.contacts.entities.KeyBackupResponse;
import org.whispersystems.signalservice.internal.contacts.entities.TokenResponse;
import org.whispersystems.signalservice.internal.push.exceptions.GroupPatchNotAcceptedException;
import org.whispersystems.signalservice.internal.push.exceptions.MismatchedDevicesException;
import org.whispersystems.signalservice.internal.push.exceptions.NotInGroupException;
import org.whispersystems.signalservice.internal.push.exceptions.StaleDevicesException;
@ -1836,26 +1838,29 @@ public class PushServiceSocket {
}
private static final ResponseCodeHandler GROUPS_V2_PUT_RESPONSE_HANDLER = NO_HANDLER;
private static final ResponseCodeHandler GROUPS_V2_PATCH_RESPONSE_HANDLER = NO_HANDLER;
private static final ResponseCodeHandler GROUPS_V2_GET_LOGS_HANDLER = NO_HANDLER;
private static final ResponseCodeHandler GROUPS_V2_GET_CURRENT_HANDLER = responseCode -> {
if (responseCode == 403) throw new NotInGroupException();
};
private static final ResponseCodeHandler GROUPS_V2_PATCH_RESPONSE_HANDLER = responseCode -> {
if (responseCode == 400) throw new GroupPatchNotAcceptedException();
if (responseCode == 403) throw new NotInGroupException();
};
public void putNewGroupsV2Group(Group group, String authorization)
public void putNewGroupsV2Group(Group group, GroupsV2AuthorizationString authorization)
throws NonSuccessfulResponseCodeException, PushNetworkException
{
makeStorageRequest(authorization,
makeStorageRequest(authorization.toString(),
GROUPSV2_GROUP,
"PUT",
protobufRequestBody(group),
GROUPS_V2_PUT_RESPONSE_HANDLER);
}
public Group getGroupsV2Group(String authorization)
public Group getGroupsV2Group(GroupsV2AuthorizationString authorization)
throws NonSuccessfulResponseCodeException, PushNetworkException, InvalidProtocolBufferException
{
ResponseBody response = makeStorageRequest(authorization,
ResponseBody response = makeStorageRequest(authorization.toString(),
GROUPSV2_GROUP,
"GET",
null,
@ -1888,10 +1893,10 @@ public class PushServiceSocket {
return GroupChange.parseFrom(readBodyBytes(response));
}
public GroupChanges getGroupsV2GroupHistory(int fromVersion, String authorization)
public GroupChanges getGroupsV2GroupHistory(int fromVersion, GroupsV2AuthorizationString authorization)
throws NonSuccessfulResponseCodeException, PushNetworkException, InvalidProtocolBufferException
{
ResponseBody response = makeStorageRequest(authorization,
ResponseBody response = makeStorageRequest(authorization.toString(),
String.format(Locale.US, GROUPSV2_GROUP_CHANGES, fromVersion),
"GET",
null,

View File

@ -0,0 +1,6 @@
package org.whispersystems.signalservice.internal.push.exceptions;
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException;
public final class GroupPatchNotAcceptedException extends NonSuccessfulResponseCodeException {
}