Group Manager V2 operations.
parent
48a693793f
commit
86f0456e8c
|
@ -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));
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
package org.thoughtcrime.securesms.groups;
|
||||
|
||||
public final class MembershipNotSuitableForV2Exception extends Exception {
|
||||
public MembershipNotSuitableForV2Exception(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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) {
|
||||
|
|
|
@ -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()));
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -731,6 +731,10 @@ public class Recipient {
|
|||
return groupsV2Capability;
|
||||
}
|
||||
|
||||
public Capability getUuidCapability() {
|
||||
return uuidCapability;
|
||||
}
|
||||
|
||||
public @Nullable byte[] getProfileKey() {
|
||||
return profileKey;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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"/>
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 {
|
||||
}
|
Loading…
Reference in New Issue