kopia lustrzana https://github.com/ryukoposting/Signal-Android
Improve GV2 update speed by only requesting a full snapshot when necessary.
rodzic
210bb23aa4
commit
bb1e6ffae0
|
@ -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);
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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()));
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
|
@ -83,7 +83,7 @@ public class MockApplicationDependencyProvider implements ApplicationDependencie
|
|||
|
||||
@Override
|
||||
public @NonNull JobManager provideJobManager() {
|
||||
return null;
|
||||
return mock(JobManager.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
|
@ -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()) {
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
Ładowanie…
Reference in New Issue