diff --git a/app/src/main/java/org/thoughtcrime/securesms/crypto/ProfileKeyUtil.java b/app/src/main/java/org/thoughtcrime/securesms/crypto/ProfileKeyUtil.java index 66d1415354..a4fb746308 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/crypto/ProfileKeyUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/crypto/ProfileKeyUtil.java @@ -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 profileKeyCredentialOptional(@Nullable byte[] profileKey) { + return Optional.fromNullable(profileKeyCredentialOrNull(profileKey)); + } + public static @NonNull ProfileKey createNew() { try { return new ProfileKey(Util.getSecretBytes(32)); diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java index aeb8d84c45..29a3dcda1e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java @@ -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; diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java index b0f7d678d8..a3a330a659 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java @@ -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 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 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 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 members = groupRecord.getMembers(); + byte[] avatar = Util.readFully(AvatarHelper.getAvatar(context, groupRecord.getRecipientId())); + Set addresses = new HashSet<>(members); + + addresses.addAll(newMembers); + GroupManagerV1.updateGroup(context, groupId, addresses, avatar, groupRecord.getTitle()); + } } public static class GroupActionResult { diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV1.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV1.java index 7b6eafdc11..8a789c8bf1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV1.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV1.java @@ -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 memberAddresses, @Nullable byte[] avatarBytes, @Nullable String name) - throws InvalidNumberException { final GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context); final RecipientId groupRecipientId = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(groupId); diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java index a73ff660c3..029208bbbe 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java @@ -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 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 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 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 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 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 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 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(); + } + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupsV2Authorization.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupsV2Authorization.java new file mode 100644 index 0000000000..633f04b41b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupsV2Authorization.java @@ -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 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 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 read(); + + void write(@NonNull Map values); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupsV2AuthorizationMemoryValueCache.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupsV2AuthorizationMemoryValueCache.java new file mode 100644 index 0000000000..5d5ebf1a2d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupsV2AuthorizationMemoryValueCache.java @@ -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 values; + + public GroupsV2AuthorizationMemoryValueCache(@NonNull GroupsV2Authorization.ValueCache inner) { + this.inner = inner; + } + + @Override + public synchronized void clear() { + inner.clear(); + values = null; + } + + @Override + public @NonNull synchronized Map read() { + Map map = values; + + if (map == null) { + map = inner.read(); + values = map; + } + + return map; + } + + @Override + public synchronized void write(@NonNull Map values) { + inner.write(values); + this.values = Collections.unmodifiableMap(new HashMap<>(values)); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupsV2CapabilityChecker.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupsV2CapabilityChecker.java new file mode 100644 index 0000000000..26d0302d5a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupsV2CapabilityChecker.java @@ -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 recipientIds) + throws IOException + { + HashSet recipientIdsSet = new HashSet<>(recipientIds); + + recipientIdsSet.add(Recipient.self().getId()); + + return allSupportGroupsV2AndUuid(recipientIdsSet); + } + + @WorkerThread + boolean allSupportGroupsV2AndUuid(@NonNull Collection recipientIds) + throws IOException + { + final HashSet 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; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/LiveGroup.java b/app/src/main/java/org/thoughtcrime/securesms/groups/LiveGroup.java index 29af8a7286..e5e72f75b6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/LiveGroup.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/LiveGroup.java @@ -91,31 +91,41 @@ public final class LiveGroup { } public LiveData 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 selfCanAddMembers() { + return LiveDataUtil.combineLatest(isSelfAdmin(), getMembershipAdditionAccessControl(), this::applyAccessControl); + } + + /** + * A string representing the count of full members and pending members if > 0. + */ public LiveData 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 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(); + } + } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/MembershipNotSuitableForV2Exception.java b/app/src/main/java/org/thoughtcrime/securesms/groups/MembershipNotSuitableForV2Exception.java new file mode 100644 index 0000000000..3f5c727a85 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/MembershipNotSuitableForV2Exception.java @@ -0,0 +1,7 @@ +package org.thoughtcrime.securesms.groups; + +public final class MembershipNotSuitableForV2Exception extends Exception { + public MembershipNotSuitableForV2Exception(String message) { + super(message); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupFragment.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupFragment.java index 6ffa6be901..6cae0a0dc2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupFragment.java @@ -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 selected = data.getParcelableArrayListExtra(PushContactSelectionActivity.KEY_SELECTED_RECIPIENTS); + viewModel.onAddMembers(selected); } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupRepository.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupRepository.java index 861f98f000..10394c00e3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupRepository.java @@ -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 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 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); diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupViewModel.java index 74c4243e13..7bb87744d0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupViewModel.java @@ -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 title; private final LiveData isAdmin; private final LiveData canEditGroupAttributes; + private final LiveData canAddMembers; private final LiveData> members; private final LiveData pendingMemberCount; private final LiveData disappearingMessageTimer; private final LiveData memberCountSummary; + private final LiveData fullMemberCountSummary; private final LiveData editMembershipRights; private final LiveData editGroupAttributesRights; + private final LiveData groupRecipient; private final MutableLiveData groupViewState = new MutableLiveData<>(null); private final LiveData muteState; private final LiveData 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 getFullMemberCountSummary() { + return fullMemberCountSummary; + } + + public LiveData getGroupRecipient() { + return groupRecipient; + } + LiveData getGroupViewState() { return groupViewState; } @@ -116,6 +136,10 @@ public class ManageGroupViewModel extends ViewModel { return canEditGroupAttributes; } + LiveData getCanAddMembers() { + return canAddMembers; + } + LiveData getDisappearingMessageTimer() { return disappearingMessageTimer; } @@ -144,6 +168,10 @@ public class ManageGroupViewModel extends ViewModel { () -> RecipientUtil.block(context, recipient))); } + void onAddMembers(List 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 { diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/pendingmemberinvites/PendingMemberRepository.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/pendingmemberinvites/PendingMemberRepository.java index 458a7d2c7d..899ea4b2f7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/pendingmemberinvites/PendingMemberRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/pendingmemberinvites/PendingMemberRepository.java @@ -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; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/GroupCandidateHelper.java b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/GroupCandidateHelper.java new file mode 100644 index 0000000000..e9ac0213fa --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/GroupCandidateHelper.java @@ -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. + *

+ * 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 = ProfileKeyUtil.profileKeyCredentialOptional(recipient.getProfileKeyCredential()); + GroupCandidate candidate = new GroupCandidate(uuid, profileKeyCredential); + + if (!candidate.hasProfileKeyCredential()) { + ProfileKey profileKey = ProfileKeyUtil.profileKeyOrNull(recipient.getProfileKey()); + + if (profileKey != null) { + try { + Optional 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 recipientIdsToCandidates(@NonNull Collection recipientIds) + throws IOException + { + Set result = new HashSet<>(recipientIds.size()); + + for (RecipientId recipientId : recipientIds) { + result.add(recipientIdToCandidate(recipientId)); + } + + return result; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.java index c9ecb16388..bedd12891e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.java @@ -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 getFullMemberHistory(@NonNull UUID selfUuid, int logsNeededFrom) throws IOException { try { - Collection groupStatesFromRevision = groupsV2Api.getGroupHistory(groupSecretParams, logsNeededFrom, groupsV2Authorization); + Collection groupStatesFromRevision = groupsV2Api.getGroupHistory(groupSecretParams, logsNeededFrom, groupsV2Authorization.getAuthorizationForToday(selfUuid, groupSecretParams)); ArrayList history = new ArrayList<>(groupStatesFromRevision.size()); for (DecryptedGroupHistoryEntry entry : groupStatesFromRevision) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/GroupsV2AuthorizationSignalStoreCache.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/GroupsV2AuthorizationSignalStoreCache.java new file mode 100644 index 0000000000..6c9238a212 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/GroupsV2AuthorizationSignalStoreCache.java @@ -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 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 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 values) { + TemporalAuthCredentialResponses.Builder builder = TemporalAuthCredentialResponses.newBuilder(); + + for (Map.Entry 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())); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java index 2115829231..45ee0de8f9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java @@ -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); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java index 1d4c697d84..5bd212bca1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java @@ -731,6 +731,10 @@ public class Recipient { return groupsV2Capability; } + public Capability getUuidCapability() { + return uuidCapability; + } + public @Nullable byte[] getProfileKey() { return profileKey; } diff --git a/app/src/main/proto/Database.proto b/app/src/main/proto/Database.proto index fcac83d00e..1831b702ba 100644 --- a/app/src/main/proto/Database.proto +++ b/app/src/main/proto/Database.proto @@ -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; +} diff --git a/app/src/main/res/drawable/ic_add_members_circle_dark.xml b/app/src/main/res/drawable/ic_add_members_circle_dark.xml new file mode 100644 index 0000000000..3721f6ab4f --- /dev/null +++ b/app/src/main/res/drawable/ic_add_members_circle_dark.xml @@ -0,0 +1,14 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_add_members_circle_light.xml b/app/src/main/res/drawable/ic_add_members_circle_light.xml new file mode 100644 index 0000000000..95d9af5f76 --- /dev/null +++ b/app/src/main/res/drawable/ic_add_members_circle_light.xml @@ -0,0 +1,14 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_plus_24_ultramarine.xml b/app/src/main/res/drawable/ic_plus_24_ultramarine.xml new file mode 100644 index 0000000000..b3a3e6c356 --- /dev/null +++ b/app/src/main/res/drawable/ic_plus_24_ultramarine.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/group_manage_fragment.xml b/app/src/main/res/layout/group_manage_fragment.xml index f200a0326a..401715c424 100644 --- a/app/src/main/res/layout/group_manage_fragment.xml +++ b/app/src/main/res/layout/group_manage_fragment.xml @@ -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" /> @@ -268,12 +268,40 @@ app:cardBackgroundColor="?android:attr/windowBackground" app:layout_constraintTop_toBottomOf="@id/group_access_control_card"> - + android:layout_height="wrap_content" + android:orientation="vertical"> + + + +