Improve GV2 update speed by only requesting a full snapshot when necessary.

fork-5.53.8
Cody Henthorne 2022-02-09 14:52:01 -05:00 zatwierdzone przez GitHub
rodzic 210bb23aa4
commit bb1e6ffae0
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 4AEE18F83AFDEB23
9 zmienionych plików z 797 dodań i 181 usunięć

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -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<GroupRecord> 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<GroupRecord> 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<DecryptedMember> selfAsMemberOptional = DecryptedGroupUtil.findMemberByUuid(newLocalState.getMembersList(), Recipient.self().requireAci().uuid());
if (selfAsMemberOptional.isPresent()) {
DecryptedMember selfAsMember = selfAsMemberOptional.get();
int revisionJoinedAt = selfAsMember.getJoinedAtRevision();
Optional<Recipient> 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<LocalGroupLogEntry> 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<RecipientId> 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<ServerGroupLogEntry> 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<DecryptedMember> selfAsMemberOptional = DecryptedGroupUtil.findMemberByUuid(newLocalState.getMembersList(), aci.uuid());
if (selfAsMemberOptional.isPresent()) {
DecryptedMember selfAsMember = selfAsMemberOptional.get();
int revisionJoinedAt = selfAsMember.getJoinedAtRevision();
Optional<Recipient> 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<LocalGroupLogEntry> 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<RecipientId> 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<ACI> 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<DecryptedPendingMember> pendingByUuid = DecryptedGroupUtil.findPendingByUuid(decryptedGroupV2Context.getGroupState().getPendingMembersList(), Recipient.self().requireAci().uuid());
Optional<DecryptedPendingMember> pendingByUuid = DecryptedGroupUtil.findPendingByUuid(decryptedGroupV2Context.getGroupState().getPendingMembersList(), aci.uuid());
if (pendingByUuid.isPresent()) {
return Optional.fromNullable(UuidUtil.fromByteStringOrNull(pendingByUuid.get().getAddedByUuid()));
}

Wyświetl plik

@ -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<DecryptedMember> = extendGroup?.membersList ?: emptyList(),
pendingMembers: List<DecryptedPendingMember> = extendGroup?.pendingMembersList ?: emptyList(),
requestingMembers: List<DecryptedRequestingMember> = 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<ChangeLog> = 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<GroupDatabase.GroupRecord> = 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<DecryptedMember> = emptyList(),
pendingMembers: List<DecryptedPendingMember> = emptyList(),
requestingMembers: List<DecryptedRequestingMember> = 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<DecryptedMember> = extendGroup?.membersList ?: emptyList(),
pendingMembers: List<DecryptedPendingMember> = extendGroup?.pendingMembersList ?: emptyList(),
requestingMembers: List<DecryptedRequestingMember> = 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<GroupDatabase.GroupRecord> {
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<DecryptedMember> = emptyList(),
pendingMembers: List<DecryptedPendingMember> = emptyList(),
requestingMembers: List<DecryptedRequestingMember> = 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()
}

Wyświetl plik

@ -83,7 +83,7 @@ public class MockApplicationDependencyProvider implements ApplicationDependencie
@Override
public @NonNull JobManager provideJobManager() {
return null;
return mock(JobManager.class);
}
@Override

Wyświetl plik

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

Wyświetl plik

@ -12,7 +12,7 @@ public final class DecryptedGroupHistoryEntry {
private final Optional<DecryptedGroup> group;
private final Optional<DecryptedGroupChange> change;
DecryptedGroupHistoryEntry(Optional<DecryptedGroup> group, Optional<DecryptedGroupChange> change)
public DecryptedGroupHistoryEntry(Optional<DecryptedGroup> group, Optional<DecryptedGroupChange> change)
throws InvalidGroupStateException
{
if (group.isPresent() && change.isPresent() && group.get().getRevision() != change.get().getRevision()) {

Wyświetl plik

@ -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<GroupChanges.GroupChangeState> changesList = new LinkedList<>();
PushServiceSocket.GroupHistory group;
PushServiceSocket.GroupHistory group = socket.getGroupsV2GroupHistory(fromRevision, authorization, GroupsV2Operations.HIGHEST_KNOWN_EPOCH, includeFirstState);
List<DecryptedGroupHistoryEntry> result = new ArrayList<>(group.getGroupChanges().getGroupChangesList().size());
GroupsV2Operations.GroupOperations groupOperations = groupsOperations.forGroup(groupSecretParams);
group = socket.getGroupsV2GroupHistory(fromRevision, authorization);
changesList.addAll(group.getGroupChanges().getGroupChangesList());
ArrayList<DecryptedGroupHistoryEntry> result = new ArrayList<>(changesList.size());
GroupsV2Operations.GroupOperations groupOperations = groupsOperations.forGroup(groupSecretParams);
for (GroupChanges.GroupChangeState change : changesList) {
Optional<DecryptedGroup> decryptedGroup = change.hasGroupState () ? Optional.of(groupOperations.decryptGroup(change.getGroupState())) : Optional.absent();
Optional<DecryptedGroupChange> decryptedChange = change.hasGroupChange() ? groupOperations.decryptChange(change.getGroupChange(), false) : Optional.absent();
for (GroupChanges.GroupChangeState change : group.getGroupChanges().getGroupChangesList()) {
Optional<DecryptedGroup> decryptedGroup = change.hasGroupState() ? Optional.of(groupOperations.decryptGroup(change.getGroupState())) : Optional.absent();
Optional<DecryptedGroupChange> decryptedChange = change.hasGroupChange() ? groupOperations.decryptChange(change.getGroupChange(), false) : Optional.absent();
result.add(new DecryptedGroupHistoryEntry(decryptedGroup, decryptedChange));
}

Wyświetl plik

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