GV2 Group Manager.

This commit is contained in:
Alan Evans
2020-05-01 19:13:23 -03:00
committed by Alex Hart
parent ff28d72db6
commit 48a693793f
42 changed files with 1877 additions and 288 deletions

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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,

View File

@@ -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();
}
}
}

View File

@@ -0,0 +1,11 @@
package org.thoughtcrime.securesms.groups;
public final class GroupNotAMemberException extends Exception {
public GroupNotAMemberException(Throwable throwable) {
super(throwable);
}
GroupNotAMemberException() {
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}
}

View File

@@ -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;

View File

@@ -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),

View File

@@ -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;
}
}

View File

@@ -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);
}
}
}
}