mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-25 11:20:47 +01:00
GV2 Group Manager.
This commit is contained in:
@@ -1,15 +1,18 @@
|
||||
package org.thoughtcrime.securesms.groups;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
public final class BadGroupIdException extends Exception {
|
||||
BadGroupIdException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
BadGroupIdException() {
|
||||
super();
|
||||
}
|
||||
|
||||
BadGroupIdException(Exception e) {
|
||||
BadGroupIdException(@NonNull String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
BadGroupIdException(@NonNull Exception e) {
|
||||
super(e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package org.thoughtcrime.securesms.groups;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
public final class GroupChangeBusyException extends Exception {
|
||||
|
||||
public GroupChangeBusyException(@NonNull Throwable throwable) {
|
||||
super(throwable);
|
||||
}
|
||||
|
||||
public GroupChangeBusyException(@NonNull String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,17 @@
|
||||
package org.thoughtcrime.securesms.groups;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
public final class GroupChangeFailedException extends Exception {
|
||||
|
||||
GroupChangeFailedException(Throwable throwable) {
|
||||
GroupChangeFailedException() {
|
||||
}
|
||||
|
||||
GroupChangeFailedException(@NonNull Throwable throwable) {
|
||||
super(throwable);
|
||||
}
|
||||
|
||||
GroupChangeFailedException(@NonNull String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.BitmapUtil;
|
||||
import org.whispersystems.signalservice.api.groupsv2.InvalidGroupStateException;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedException;
|
||||
import org.whispersystems.signalservice.api.util.InvalidNumberException;
|
||||
|
||||
import java.io.IOException;
|
||||
@@ -34,7 +33,7 @@ public final class GroupManager {
|
||||
{
|
||||
Set<RecipientId> addresses = getMemberIds(members);
|
||||
|
||||
return V1GroupManager.createGroup(context, addresses, avatar, name, mms);
|
||||
return GroupManagerV1.createGroup(context, addresses, avatar, name, mms);
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
@@ -48,7 +47,7 @@ public final class GroupManager {
|
||||
List<Recipient> members = DatabaseFactory.getGroupDatabase(context)
|
||||
.getGroupMembers(groupId, GroupDatabase.MemberSet.FULL_MEMBERS_EXCLUDING_SELF);
|
||||
|
||||
return V1GroupManager.updateGroup(context, groupId, getMemberIds(members), avatar, name);
|
||||
return GroupManagerV1.updateGroup(context, groupId, getMemberIds(members), avatar, name);
|
||||
}
|
||||
|
||||
public static GroupActionResult updateGroup(@NonNull Context context,
|
||||
@@ -60,7 +59,7 @@ public final class GroupManager {
|
||||
{
|
||||
Set<RecipientId> addresses = getMemberIds(members);
|
||||
|
||||
return V1GroupManager.updateGroup(context, groupId, addresses, BitmapUtil.toByteArray(avatar), name);
|
||||
return GroupManagerV1.updateGroup(context, groupId, addresses, BitmapUtil.toByteArray(avatar), name);
|
||||
}
|
||||
|
||||
private static Set<RecipientId> getMemberIds(Collection<Recipient> recipients) {
|
||||
@@ -74,17 +73,29 @@ public final class GroupManager {
|
||||
|
||||
@WorkerThread
|
||||
public static boolean leaveGroup(@NonNull Context context, @NonNull GroupId.Push groupId) {
|
||||
return V1GroupManager.leaveGroup(context, groupId.requireV1());
|
||||
return GroupManagerV1.leaveGroup(context, groupId.requireV1());
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
public static void updateGroupFromServer(@NonNull Context context,
|
||||
@NonNull GroupId.V2 groupId,
|
||||
int version)
|
||||
throws GroupChangeBusyException, IOException, GroupNotAMemberException
|
||||
{
|
||||
try (GroupManagerV2.GroupEditor edit = new GroupManagerV2(context).edit(groupId)) {
|
||||
edit.updateLocalToServerVersion(version);
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
public static void updateGroupTimer(@NonNull Context context, @NonNull GroupId.Push groupId, int expirationTime)
|
||||
throws GroupChangeFailedException, GroupInsufficientRightsException
|
||||
throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException, GroupChangeBusyException
|
||||
{
|
||||
if (groupId.isV2()) {
|
||||
throw new GroupChangeFailedException(new AssertionError("NYI")); // TODO: GV2 allow timer change
|
||||
new GroupManagerV2(context).edit(groupId.requireV2())
|
||||
.updateGroupTimer(expirationTime);
|
||||
} else {
|
||||
V1GroupManager.updateGroupTimer(context, groupId.requireV1(), expirationTime);
|
||||
GroupManagerV1.updateGroupTimer(context, groupId.requireV1(), expirationTime);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,9 +45,9 @@ import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
final class V1GroupManager {
|
||||
final class GroupManagerV1 {
|
||||
|
||||
private static final String TAG = Log.tag(V1GroupManager.class);
|
||||
private static final String TAG = Log.tag(GroupManagerV1.class);
|
||||
|
||||
static @NonNull GroupActionResult createGroup(@NonNull Context context,
|
||||
@NonNull Set<RecipientId> memberIds,
|
||||
@@ -0,0 +1,200 @@
|
||||
package org.thoughtcrime.securesms.groups;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.WorkerThread;
|
||||
|
||||
import org.signal.storageservice.protos.groups.GroupChange;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroup;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroupChange;
|
||||
import org.signal.zkgroup.VerificationFailedException;
|
||||
import org.signal.zkgroup.groups.GroupMasterKey;
|
||||
import org.signal.zkgroup.groups.GroupSecretParams;
|
||||
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.processing.GroupsV2StateProcessor;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.mms.OutgoingGroupMediaMessage;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.sms.MessageSender;
|
||||
import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil;
|
||||
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.NotInGroupException;
|
||||
|
||||
import java.io.Closeable;
|
||||
import java.io.IOException;
|
||||
import java.util.Collections;
|
||||
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;
|
||||
|
||||
GroupManagerV2(@NonNull Context context) {
|
||||
this.context = context;
|
||||
this.groupDatabase = DatabaseFactory.getGroupDatabase(context);
|
||||
this.groupsV2Api = ApplicationDependencies.getSignalServiceAccountManager().getGroupsV2Api();
|
||||
this.groupsV2Operations = ApplicationDependencies.getGroupsV2Operations();
|
||||
this.authorization = ApplicationDependencies.getGroupsV2Authorization();
|
||||
this.groupsV2StateProcessor = ApplicationDependencies.getGroupsV2StateProcessor();
|
||||
this.selfUuid = Recipient.self().getUuid().get();
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
GroupEditor edit(@NonNull GroupId.V2 groupId) throws GroupChangeBusyException {
|
||||
return new GroupEditor(groupId, GroupsV2ProcessingLock.acquireGroupProcessingLock());
|
||||
}
|
||||
|
||||
class GroupEditor implements Closeable {
|
||||
|
||||
private final Closeable lock;
|
||||
private final GroupId.V2 groupId;
|
||||
private final GroupMasterKey groupMasterKey;
|
||||
private final GroupSecretParams groupSecretParams;
|
||||
private final GroupsV2Operations.GroupOperations groupOperations;
|
||||
|
||||
GroupEditor(@NonNull GroupId.V2 groupId, @NonNull Closeable lock) {
|
||||
GroupDatabase.GroupRecord groupRecord = groupDatabase.requireGroup(groupId);
|
||||
GroupDatabase.V2GroupProperties v2GroupProperties = groupRecord.requireV2GroupProperties();
|
||||
|
||||
this.lock = lock;
|
||||
this.groupId = groupId;
|
||||
this.groupMasterKey = v2GroupProperties.getGroupMasterKey();
|
||||
this.groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey);
|
||||
this.groupOperations = groupsV2Operations.forGroup(groupSecretParams);
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
@NonNull GroupManager.GroupActionResult updateGroupTimer(int expirationTime)
|
||||
throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException
|
||||
{
|
||||
return commitChangeWithConflictResolution(groupOperations.createModifyGroupTimerChange(expirationTime));
|
||||
}
|
||||
|
||||
void updateLocalToServerVersion(int version)
|
||||
throws IOException, GroupNotAMemberException
|
||||
{
|
||||
new GroupsV2StateProcessor(context).forGroup(groupMasterKey)
|
||||
.updateLocalGroupToRevision(version, System.currentTimeMillis());
|
||||
}
|
||||
|
||||
private GroupManager.GroupActionResult commitChangeWithConflictResolution(GroupChange.Actions.Builder change)
|
||||
throws GroupChangeFailedException, GroupNotAMemberException, GroupInsufficientRightsException, IOException
|
||||
{
|
||||
change.setSourceUuid(UuidUtil.toByteString(Recipient.self().getUuid().get()));
|
||||
|
||||
for (int attempt = 0; attempt < 5; attempt++) {
|
||||
try {
|
||||
return commitChange(change);
|
||||
} catch (ConflictException e) {
|
||||
Log.w(TAG, "Conflict on group");
|
||||
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 {
|
||||
change = GroupChangeUtil.resolveConflict(groupUpdateResult.getLatestServer(),
|
||||
groupOperations.decryptChange(change.build(), selfUuid),
|
||||
change.build());
|
||||
} catch (VerificationFailedException | InvalidGroupStateException ex) {
|
||||
throw new GroupChangeFailedException(ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new GroupChangeFailedException("Unable to apply change to group after conflicts");
|
||||
}
|
||||
|
||||
private GroupManager.GroupActionResult commitChange(GroupChange.Actions.Builder change)
|
||||
throws GroupNotAMemberException, GroupChangeFailedException, IOException, GroupInsufficientRightsException
|
||||
{
|
||||
final GroupDatabase.GroupRecord groupRecord = groupDatabase.requireGroup(groupId);
|
||||
final GroupDatabase.V2GroupProperties v2GroupProperties = groupRecord.requireV2GroupProperties();
|
||||
final int nextRevision = v2GroupProperties.getGroupRevision() + 1;
|
||||
final GroupChange.Actions changeActions = change.setVersion(nextRevision).build();
|
||||
final DecryptedGroupChange decryptedChange;
|
||||
final DecryptedGroup decryptedGroupState;
|
||||
|
||||
try {
|
||||
decryptedChange = groupOperations.decryptChange(changeActions, selfUuid);
|
||||
decryptedGroupState = DecryptedGroupUtil.apply(v2GroupProperties.getDecryptedGroup(), decryptedChange);
|
||||
} catch (VerificationFailedException | InvalidGroupStateException | DecryptedGroupUtil.NotAbleToApplyChangeException e) {
|
||||
Log.w(TAG, e);
|
||||
throw new IOException(e);
|
||||
}
|
||||
|
||||
commitToServer(changeActions);
|
||||
groupDatabase.update(groupId, decryptedGroupState);
|
||||
|
||||
return sendGroupUpdate(groupMasterKey, decryptedGroupState, decryptedChange);
|
||||
}
|
||||
|
||||
private void commitToServer(GroupChange.Actions change)
|
||||
throws GroupNotAMemberException, GroupChangeFailedException, IOException, GroupInsufficientRightsException
|
||||
{
|
||||
try {
|
||||
groupsV2Api.patchGroup(change, groupSecretParams, authorization);
|
||||
} catch (NotInGroupException e) {
|
||||
Log.w(TAG, e);
|
||||
throw new GroupNotAMemberException(e);
|
||||
} catch (AuthorizationFailedException e) {
|
||||
Log.w(TAG, e);
|
||||
throw new GroupInsufficientRightsException(e);
|
||||
} catch (VerificationFailedException | InvalidGroupStateException e) {
|
||||
Log.w(TAG, e);
|
||||
throw new GroupChangeFailedException(e);
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.thoughtcrime.securesms.groups;
|
||||
|
||||
public final class GroupNotAMemberException extends Exception {
|
||||
|
||||
public GroupNotAMemberException(Throwable throwable) {
|
||||
super(throwable);
|
||||
}
|
||||
|
||||
GroupNotAMemberException() {
|
||||
}
|
||||
}
|
||||
@@ -3,15 +3,24 @@ package org.thoughtcrime.securesms.groups;
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.WorkerThread;
|
||||
|
||||
import com.google.protobuf.ByteString;
|
||||
|
||||
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.storageservice.protos.groups.local.DecryptedPendingMember;
|
||||
import org.signal.zkgroup.groups.GroupMasterKey;
|
||||
import org.signal.zkgroup.util.UUIDUtil;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.whispersystems.signalservice.api.util.UuidUtil;
|
||||
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations;
|
||||
import org.whispersystems.signalservice.internal.push.SignalServiceProtos;
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public final class GroupProtoUtil {
|
||||
@@ -19,6 +28,45 @@ public final class GroupProtoUtil {
|
||||
private GroupProtoUtil() {
|
||||
}
|
||||
|
||||
public static int findVersionWeWereAdded(@NonNull DecryptedGroup group, @NonNull UUID uuid)
|
||||
throws GroupNotAMemberException
|
||||
{
|
||||
ByteString bytes = UuidUtil.toByteString(uuid);
|
||||
for (DecryptedMember decryptedMember : group.getMembersList()) {
|
||||
if (decryptedMember.getUuid().equals(bytes)) {
|
||||
return decryptedMember.getJoinedAtVersion();
|
||||
}
|
||||
}
|
||||
for (DecryptedPendingMember decryptedMember : group.getPendingMembersList()) {
|
||||
if (decryptedMember.getUuid().equals(bytes)) {
|
||||
// Assume latest, we don't have any information about when pending members were invited
|
||||
return group.getVersion();
|
||||
}
|
||||
}
|
||||
throw new GroupNotAMemberException();
|
||||
}
|
||||
|
||||
public static DecryptedGroupV2Context createDecryptedGroupV2Context(@NonNull GroupMasterKey masterKey,
|
||||
@NonNull DecryptedGroup decryptedGroup,
|
||||
@Nullable DecryptedGroupChange plainGroupChange)
|
||||
{
|
||||
int version = plainGroupChange != null ? plainGroupChange.getVersion() : decryptedGroup.getVersion();
|
||||
SignalServiceProtos.GroupContextV2 groupContext = SignalServiceProtos.GroupContextV2.newBuilder()
|
||||
.setMasterKey(ByteString.copyFrom(masterKey.serialize()))
|
||||
.setRevision(version)
|
||||
.build();
|
||||
|
||||
DecryptedGroupV2Context.Builder builder = DecryptedGroupV2Context.newBuilder()
|
||||
.setContext(groupContext)
|
||||
.setGroupState(decryptedGroup);
|
||||
|
||||
if (plainGroupChange != null) {
|
||||
builder.setChange(plainGroupChange);
|
||||
}
|
||||
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
public static Recipient pendingMemberToRecipient(@NonNull Context context, @NonNull DecryptedPendingMember pendingMember) {
|
||||
return uuidByteStringToRecipient(context, pendingMember.getUuid());
|
||||
@@ -34,4 +82,16 @@ public final class GroupProtoUtil {
|
||||
|
||||
return Recipient.externalPush(context, uuid, null);
|
||||
}
|
||||
|
||||
public static boolean isMember(@NonNull UUID uuid, @NonNull List<DecryptedMember> membersList) {
|
||||
ByteString uuidBytes = UuidUtil.toByteString(uuid);
|
||||
|
||||
for (DecryptedMember member : membersList) {
|
||||
if (uuidBytes.equals(member.getUuid())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package org.thoughtcrime.securesms.groups;
|
||||
|
||||
import androidx.annotation.WorkerThread;
|
||||
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
|
||||
import java.io.Closeable;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.locks.Lock;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
|
||||
final class GroupsV2ProcessingLock {
|
||||
|
||||
private static final String TAG = Log.tag(GroupsV2ProcessingLock.class);
|
||||
|
||||
private GroupsV2ProcessingLock() {
|
||||
}
|
||||
|
||||
private static final Lock lock = new ReentrantLock();
|
||||
|
||||
@WorkerThread
|
||||
static Closeable acquireGroupProcessingLock() throws GroupChangeBusyException {
|
||||
return acquireGroupProcessingLock(5000);
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
static Closeable acquireGroupProcessingLock(long timeoutMs) throws GroupChangeBusyException {
|
||||
Util.assertNotMainThread();
|
||||
|
||||
try {
|
||||
if (!lock.tryLock(timeoutMs, TimeUnit.MILLISECONDS)) {
|
||||
throw new GroupChangeBusyException("Failed to get a lock on the group processing in the timeout period");
|
||||
}
|
||||
return lock::unlock;
|
||||
} catch (InterruptedException e) {
|
||||
Log.w(TAG, e);
|
||||
throw new GroupChangeBusyException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,16 +11,19 @@ import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.ThreadDatabase;
|
||||
import org.thoughtcrime.securesms.groups.GroupAccessControl;
|
||||
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.logging.Log;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
|
||||
final class ManageGroupRepository {
|
||||
@@ -61,7 +64,10 @@ final class ManageGroupRepository {
|
||||
} catch (GroupInsufficientRightsException e) {
|
||||
Log.w(TAG, e);
|
||||
error.onError(FailureReason.NO_RIGHTS);
|
||||
} catch (GroupChangeFailedException e) {
|
||||
} catch (GroupNotAMemberException e) {
|
||||
Log.w(TAG, e);
|
||||
error.onError(FailureReason.NOT_A_MEMBER);
|
||||
} catch (GroupChangeFailedException | GroupChangeBusyException | IOException e) {
|
||||
Log.w(TAG, e);
|
||||
error.onError(FailureReason.OTHER);
|
||||
}
|
||||
@@ -132,6 +138,7 @@ final class ManageGroupRepository {
|
||||
|
||||
public enum FailureReason {
|
||||
NO_RIGHTS(R.string.ManageGroupActivity_you_dont_have_the_rights_to_do_this),
|
||||
NOT_A_MEMBER(R.string.ManageGroupActivity_youre_not_a_member_of_the_group),
|
||||
OTHER(R.string.ManageGroupActivity_failed_to_update_the_group);
|
||||
|
||||
private final @StringRes int toastMessage;
|
||||
|
||||
@@ -24,17 +24,17 @@ public final class GroupRightsDialog {
|
||||
rights = currentRights;
|
||||
|
||||
builder = new AlertDialog.Builder(context)
|
||||
.setTitle(type.message)
|
||||
.setSingleChoiceItems(type.choices, currentRights.ordinal(), (dialog, which) -> rights = GroupAccessControl.values()[which])
|
||||
.setNegativeButton(android.R.string.cancel, (dialog, which) -> {
|
||||
})
|
||||
.setPositiveButton(android.R.string.ok, (dialog, which) -> {
|
||||
GroupAccessControl newGroupAccessControl = rights;
|
||||
.setTitle(type.message)
|
||||
.setSingleChoiceItems(type.choices, currentRights.ordinal(), (dialog, which) -> rights = GroupAccessControl.values()[which])
|
||||
.setNegativeButton(android.R.string.cancel, (dialog, which) -> {
|
||||
})
|
||||
.setPositiveButton(android.R.string.ok, (dialog, which) -> {
|
||||
GroupAccessControl newGroupAccessControl = rights;
|
||||
|
||||
if (newGroupAccessControl != currentRights) {
|
||||
onChange.changed(currentRights, newGroupAccessControl);
|
||||
}
|
||||
});
|
||||
if (newGroupAccessControl != currentRights) {
|
||||
onChange.changed(currentRights, newGroupAccessControl);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void show() {
|
||||
@@ -46,6 +46,7 @@ public final class GroupRightsDialog {
|
||||
}
|
||||
|
||||
public enum Type {
|
||||
|
||||
MEMBERSHIP(R.string.GroupManagement_choose_who_can_add_or_invite_new_members,
|
||||
R.array.GroupManagement_edit_group_membership_choices),
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package org.thoughtcrime.securesms.groups.v2;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroup;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedMember;
|
||||
import org.signal.zkgroup.InvalidInputException;
|
||||
import org.signal.zkgroup.profiles.ProfileKey;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.whispersystems.signalservice.api.util.UuidUtil;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Collects profile keys from group states.
|
||||
* <p>
|
||||
* Separates out "authoritative" profile keys that came from a group update created by their owner.
|
||||
* <p>
|
||||
* Authoritative profile keys can be used to overwrite local profile keys.
|
||||
* Non-authoritative profile keys can be used to fill in missing knowledge.
|
||||
*/
|
||||
public final class ProfileKeySet {
|
||||
|
||||
private static final String TAG = Log.tag(ProfileKeySet.class);
|
||||
|
||||
private final Map<UUID, ProfileKey> profileKeys = new LinkedHashMap<>();
|
||||
private final Map<UUID, ProfileKey> authoritativeProfileKeys = new LinkedHashMap<>();
|
||||
|
||||
/**
|
||||
* Add new profile keys from the group state.
|
||||
*/
|
||||
public void addKeysFromGroupState(@NonNull DecryptedGroup group,
|
||||
@Nullable UUID changeSource)
|
||||
{
|
||||
for (DecryptedMember member : group.getMembersList()) {
|
||||
UUID memberUuid = UuidUtil.fromByteString(member.getUuid());
|
||||
ProfileKey profileKey;
|
||||
try {
|
||||
profileKey = new ProfileKey(member.getProfileKey().toByteArray());
|
||||
} catch (InvalidInputException e) {
|
||||
Log.w(TAG, "Bad profile key in group");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (changeSource != null) {
|
||||
Log.d(TAG, String.format("Change %s by %s", memberUuid, changeSource));
|
||||
|
||||
if (changeSource.equals(memberUuid)) {
|
||||
authoritativeProfileKeys.put(memberUuid, profileKey);
|
||||
profileKeys.remove(memberUuid);
|
||||
} else {
|
||||
if (!authoritativeProfileKeys.containsKey(memberUuid)) {
|
||||
profileKeys.put(memberUuid, profileKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Map<UUID, ProfileKey> getProfileKeys() {
|
||||
return profileKeys;
|
||||
}
|
||||
|
||||
public Map<UUID, ProfileKey> getAuthoritativeProfileKeys() {
|
||||
return authoritativeProfileKeys;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package org.thoughtcrime.securesms.groups.v2.processing;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.WorkerThread;
|
||||
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroup;
|
||||
import org.signal.zkgroup.VerificationFailedException;
|
||||
import org.signal.zkgroup.groups.GroupMasterKey;
|
||||
import org.signal.zkgroup.groups.GroupSecretParams;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.GroupDatabase;
|
||||
import org.thoughtcrime.securesms.database.MmsDatabase;
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase;
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context;
|
||||
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.v2.ProfileKeySet;
|
||||
import org.thoughtcrime.securesms.jobmanager.JobManager;
|
||||
import org.thoughtcrime.securesms.jobs.AvatarGroupsV2DownloadJob;
|
||||
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.mms.MmsException;
|
||||
import org.thoughtcrime.securesms.mms.OutgoingGroupMediaMessage;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
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;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Advances a groups state to a specified revision.
|
||||
*/
|
||||
public final class GroupsV2StateProcessor {
|
||||
|
||||
private static final String TAG = Log.tag(GroupsV2StateProcessor.class);
|
||||
|
||||
public static final int LATEST = GroupStateMapper.LATEST;
|
||||
|
||||
private final Context context;
|
||||
private final JobManager jobManager;
|
||||
private final RecipientDatabase recipientDatabase;
|
||||
private final GroupDatabase groupDatabase;
|
||||
private final GroupsV2Authorization groupsV2Authorization;
|
||||
private final GroupsV2Api groupsV2Api;
|
||||
|
||||
public GroupsV2StateProcessor(@NonNull Context context) {
|
||||
this.context = context.getApplicationContext();
|
||||
this.jobManager = ApplicationDependencies.getJobManager();
|
||||
this.groupsV2Authorization = ApplicationDependencies.getGroupsV2Authorization();
|
||||
this.groupsV2Api = ApplicationDependencies.getSignalServiceAccountManager().getGroupsV2Api();
|
||||
this.recipientDatabase = DatabaseFactory.getRecipientDatabase(context);
|
||||
this.groupDatabase = DatabaseFactory.getGroupDatabase(context);
|
||||
}
|
||||
|
||||
public StateProcessorForGroup forGroup(@NonNull GroupMasterKey groupMasterKey) {
|
||||
return new StateProcessorForGroup(groupMasterKey);
|
||||
}
|
||||
|
||||
public enum GroupState {
|
||||
/**
|
||||
* The message revision was inconsistent with server revision, should ignore
|
||||
*/
|
||||
INCONSISTENT,
|
||||
|
||||
/**
|
||||
* The local group was successfully updated to be consistent with the message revision
|
||||
*/
|
||||
GROUP_UPDATED,
|
||||
|
||||
/**
|
||||
* The local group is already consistent with the message revision or is ahead of the message revision
|
||||
*/
|
||||
GROUP_CONSISTENT_OR_AHEAD
|
||||
}
|
||||
|
||||
public static class GroupUpdateResult {
|
||||
private final GroupState groupState;
|
||||
@Nullable private DecryptedGroup latestServer;
|
||||
|
||||
GroupUpdateResult(@NonNull GroupState groupState, @Nullable DecryptedGroup latestServer) {
|
||||
this.groupState = groupState;
|
||||
this.latestServer = latestServer;
|
||||
}
|
||||
|
||||
public GroupState getGroupState() {
|
||||
return groupState;
|
||||
}
|
||||
|
||||
public @Nullable DecryptedGroup getLatestServer() {
|
||||
return latestServer;
|
||||
}
|
||||
}
|
||||
|
||||
public final class StateProcessorForGroup {
|
||||
private final GroupMasterKey masterKey;
|
||||
private final GroupId.V2 groupId;
|
||||
private final GroupSecretParams groupSecretParams;
|
||||
|
||||
private StateProcessorForGroup(@NonNull GroupMasterKey groupMasterKey) {
|
||||
this.masterKey = groupMasterKey;
|
||||
this.groupId = GroupId.v2(masterKey);
|
||||
this.groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Using network where required, will attempt to bring the local copy of the group up to the revision specified.
|
||||
*
|
||||
* @param revision use {@link #LATEST} to get latest.
|
||||
*/
|
||||
@WorkerThread
|
||||
public GroupUpdateResult updateLocalGroupToRevision(final int revision,
|
||||
final long timestamp)
|
||||
throws IOException, GroupNotAMemberException
|
||||
{
|
||||
if (localIsAtLeast(revision)) {
|
||||
return new GroupUpdateResult(GroupState.GROUP_CONSISTENT_OR_AHEAD, null);
|
||||
}
|
||||
|
||||
GlobalGroupState inputGroupState = queryServer();
|
||||
AdvanceGroupStateResult advanceGroupStateResult = GroupStateMapper.partiallyAdvanceGroupState(inputGroupState, revision);
|
||||
DecryptedGroup newLocalState = advanceGroupStateResult.getNewGlobalGroupState().getLocalState();
|
||||
|
||||
if (newLocalState == null || newLocalState == inputGroupState.getLocalState()) {
|
||||
return new GroupUpdateResult(GroupState.GROUP_CONSISTENT_OR_AHEAD, null);
|
||||
}
|
||||
|
||||
updateLocalDatabaseGroupState(inputGroupState, newLocalState);
|
||||
insertUpdateMessages(timestamp, advanceGroupStateResult.getProcessedLogEntries());
|
||||
persistLearnedProfileKeys(inputGroupState);
|
||||
|
||||
GlobalGroupState remainingWork = advanceGroupStateResult.getNewGlobalGroupState();
|
||||
if (remainingWork.getHistory().size() > 0) {
|
||||
Log.i(TAG, String.format(Locale.US, "There are more versions on the server for this group, not applying at this time, V[%d..%d]", newLocalState.getVersion() + 1, remainingWork.getLatestVersionNumber()));
|
||||
}
|
||||
|
||||
return new GroupUpdateResult(GroupState.GROUP_UPDATED, newLocalState);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true iff group exists locally and is at least the specified revision.
|
||||
*/
|
||||
private boolean localIsAtLeast(int revision) {
|
||||
if (groupDatabase.isUnknownGroup(groupId) || revision == LATEST) {
|
||||
return false;
|
||||
}
|
||||
int dbRevision = groupDatabase.getGroup(groupId).get().requireV2GroupProperties().getGroupRevision();
|
||||
return revision <= dbRevision;
|
||||
}
|
||||
|
||||
private void updateLocalDatabaseGroupState(@NonNull GlobalGroupState inputGroupState,
|
||||
@NonNull DecryptedGroup newLocalState)
|
||||
{
|
||||
if (inputGroupState.getLocalState() == null) {
|
||||
groupDatabase.create(masterKey, newLocalState);
|
||||
} else {
|
||||
groupDatabase.update(masterKey, newLocalState);
|
||||
}
|
||||
|
||||
String avatar = newLocalState.getAvatar();
|
||||
if (!avatar.isEmpty()) {
|
||||
jobManager.add(new AvatarGroupsV2DownloadJob(groupId, avatar));
|
||||
}
|
||||
|
||||
final boolean fullMemberPostUpdate = GroupProtoUtil.isMember(Recipient.self().getUuid().get(), newLocalState.getMembersList());
|
||||
if (fullMemberPostUpdate) {
|
||||
recipientDatabase.setProfileSharing(Recipient.externalGroup(context, groupId).getId(), true);
|
||||
}
|
||||
}
|
||||
|
||||
private void insertUpdateMessages(long timestamp, Collection<GroupLogEntry> processedLogEntries) {
|
||||
for (GroupLogEntry entry : processedLogEntries) {
|
||||
storeMessage(GroupProtoUtil.createDecryptedGroupV2Context(masterKey, entry.getGroup(), entry.getChange()), timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
private void persistLearnedProfileKeys(@NonNull GlobalGroupState globalGroupState) {
|
||||
final ProfileKeySet profileKeys = new ProfileKeySet();
|
||||
|
||||
for (GroupLogEntry entry : globalGroupState.getHistory()) {
|
||||
profileKeys.addKeysFromGroupState(entry.getGroup(), DecryptedGroupUtil.editorUuid(entry.getChange()));
|
||||
}
|
||||
|
||||
Collection<RecipientId> updated = recipientDatabase.persistProfileKeySet(profileKeys);
|
||||
|
||||
if (!updated.isEmpty()) {
|
||||
Log.i(TAG, String.format(Locale.US, "Learned %d new profile keys, scheduling profile retrievals", updated.size()));
|
||||
for (RecipientId recipient : updated) {
|
||||
ApplicationDependencies.getJobManager().add(RetrieveProfileJob.forRecipient(recipient));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private GlobalGroupState queryServer()
|
||||
throws IOException, GroupNotAMemberException
|
||||
{
|
||||
DecryptedGroup latestServerGroup;
|
||||
List<GroupLogEntry> history;
|
||||
UUID selfUuid = Recipient.self().getUuid().get();
|
||||
DecryptedGroup localState = groupDatabase.getGroup(groupId)
|
||||
.transform(g -> g.requireV2GroupProperties().getDecryptedGroup())
|
||||
.orNull();
|
||||
|
||||
try {
|
||||
latestServerGroup = groupsV2Api.getGroup(groupSecretParams, groupsV2Authorization);
|
||||
} catch (NotInGroupException e) {
|
||||
throw new GroupNotAMemberException(e);
|
||||
} catch (VerificationFailedException | InvalidGroupStateException e) {
|
||||
throw new IOException(e);
|
||||
}
|
||||
|
||||
int versionWeWereAdded = GroupProtoUtil.findVersionWeWereAdded(latestServerGroup, selfUuid);
|
||||
int logsNeededFrom = localState != null ? Math.max(localState.getVersion(), versionWeWereAdded) : versionWeWereAdded;
|
||||
|
||||
if (GroupProtoUtil.isMember(selfUuid, latestServerGroup.getMembersList())) {
|
||||
history = getFullMemberHistory(selfUuid, logsNeededFrom);
|
||||
} else {
|
||||
history = Collections.singletonList(new GroupLogEntry(latestServerGroup, null));
|
||||
}
|
||||
|
||||
return new GlobalGroupState(localState, history);
|
||||
}
|
||||
|
||||
private List<GroupLogEntry> getFullMemberHistory(@NonNull UUID selfUuid, int logsNeededFrom) throws IOException {
|
||||
try {
|
||||
Collection<DecryptedGroupHistoryEntry> groupStatesFromRevision = groupsV2Api.getGroupHistory(groupSecretParams, logsNeededFrom, groupsV2Authorization);
|
||||
ArrayList<GroupLogEntry> history = new ArrayList<>(groupStatesFromRevision.size());
|
||||
|
||||
for (DecryptedGroupHistoryEntry entry : groupStatesFromRevision) {
|
||||
history.add(new GroupLogEntry(entry.getGroup(), entry.getChange()));
|
||||
}
|
||||
|
||||
return history;
|
||||
} catch (InvalidGroupStateException | VerificationFailedException e) {
|
||||
throw new IOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private void storeMessage(@NonNull DecryptedGroupV2Context decryptedGroupV2Context, long timestamp) {
|
||||
try {
|
||||
MmsDatabase mmsDatabase = DatabaseFactory.getMmsDatabase(context);
|
||||
RecipientId recipientId = recipientDatabase.getOrInsertFromGroupId(groupId);
|
||||
Recipient recipient = Recipient.resolved(recipientId);
|
||||
OutgoingGroupMediaMessage outgoingMessage = new OutgoingGroupMediaMessage(recipient, decryptedGroupV2Context, null, timestamp, 0, false, null, Collections.emptyList(), Collections.emptyList());
|
||||
long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(recipient);
|
||||
long messageId = mmsDatabase.insertMessageOutbox(outgoingMessage, threadId, false, null);
|
||||
|
||||
mmsDatabase.markAsSent(messageId, true);
|
||||
} catch (MmsException e) {
|
||||
Log.w(TAG, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user