diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java index fcd9a84727..8ed48223aa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java @@ -58,7 +58,7 @@ import java.util.Set; import java.util.UUID; import java.util.stream.Collectors; -public final class GroupDatabase extends Database { +public class GroupDatabase extends Database { private static final String TAG = Log.tag(GroupDatabase.class); diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupsV2Authorization.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupsV2Authorization.java index c9196b017b..8697e211ee 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupsV2Authorization.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupsV2Authorization.java @@ -16,7 +16,7 @@ import java.util.Map; import java.util.UUID; import java.util.concurrent.TimeUnit; -public final class GroupsV2Authorization { +public class GroupsV2Authorization { private static final String TAG = Log.tag(GroupsV2Authorization.class); diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.java index 764f0fb2a5..d198824817 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.java @@ -5,6 +5,7 @@ import android.text.TextUtils; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; import androidx.annotation.WorkerThread; import com.annimon.stream.Stream; @@ -33,7 +34,6 @@ import org.thoughtcrime.securesms.groups.GroupProtoUtil; import org.thoughtcrime.securesms.groups.GroupsV2Authorization; import org.thoughtcrime.securesms.groups.v2.ProfileKeySet; import org.thoughtcrime.securesms.jobmanager.Job; -import org.thoughtcrime.securesms.jobmanager.JobManager; import org.thoughtcrime.securesms.jobs.AvatarGroupsV2DownloadJob; import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob; import org.thoughtcrime.securesms.jobs.RetrieveProfileJob; @@ -44,7 +44,6 @@ import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.sms.IncomingGroupUpdateMessage; import org.thoughtcrime.securesms.sms.IncomingTextMessage; -import org.thoughtcrime.securesms.util.FeatureFlags; import org.whispersystems.libsignal.util.guava.Optional; import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupHistoryEntry; import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil; @@ -61,7 +60,6 @@ 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.Objects; import java.util.Set; @@ -90,7 +88,6 @@ public final class GroupsV2StateProcessor { public static final int RESTORE_PLACEHOLDER_REVISION = GroupStateMapper.RESTORE_PLACEHOLDER_REVISION; private final Context context; - private final JobManager jobManager; private final RecipientDatabase recipientDatabase; private final GroupDatabase groupDatabase; private final GroupsV2Authorization groupsV2Authorization; @@ -98,7 +95,6 @@ public final class GroupsV2StateProcessor { public GroupsV2StateProcessor(@NonNull Context context) { this.context = context.getApplicationContext(); - this.jobManager = ApplicationDependencies.getJobManager(); this.groupsV2Authorization = ApplicationDependencies.getGroupsV2Authorization(); this.groupsV2Api = ApplicationDependencies.getSignalServiceAccountManager().getGroupsV2Api(); this.recipientDatabase = SignalDatabase.recipients(); @@ -106,7 +102,10 @@ public final class GroupsV2StateProcessor { } public StateProcessorForGroup forGroup(@NonNull GroupMasterKey groupMasterKey) { - return new StateProcessorForGroup(groupMasterKey); + ACI selfAci = Recipient.self().requireAci(); + ProfileAndMessageHelper profileAndMessageHelper = new ProfileAndMessageHelper(context, selfAci, groupMasterKey, GroupId.v2(groupMasterKey), recipientDatabase); + + return new StateProcessorForGroup(selfAci, context, groupDatabase, groupsV2Api, groupsV2Authorization, groupMasterKey, profileAndMessageHelper); } public enum GroupState { @@ -127,8 +126,8 @@ public final class GroupsV2StateProcessor { } public static class GroupUpdateResult { - private final GroupState groupState; - @Nullable private final DecryptedGroup latestServer; + private final GroupState groupState; + private final DecryptedGroup latestServer; GroupUpdateResult(@NonNull GroupState groupState, @Nullable DecryptedGroup latestServer) { this.groupState = groupState; @@ -144,15 +143,34 @@ public final class GroupsV2StateProcessor { } } - public final class StateProcessorForGroup { - private final GroupMasterKey masterKey; - private final GroupId.V2 groupId; - private final GroupSecretParams groupSecretParams; + public static final class StateProcessorForGroup { + private final ACI selfAci; + private final Context context; + private final GroupDatabase groupDatabase; + private final GroupsV2Api groupsV2Api; + private final GroupsV2Authorization groupsV2Authorization; + private final GroupMasterKey masterKey; + private final GroupId.V2 groupId; + private final GroupSecretParams groupSecretParams; + private final ProfileAndMessageHelper profileAndMessageHelper; - private StateProcessorForGroup(@NonNull GroupMasterKey groupMasterKey) { - this.masterKey = groupMasterKey; - this.groupId = GroupId.v2(masterKey); - this.groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey); + @VisibleForTesting StateProcessorForGroup(@NonNull ACI selfAci, + @NonNull Context context, + @NonNull GroupDatabase groupDatabase, + @NonNull GroupsV2Api groupsV2Api, + @NonNull GroupsV2Authorization groupsV2Authorization, + @NonNull GroupMasterKey groupMasterKey, + @NonNull ProfileAndMessageHelper profileAndMessageHelper) + { + this.selfAci = selfAci; + this.context = context; + this.groupDatabase = groupDatabase; + this.groupsV2Api = groupsV2Api; + this.groupsV2Authorization = groupsV2Authorization; + this.masterKey = groupMasterKey; + this.groupId = GroupId.v2(masterKey); + this.groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey); + this.profileAndMessageHelper = profileAndMessageHelper; } /** @@ -175,8 +193,8 @@ public final class GroupsV2StateProcessor { Optional localRecord = groupDatabase.getGroup(groupId); DecryptedGroup localState = localRecord.transform(g -> g.requireV2GroupProperties().getDecryptedGroup()).orNull(); - if (signedGroupChange != null && - localState != null && + if (signedGroupChange != null && + localState != null && localState.getRevision() + 1 == signedGroupChange.getRevision() && revision == signedGroupChange.getRevision()) { @@ -217,7 +235,7 @@ public final class GroupsV2StateProcessor { } if (inputGroupState == null) { - if (localState != null && DecryptedGroupUtil.isPendingOrRequesting(localState, Recipient.self().requireAci().uuid())) { + if (localState != null && DecryptedGroupUtil.isPendingOrRequesting(localState, selfAci.uuid())) { Log.w(TAG, "Unable to query server for group " + groupId + " server says we're not in group, but we think we are a pending or requesting member"); } else { Log.w(TAG, "Unable to query server for group " + groupId + " server says we're not in group, inserting leave message"); @@ -226,8 +244,6 @@ public final class GroupsV2StateProcessor { throw e; } } - } else { - Log.i(TAG, "Saved server query for group change"); } AdvanceGroupStateResult advanceGroupStateResult = GroupStateMapper.partiallyAdvanceGroupState(inputGroupState, revision); @@ -240,11 +256,11 @@ public final class GroupsV2StateProcessor { updateLocalDatabaseGroupState(inputGroupState, newLocalState); if (localState != null && localState.getRevision() == GroupsV2StateProcessor.RESTORE_PLACEHOLDER_REVISION) { Log.i(TAG, "Inserting single update message for restore placeholder"); - insertUpdateMessages(timestamp, null, Collections.singleton(new LocalGroupLogEntry(newLocalState, null))); + profileAndMessageHelper.insertUpdateMessages(timestamp, null, Collections.singleton(new LocalGroupLogEntry(newLocalState, null))); } else { - insertUpdateMessages(timestamp, localState, advanceGroupStateResult.getProcessedLogEntries()); + profileAndMessageHelper.insertUpdateMessages(timestamp, localState, advanceGroupStateResult.getProcessedLogEntries()); } - persistLearnedProfileKeys(inputGroupState); + profileAndMessageHelper.persistLearnedProfileKeys(inputGroupState); GlobalGroupState remainingWork = advanceGroupStateResult.getNewGlobalGroupState(); if (remainingWork.getServerHistory().size() > 0) { @@ -256,21 +272,23 @@ public final class GroupsV2StateProcessor { } private boolean notInGroupAndNotBeingAdded(@NonNull Optional localRecord, @NonNull DecryptedGroupChange signedGroupChange) { - boolean currentlyInGroup = localRecord.isPresent() && localRecord.get().isActive(); - boolean addedAsMember = signedGroupChange.getNewMembersList() - .stream() - .map(DecryptedMember::getUuid) - .map(UuidUtil::fromByteStringOrNull) - .filter(Objects::nonNull) - .collect(Collectors.toSet()) - .contains(Recipient.self().requireAci().uuid()); + boolean currentlyInGroup = localRecord.isPresent() && localRecord.get().isActive(); + + boolean addedAsMember = signedGroupChange.getNewMembersList() + .stream() + .map(DecryptedMember::getUuid) + .map(UuidUtil::fromByteStringOrNull) + .filter(Objects::nonNull) + .collect(Collectors.toSet()) + .contains(selfAci.uuid()); + boolean addedAsPendingMember = signedGroupChange.getNewPendingMembersList() .stream() .map(DecryptedPendingMember::getUuid) .map(UuidUtil::fromByteStringOrNull) .filter(Objects::nonNull) .collect(Collectors.toSet()) - .contains(Recipient.self().requireAci().uuid()); + .contains(selfAci.uuid()); return !currentlyInGroup && !addedAsMember && !addedAsPendingMember; } @@ -280,9 +298,9 @@ public final class GroupsV2StateProcessor { */ private GroupUpdateResult updateLocalGroupFromServerPaged(int revision, DecryptedGroup localState, long timestamp) throws IOException, GroupNotAMemberException { boolean latestRevisionOnly = revision == LATEST && (localState == null || localState.getRevision() == GroupsV2StateProcessor.RESTORE_PLACEHOLDER_REVISION); - ACI selfAci = Recipient.self().requireAci(); + ACI selfAci = this.selfAci; - Log.i(TAG, "Paging from server revision: " + (revision == LATEST ? "latest" : revision) + " latest only: " + latestRevisionOnly); + Log.i(TAG, "Paging from server revision: " + (revision == LATEST ? "latest" : revision) + ", latestOnly: " + latestRevisionOnly); DecryptedGroup latestServerGroup; GlobalGroupState inputGroupState; @@ -295,15 +313,21 @@ public final class GroupsV2StateProcessor { throw new IOException(e); } + if (localState != null && localState.getRevision() >= latestServerGroup.getRevision()) { + Log.i(TAG, "Local state is at or later than server"); + return new GroupUpdateResult(GroupState.GROUP_CONSISTENT_OR_AHEAD, latestServerGroup); + } + if (latestRevisionOnly || !GroupProtoUtil.isMember(selfAci.uuid(), latestServerGroup.getMembersList())) { Log.i(TAG, "Latest revision or not a member, use latest only"); inputGroupState = new GlobalGroupState(localState, Collections.singletonList(new ServerGroupLogEntry(latestServerGroup, null))); } else { - int revisionWeWereAdded = GroupProtoUtil.findRevisionWeWereAdded(latestServerGroup, selfAci.uuid()); - int logsNeededFrom = localState != null ? Math.max(localState.getRevision(), revisionWeWereAdded) : revisionWeWereAdded; + int revisionWeWereAdded = GroupProtoUtil.findRevisionWeWereAdded(latestServerGroup, selfAci.uuid()); + int logsNeededFrom = localState != null ? Math.max(localState.getRevision(), revisionWeWereAdded) : revisionWeWereAdded; + boolean includeFirstState = localState == null || localState.getRevision() < 0 || (revision == LATEST && localState.getRevision() + 1 < latestServerGroup.getRevision()); Log.i(TAG, "Requesting from server currentRevision: " + (localState != null ? localState.getRevision() : "null") + " logsNeededFrom: " + logsNeededFrom); - inputGroupState = getFullMemberHistoryPage(localState, selfAci, logsNeededFrom); + inputGroupState = getFullMemberHistoryPage(localState, selfAci, logsNeededFrom, includeFirstState); } ProfileKeySet profileKeys = new ProfileKeySet(); @@ -324,7 +348,7 @@ public final class GroupsV2StateProcessor { updateLocalDatabaseGroupState(inputGroupState, newLocalState); if (localState == null || localState.getRevision() != GroupsV2StateProcessor.RESTORE_PLACEHOLDER_REVISION) { - timestamp = insertUpdateMessages(timestamp, localState, advanceGroupStateResult.getProcessedLogEntries()); + timestamp = profileAndMessageHelper.insertUpdateMessages(timestamp, localState, advanceGroupStateResult.getProcessedLogEntries()); } for (ServerGroupLogEntry entry : inputGroupState.getServerHistory()) { @@ -342,16 +366,16 @@ public final class GroupsV2StateProcessor { if (hasMore) { Log.i(TAG, "Request next page from server revision: " + finalState.getRevision() + " nextPageRevision: " + inputGroupState.getNextPageRevision()); - inputGroupState = getFullMemberHistoryPage(finalState, selfAci, inputGroupState.getNextPageRevision()); + inputGroupState = getFullMemberHistoryPage(finalState, selfAci, inputGroupState.getNextPageRevision(), false); } } if (localState != null && localState.getRevision() == GroupsV2StateProcessor.RESTORE_PLACEHOLDER_REVISION) { Log.i(TAG, "Inserting single update message for restore placeholder"); - insertUpdateMessages(timestamp, null, Collections.singleton(new LocalGroupLogEntry(finalState, null))); + profileAndMessageHelper.insertUpdateMessages(timestamp, null, Collections.singleton(new LocalGroupLogEntry(finalState, null))); } - persistLearnedProfileKeys(profileKeys); + profileAndMessageHelper.persistLearnedProfileKeys(profileKeys); if (finalGlobalGroupState.getServerHistory().size() > 0) { Log.i(TAG, String.format(Locale.US, "There are more revisions on the server for this group, scheduling for later, V[%d..%d]", finalState.getRevision() + 1, finalGlobalGroupState.getLatestRevisionNumber())); @@ -366,7 +390,7 @@ public final class GroupsV2StateProcessor { throws IOException, GroupNotAMemberException, GroupDoesNotExistException { try { - return groupsV2Api.getGroup(groupSecretParams, groupsV2Authorization.getAuthorizationForToday(Recipient.self().requireAci(), groupSecretParams)); + return groupsV2Api.getGroup(groupSecretParams, groupsV2Authorization.getAuthorizationForToday(selfAci, groupSecretParams)); } catch (GroupNotFoundException e) { throw new GroupDoesNotExistException(e); } catch (NotInGroupException e) { @@ -381,7 +405,7 @@ public final class GroupsV2StateProcessor { throws IOException, GroupNotAMemberException, GroupDoesNotExistException { try { - return groupsV2Api.getGroupHistoryPage(groupSecretParams, revision, groupsV2Authorization.getAuthorizationForToday(Recipient.self().requireAci(), groupSecretParams)) + return groupsV2Api.getGroupHistoryPage(groupSecretParams, revision, groupsV2Authorization.getAuthorizationForToday(selfAci, groupSecretParams), true) .getResults() .get(0) .getGroup() @@ -401,30 +425,33 @@ public final class GroupsV2StateProcessor { return; } - Recipient groupRecipient = Recipient.externalGroupExact(context, groupId); - UUID selfUuid = Recipient.self().requireAci().uuid(); + Recipient groupRecipient = Recipient.externalGroupExact(context, groupId); + UUID selfUuid = selfAci.uuid(); + DecryptedGroup decryptedGroup = groupDatabase.requireGroup(groupId) .requireV2GroupProperties() .getDecryptedGroup(); - DecryptedGroup simulatedGroupState = DecryptedGroupUtil.removeMember(decryptedGroup, selfUuid, decryptedGroup.getRevision() + 1); + DecryptedGroup simulatedGroupState = DecryptedGroupUtil.removeMember(decryptedGroup, selfUuid, decryptedGroup.getRevision() + 1); + DecryptedGroupChange simulatedGroupChange = DecryptedGroupChange.newBuilder() .setEditor(UuidUtil.toByteString(UuidUtil.UNKNOWN_UUID)) .setRevision(simulatedGroupState.getRevision()) .addDeleteMembers(UuidUtil.toByteString(selfUuid)) .build(); - DecryptedGroupV2Context decryptedGroupV2Context = GroupProtoUtil.createDecryptedGroupV2Context(masterKey, new GroupMutation(decryptedGroup, simulatedGroupChange, simulatedGroupState), null); - OutgoingGroupUpdateMessage leaveMessage = new OutgoingGroupUpdateMessage(groupRecipient, - decryptedGroupV2Context, - null, - System.currentTimeMillis(), - 0, - false, - null, - Collections.emptyList(), - Collections.emptyList(), - Collections.emptyList()); + DecryptedGroupV2Context decryptedGroupV2Context = GroupProtoUtil.createDecryptedGroupV2Context(masterKey, new GroupMutation(decryptedGroup, simulatedGroupChange, simulatedGroupState), null); + + OutgoingGroupUpdateMessage leaveMessage = new OutgoingGroupUpdateMessage(groupRecipient, + decryptedGroupV2Context, + null, + System.currentTimeMillis(), + 0, + false, + null, + Collections.emptyList(), + Collections.emptyList(), + Collections.emptyList()); try { MessageDatabase mmsDatabase = SignalDatabase.mms(); @@ -466,108 +493,15 @@ public final class GroupsV2StateProcessor { } if (needsAvatarFetch) { - jobManager.add(new AvatarGroupsV2DownloadJob(groupId, newLocalState.getAvatar())); + ApplicationDependencies.getJobManager().add(new AvatarGroupsV2DownloadJob(groupId, newLocalState.getAvatar())); } - determineProfileSharing(inputGroupState, newLocalState); + profileAndMessageHelper.determineProfileSharing(inputGroupState, newLocalState); } - private void determineProfileSharing(@NonNull GlobalGroupState inputGroupState, - @NonNull DecryptedGroup newLocalState) - { - if (inputGroupState.getLocalState() != null) { - boolean wasAMemberAlready = DecryptedGroupUtil.findMemberByUuid(inputGroupState.getLocalState().getMembersList(), Recipient.self().requireAci().uuid()).isPresent(); - - if (wasAMemberAlready) { - Log.i(TAG, "Skipping profile sharing detection as was already a full member before update"); - return; - } - } - - Optional selfAsMemberOptional = DecryptedGroupUtil.findMemberByUuid(newLocalState.getMembersList(), Recipient.self().requireAci().uuid()); - - if (selfAsMemberOptional.isPresent()) { - DecryptedMember selfAsMember = selfAsMemberOptional.get(); - int revisionJoinedAt = selfAsMember.getJoinedAtRevision(); - - Optional addedByOptional = Stream.of(inputGroupState.getServerHistory()) - .map(ServerGroupLogEntry::getChange) - .filter(c -> c != null && c.getRevision() == revisionJoinedAt) - .findFirst() - .map(c -> Optional.fromNullable(UuidUtil.fromByteStringOrNull(c.getEditor())) - .transform(a -> Recipient.externalPush(context, ACI.fromByteStringOrNull(c.getEditor()), null, false))) - .orElse(Optional.absent()); - - if (addedByOptional.isPresent()) { - Recipient addedBy = addedByOptional.get(); - - Log.i(TAG, String.format("Added as a full member of %s by %s", groupId, addedBy.getId())); - - if (addedBy.isSystemContact() || addedBy.isProfileSharing()) { - Log.i(TAG, "Group 'adder' is trusted. contact: " + addedBy.isSystemContact() + ", profileSharing: " + addedBy.isProfileSharing()); - Log.i(TAG, "Added to a group and auto-enabling profile sharing"); - recipientDatabase.setProfileSharing(Recipient.externalGroupExact(context, groupId).getId(), true); - } else { - Log.i(TAG, "Added to a group, but not enabling profile sharing, as 'adder' is not trusted"); - } - } else { - Log.w(TAG, "Could not find founding member during gv2 create. Not enabling profile sharing."); - } - } else { - Log.i(TAG, String.format("Added to %s, but not enabling profile sharing as not a fullMember.", groupId)); - } - } - - private long insertUpdateMessages(long timestamp, - @Nullable DecryptedGroup previousGroupState, - Collection processedLogEntries) - { - for (LocalGroupLogEntry entry : processedLogEntries) { - if (entry.getChange() != null && DecryptedGroupUtil.changeIsEmptyExceptForProfileKeyChanges(entry.getChange()) && !DecryptedGroupUtil.changeIsEmpty(entry.getChange())) { - Log.d(TAG, "Skipping profile key changes only update message"); - } else { - if (entry.getChange() != null && DecryptedGroupUtil.changeIsEmpty(entry.getChange()) && previousGroupState != null) { - Log.w(TAG, "Empty group update message seen. Not inserting."); - } else { - storeMessage(GroupProtoUtil.createDecryptedGroupV2Context(masterKey, new GroupMutation(previousGroupState, entry.getChange(), entry.getGroup()), null), timestamp); - timestamp++; - } - } - previousGroupState = entry.getGroup(); - } - return timestamp; - } - - private void persistLearnedProfileKeys(@NonNull GlobalGroupState globalGroupState) { - final ProfileKeySet profileKeys = new ProfileKeySet(); - - for (ServerGroupLogEntry entry : globalGroupState.getServerHistory()) { - if (entry.getGroup() != null) { - profileKeys.addKeysFromGroupState(entry.getGroup()); - } - if (entry.getChange() != null) { - profileKeys.addKeysFromGroupChange(entry.getChange()); - } - } - - persistLearnedProfileKeys(profileKeys); - } - - private void persistLearnedProfileKeys(@NonNull ProfileKeySet profileKeys) { - Set updated = recipientDatabase.persistProfileKeySet(profileKeys); - - if (!updated.isEmpty()) { - Log.i(TAG, String.format(Locale.US, "Learned %d new profile keys, fetching profiles", updated.size())); - - for (Job job : RetrieveProfileJob.forRecipients(updated)) { - jobManager.runSynchronously(job, 5000); - } - } - } - - private GlobalGroupState getFullMemberHistoryPage(DecryptedGroup localState, @NonNull ACI selfAci, int logsNeededFromRevision) throws IOException { + private GlobalGroupState getFullMemberHistoryPage(DecryptedGroup localState, @NonNull ACI selfAci, int logsNeededFromRevision, boolean includeFirstState) throws IOException { try { - GroupHistoryPage groupHistoryPage = groupsV2Api.getGroupHistoryPage(groupSecretParams, logsNeededFromRevision, groupsV2Authorization.getAuthorizationForToday(selfAci, groupSecretParams)); + GroupHistoryPage groupHistoryPage = groupsV2Api.getGroupHistoryPage(groupSecretParams, logsNeededFromRevision, groupsV2Authorization.getAuthorizationForToday(selfAci, groupSecretParams), includeFirstState); ArrayList history = new ArrayList<>(groupHistoryPage.getResults().size()); boolean ignoreServerChanges = SignalStore.internalValues().gv2IgnoreServerChanges(); @@ -589,11 +523,119 @@ public final class GroupsV2StateProcessor { throw new IOException(e); } } + } - private void storeMessage(@NonNull DecryptedGroupV2Context decryptedGroupV2Context, long timestamp) { + @VisibleForTesting + static class ProfileAndMessageHelper { + + private final Context context; + private final ACI aci; + private final GroupMasterKey masterKey; + private final GroupId.V2 groupId; + private final RecipientDatabase recipientDatabase; + + ProfileAndMessageHelper(@NonNull Context context, @NonNull ACI aci, @NonNull GroupMasterKey masterKey, @NonNull GroupId.V2 groupId, @NonNull RecipientDatabase recipientDatabase) { + this.context = context; + this.aci = aci; + this.masterKey = masterKey; + this.groupId = groupId; + this.recipientDatabase = recipientDatabase; + } + + void determineProfileSharing(@NonNull GlobalGroupState inputGroupState, @NonNull DecryptedGroup newLocalState) { + if (inputGroupState.getLocalState() != null) { + boolean wasAMemberAlready = DecryptedGroupUtil.findMemberByUuid(inputGroupState.getLocalState().getMembersList(), aci.uuid()).isPresent(); + + if (wasAMemberAlready) { + return; + } + } + + Optional selfAsMemberOptional = DecryptedGroupUtil.findMemberByUuid(newLocalState.getMembersList(), aci.uuid()); + + if (selfAsMemberOptional.isPresent()) { + DecryptedMember selfAsMember = selfAsMemberOptional.get(); + int revisionJoinedAt = selfAsMember.getJoinedAtRevision(); + + Optional addedByOptional = Stream.of(inputGroupState.getServerHistory()) + .map(ServerGroupLogEntry::getChange) + .filter(c -> c != null && c.getRevision() == revisionJoinedAt) + .findFirst() + .map(c -> Optional.fromNullable(UuidUtil.fromByteStringOrNull(c.getEditor())) + .transform(a -> Recipient.externalPush(context, ACI.fromByteStringOrNull(c.getEditor()), null, false))) + .orElse(Optional.absent()); + + if (addedByOptional.isPresent()) { + Recipient addedBy = addedByOptional.get(); + + Log.i(TAG, String.format("Added as a full member of %s by %s", groupId, addedBy.getId())); + + if (addedBy.isSystemContact() || addedBy.isProfileSharing()) { + Log.i(TAG, "Group 'adder' is trusted. contact: " + addedBy.isSystemContact() + ", profileSharing: " + addedBy.isProfileSharing()); + Log.i(TAG, "Added to a group and auto-enabling profile sharing"); + recipientDatabase.setProfileSharing(Recipient.externalGroupExact(context, groupId).getId(), true); + } else { + Log.i(TAG, "Added to a group, but not enabling profile sharing, as 'adder' is not trusted"); + } + } else { + Log.w(TAG, "Could not find founding member during gv2 create. Not enabling profile sharing."); + } + } else { + Log.i(TAG, String.format("Added to %s, but not enabling profile sharing as not a fullMember.", groupId)); + } + } + + long insertUpdateMessages(long timestamp, + @Nullable DecryptedGroup previousGroupState, + Collection processedLogEntries) + { + for (LocalGroupLogEntry entry : processedLogEntries) { + if (entry.getChange() != null && DecryptedGroupUtil.changeIsEmptyExceptForProfileKeyChanges(entry.getChange()) && !DecryptedGroupUtil.changeIsEmpty(entry.getChange())) { + Log.d(TAG, "Skipping profile key changes only update message"); + } else { + if (entry.getChange() != null && DecryptedGroupUtil.changeIsEmpty(entry.getChange()) && previousGroupState != null) { + Log.w(TAG, "Empty group update message seen. Not inserting."); + } else { + storeMessage(GroupProtoUtil.createDecryptedGroupV2Context(masterKey, new GroupMutation(previousGroupState, entry.getChange(), entry.getGroup()), null), timestamp); + timestamp++; + } + } + previousGroupState = entry.getGroup(); + } + return timestamp; + } + + void persistLearnedProfileKeys(@NonNull GlobalGroupState globalGroupState) { + final ProfileKeySet profileKeys = new ProfileKeySet(); + + for (ServerGroupLogEntry entry : globalGroupState.getServerHistory()) { + if (entry.getGroup() != null) { + profileKeys.addKeysFromGroupState(entry.getGroup()); + } + if (entry.getChange() != null) { + profileKeys.addKeysFromGroupChange(entry.getChange()); + } + } + + persistLearnedProfileKeys(profileKeys); + } + + void persistLearnedProfileKeys(@NonNull ProfileKeySet profileKeys) { + Set updated = recipientDatabase.persistProfileKeySet(profileKeys); + + if (!updated.isEmpty()) { + Log.i(TAG, String.format(Locale.US, "Learned %d new profile keys, fetching profiles", updated.size())); + + for (Job job : RetrieveProfileJob.forRecipients(updated)) { + ApplicationDependencies.getJobManager().runSynchronously(job, 5000); + } + } + } + + void storeMessage(@NonNull DecryptedGroupV2Context decryptedGroupV2Context, long timestamp) { Optional editor = getEditor(decryptedGroupV2Context).transform(ACI::from); - boolean outgoing = !editor.isPresent() || Recipient.self().requireAci().equals(editor.get()); + boolean outgoing = !editor.isPresent() || aci.equals(editor.get()); if (outgoing) { try { @@ -631,7 +673,7 @@ public final class GroupsV2StateProcessor { if (changeEditor.isPresent()) { return changeEditor; } else { - Optional pendingByUuid = DecryptedGroupUtil.findPendingByUuid(decryptedGroupV2Context.getGroupState().getPendingMembersList(), Recipient.self().requireAci().uuid()); + Optional pendingByUuid = DecryptedGroupUtil.findPendingByUuid(decryptedGroupV2Context.getGroupState().getPendingMembersList(), aci.uuid()); if (pendingByUuid.isPresent()) { return Optional.fromNullable(UuidUtil.fromByteStringOrNull(pendingByUuid.get().getAddedByUuid())); } diff --git a/app/src/test/java/org/thoughtcrime/securesms/database/GroupTestUtil.kt b/app/src/test/java/org/thoughtcrime/securesms/database/GroupTestUtil.kt new file mode 100644 index 0000000000..61abe9599b --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/database/GroupTestUtil.kt @@ -0,0 +1,204 @@ +package org.thoughtcrime.securesms.database + +import com.google.protobuf.ByteString +import org.signal.storageservice.protos.groups.AccessControl +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.storageservice.protos.groups.local.DecryptedPendingMember +import org.signal.storageservice.protos.groups.local.DecryptedRequestingMember +import org.signal.storageservice.protos.groups.local.DecryptedString +import org.signal.storageservice.protos.groups.local.DecryptedTimer +import org.signal.storageservice.protos.groups.local.EnabledState +import org.signal.zkgroup.groups.GroupMasterKey +import org.thoughtcrime.securesms.groups.GroupId +import org.thoughtcrime.securesms.recipients.RecipientId +import org.whispersystems.libsignal.util.guava.Optional +import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupHistoryEntry +import org.whispersystems.signalservice.api.groupsv2.GroupHistoryPage +import org.whispersystems.signalservice.api.push.ACI +import org.whispersystems.signalservice.api.push.DistributionId + +fun DecryptedGroupChange.Builder.setNewDescription(description: String) { + newDescription = DecryptedString.newBuilder().setValue(description).build() +} + +fun DecryptedGroupChange.Builder.setNewTitle(title: String) { + newTitle = DecryptedString.newBuilder().setValue(title).build() +} + +class ChangeLog(private val revision: Int) { + var groupSnapshot: DecryptedGroup? = null + var groupChange: DecryptedGroupChange? = null + + fun change(init: DecryptedGroupChange.Builder.() -> Unit) { + val builder = DecryptedGroupChange.newBuilder().setRevision(revision) + builder.init() + groupChange = builder.build() + } + + fun fullSnapshot( + extendGroup: DecryptedGroup? = null, + title: String = extendGroup?.title ?: "", + avatar: String = extendGroup?.avatar ?: "", + description: String = extendGroup?.description ?: "", + accessControl: AccessControl = extendGroup?.accessControl ?: AccessControl.getDefaultInstance(), + members: List = extendGroup?.membersList ?: emptyList(), + pendingMembers: List = extendGroup?.pendingMembersList ?: emptyList(), + requestingMembers: List = extendGroup?.requestingMembersList ?: emptyList(), + inviteLinkPassword: ByteArray = extendGroup?.inviteLinkPassword?.toByteArray() ?: ByteArray(0), + disappearingMessageTimer: DecryptedTimer = extendGroup?.disappearingMessagesTimer ?: DecryptedTimer.getDefaultInstance() + ) { + groupSnapshot = decryptedGroup(revision, title, avatar, description, accessControl, members, pendingMembers, requestingMembers, inviteLinkPassword, disappearingMessageTimer) + } +} + +class ChangeSet { + private val changeSet: MutableList = mutableListOf() + + fun changeLog(revision: Int, init: ChangeLog.() -> Unit) { + val entry = ChangeLog(revision) + entry.init() + changeSet += entry + } + + fun toApiResponse(): GroupHistoryPage { + return GroupHistoryPage(changeSet.map { DecryptedGroupHistoryEntry(Optional.fromNullable(it.groupSnapshot), Optional.fromNullable(it.groupChange)) }, GroupHistoryPage.PagingData.NONE) + } +} + +class GroupStateTestData(private val masterKey: GroupMasterKey) { + + var localState: DecryptedGroup? = null + var groupRecord: Optional = Optional.absent() + var serverState: DecryptedGroup? = null + var changeSet: ChangeSet? = null + var includeFirst: Boolean = false + var requestedRevision: Int = 0 + + fun localState( + revision: Int = 0, + title: String = "", + avatar: String = "", + description: String = "", + accessControl: AccessControl = AccessControl.getDefaultInstance(), + members: List = emptyList(), + pendingMembers: List = emptyList(), + requestingMembers: List = emptyList(), + inviteLinkPassword: ByteArray = ByteArray(0), + disappearingMessageTimer: DecryptedTimer = DecryptedTimer.getDefaultInstance() + ) { + localState = decryptedGroup(revision, title, avatar, description, accessControl, members, pendingMembers, requestingMembers, inviteLinkPassword, disappearingMessageTimer) + groupRecord = groupRecord(masterKey, localState!!) + } + + fun serverState( + revision: Int, + extendGroup: DecryptedGroup? = null, + title: String = extendGroup?.title ?: "", + avatar: String = extendGroup?.avatar ?: "", + description: String = extendGroup?.description ?: "", + accessControl: AccessControl = extendGroup?.accessControl ?: AccessControl.getDefaultInstance(), + members: List = extendGroup?.membersList ?: emptyList(), + pendingMembers: List = extendGroup?.pendingMembersList ?: emptyList(), + requestingMembers: List = extendGroup?.requestingMembersList ?: emptyList(), + inviteLinkPassword: ByteArray = extendGroup?.inviteLinkPassword?.toByteArray() ?: ByteArray(0), + disappearingMessageTimer: DecryptedTimer = extendGroup?.disappearingMessagesTimer ?: DecryptedTimer.getDefaultInstance() + ) { + serverState = decryptedGroup(revision, title, avatar, description, accessControl, members, pendingMembers, requestingMembers, inviteLinkPassword, disappearingMessageTimer) + } + + fun changeSet(init: ChangeSet.() -> Unit) { + val changeSet = ChangeSet() + changeSet.init() + this.changeSet = changeSet + } + + fun apiCallParameters(requestedRevision: Int, includeFirst: Boolean) { + this.requestedRevision = requestedRevision + this.includeFirst = includeFirst + } +} + +fun groupRecord( + masterKey: GroupMasterKey, + decryptedGroup: DecryptedGroup, + id: GroupId = GroupId.v2(masterKey), + recipientId: RecipientId = RecipientId.from(100), + members: String = "1", + unmigratedV1Members: String? = null, + avatarId: Long = 1, + avatarKey: ByteArray = ByteArray(0), + avatarContentType: String = "", + relay: String = "", + active: Boolean = true, + avatarDigest: ByteArray = ByteArray(0), + mms: Boolean = false, + distributionId: DistributionId? = null +): Optional { + return Optional.of( + GroupDatabase.GroupRecord( + id, + recipientId, + decryptedGroup.title, + members, + unmigratedV1Members, + avatarId, + avatarKey, + avatarContentType, + relay, + active, + avatarDigest, + mms, + masterKey.serialize(), + decryptedGroup.revision, + decryptedGroup.toByteArray(), + distributionId + ) + ) +} + +fun decryptedGroup( + revision: Int = 0, + title: String = "", + avatar: String = "", + description: String = "", + accessControl: AccessControl = AccessControl.getDefaultInstance(), + members: List = emptyList(), + pendingMembers: List = emptyList(), + requestingMembers: List = emptyList(), + inviteLinkPassword: ByteArray = ByteArray(0), + disappearingMessageTimer: DecryptedTimer = DecryptedTimer.getDefaultInstance() +): DecryptedGroup { + + val builder = DecryptedGroup.newBuilder() + .setAccessControl(accessControl) + .setAvatar(avatar) + .setAvatarBytes(ByteString.EMPTY) + .setDescription(description) + .setDisappearingMessagesTimer(disappearingMessageTimer) + .setInviteLinkPassword(ByteString.copyFrom(inviteLinkPassword)) + .setIsAnnouncementGroup(EnabledState.DISABLED) + .setTitle(title) + .setRevision(revision) + .addAllMembers(members) + .addAllPendingMembers(pendingMembers) + .addAllRequestingMembers(requestingMembers) + + return builder.build() +} + +fun member(aci: ACI, role: Member.Role = Member.Role.DEFAULT, joinedAt: Int = 0): DecryptedMember { + return DecryptedMember.newBuilder() + .setRole(role) + .setUuid(aci.toByteString()) + .setJoinedAtRevision(joinedAt) + .build() +} + +fun requestingMember(aci: ACI): DecryptedRequestingMember { + return DecryptedRequestingMember.newBuilder() + .setUuid(aci.toByteString()) + .build() +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.java b/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.java index 4ef49574ee..fc1b3b8ec2 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.java +++ b/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.java @@ -83,7 +83,7 @@ public class MockApplicationDependencyProvider implements ApplicationDependencie @Override public @NonNull JobManager provideJobManager() { - return null; + return mock(JobManager.class); } @Override diff --git a/app/src/test/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessorTest.kt b/app/src/test/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessorTest.kt new file mode 100644 index 0000000000..8c881595eb --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessorTest.kt @@ -0,0 +1,375 @@ +package org.thoughtcrime.securesms.groups.v2.processing + +import android.app.Application +import androidx.test.core.app.ApplicationProvider +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.`is` +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers.eq +import org.mockito.Mockito.any +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.isA +import org.mockito.Mockito.mock +import org.mockito.Mockito.reset +import org.mockito.Mockito.verify +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.signal.storageservice.protos.groups.local.DecryptedGroupChange +import org.signal.storageservice.protos.groups.local.DecryptedTimer +import org.signal.zkgroup.groups.GroupMasterKey +import org.thoughtcrime.securesms.SignalStoreRule +import org.thoughtcrime.securesms.database.GroupDatabase +import org.thoughtcrime.securesms.database.GroupStateTestData +import org.thoughtcrime.securesms.database.RecipientDatabase +import org.thoughtcrime.securesms.database.member +import org.thoughtcrime.securesms.database.requestingMember +import org.thoughtcrime.securesms.database.setNewDescription +import org.thoughtcrime.securesms.database.setNewTitle +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies +import org.thoughtcrime.securesms.groups.GroupId +import org.thoughtcrime.securesms.groups.GroupsV2Authorization +import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob +import org.thoughtcrime.securesms.util.Hex.fromStringCondensed +import org.whispersystems.signalservice.api.groupsv2.GroupsV2Api +import org.whispersystems.signalservice.api.push.ACI +import java.util.UUID + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, application = Application::class) +class GroupsV2StateProcessorTest { + + companion object { + val masterKey = GroupMasterKey(fromStringCondensed("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")) + val selfAci = ACI.from(UUID.randomUUID()) + val otherAci = ACI.from(UUID.randomUUID()) + val selfAndOthers = listOf(member(selfAci), member(otherAci)) + val others = listOf(member(otherAci)) + } + + private lateinit var groupDatabase: GroupDatabase + private lateinit var recipientDatabase: RecipientDatabase + private lateinit var groupsV2API: GroupsV2Api + private lateinit var groupsV2Authorization: GroupsV2Authorization + private lateinit var profileAndMessageHelper: GroupsV2StateProcessor.ProfileAndMessageHelper + + private lateinit var processor: GroupsV2StateProcessor.StateProcessorForGroup + + @get:Rule + val signalStore: SignalStoreRule = SignalStoreRule() + + @Before + fun setUp() { + groupDatabase = mock(GroupDatabase::class.java) + recipientDatabase = mock(RecipientDatabase::class.java) + groupsV2API = mock(GroupsV2Api::class.java) + groupsV2Authorization = mock(GroupsV2Authorization::class.java) + profileAndMessageHelper = mock(GroupsV2StateProcessor.ProfileAndMessageHelper::class.java) + + processor = GroupsV2StateProcessor.StateProcessorForGroup(selfAci, ApplicationProvider.getApplicationContext(), groupDatabase, groupsV2API, groupsV2Authorization, masterKey, profileAndMessageHelper) + } + + @After + fun tearDown() { + reset(ApplicationDependencies.getJobManager()) + } + + private fun given(init: GroupStateTestData.() -> Unit) { + val data = GroupStateTestData(masterKey) + data.init() + + doReturn(data.groupRecord).`when`(groupDatabase).getGroup(any(GroupId.V2::class.java)) + doReturn(!data.groupRecord.isPresent).`when`(groupDatabase).isUnknownGroup(any()) + + if (data.serverState != null) { + doReturn(data.serverState).`when`(groupsV2API).getGroup(any(), any()) + } + + data.changeSet?.let { changeSet -> + doReturn(changeSet.toApiResponse()).`when`(groupsV2API).getGroupHistoryPage(any(), eq(data.requestedRevision), any(), eq(data.includeFirst)) + } + } + + @Test + fun `when local revision matches server revision, then return consistent or ahead`() { + given { + localState( + revision = 5, + members = selfAndOthers + ) + serverState( + revision = 5, + extendGroup = localState + ) + } + + val result = processor.updateLocalGroupToRevision(GroupsV2StateProcessor.LATEST, 0, null) + assertThat("local and server match revisions", result.groupState, `is`(GroupsV2StateProcessor.GroupState.GROUP_CONSISTENT_OR_AHEAD)) + } + + @Test + fun `when local revision is one less than latest server version, then update from server with group change only`() { + given { + localState( + revision = 5, + title = "Fdsa", + members = selfAndOthers + ) + serverState( + revision = 6, + extendGroup = localState, + title = "Asdf" + ) + changeSet { + changeLog(6) { + change { + setNewTitle("Asdf") + } + } + } + apiCallParameters(requestedRevision = 5, includeFirst = false) + } + + val result = processor.updateLocalGroupToRevision(GroupsV2StateProcessor.LATEST, 0, null) + assertThat("local should update to server", result.groupState, `is`(GroupsV2StateProcessor.GroupState.GROUP_UPDATED)) + assertThat("title changed to match server", result.latestServer!!.title, `is`("Asdf")) + } + + @Test + fun `when local revision is two less than server revision, then update from server with full group state and change`() { + given { + localState( + revision = 5, + title = "Fdsa", + members = selfAndOthers + ) + serverState( + revision = 7, + title = "Asdf!" + ) + changeSet { + changeLog(6) { + fullSnapshot(extendGroup = localState, title = "Asdf") + change { + setNewTitle("Asdf") + } + } + changeLog(7) { + change { + setNewTitle("Asdf!") + } + } + } + apiCallParameters(requestedRevision = 5, includeFirst = true) + } + + val result = processor.updateLocalGroupToRevision(GroupsV2StateProcessor.LATEST, 0, null) + assertThat("local should update to server", result.groupState, `is`(GroupsV2StateProcessor.GroupState.GROUP_UPDATED)) + assertThat("revision matches server", result.latestServer!!.revision, `is`(7)) + assertThat("title changed on server to final result", result.latestServer!!.title, `is`("Asdf!")) + } + + @Test + fun `when change log returns a group state more than one higher than our local state, then still update to server state`() { + given { + localState( + revision = 100, + title = "To infinity", + members = selfAndOthers + ) + serverState( + revision = 111, + title = "And beyond", + description = "Description" + ) + changeSet { + changeLog(110) { + fullSnapshot( + extendGroup = localState, + title = "And beyond" + ) + } + changeLog(111) { + change { + setNewDescription("Description") + } + } + } + } + + val result = processor.updateLocalGroupToRevision(GroupsV2StateProcessor.LATEST, 0, null) + assertThat("local should update to server", result.groupState, `is`(GroupsV2StateProcessor.GroupState.GROUP_UPDATED)) + assertThat("revision matches server", result.latestServer!!.revision, `is`(111)) + assertThat("title changed on server to final result", result.latestServer!!.title, `is`("And beyond")) + assertThat("Description updated in change after full snapshot", result.latestServer!!.description, `is`("Description")) + } + + @Test + fun `when receiving peer change for next revision, then apply change without server call`() { + given { + localState( + revision = 5, + disappearingMessageTimer = DecryptedTimer.newBuilder().setDuration(1000).build() + ) + } + + val signedChange = DecryptedGroupChange.newBuilder().apply { + revision = 6 + setNewTimer(DecryptedTimer.newBuilder().setDuration(5000)) + } + + val result = processor.updateLocalGroupToRevision(6, 0, signedChange.build()) + assertThat("revision matches peer change", result.latestServer!!.revision, `is`(6)) + assertThat("timer changed by peer change", result.latestServer!!.disappearingMessagesTimer.duration, `is`(5000)) + } + + @Test + fun `when freshly added to a group, with no group changes after being added, then update from server at the revision we were added`() { + given { + serverState( + revision = 2, + title = "Breaking Signal for Science", + description = "We break stuff, because we must.", + members = listOf(member(otherAci), member(selfAci, joinedAt = 2)) + ) + changeSet { + changeLog(2) { + fullSnapshot(serverState) + } + } + apiCallParameters(2, true) + } + + val result = processor.updateLocalGroupToRevision(2, 0, DecryptedGroupChange.getDefaultInstance()) + assertThat("local should update to server", result.groupState, `is`(GroupsV2StateProcessor.GroupState.GROUP_UPDATED)) + assertThat("revision matches server", result.latestServer!!.revision, `is`(2)) + } + + @Test + fun `when freshly added to a group, with additional group changes after being added, then only update from server at the revision we were added, and then schedule pulling additional changes later`() { + given { + serverState( + revision = 3, + title = "Breaking Signal for Science", + description = "We break stuff, because we must.", + members = listOf(member(otherAci), member(selfAci, joinedAt = 2)) + ) + changeSet { + changeLog(2) { + fullSnapshot(serverState, title = "Baking Signal for Science") + } + changeLog(3) { + change { + setNewTitle("Breaking Signal for Science") + } + } + } + apiCallParameters(2, true) + } + + doReturn(true).`when`(groupDatabase).isUnknownGroup(any()) + + val result = processor.updateLocalGroupToRevision(2, 0, DecryptedGroupChange.getDefaultInstance()) + + assertThat("local should update to revision added", result.groupState, `is`(GroupsV2StateProcessor.GroupState.GROUP_UPDATED)) + assertThat("revision matches peer revision added", result.latestServer!!.revision, `is`(2)) + assertThat("title matches that as it was in revision added", result.latestServer!!.title, `is`("Baking Signal for Science")) + + verify(ApplicationDependencies.getJobManager()).add(isA(RequestGroupV2InfoJob::class.java)) + } + + @Test + fun `when learning of a group via storage service, then update from server to latest revision`() { + given { + localState( + revision = GroupsV2StateProcessor.RESTORE_PLACEHOLDER_REVISION + ) + serverState( + revision = 10, + title = "Stargate Fan Club", + description = "Indeed.", + members = selfAndOthers + ) + } + + val result = processor.updateLocalGroupToRevision(GroupsV2StateProcessor.LATEST, 0, null) + + assertThat("local should update to server", result.groupState, `is`(GroupsV2StateProcessor.GroupState.GROUP_UPDATED)) + assertThat("revision matches latest server", result.latestServer!!.revision, `is`(10)) + } + + @Test + fun `when request to join group is approved, with no group changes after approved, then update from server to revision we were added`() { + given { + localState( + revision = GroupsV2StateProcessor.PLACEHOLDER_REVISION, + title = "Beam me up", + requestingMembers = listOf(requestingMember(selfAci)) + ) + serverState( + revision = 3, + title = "Beam me up", + members = listOf(member(otherAci), member(selfAci, joinedAt = 3)) + ) + changeSet { + changeLog(3) { + fullSnapshot(serverState) + change { + addNewMembers(member(selfAci, joinedAt = 3)) + } + } + } + apiCallParameters(requestedRevision = 3, includeFirst = true) + } + + val result = processor.updateLocalGroupToRevision(3, 0, null) + + assertThat("local should update to server", result.groupState, `is`(GroupsV2StateProcessor.GroupState.GROUP_UPDATED)) + assertThat("revision matches server", result.latestServer!!.revision, `is`(3)) + } + + @Test + fun `when request to join group is approved, with group changes occurring after approved, then update from server to revision we were added, and then schedule pulling additional changes later`() { + given { + localState( + revision = GroupsV2StateProcessor.PLACEHOLDER_REVISION, + title = "Beam me up", + requestingMembers = listOf(requestingMember(selfAci)) + ) + serverState( + revision = 5, + title = "Beam me up!", + members = listOf(member(otherAci), member(selfAci, joinedAt = 3)) + ) + changeSet { + changeLog(3) { + fullSnapshot(extendGroup = serverState, title = "Beam me up") + change { + addNewMembers(member(selfAci, joinedAt = 3)) + } + } + changeLog(4) { + change { + setNewTitle("May the force be with you") + } + } + changeLog(5) { + change { + setNewTitle("Beam me up!") + } + } + } + apiCallParameters(requestedRevision = 3, includeFirst = true) + } + + val result = processor.updateLocalGroupToRevision(3, 0, null) + + assertThat("local should update to server", result.groupState, `is`(GroupsV2StateProcessor.GroupState.GROUP_UPDATED)) + assertThat("revision matches revision approved at", result.latestServer!!.revision, `is`(3)) + assertThat("title matches revision approved at", result.latestServer!!.title, `is`("Beam me up")) + verify(ApplicationDependencies.getJobManager()).add(isA(RequestGroupV2InfoJob::class.java)) + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupHistoryEntry.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupHistoryEntry.java index 9dffc87602..6a809b7133 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupHistoryEntry.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupHistoryEntry.java @@ -12,7 +12,7 @@ public final class DecryptedGroupHistoryEntry { private final Optional group; private final Optional change; - DecryptedGroupHistoryEntry(Optional group, Optional change) + public DecryptedGroupHistoryEntry(Optional group, Optional change) throws InvalidGroupStateException { if (group.isPresent() && change.isPresent() && group.get().getRevision() != change.get().getRevision()) { diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Api.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Api.java index 3fc8b7b47e..ed9c4b1972 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Api.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Api.java @@ -20,6 +20,7 @@ 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.libsignal.logging.Log; import org.whispersystems.libsignal.util.guava.Optional; import org.whispersystems.signalservice.api.push.ACI; import org.whispersystems.signalservice.internal.push.PushServiceSocket; @@ -31,9 +32,8 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.LinkedList; import java.util.List; -import java.util.UUID; -public final class GroupsV2Api { +public class GroupsV2Api { private final PushServiceSocket socket; private final GroupsV2Operations groupsOperations; @@ -96,23 +96,18 @@ public final class GroupsV2Api { } public GroupHistoryPage getGroupHistoryPage(GroupSecretParams groupSecretParams, - int fromRevision, - GroupsV2AuthorizationString authorization) + int fromRevision, + GroupsV2AuthorizationString authorization, + boolean includeFirstState) throws IOException, InvalidGroupStateException, VerificationFailedException { - List changesList = new LinkedList<>(); - PushServiceSocket.GroupHistory group; + PushServiceSocket.GroupHistory group = socket.getGroupsV2GroupHistory(fromRevision, authorization, GroupsV2Operations.HIGHEST_KNOWN_EPOCH, includeFirstState); + List result = new ArrayList<>(group.getGroupChanges().getGroupChangesList().size()); + GroupsV2Operations.GroupOperations groupOperations = groupsOperations.forGroup(groupSecretParams); - group = socket.getGroupsV2GroupHistory(fromRevision, authorization); - - changesList.addAll(group.getGroupChanges().getGroupChangesList()); - - ArrayList result = new ArrayList<>(changesList.size()); - GroupsV2Operations.GroupOperations groupOperations = groupsOperations.forGroup(groupSecretParams); - - for (GroupChanges.GroupChangeState change : changesList) { - Optional decryptedGroup = change.hasGroupState () ? Optional.of(groupOperations.decryptGroup(change.getGroupState())) : Optional.absent(); - Optional decryptedChange = change.hasGroupChange() ? groupOperations.decryptChange(change.getGroupChange(), false) : Optional.absent(); + for (GroupChanges.GroupChangeState change : group.getGroupChanges().getGroupChangesList()) { + Optional decryptedGroup = change.hasGroupState() ? Optional.of(groupOperations.decryptGroup(change.getGroupState())) : Optional.absent(); + Optional decryptedChange = change.hasGroupChange() ? groupOperations.decryptChange(change.getGroupChange(), false) : Optional.absent(); result.add(new DecryptedGroupHistoryEntry(decryptedGroup, decryptedChange)); } diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java index 0a12f7fea7..fbee12873b 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java @@ -231,7 +231,7 @@ public class PushServiceSocket { private static final String GROUPSV2_CREDENTIAL = "/v1/certificate/group/%d/%d"; private static final String GROUPSV2_GROUP = "/v1/groups/"; private static final String GROUPSV2_GROUP_PASSWORD = "/v1/groups/?inviteLinkPassword=%s"; - private static final String GROUPSV2_GROUP_CHANGES = "/v1/groups/logs/%s"; + private static final String GROUPSV2_GROUP_CHANGES = "/v1/groups/logs/%s?maxSupportedChangeEpoch=%d&includeFirstState=%s&includeLastState=false"; private static final String GROUPSV2_AVATAR_REQUEST = "/v1/groups/avatar/form"; private static final String GROUPSV2_GROUP_JOIN = "/v1/groups/join/%s"; private static final String GROUPSV2_TOKEN = "/v1/groups/token"; @@ -2381,11 +2381,11 @@ public class PushServiceSocket { return GroupChange.parseFrom(readBodyBytes(response)); } - public GroupHistory getGroupsV2GroupHistory(int fromVersion, GroupsV2AuthorizationString authorization) + public GroupHistory getGroupsV2GroupHistory(int fromVersion, GroupsV2AuthorizationString authorization, int highestKnownEpoch, boolean includeFirstState) throws IOException { Response response = makeStorageRequestResponse(authorization.toString(), - String.format(Locale.US, GROUPSV2_GROUP_CHANGES, fromVersion), + String.format(Locale.US, GROUPSV2_GROUP_CHANGES, fromVersion, highestKnownEpoch, includeFirstState), "GET", null, GROUPS_V2_GET_LOGS_HANDLER);