Refactor group state processing.

This commit is contained in:
Cody Henthorne
2024-05-22 10:00:17 -04:00
parent 1296365bed
commit 6362da7a50
39 changed files with 1930 additions and 2009 deletions

View File

@@ -340,15 +340,6 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
}
)
switchPref(
title = DSLSettingsText.from("Ignore server changes"),
summary = DSLSettingsText.from("Changes in server's response will be ignored, causing passive voice update messages if P2P is also ignored."),
isChecked = state.gv2ignoreServerChanges,
onClick = {
viewModel.setGv2IgnoreServerChanges(!state.gv2ignoreServerChanges)
}
)
switchPref(
title = DSLSettingsText.from("Ignore P2P changes"),
summary = DSLSettingsText.from("Changes sent P2P will be ignored. In conjunction with ignoring server changes, will cause passive voice."),

View File

@@ -7,7 +7,6 @@ data class InternalSettingsState(
val seeMoreUserDetails: Boolean,
val shakeToReport: Boolean,
val gv2forceInvites: Boolean,
val gv2ignoreServerChanges: Boolean,
val gv2ignoreP2PChanges: Boolean,
val allowCensorshipSetting: Boolean,
val forceWebsocketMode: Boolean,

View File

@@ -54,11 +54,6 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
refresh()
}
fun setGv2IgnoreServerChanges(enabled: Boolean) {
preferenceDataStore.putBoolean(InternalValues.GV2_IGNORE_SERVER_CHANGES, enabled)
refresh()
}
fun setGv2IgnoreP2PChanges(enabled: Boolean) {
preferenceDataStore.putBoolean(InternalValues.GV2_IGNORE_P2P_CHANGES, enabled)
refresh()
@@ -148,7 +143,6 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
seeMoreUserDetails = SignalStore.internalValues().recipientDetails(),
shakeToReport = SignalStore.internalValues().shakeToReport(),
gv2forceInvites = SignalStore.internalValues().gv2ForceInvites(),
gv2ignoreServerChanges = SignalStore.internalValues().gv2IgnoreServerChanges(),
gv2ignoreP2PChanges = SignalStore.internalValues().gv2IgnoreP2PChanges(),
allowCensorshipSetting = SignalStore.internalValues().allowChangingCensorshipSetting(),
forceWebsocketMode = SignalStore.internalValues().isWebsocketModeForced,

View File

@@ -18,7 +18,7 @@ import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.model.GroupRecord;
import org.thoughtcrime.securesms.groups.v2.GroupInviteLinkUrl;
import org.thoughtcrime.securesms.groups.v2.GroupLinkPassword;
import org.thoughtcrime.securesms.groups.v2.processing.GroupsV2StateProcessor;
import org.thoughtcrime.securesms.groups.v2.processing.GroupUpdateResult;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.whispersystems.signalservice.api.groupsv2.GroupLinkNotActiveException;
@@ -146,14 +146,14 @@ public final class GroupManager {
}
@WorkerThread
public static GroupsV2StateProcessor.GroupUpdateResult updateGroupFromServer(@NonNull Context context,
@NonNull GroupMasterKey groupMasterKey,
@NonNull Optional<GroupRecord> groupRecord,
@Nullable GroupSecretParams groupSecretParams,
int revision,
long timestamp,
@Nullable byte[] signedGroupChange,
@Nullable String serverGuid)
public static GroupUpdateResult updateGroupFromServer(@NonNull Context context,
@NonNull GroupMasterKey groupMasterKey,
@NonNull Optional<GroupRecord> groupRecord,
@Nullable GroupSecretParams groupSecretParams,
int revision,
long timestamp,
@Nullable byte[] signedGroupChange,
@Nullable String serverGuid)
throws GroupChangeBusyException, IOException, GroupNotAMemberException
{
try (GroupManagerV2.GroupUpdater updater = new GroupManagerV2(context).updater(groupMasterKey)) {

View File

@@ -36,6 +36,7 @@ import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.groups.v2.GroupCandidateHelper;
import org.thoughtcrime.securesms.groups.v2.GroupInviteLinkUrl;
import org.thoughtcrime.securesms.groups.v2.GroupLinkPassword;
import org.thoughtcrime.securesms.groups.v2.processing.GroupUpdateResult;
import org.thoughtcrime.securesms.groups.v2.processing.GroupsV2StateProcessor;
import org.thoughtcrime.securesms.jobs.ProfileUploadJob;
import org.thoughtcrime.securesms.jobs.PushGroupSilentUpdateSendJob;
@@ -603,15 +604,15 @@ final class GroupManagerV2 {
private GroupChange.Actions.Builder resolveConflict(@NonNull ServiceId authServiceId, @NonNull GroupChange.Actions.Builder change)
throws IOException, GroupNotAMemberException, GroupChangeFailedException
{
GroupsV2StateProcessor.GroupUpdateResult groupUpdateResult = GroupsV2StateProcessor.forGroup(serviceIds, groupMasterKey)
.updateLocalGroupToRevision(GroupsV2StateProcessor.LATEST, System.currentTimeMillis(), null);
GroupUpdateResult groupUpdateResult = GroupsV2StateProcessor.forGroup(serviceIds, groupMasterKey)
.updateLocalGroupToRevision(GroupsV2StateProcessor.LATEST, System.currentTimeMillis());
if (groupUpdateResult.getLatestServer() == null) {
Log.w(TAG, "Latest server state null.");
throw new GroupChangeFailedException();
}
if (groupUpdateResult.getGroupState() != GroupsV2StateProcessor.GroupState.GROUP_UPDATED) {
if (groupUpdateResult.getUpdateStatus() != GroupUpdateResult.UpdateStatus.GROUP_UPDATED) {
int serverRevision = groupUpdateResult.getLatestServer().revision;
int localRevision = groupDatabase.requireGroup(groupId).requireV2GroupProperties().getGroupRevision();
int revisionDelta = serverRevision - localRevision;
@@ -722,20 +723,20 @@ final class GroupManagerV2 {
throws IOException, GroupNotAMemberException
{
GroupsV2StateProcessor.forGroup(serviceIds, groupMasterKey)
.updateLocalGroupToRevision(revision, timestamp, null);
.updateLocalGroupToRevision(revision, timestamp);
}
@WorkerThread
GroupsV2StateProcessor.GroupUpdateResult updateLocalToServerRevision(int revision,
long timestamp,
@NonNull Optional<GroupRecord> localRecord,
@Nullable GroupSecretParams groupSecretParams,
@Nullable byte[] signedGroupChange,
@Nullable String serverGuid)
GroupUpdateResult updateLocalToServerRevision(int revision,
long timestamp,
@NonNull Optional<GroupRecord> localRecord,
@Nullable GroupSecretParams groupSecretParams,
@Nullable byte[] signedGroupChange,
@Nullable String serverGuid)
throws IOException, GroupNotAMemberException
{
return GroupsV2StateProcessor.forGroup(serviceIds, groupMasterKey, groupSecretParams)
.updateLocalGroupToRevision(revision, timestamp, localRecord, getDecryptedGroupChange(signedGroupChange), serverGuid);
.updateLocalGroupToRevision(revision, timestamp, getDecryptedGroupChange(signedGroupChange), localRecord, serverGuid);
}
@WorkerThread

View File

@@ -1,24 +0,0 @@
package org.thoughtcrime.securesms.groups;
public final class GroupNotAMemberException extends GroupChangeException {
private final boolean likelyPendingMember;
public GroupNotAMemberException(Throwable throwable) {
super(throwable);
this.likelyPendingMember = false;
}
public GroupNotAMemberException(GroupNotAMemberException throwable, boolean likelyPendingMember) {
super(throwable.getCause() != null ? throwable.getCause() : throwable);
this.likelyPendingMember = likelyPendingMember;
}
GroupNotAMemberException() {
this.likelyPendingMember = false;
}
public boolean isLikelyPendingMember() {
return likelyPendingMember;
}
}

View File

@@ -0,0 +1,7 @@
package org.thoughtcrime.securesms.groups
class GroupNotAMemberException : GroupChangeException {
constructor()
constructor(throwable: Throwable) : super(throwable)
constructor(throwable: GroupNotAMemberException) : super(throwable.cause ?: throwable)
}

View File

@@ -11,15 +11,12 @@ 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.thoughtcrime.securesms.backup.v2.proto.GroupChangeChatUpdate;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.model.GroupsV2UpdateMessageConverter;
import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context;
import org.thoughtcrime.securesms.database.model.databaseprotos.GV2UpdateDescription;
import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExtras;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.whispersystems.signalservice.api.groupsv2.PartialDecryptedGroup;
import org.whispersystems.signalservice.api.push.ServiceId;
import org.whispersystems.signalservice.api.push.ServiceId.ACI;
import org.whispersystems.signalservice.internal.push.GroupContextV2;
@@ -33,24 +30,6 @@ public final class GroupProtoUtil {
private GroupProtoUtil() {
}
public static int findRevisionWeWereAdded(@NonNull PartialDecryptedGroup partialDecryptedGroup, @NonNull ACI self)
throws GroupNotAMemberException
{
ByteString bytes = self.toByteString();
for (DecryptedMember decryptedMember : partialDecryptedGroup.getMembersList()) {
if (decryptedMember.aciBytes.equals(bytes)) {
return decryptedMember.joinedAtRevision;
}
}
for (DecryptedPendingMember decryptedMember : partialDecryptedGroup.getPendingMembersList()) {
if (decryptedMember.serviceIdBytes.equals(bytes)) {
// Assume latest, we don't have any information about when pending members were invited
return partialDecryptedGroup.getRevision();
}
}
throw new GroupNotAMemberException();
}
public static GV2UpdateDescription createOutgoingGroupV2UpdateDescription(@NonNull GroupMasterKey masterKey,
@NonNull GroupMutation groupMutation,
@Nullable GroupChange signedServerChange)

View File

@@ -1,29 +0,0 @@
package org.thoughtcrime.securesms.groups.v2.processing;
import androidx.annotation.NonNull;
import java.util.Collection;
/**
* Pair of log entries applied and a new {@link GlobalGroupState}.
*/
final class AdvanceGroupStateResult {
@NonNull private final Collection<LocalGroupLogEntry> processedLogEntries;
@NonNull private final GlobalGroupState newGlobalGroupState;
AdvanceGroupStateResult(@NonNull Collection<LocalGroupLogEntry> processedLogEntries,
@NonNull GlobalGroupState newGlobalGroupState)
{
this.processedLogEntries = processedLogEntries;
this.newGlobalGroupState = newGlobalGroupState;
}
@NonNull Collection<LocalGroupLogEntry> getProcessedLogEntries() {
return processedLogEntries;
}
@NonNull GlobalGroupState getNewGlobalGroupState() {
return newGlobalGroupState;
}
}

View File

@@ -0,0 +1,17 @@
package org.thoughtcrime.securesms.groups.v2.processing
import org.signal.storageservice.protos.groups.local.DecryptedGroup
import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupChangeLog
/**
* Result of applying group state changes to a local group state.
*
* @param updatedGroupState cumulative result of applying changes to the input group state
* @param processedLogEntries Local view of logs/changes applied from input group state to [updatedGroupState]
* @param remainingRemoteGroupChanges Remote view of logs/changes yet to be applied to the local [updatedGroupState]
*/
data class AdvanceGroupStateResult @JvmOverloads constructor(
val updatedGroupState: DecryptedGroup?,
val processedLogEntries: Collection<AppliedGroupChangeLog> = emptyList(),
val remainingRemoteGroupChanges: List<DecryptedGroupChangeLog> = emptyList()
)

View File

@@ -0,0 +1,19 @@
package org.thoughtcrime.securesms.groups.v2.processing
import org.signal.storageservice.protos.groups.local.DecryptedGroup
import org.signal.storageservice.protos.groups.local.DecryptedGroupChange
/**
* Pair of a group state and optionally the corresponding change.
*
* Similar to [org.whispersystems.signalservice.api.groupsv2.DecryptedGroupChangeLog] but guaranteed to have a group state.
*
* Changes are typically not available for pending members.
*/
data class AppliedGroupChangeLog internal constructor(val group: DecryptedGroup, val change: DecryptedGroupChange?) {
init {
if (change != null && group.revision != change.revision) {
throw AssertionError()
}
}
}

View File

@@ -1,75 +0,0 @@
package org.thoughtcrime.securesms.groups.v2.processing;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.signal.storageservice.protos.groups.local.DecryptedGroup;
import org.whispersystems.signalservice.api.groupsv2.GroupHistoryPage;
import java.util.Collection;
import java.util.List;
/**
* Combination of Local and Server group state.
*/
final class GlobalGroupState {
@Nullable private final DecryptedGroup localState;
@NonNull private final List<ServerGroupLogEntry> serverHistory;
@NonNull private final GroupHistoryPage.PagingData pagingData;
GlobalGroupState(@Nullable DecryptedGroup localState,
@NonNull List<ServerGroupLogEntry> serverHistory,
@NonNull GroupHistoryPage.PagingData pagingData)
{
this.localState = localState;
this.serverHistory = serverHistory;
this.pagingData = pagingData;
}
GlobalGroupState(@Nullable DecryptedGroup localState,
@NonNull List<ServerGroupLogEntry> serverHistory)
{
this(localState, serverHistory, GroupHistoryPage.PagingData.NONE);
}
@Nullable DecryptedGroup getLocalState() {
return localState;
}
@NonNull Collection<ServerGroupLogEntry> getServerHistory() {
return serverHistory;
}
int getEarliestRevisionNumber() {
if (localState != null) {
return localState.revision;
} else {
if (serverHistory.isEmpty()) {
throw new AssertionError();
}
return serverHistory.get(0).getRevision();
}
}
int getLatestRevisionNumber() {
if (serverHistory.isEmpty()) {
if (localState == null) {
throw new AssertionError();
}
return localState.revision;
}
return serverHistory.get(serverHistory.size() - 1).getRevision();
}
public boolean hasMore() {
return pagingData.hasMorePages();
}
public int getNextPageRevision() {
if (!pagingData.hasMorePages()) {
throw new AssertionError("No paging data available");
}
return pagingData.getNextPageRevision();
}
}

View File

@@ -0,0 +1,39 @@
package org.thoughtcrime.securesms.groups.v2.processing
import org.signal.storageservice.protos.groups.local.DecryptedGroup
import org.signal.storageservice.protos.groups.local.DecryptedGroupChange
import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupChangeLog
/**
* Combination of Local and Server group state.
*/
class GroupStateDiff(
val previousGroupState: DecryptedGroup?,
val serverHistory: List<DecryptedGroupChangeLog>
) {
constructor(previousGroupState: DecryptedGroup?, changedGroupState: DecryptedGroup?, change: DecryptedGroupChange?) : this(previousGroupState, listOf(DecryptedGroupChangeLog(changedGroupState, change)))
val earliestRevisionNumber: Int
get() {
if (previousGroupState != null) {
return previousGroupState.revision
} else {
if (serverHistory.isEmpty()) {
throw AssertionError()
}
return serverHistory[0].revision
}
}
val latestRevisionNumber: Int
get() {
if (serverHistory.isEmpty()) {
if (previousGroupState == null) {
throw AssertionError()
}
return previousGroupState.revision
}
return serverHistory[serverHistory.size - 1].revision
}
}

View File

@@ -6,6 +6,7 @@ import androidx.annotation.Nullable;
import org.signal.core.util.logging.Log;
import org.signal.storageservice.protos.groups.local.DecryptedGroup;
import org.signal.storageservice.protos.groups.local.DecryptedGroupChange;
import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupChangeLog;
import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil;
import org.whispersystems.signalservice.api.groupsv2.GroupChangeReconstruct;
import org.whispersystems.signalservice.api.groupsv2.GroupChangeUtil;
@@ -17,47 +18,47 @@ import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
final class GroupStateMapper {
final class GroupStatePatcher {
private static final String TAG = Log.tag(GroupStateMapper.class);
private static final String TAG = Log.tag(GroupStatePatcher.class);
static final int LATEST = Integer.MAX_VALUE;
static final int PLACEHOLDER_REVISION = -1;
static final int RESTORE_PLACEHOLDER_REVISION = -2;
private static final Comparator<ServerGroupLogEntry> BY_REVISION = (o1, o2) -> Integer.compare(o1.getRevision(), o2.getRevision());
private static final Comparator<DecryptedGroupChangeLog> BY_REVISION = (o1, o2) -> Integer.compare(o1.getRevision(), o2.getRevision());
private GroupStateMapper() {
private GroupStatePatcher() {
}
/**
* Given an input {@link GlobalGroupState} and a {@param maximumRevisionToApply}, returns a result
* Given an input {@link GroupStateDiff} and a {@param maximumRevisionToApply}, returns a result
* containing what the new local group state should be, and any remaining revision history to apply.
* <p>
* Function is pure.
* @param maximumRevisionToApply Use {@link #LATEST} to apply the very latest.
*/
static @NonNull AdvanceGroupStateResult partiallyAdvanceGroupState(@NonNull GlobalGroupState inputState,
int maximumRevisionToApply)
static @NonNull AdvanceGroupStateResult applyGroupStateDiff(@NonNull GroupStateDiff inputState,
int maximumRevisionToApply)
{
AdvanceGroupStateResult groupStateResult = processChanges(inputState, maximumRevisionToApply);
return cleanDuplicatedChanges(groupStateResult, inputState.getLocalState());
return cleanDuplicatedChanges(groupStateResult, inputState.getPreviousGroupState());
}
private static @NonNull AdvanceGroupStateResult processChanges(@NonNull GlobalGroupState inputState,
private static @NonNull AdvanceGroupStateResult processChanges(@NonNull GroupStateDiff inputState,
int maximumRevisionToApply)
{
HashMap<Integer, ServerGroupLogEntry> statesToApplyNow = new HashMap<>(inputState.getServerHistory().size());
ArrayList<ServerGroupLogEntry> statesToApplyLater = new ArrayList<>(inputState.getServerHistory().size());
DecryptedGroup current = inputState.getLocalState();
HashMap<Integer, DecryptedGroupChangeLog> statesToApplyNow = new HashMap<>(inputState.getServerHistory().size());
ArrayList<DecryptedGroupChangeLog> statesToApplyLater = new ArrayList<>(inputState.getServerHistory().size());
DecryptedGroup current = inputState.getPreviousGroupState();
StateChain<DecryptedGroup, DecryptedGroupChange> stateChain = createNewMapper();
if (inputState.getServerHistory().isEmpty()) {
return new AdvanceGroupStateResult(Collections.emptyList(), new GlobalGroupState(current, Collections.emptyList()));
return new AdvanceGroupStateResult(current);
}
for (ServerGroupLogEntry entry : inputState.getServerHistory()) {
for (DecryptedGroupChangeLog entry : inputState.getServerHistory()) {
if (entry.getRevision() > maximumRevisionToApply) {
statesToApplyLater.add(entry);
} else {
@@ -77,7 +78,7 @@ final class GroupStateMapper {
}
for (int revision = from; revision >= 0 && revision <= to; revision++) {
ServerGroupLogEntry entry = statesToApplyNow.get(revision);
DecryptedGroupChangeLog entry = statesToApplyNow.get(revision);
if (entry == null) {
Log.w(TAG, "Could not find group log on server V" + revision);
continue;
@@ -96,15 +97,15 @@ final class GroupStateMapper {
}
List<StateChain.Pair<DecryptedGroup, DecryptedGroupChange>> mapperList = stateChain.getList();
List<LocalGroupLogEntry> appliedChanges = new ArrayList<>(mapperList.size());
List<AppliedGroupChangeLog> appliedChanges = new ArrayList<>(mapperList.size());
for (StateChain.Pair<DecryptedGroup, DecryptedGroupChange> entry : mapperList) {
if (current == null || entry.getDelta() != null) {
appliedChanges.add(new LocalGroupLogEntry(entry.getState(), entry.getDelta()));
appliedChanges.add(new AppliedGroupChangeLog(entry.getState(), entry.getDelta()));
}
}
return new AdvanceGroupStateResult(appliedChanges, new GlobalGroupState(stateChain.getLatestState(), statesToApplyLater));
return new AdvanceGroupStateResult(stateChain.getLatestState(), appliedChanges, statesToApplyLater);
}
private static AdvanceGroupStateResult cleanDuplicatedChanges(@NonNull AdvanceGroupStateResult groupStateResult,
@@ -112,21 +113,21 @@ final class GroupStateMapper {
{
if (previousGroupState == null) return groupStateResult;
ArrayList<LocalGroupLogEntry> appliedChanges = new ArrayList<>(groupStateResult.getProcessedLogEntries().size());
ArrayList<AppliedGroupChangeLog> appliedChanges = new ArrayList<>(groupStateResult.getProcessedLogEntries().size());
for (LocalGroupLogEntry entry : groupStateResult.getProcessedLogEntries()) {
for (AppliedGroupChangeLog entry : groupStateResult.getProcessedLogEntries()) {
DecryptedGroupChange change = entry.getChange();
if (change != null) {
change = GroupChangeUtil.resolveConflict(previousGroupState, change).build();
}
appliedChanges.add(new LocalGroupLogEntry(entry.getGroup(), change));
appliedChanges.add(new AppliedGroupChangeLog(entry.getGroup(), change));
previousGroupState = entry.getGroup();
}
return new AdvanceGroupStateResult(appliedChanges, groupStateResult.getNewGlobalGroupState());
return new AdvanceGroupStateResult(groupStateResult.getUpdatedGroupState(), appliedChanges, groupStateResult.getRemainingRemoteGroupChanges());
}
private static StateChain<DecryptedGroup, DecryptedGroupChange> createNewMapper() {

View File

@@ -0,0 +1,29 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.groups.v2.processing
import org.signal.storageservice.protos.groups.local.DecryptedGroup
/**
* Result of updating a local group state via P2P group change or a server pull.
*/
data class GroupUpdateResult(val updateStatus: UpdateStatus, val latestServer: DecryptedGroup?) {
companion object {
val CONSISTENT_OR_AHEAD = GroupUpdateResult(UpdateStatus.GROUP_CONSISTENT_OR_AHEAD, null)
fun updated(updatedGroupState: DecryptedGroup): GroupUpdateResult {
return GroupUpdateResult(UpdateStatus.GROUP_UPDATED, updatedGroupState)
}
}
enum class UpdateStatus {
/** The local group was successfully updated to be consistent with the message revision */
GROUP_UPDATED,
/** The local group is already consistent with the message revision or is ahead of the message revision */
GROUP_CONSISTENT_OR_AHEAD
}
}

View File

@@ -1,799 +0,0 @@
package org.thoughtcrime.securesms.groups.v2.processing;
import android.text.TextUtils;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.annotation.WorkerThread;
import org.signal.core.util.logging.Log;
import org.signal.libsignal.zkgroup.VerificationFailedException;
import org.signal.libsignal.zkgroup.groups.GroupMasterKey;
import org.signal.libsignal.zkgroup.groups.GroupSecretParams;
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.thoughtcrime.securesms.database.GroupTable;
import org.thoughtcrime.securesms.database.MessageTable;
import org.thoughtcrime.securesms.database.RecipientTable;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.ThreadTable;
import org.thoughtcrime.securesms.database.model.GroupRecord;
import org.thoughtcrime.securesms.database.model.GroupsV2UpdateMessageConverter;
import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context;
import org.thoughtcrime.securesms.database.model.databaseprotos.GV2UpdateDescription;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.groups.GroupId;
import org.thoughtcrime.securesms.groups.GroupMutation;
import org.thoughtcrime.securesms.groups.GroupNotAMemberException;
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.jobs.AvatarGroupsV2DownloadJob;
import org.thoughtcrime.securesms.jobs.DirectoryRefreshJob;
import org.thoughtcrime.securesms.jobs.LeaveGroupV2Job;
import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob;
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.mms.IncomingMessage;
import org.thoughtcrime.securesms.mms.MmsException;
import org.thoughtcrime.securesms.mms.OutgoingMessage;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupHistoryEntry;
import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil;
import org.whispersystems.signalservice.api.groupsv2.GroupChangeReconstruct;
import org.whispersystems.signalservice.api.groupsv2.GroupHistoryPage;
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Api;
import org.whispersystems.signalservice.api.groupsv2.InvalidGroupStateException;
import org.whispersystems.signalservice.api.groupsv2.NotAbleToApplyGroupV2ChangeException;
import org.whispersystems.signalservice.api.groupsv2.PartialDecryptedGroup;
import org.whispersystems.signalservice.api.push.ServiceId;
import org.whispersystems.signalservice.api.push.ServiceId.ACI;
import org.whispersystems.signalservice.api.push.ServiceIds;
import org.whispersystems.signalservice.api.util.UuidUtil;
import org.whispersystems.signalservice.internal.push.exceptions.GroupNotFoundException;
import org.whispersystems.signalservice.internal.push.exceptions.NotInGroupException;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
/**
* Advances a groups state to a specified revision.
*/
public class GroupsV2StateProcessor {
private static final String TAG = Log.tag(GroupsV2StateProcessor.class);
public static final int LATEST = GroupStateMapper.LATEST;
/**
* Used to mark a group state as a placeholder when there is partial knowledge (title and avater)
* gathered from a group join link.
*/
public static final int PLACEHOLDER_REVISION = GroupStateMapper.PLACEHOLDER_REVISION;
/**
* Used to mark a group state as a placeholder when you have no knowledge at all of the group
* e.g. from a group master key from a storage service restore.
*/
public static final int RESTORE_PLACEHOLDER_REVISION = GroupStateMapper.RESTORE_PLACEHOLDER_REVISION;
private GroupsV2StateProcessor() {
}
public static StateProcessorForGroup forGroup(@NonNull ServiceIds serviceIds, @NonNull GroupMasterKey groupMasterKey) {
return forGroup(serviceIds, groupMasterKey, null);
}
public static StateProcessorForGroup forGroup(@NonNull ServiceIds serviceIds, @NonNull GroupMasterKey groupMasterKey, @Nullable GroupSecretParams groupSecretParams) {
groupSecretParams = groupSecretParams != null ? groupSecretParams : GroupSecretParams.deriveFromMasterKey(groupMasterKey);
return new StateProcessorForGroup(
serviceIds,
SignalDatabase.groups(),
ApplicationDependencies.getSignalServiceAccountManager().getGroupsV2Api(),
ApplicationDependencies.getGroupsV2Authorization(),
groupMasterKey,
groupSecretParams,
SignalDatabase.recipients()
);
}
public enum GroupState {
/**
* The local group was successfully updated to be consistent with the message revision
*/
GROUP_UPDATED,
/**
* The local group is already consistent with the message revision or is ahead of the message revision
*/
GROUP_CONSISTENT_OR_AHEAD
}
public static class GroupUpdateResult {
private final GroupState groupState;
private final DecryptedGroup latestServer;
GroupUpdateResult(@NonNull GroupState groupState, @Nullable DecryptedGroup latestServer) {
this.groupState = groupState;
this.latestServer = latestServer;
}
public @NonNull GroupState getGroupState() {
return groupState;
}
public @Nullable DecryptedGroup getLatestServer() {
return latestServer;
}
}
public static final class StateProcessorForGroup {
private final ServiceIds serviceIds;
private final GroupTable 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 ServiceIds serviceIds,
@NonNull GroupTable groupDatabase,
@NonNull GroupsV2Api groupsV2Api,
@NonNull GroupsV2Authorization groupsV2Authorization,
@NonNull GroupMasterKey groupMasterKey,
@NonNull GroupSecretParams groupSecretParams,
@NonNull RecipientTable recipientTable)
{
this.serviceIds = serviceIds;
this.groupDatabase = groupDatabase;
this.groupsV2Api = groupsV2Api;
this.groupsV2Authorization = groupsV2Authorization;
this.masterKey = groupMasterKey;
this.groupSecretParams = groupSecretParams;
this.groupId = GroupId.v2(groupSecretParams.getPublicParams().getGroupIdentifier());
this.profileAndMessageHelper = new ProfileAndMessageHelper(serviceIds.getAci(), groupMasterKey, groupId, recipientTable);
}
@VisibleForTesting StateProcessorForGroup(@NonNull ServiceIds serviceIds,
@NonNull GroupTable groupDatabase,
@NonNull GroupsV2Api groupsV2Api,
@NonNull GroupsV2Authorization groupsV2Authorization,
@NonNull GroupMasterKey groupMasterKey,
@NonNull ProfileAndMessageHelper profileAndMessageHelper)
{
this.serviceIds = serviceIds;
this.groupDatabase = groupDatabase;
this.groupsV2Api = groupsV2Api;
this.groupsV2Authorization = groupsV2Authorization;
this.masterKey = groupMasterKey;
this.groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey);
this.groupId = GroupId.v2(groupSecretParams.getPublicParams().getGroupIdentifier());
this.profileAndMessageHelper = profileAndMessageHelper;
}
@WorkerThread
public GroupUpdateResult forceSanityUpdateFromServer(long timestamp)
throws IOException, GroupNotAMemberException
{
Optional<GroupRecord> localRecord = groupDatabase.getGroup(groupId);
DecryptedGroup localState = localRecord.map(g -> g.requireV2GroupProperties().getDecryptedGroup()).orElse(null);
DecryptedGroup serverState;
if (localState == null) {
info("No local state to force update");
return new GroupUpdateResult(GroupState.GROUP_CONSISTENT_OR_AHEAD, null);
}
try {
serverState = groupsV2Api.getGroup(groupSecretParams, groupsV2Authorization.getAuthorizationForToday(serviceIds, groupSecretParams));
} catch (NotInGroupException | GroupNotFoundException e) {
throw new GroupNotAMemberException(e);
} catch (VerificationFailedException | InvalidGroupStateException e) {
throw new IOException(e);
}
DecryptedGroupChange decryptedGroupChange = GroupChangeReconstruct.reconstructGroupChange(localState, serverState);
GlobalGroupState inputGroupState = new GlobalGroupState(localState, Collections.singletonList(new ServerGroupLogEntry(serverState, decryptedGroupChange)));
AdvanceGroupStateResult advanceGroupStateResult = GroupStateMapper.partiallyAdvanceGroupState(inputGroupState, serverState.revision);
DecryptedGroup newLocalState = advanceGroupStateResult.getNewGlobalGroupState().getLocalState();
if (newLocalState == null || newLocalState == inputGroupState.getLocalState()) {
info("Local state and server state are equal");
return new GroupUpdateResult(GroupState.GROUP_CONSISTENT_OR_AHEAD, null);
} else {
info("Local state (revision: " + localState.revision + ") does not match server state (revision: " + serverState.revision + "), updating");
}
updateLocalDatabaseGroupState(inputGroupState, newLocalState);
if (localState.revision == GroupsV2StateProcessor.RESTORE_PLACEHOLDER_REVISION) {
info("Inserting single update message for restore placeholder");
profileAndMessageHelper.insertUpdateMessages(timestamp, null, Collections.singleton(new LocalGroupLogEntry(newLocalState, null)), null);
} else {
info("Inserting force update messages");
profileAndMessageHelper.insertUpdateMessages(timestamp, localState, advanceGroupStateResult.getProcessedLogEntries(), null);
}
profileAndMessageHelper.persistLearnedProfileKeys(inputGroupState);
return new GroupUpdateResult(GroupState.GROUP_UPDATED, newLocalState);
}
/**
* Using network where required, will attempt to bring the local copy of the group up to the revision specified.
*
* @param revision use {@link #LATEST} to get latest.
*/
@WorkerThread
public GroupUpdateResult updateLocalGroupToRevision(final int revision,
final long timestamp,
@Nullable DecryptedGroupChange signedGroupChange)
throws IOException, GroupNotAMemberException
{
return updateLocalGroupToRevision(revision, timestamp, groupDatabase.getGroup(groupId), signedGroupChange, null);
}
/**
* Using network where required, will attempt to bring the local copy of the group up to the revision specified.
*
* @param revision use {@link #LATEST} to get latest.
*/
@WorkerThread
public GroupUpdateResult updateLocalGroupToRevision(final int revision,
final long timestamp,
@NonNull Optional<GroupRecord> localRecord,
@Nullable DecryptedGroupChange signedGroupChange,
@Nullable String serverGuid)
throws IOException, GroupNotAMemberException
{
if (localIsAtLeast(localRecord, revision)) {
return new GroupUpdateResult(GroupState.GROUP_CONSISTENT_OR_AHEAD, null);
}
GlobalGroupState inputGroupState = null;
DecryptedGroup localState = localRecord.map(g -> g.requireV2GroupProperties().getDecryptedGroup()).orElse(null);
if (signedGroupChange != null &&
localState != null &&
localState.revision + 1 == signedGroupChange.revision &&
revision == signedGroupChange.revision)
{
if (notInGroupAndNotBeingAdded(localRecord, signedGroupChange) && notHavingInviteRevoked(signedGroupChange)) {
warn("Ignoring P2P group change because we're not currently in the group and this change doesn't add us in. Falling back to a server fetch.");
} else if (SignalStore.internalValues().gv2IgnoreP2PChanges()) {
warn( "Ignoring P2P group change by setting");
} else {
try {
info("Applying P2P group change");
DecryptedGroup newState = DecryptedGroupUtil.apply(localState, signedGroupChange);
inputGroupState = new GlobalGroupState(localState, Collections.singletonList(new ServerGroupLogEntry(newState, signedGroupChange)));
} catch (NotAbleToApplyGroupV2ChangeException e) {
warn( "Unable to apply P2P group change", e);
}
}
}
if (inputGroupState == null) {
try {
return updateLocalGroupFromServerPaged(revision, localState, timestamp, false, serverGuid);
} catch (GroupNotAMemberException e) {
if (localState != null && signedGroupChange != null) {
try {
if (notInGroupAndNotBeingAdded(localRecord, signedGroupChange)) {
warn( "Server says we're not a member. Ignoring P2P group change because we're not currently in the group and this change doesn't add us in.");
} else {
info("Server says we're not a member. Applying P2P group change.");
DecryptedGroup newState = DecryptedGroupUtil.applyWithoutRevisionCheck(localState, signedGroupChange);
inputGroupState = new GlobalGroupState(localState, Collections.singletonList(new ServerGroupLogEntry(newState, signedGroupChange)));
}
} catch (NotAbleToApplyGroupV2ChangeException failed) {
warn( "Unable to apply P2P group change when not a member", failed);
}
}
if (inputGroupState == null) {
if (localState != null && DecryptedGroupUtil.isPendingOrRequesting(localState, serviceIds)) {
warn( "Unable to query server for group " + groupId + " server says we're not in group, but we think we are a pending or requesting member");
throw new GroupNotAMemberException(e, true);
} else {
warn( "Unable to query server for group " + groupId + " server says we're not in group, inserting leave message");
insertGroupLeave();
}
throw e;
}
}
}
AdvanceGroupStateResult advanceGroupStateResult = GroupStateMapper.partiallyAdvanceGroupState(inputGroupState, revision);
DecryptedGroup newLocalState = advanceGroupStateResult.getNewGlobalGroupState().getLocalState();
if (newLocalState == null || newLocalState == inputGroupState.getLocalState()) {
return new GroupUpdateResult(GroupState.GROUP_CONSISTENT_OR_AHEAD, null);
}
updateLocalDatabaseGroupState(inputGroupState, newLocalState);
if (localState != null && localState.revision == GroupsV2StateProcessor.RESTORE_PLACEHOLDER_REVISION) {
info("Inserting single update message for restore placeholder");
profileAndMessageHelper.insertUpdateMessages(timestamp, null, Collections.singleton(new LocalGroupLogEntry(newLocalState, null)), null);
} else {
profileAndMessageHelper.insertUpdateMessages(timestamp, localState, advanceGroupStateResult.getProcessedLogEntries(), serverGuid);
}
profileAndMessageHelper.persistLearnedProfileKeys(inputGroupState);
if (!signedGroupChange.promotePendingPniAciMembers.isEmpty()) {
ApplicationDependencies.getJobManager().add(new DirectoryRefreshJob(false));
}
GlobalGroupState remainingWork = advanceGroupStateResult.getNewGlobalGroupState();
if (remainingWork.getServerHistory().size() > 0) {
info(String.format(Locale.US, "There are more revisions on the server for this group, scheduling for later, V[%d..%d]", newLocalState.revision + 1, remainingWork.getLatestRevisionNumber()));
ApplicationDependencies.getJobManager().add(new RequestGroupV2InfoJob(groupId, remainingWork.getLatestRevisionNumber()));
}
return new GroupUpdateResult(GroupState.GROUP_UPDATED, newLocalState);
}
private boolean notInGroupAndNotBeingAdded(@NonNull Optional<GroupRecord> localRecord, @NonNull DecryptedGroupChange signedGroupChange) {
boolean currentlyInGroup = localRecord.isPresent() && localRecord.get().isActive();
boolean addedAsMember = signedGroupChange.newMembers
.stream()
.map(m -> m.aciBytes)
.map(ACI::parseOrNull)
.filter(Objects::nonNull)
.anyMatch(serviceIds::matches);
boolean addedAsPendingMember = signedGroupChange.newPendingMembers
.stream()
.map(m -> m.serviceIdBytes)
.anyMatch(serviceIds::matches);
boolean addedAsRequestingMember = signedGroupChange.newRequestingMembers
.stream()
.map(m -> m.aciBytes)
.map(ACI::parseOrNull)
.filter(Objects::nonNull)
.anyMatch(serviceIds::matches);
return !currentlyInGroup && !addedAsMember && !addedAsPendingMember && !addedAsRequestingMember;
}
private boolean notHavingInviteRevoked(@NonNull DecryptedGroupChange signedGroupChange) {
boolean havingInviteRevoked = signedGroupChange.deletePendingMembers
.stream()
.map(m -> m.serviceIdBytes)
.anyMatch(serviceIds::matches);
return !havingInviteRevoked;
}
/**
* Using network, attempt to bring the local copy of the group up to the revision specified via paging.
*/
private GroupUpdateResult updateLocalGroupFromServerPaged(int revision, DecryptedGroup localState, long timestamp, boolean forceIncludeFirst, @Nullable String serverGuid) throws IOException, GroupNotAMemberException {
boolean latestRevisionOnly = revision == LATEST && (localState == null || localState.revision == GroupsV2StateProcessor.RESTORE_PLACEHOLDER_REVISION);
info("Paging from server revision: " + (revision == LATEST ? "latest" : revision) + ", latestOnly: " + latestRevisionOnly);
PartialDecryptedGroup latestServerGroup;
GlobalGroupState inputGroupState;
try {
latestServerGroup = groupsV2Api.getPartialDecryptedGroup(groupSecretParams, groupsV2Authorization.getAuthorizationForToday(serviceIds, groupSecretParams));
} catch (NotInGroupException | GroupNotFoundException e) {
throw new GroupNotAMemberException(e);
} catch (VerificationFailedException | InvalidGroupStateException e) {
throw new IOException(e);
}
if (localState != null && localState.revision >= latestServerGroup.getRevision() && GroupProtoUtil.isMember(serviceIds.getAci(), localState.members)) {
info("Local state is at or later than server");
return new GroupUpdateResult(GroupState.GROUP_CONSISTENT_OR_AHEAD, null);
}
if (latestRevisionOnly || !GroupProtoUtil.isMember(serviceIds.getAci(), latestServerGroup.getMembersList())) {
info("Latest revision or not a member, use latest only");
inputGroupState = new GlobalGroupState(localState, Collections.singletonList(new ServerGroupLogEntry(latestServerGroup.getFullyDecryptedGroup(), null)));
} else {
int revisionWeWereAdded = GroupProtoUtil.findRevisionWeWereAdded(latestServerGroup, serviceIds.getAci());
int logsNeededFrom = localState != null ? Math.max(localState.revision, revisionWeWereAdded) : revisionWeWereAdded;
boolean includeFirstState = forceIncludeFirst ||
localState == null ||
localState.revision < 0 ||
localState.revision == revisionWeWereAdded ||
!GroupProtoUtil.isMember(serviceIds.getAci(), localState.members) ||
(revision == LATEST && localState.revision + 1 < latestServerGroup.getRevision());
info("Requesting from server currentRevision: " + (localState != null ? localState.revision : "null") +
" logsNeededFrom: " + logsNeededFrom +
" includeFirstState: " + includeFirstState +
" forceIncludeFirst: " + forceIncludeFirst);
inputGroupState = getFullMemberHistoryPage(localState, logsNeededFrom, includeFirstState);
}
ProfileKeySet profileKeys = new ProfileKeySet();
DecryptedGroup finalState = localState;
GlobalGroupState finalGlobalGroupState = inputGroupState;
boolean performCdsLookup = false;
boolean hasMore = true;
while (hasMore) {
AdvanceGroupStateResult advanceGroupStateResult = GroupStateMapper.partiallyAdvanceGroupState(inputGroupState, revision);
DecryptedGroup newLocalState = advanceGroupStateResult.getNewGlobalGroupState().getLocalState();
info("Advanced group to revision: " + (newLocalState != null ? newLocalState.revision : "null"));
if (newLocalState != null && !inputGroupState.hasMore() && !forceIncludeFirst) {
int newLocalRevision = newLocalState.revision;
int requestRevision = (revision == LATEST) ? latestServerGroup.getRevision() : revision;
if (newLocalRevision < requestRevision) {
warn( "Paging again with force first snapshot enabled due to error processing changes. New local revision [" + newLocalRevision + "] hasn't reached our desired level [" + requestRevision + "]");
return updateLocalGroupFromServerPaged(revision, localState, timestamp, true, serverGuid);
}
}
if (newLocalState == null || newLocalState == inputGroupState.getLocalState()) {
return new GroupUpdateResult(GroupState.GROUP_CONSISTENT_OR_AHEAD, null);
}
updateLocalDatabaseGroupState(inputGroupState, newLocalState);
if (localState == null || localState.revision != GroupsV2StateProcessor.RESTORE_PLACEHOLDER_REVISION) {
timestamp = profileAndMessageHelper.insertUpdateMessages(timestamp, localState, advanceGroupStateResult.getProcessedLogEntries(), serverGuid);
}
for (ServerGroupLogEntry entry : inputGroupState.getServerHistory()) {
if (entry.getGroup() != null) {
profileKeys.addKeysFromGroupState(entry.getGroup());
}
if (entry.getChange() != null) {
profileKeys.addKeysFromGroupChange(entry.getChange());
if (!entry.getChange().promotePendingPniAciMembers.isEmpty()) {
performCdsLookup = true;
}
}
}
finalState = newLocalState;
finalGlobalGroupState = advanceGroupStateResult.getNewGlobalGroupState();
hasMore = inputGroupState.hasMore();
if (hasMore) {
info("Request next page from server revision: " + finalState.revision + " nextPageRevision: " + inputGroupState.getNextPageRevision());
inputGroupState = getFullMemberHistoryPage(finalState, inputGroupState.getNextPageRevision(), false);
}
}
if (localState != null && localState.revision == GroupsV2StateProcessor.RESTORE_PLACEHOLDER_REVISION) {
info("Inserting single update message for restore placeholder");
profileAndMessageHelper.insertUpdateMessages(timestamp, null, Collections.singleton(new LocalGroupLogEntry(finalState, null)), serverGuid);
}
profileAndMessageHelper.persistLearnedProfileKeys(profileKeys);
if (performCdsLookup) {
ApplicationDependencies.getJobManager().add(new DirectoryRefreshJob(false));
}
if (finalGlobalGroupState.getServerHistory().size() > 0) {
info(String.format(Locale.US, "There are more revisions on the server for this group, scheduling for later, V[%d..%d]", finalState.revision + 1, finalGlobalGroupState.getLatestRevisionNumber()));
ApplicationDependencies.getJobManager().add(new RequestGroupV2InfoJob(groupId, finalGlobalGroupState.getLatestRevisionNumber()));
}
return new GroupUpdateResult(GroupState.GROUP_UPDATED, finalState);
}
private void insertGroupLeave() {
if (!groupDatabase.isActive(groupId)) {
warn("Group has already been left.");
return;
}
Recipient groupRecipient = Recipient.externalGroupExact(groupId);
DecryptedGroup decryptedGroup = groupDatabase.requireGroup(groupId)
.requireV2GroupProperties()
.getDecryptedGroup();
DecryptedGroup simulatedGroupState = DecryptedGroupUtil.removeMember(decryptedGroup, serviceIds.getAci(), decryptedGroup.revision + 1);
DecryptedGroupChange simulatedGroupChange = new DecryptedGroupChange.Builder()
.editorServiceIdBytes(ACI.UNKNOWN.toByteString())
.revision(simulatedGroupState.revision)
.deleteMembers(Collections.singletonList(serviceIds.getAci().toByteString()))
.build();
GV2UpdateDescription updateDescription = GroupProtoUtil.createOutgoingGroupV2UpdateDescription(masterKey, new GroupMutation(decryptedGroup, simulatedGroupChange, simulatedGroupState), null);
OutgoingMessage leaveMessage = OutgoingMessage.groupUpdateMessage(groupRecipient, updateDescription, System.currentTimeMillis());
try {
MessageTable mmsDatabase = SignalDatabase.messages();
ThreadTable threadTable = SignalDatabase.threads();
long threadId = threadTable.getOrCreateThreadIdFor(groupRecipient);
long id = mmsDatabase.insertMessageOutbox(leaveMessage, threadId, false, null);
mmsDatabase.markAsSent(id, true);
threadTable.update(threadId, false, false);
} catch (MmsException e) {
warn( "Failed to insert leave message.", e);
}
groupDatabase.setActive(groupId, false);
groupDatabase.remove(groupId, Recipient.self().getId());
}
/**
* @return true iff group exists locally and is at least the specified revision.
*/
private boolean localIsAtLeast(Optional<GroupRecord> localRecord, int revision) {
if (revision == LATEST || localRecord.isEmpty() || groupDatabase.isUnknownGroup(localRecord)) {
return false;
}
int dbRevision = localRecord.get().requireV2GroupProperties().getGroupRevision();
return revision <= dbRevision;
}
private void updateLocalDatabaseGroupState(@NonNull GlobalGroupState inputGroupState,
@NonNull DecryptedGroup newLocalState)
{
boolean needsAvatarFetch;
if (inputGroupState.getLocalState() == null) {
GroupId.V2 groupId = groupDatabase.create(masterKey, newLocalState);
if (groupId == null) {
Log.w(TAG, "Group create failed, trying to update");
groupDatabase.update(masterKey, newLocalState);
}
needsAvatarFetch = !TextUtils.isEmpty(newLocalState.avatar);
} else {
groupDatabase.update(masterKey, newLocalState);
needsAvatarFetch = !newLocalState.avatar.equals(inputGroupState.getLocalState().avatar);
}
if (needsAvatarFetch) {
ApplicationDependencies.getJobManager().add(new AvatarGroupsV2DownloadJob(groupId, newLocalState.avatar));
}
profileAndMessageHelper.determineProfileSharing(inputGroupState, newLocalState);
}
private GlobalGroupState getFullMemberHistoryPage(DecryptedGroup localState, int logsNeededFromRevision, boolean includeFirstState) throws IOException {
try {
GroupHistoryPage groupHistoryPage = groupsV2Api.getGroupHistoryPage(groupSecretParams, logsNeededFromRevision, groupsV2Authorization.getAuthorizationForToday(serviceIds, groupSecretParams), includeFirstState);
ArrayList<ServerGroupLogEntry> history = new ArrayList<>(groupHistoryPage.getResults().size());
boolean ignoreServerChanges = SignalStore.internalValues().gv2IgnoreServerChanges();
if (ignoreServerChanges) {
warn( "Server change logs are ignored by setting");
}
for (DecryptedGroupHistoryEntry entry : groupHistoryPage.getResults()) {
DecryptedGroup group = entry.getGroup().orElse(null);
DecryptedGroupChange change = ignoreServerChanges ? null : entry.getChange().orElse(null);
if (group != null || change != null) {
history.add(new ServerGroupLogEntry(group, change));
}
}
return new GlobalGroupState(localState, history, groupHistoryPage.getPagingData());
} catch (InvalidGroupStateException | VerificationFailedException e) {
throw new IOException(e);
}
}
private void info(String message) {
Log.i(TAG, "[" + groupId.toString() + "] " + message);
}
private void warn(String message) {
warn(message, null);
}
private void warn(String message, Throwable e) {
Log.w(TAG, "[" + groupId.toString() + "] " + message, e);
}
}
@VisibleForTesting
static class ProfileAndMessageHelper {
private final ACI aci;
private final GroupId.V2 groupId;
private final RecipientTable recipientTable;
@VisibleForTesting
GroupMasterKey masterKey;
ProfileAndMessageHelper(@NonNull ACI aci, @NonNull GroupMasterKey masterKey, @NonNull GroupId.V2 groupId, @NonNull RecipientTable recipientTable) {
this.aci = aci;
this.masterKey = masterKey;
this.groupId = groupId;
this.recipientTable = recipientTable;
}
void determineProfileSharing(@NonNull GlobalGroupState inputGroupState, @NonNull DecryptedGroup newLocalState) {
if (inputGroupState.getLocalState() != null) {
boolean wasAMemberAlready = DecryptedGroupUtil.findMemberByAci(inputGroupState.getLocalState().members, aci).isPresent();
if (wasAMemberAlready) {
return;
}
}
Optional<DecryptedMember> selfAsMemberOptional = DecryptedGroupUtil.findMemberByAci(newLocalState.members, aci);
Optional<DecryptedPendingMember> selfAsPendingOptional = DecryptedGroupUtil.findPendingByServiceId(newLocalState.pendingMembers, aci);
if (selfAsMemberOptional.isPresent()) {
DecryptedMember selfAsMember = selfAsMemberOptional.get();
int revisionJoinedAt = selfAsMember.joinedAtRevision;
Optional<Recipient> addedByOptional = inputGroupState.getServerHistory()
.stream()
.map(ServerGroupLogEntry::getChange)
.filter(c -> c != null && c.revision == revisionJoinedAt)
.findFirst()
.flatMap(c -> Optional.ofNullable(ServiceId.parseOrNull(c.editorServiceIdBytes))
.map(Recipient::externalPush));
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.isBlocked() && (inputGroupState.getLocalState() == null || !DecryptedGroupUtil.isRequesting(inputGroupState.getLocalState(), aci))) {
Log.i(TAG, "Added by a blocked user. Leaving group.");
ApplicationDependencies.getJobManager().add(new LeaveGroupV2Job(groupId));
//noinspection UnnecessaryReturnStatement
return;
} else if ((addedBy.isSystemContact() || addedBy.isProfileSharing()) && !addedBy.isHidden()) {
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");
recipientTable.setProfileSharing(Recipient.externalGroupExact(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 if (selfAsPendingOptional.isPresent()) {
Optional<Recipient> addedBy = selfAsPendingOptional.flatMap(adder -> Optional.ofNullable(UuidUtil.fromByteStringOrNull(adder.addedByAci))
.map(uuid -> Recipient.externalPush(ACI.from(uuid))));
if (addedBy.isPresent() && addedBy.get().isBlocked()) {
Log.i(TAG, String.format("Added to group %s by a blocked user %s. Leaving group.", groupId, addedBy.get().getId()));
ApplicationDependencies.getJobManager().add(new LeaveGroupV2Job(groupId));
//noinspection UnnecessaryReturnStatement
return;
} else {
Log.i(TAG, String.format("Added to %s, but not enabling profile sharing as we are a pending member.", groupId));
}
} 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,
@Nullable String serverGuid)
{
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.changeIsEmptyExceptForBanChangesAndOptionalProfileKeyChanges(entry.getChange())) {
Log.d(TAG, "Skipping ban 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, serverGuid);
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 = recipientTable.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, @Nullable String serverGuid) {
Optional<ServiceId> editor = getEditor(decryptedGroupV2Context);
boolean outgoing = editor.isEmpty() || aci.equals(editor.get());
GV2UpdateDescription updateDescription = new GV2UpdateDescription.Builder()
.gv2ChangeDescription(decryptedGroupV2Context)
.groupChangeUpdate(GroupsV2UpdateMessageConverter.translateDecryptedChange(SignalStore.account().getServiceIds(), decryptedGroupV2Context))
.build();
if (outgoing) {
try {
MessageTable mmsDatabase = SignalDatabase.messages();
ThreadTable threadTable = SignalDatabase.threads();
RecipientId recipientId = recipientTable.getOrInsertFromGroupId(groupId);
Recipient recipient = Recipient.resolved(recipientId);
OutgoingMessage outgoingMessage = OutgoingMessage.groupUpdateMessage(recipient, updateDescription, timestamp);
long threadId = threadTable.getOrCreateThreadIdFor(recipient);
long messageId = mmsDatabase.insertMessageOutbox(outgoingMessage, threadId, false, null);
mmsDatabase.markAsSent(messageId, true);
threadTable.update(threadId, false, false);
} catch (MmsException e) {
Log.w(TAG, "Failed to insert outgoing update message!", e);
}
} else {
try {
MessageTable smsDatabase = SignalDatabase.messages();
RecipientId sender = RecipientId.from(editor.get());
IncomingMessage groupMessage = IncomingMessage.groupUpdate(sender, timestamp, groupId, decryptedGroupV2Context, serverGuid);
Optional<MessageTable.InsertResult> insertResult = smsDatabase.insertMessageInbox(groupMessage);
if (insertResult.isPresent()) {
SignalDatabase.threads().update(insertResult.get().getThreadId(), false, false);
} else {
Log.w(TAG, "Could not insert update message");
}
} catch (MmsException e) {
Log.w(TAG, "Failed to insert incoming update message!", e);
}
}
}
private Optional<ServiceId> getEditor(@NonNull DecryptedGroupV2Context decryptedGroupV2Context) {
DecryptedGroupChange change = decryptedGroupV2Context.change;
Optional<ServiceId> changeEditor = DecryptedGroupUtil.editorServiceId(change);
if (changeEditor.isPresent()) {
return changeEditor;
} else {
Optional<DecryptedPendingMember> pending = DecryptedGroupUtil.findPendingByServiceId(decryptedGroupV2Context.groupState.pendingMembers, aci);
if (pending.isPresent()) {
return Optional.ofNullable(ACI.parseOrNull(pending.get().addedByAci));
}
}
return Optional.empty();
}
}
}

View File

@@ -0,0 +1,714 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.groups.v2.processing
import androidx.annotation.VisibleForTesting
import androidx.annotation.WorkerThread
import org.signal.core.util.logging.Log
import org.signal.core.util.orNull
import org.signal.libsignal.zkgroup.VerificationFailedException
import org.signal.libsignal.zkgroup.groups.GroupMasterKey
import org.signal.libsignal.zkgroup.groups.GroupSecretParams
import org.signal.storageservice.protos.groups.local.DecryptedGroup
import org.signal.storageservice.protos.groups.local.DecryptedGroupChange
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.GroupRecord
import org.thoughtcrime.securesms.database.model.GroupsV2UpdateMessageConverter.translateDecryptedChange
import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context
import org.thoughtcrime.securesms.database.model.databaseprotos.GV2UpdateDescription
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.GroupMutation
import org.thoughtcrime.securesms.groups.GroupNotAMemberException
import org.thoughtcrime.securesms.groups.GroupProtoUtil
import org.thoughtcrime.securesms.groups.v2.ProfileKeySet
import org.thoughtcrime.securesms.groups.v2.processing.GroupsV2StateProcessor.Companion.LATEST
import org.thoughtcrime.securesms.jobs.AvatarGroupsV2DownloadJob
import org.thoughtcrime.securesms.jobs.DirectoryRefreshJob
import org.thoughtcrime.securesms.jobs.LeaveGroupV2Job
import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.mms.IncomingMessage
import org.thoughtcrime.securesms.mms.MmsException
import org.thoughtcrime.securesms.mms.OutgoingMessage
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil
import org.whispersystems.signalservice.api.groupsv2.GroupChangeReconstruct
import org.whispersystems.signalservice.api.groupsv2.GroupHistoryPage
import org.whispersystems.signalservice.api.groupsv2.InvalidGroupStateException
import org.whispersystems.signalservice.api.groupsv2.NotAbleToApplyGroupV2ChangeException
import org.whispersystems.signalservice.api.push.ServiceId
import org.whispersystems.signalservice.api.push.ServiceId.ACI
import org.whispersystems.signalservice.api.push.ServiceIds
import org.whispersystems.signalservice.api.util.UuidUtil
import org.whispersystems.signalservice.internal.push.exceptions.GroupNotFoundException
import org.whispersystems.signalservice.internal.push.exceptions.NotInGroupException
import java.io.IOException
import java.util.Optional
import kotlin.math.max
/**
* Given a group, provide various state operations to update from the current local revision to a
* specific number or [LATEST] revision via a P2P update and/or by fetching changes from the server.
*/
class GroupsV2StateProcessor private constructor(
private val serviceIds: ServiceIds,
private val groupMasterKey: GroupMasterKey,
private val groupSecretParams: GroupSecretParams
) {
companion object {
private val TAG = Log.tag(GroupsV2StateProcessor::class.java)
const val LATEST = GroupStatePatcher.LATEST
/**
* Used to mark a group state as a placeholder when there is partial knowledge (title and avater)
* gathered from a group join link.
*/
const val PLACEHOLDER_REVISION = GroupStatePatcher.PLACEHOLDER_REVISION
/**
* Used to mark a group state as a placeholder when you have no knowledge at all of the group
* e.g. from a group master key from a storage service restore.
*/
const val RESTORE_PLACEHOLDER_REVISION = GroupStatePatcher.RESTORE_PLACEHOLDER_REVISION
@JvmStatic
@JvmOverloads
fun forGroup(
serviceIds: ServiceIds,
groupMasterKey: GroupMasterKey,
groupSecretParams: GroupSecretParams? = null
): GroupsV2StateProcessor {
return GroupsV2StateProcessor(
serviceIds = serviceIds,
groupMasterKey = groupMasterKey,
groupSecretParams = groupSecretParams ?: GroupSecretParams.deriveFromMasterKey(groupMasterKey)
)
}
}
private val groupsApi = ApplicationDependencies.getSignalServiceAccountManager().getGroupsV2Api()
private val groupsV2Authorization = ApplicationDependencies.getGroupsV2Authorization()
private val groupId = GroupId.v2(groupSecretParams.getPublicParams().getGroupIdentifier())
private val profileAndMessageHelper = ProfileAndMessageHelper.create(serviceIds.getAci(), groupMasterKey, groupId)
private val logPrefix = "[$groupId]"
/**
* Forces the local state to be updated directly to the latest available from the server without
* processing individual changes and instead inserting one large change to go from local to server.
*/
@WorkerThread
@Throws(IOException::class, GroupNotAMemberException::class)
fun forceSanityUpdateFromServer(timestamp: Long): GroupUpdateResult {
val currentLocalState: DecryptedGroup? = SignalDatabase.groups.getGroup(groupId).map { it.requireV2GroupProperties().decryptedGroup }.orNull()
if (currentLocalState == null) {
Log.i(TAG, "$logPrefix No local state to force update")
return GroupUpdateResult.CONSISTENT_OR_AHEAD
}
return when (val result = updateToLatestViaServer(timestamp, currentLocalState, reconstructChange = true)) {
InternalUpdateResult.NoUpdateNeeded -> GroupUpdateResult.CONSISTENT_OR_AHEAD
is InternalUpdateResult.Updated -> GroupUpdateResult(GroupUpdateResult.UpdateStatus.GROUP_UPDATED, result.updatedLocalState)
is InternalUpdateResult.NotAMember -> throw result.exception
is InternalUpdateResult.UpdateFailed -> throw result.throwable
}
}
/**
* Using network where required, will attempt to bring the local copy of the group up to the revision specified.
*
* @param targetRevision use [.LATEST] to get latest.
*/
@JvmOverloads
@WorkerThread
@Throws(IOException::class, GroupNotAMemberException::class)
fun updateLocalGroupToRevision(
targetRevision: Int,
timestamp: Long,
signedGroupChange: DecryptedGroupChange? = null,
groupRecord: Optional<GroupRecord> = SignalDatabase.groups.getGroup(groupId),
serverGuid: String? = null
): GroupUpdateResult {
if (localIsAtLeast(groupRecord, targetRevision)) {
return GroupUpdateResult(GroupUpdateResult.UpdateStatus.GROUP_CONSISTENT_OR_AHEAD, null)
}
val currentLocalState: DecryptedGroup? = groupRecord.map { it.requireV2GroupProperties().decryptedGroup }.orNull()
if (signedGroupChange != null && canApplyP2pChange(targetRevision, signedGroupChange, currentLocalState, groupRecord)) {
when (val p2pUpdateResult = updateViaPeerGroupChange(timestamp, serverGuid, signedGroupChange, currentLocalState!!, forceApply = false)) {
InternalUpdateResult.NoUpdateNeeded -> return GroupUpdateResult.CONSISTENT_OR_AHEAD
is InternalUpdateResult.Updated -> return GroupUpdateResult.updated(p2pUpdateResult.updatedLocalState)
else -> Log.w(TAG, "$logPrefix P2P update was not successfully processed, falling back to server update")
}
}
val serverUpdateResult = updateViaServer(targetRevision, timestamp, serverGuid, groupRecord)
when (serverUpdateResult) {
InternalUpdateResult.NoUpdateNeeded -> return GroupUpdateResult.CONSISTENT_OR_AHEAD
is InternalUpdateResult.Updated -> return GroupUpdateResult.updated(serverUpdateResult.updatedLocalState)
is InternalUpdateResult.UpdateFailed -> throw serverUpdateResult.throwable
is InternalUpdateResult.NotAMember -> Unit
}
Log.w(TAG, "$logPrefix Unable to query server for group, says we're not in group, trying to resolve locally")
if (currentLocalState != null && signedGroupChange != null) {
if (notInGroupAndNotBeingAdded(groupRecord, signedGroupChange)) {
Log.w(TAG, "$logPrefix Server says we're not a member. Ignoring P2P group change because we're not currently in the group and this change doesn't add us in.")
} else {
Log.i(TAG, "$logPrefix Server says we're not a member. Force applying P2P group change.")
when (val forcedP2pUpdateResult = updateViaPeerGroupChange(timestamp, serverGuid, signedGroupChange, currentLocalState, forceApply = true)) {
is InternalUpdateResult.Updated -> return GroupUpdateResult.updated(forcedP2pUpdateResult.updatedLocalState)
InternalUpdateResult.NoUpdateNeeded -> return GroupUpdateResult.CONSISTENT_OR_AHEAD
is InternalUpdateResult.NotAMember, is InternalUpdateResult.UpdateFailed -> Log.w(TAG, "$logPrefix Unable to apply P2P group change when not a member: $forcedP2pUpdateResult")
}
}
}
if (currentLocalState != null && DecryptedGroupUtil.isPendingOrRequesting(currentLocalState, serviceIds)) {
Log.w(TAG, "$logPrefix Unable to query server for group. Server says we're not in group, but we think we are a pending or requesting member")
} else {
Log.w(TAG, "$logPrefix Unable to query server for group $groupId server says we're not in group, we agree, inserting leave message")
profileAndMessageHelper.leaveGroupLocally(serviceIds)
}
throw GroupNotAMemberException(serverUpdateResult.exception)
}
private fun canApplyP2pChange(
targetRevision: Int,
signedGroupChange: DecryptedGroupChange,
currentLocalState: DecryptedGroup?,
groupRecord: Optional<GroupRecord>
): Boolean {
if (SignalStore.internalValues().gv2IgnoreP2PChanges()) {
Log.w(TAG, "$logPrefix Ignoring P2P group change by setting")
return false
}
if (currentLocalState == null || currentLocalState.revision + 1 != signedGroupChange.revision || targetRevision != signedGroupChange.revision) {
return false
}
if (notInGroupAndNotBeingAdded(groupRecord, signedGroupChange) && notHavingInviteRevoked(signedGroupChange)) {
Log.w(TAG, "$logPrefix Ignoring P2P group change because we're not currently in the group and this change doesn't add us in.")
return false
}
return true
}
private fun updateViaPeerGroupChange(
timestamp: Long,
serverGuid: String?,
signedGroupChange: DecryptedGroupChange,
currentLocalState: DecryptedGroup,
forceApply: Boolean
): InternalUpdateResult {
val updatedGroupState = try {
if (forceApply) {
DecryptedGroupUtil.applyWithoutRevisionCheck(currentLocalState, signedGroupChange)
} else {
DecryptedGroupUtil.apply(currentLocalState, signedGroupChange)
}
} catch (e: NotAbleToApplyGroupV2ChangeException) {
Log.w(TAG, "$logPrefix Unable to apply P2P group change", e)
return InternalUpdateResult.UpdateFailed(e)
}
val groupStateDiff = GroupStateDiff(currentLocalState, updatedGroupState, signedGroupChange)
return saveGroupUpdate(
timestamp = timestamp,
serverGuid = serverGuid,
groupStateDiff = groupStateDiff
)
}
private fun updateViaServer(
targetRevision: Int,
timestamp: Long,
serverGuid: String?,
groupRecord: Optional<GroupRecord> = SignalDatabase.groups.getGroup(groupId)
): InternalUpdateResult {
var currentLocalState: DecryptedGroup? = groupRecord.map { it.requireV2GroupProperties().decryptedGroup }.orNull()
val latestRevisionOnly = targetRevision == LATEST && (currentLocalState == null || currentLocalState.revision == RESTORE_PLACEHOLDER_REVISION)
if (latestRevisionOnly) {
Log.i(TAG, "$logPrefix Latest revision or not a member, update to latest directly")
return updateToLatestViaServer(timestamp, currentLocalState, reconstructChange = false)
}
Log.i(TAG, "$logPrefix Paging from server targetRevision: ${if (targetRevision == LATEST) "latest" else targetRevision}")
val joinedAtRevision = when (val result = groupsApi.getGroupJoinedAt(groupsV2Authorization.getAuthorizationForToday(serviceIds, groupSecretParams))) {
is NetworkResult.Success -> result.result
else -> return InternalUpdateResult.from(result.getCause()!!)
}
var includeFirstState = currentLocalState == null ||
currentLocalState.revision < 0 ||
currentLocalState.revision == joinedAtRevision ||
!GroupProtoUtil.isMember(serviceIds.aci, currentLocalState.members)
val profileKeys = ProfileKeySet()
val addMessagesForAllUpdates = currentLocalState == null || currentLocalState.revision != RESTORE_PLACEHOLDER_REVISION
var logsNeededFrom = if (currentLocalState != null) max(currentLocalState.revision, joinedAtRevision) else joinedAtRevision
var hasMore = true
var runningTimestamp = timestamp
var performCdsLookup = false
var hasRemainingRemoteChanges = false
while (hasMore) {
Log.i(TAG, "$logPrefix Requesting change logs from server, currentRevision=${currentLocalState?.revision ?: "null"} logsNeededFrom=$logsNeededFrom includeFirstState=$includeFirstState")
val (remoteGroupStateDiff, pagingData) = getGroupChangeLogs(currentLocalState, logsNeededFrom, includeFirstState)
val applyGroupStateDiffResult: AdvanceGroupStateResult = GroupStatePatcher.applyGroupStateDiff(remoteGroupStateDiff, targetRevision)
val updatedGroupState: DecryptedGroup? = applyGroupStateDiffResult.updatedGroupState
if (updatedGroupState == null || updatedGroupState == remoteGroupStateDiff.previousGroupState) {
Log.i(TAG, "$logPrefix Local state is at or later than server revision: ${currentLocalState?.revision ?: "null"}")
return InternalUpdateResult.NoUpdateNeeded
}
Log.i(TAG, "$logPrefix Saving updated group state at revision: ${updatedGroupState.revision}")
saveGroupState(remoteGroupStateDiff, updatedGroupState)
if (addMessagesForAllUpdates) {
Log.d(TAG, "$logPrefix Inserting group changes into chat history")
runningTimestamp = profileAndMessageHelper.insertUpdateMessages(runningTimestamp, currentLocalState, applyGroupStateDiffResult.processedLogEntries, serverGuid)
}
remoteGroupStateDiff
.serverHistory
.forEach { entry ->
entry.group?.let { profileKeys.addKeysFromGroupState(it) }
entry.change?.let { change ->
profileKeys.addKeysFromGroupChange(change)
if (change.promotePendingPniAciMembers.isNotEmpty()) {
performCdsLookup = true
}
}
}
currentLocalState = updatedGroupState
hasMore = pagingData.hasMorePages
if (hasMore) {
includeFirstState = false
logsNeededFrom = pagingData.nextPageRevision
} else {
if (applyGroupStateDiffResult.remainingRemoteGroupChanges.isNotEmpty()) {
hasRemainingRemoteChanges = true
}
}
}
if (!addMessagesForAllUpdates) {
Log.i(TAG, "Inserting single update message for restore placeholder")
profileAndMessageHelper.insertUpdateMessages(runningTimestamp, null, setOf(AppliedGroupChangeLog(currentLocalState!!, null)), serverGuid)
}
profileAndMessageHelper.persistLearnedProfileKeys(profileKeys)
if (performCdsLookup) {
ApplicationDependencies.getJobManager().add(DirectoryRefreshJob(false))
}
if (hasRemainingRemoteChanges) {
Log.i(TAG, "$logPrefix There are more revisions on the server for this group, scheduling for later")
ApplicationDependencies.getJobManager().add(RequestGroupV2InfoJob(groupId))
}
return InternalUpdateResult.Updated(currentLocalState!!)
}
private fun updateToLatestViaServer(timestamp: Long, currentLocalState: DecryptedGroup?, reconstructChange: Boolean): InternalUpdateResult {
val result = groupsApi.getGroupAsResult(groupSecretParams, groupsV2Authorization.getAuthorizationForToday(serviceIds, groupSecretParams))
val serverState = if (result is NetworkResult.Success) {
result.result
} else {
return InternalUpdateResult.from(result.getCause()!!)
}
val completeGroupChange = if (reconstructChange) GroupChangeReconstruct.reconstructGroupChange(currentLocalState, serverState) else null
val remoteGroupStateDiff = GroupStateDiff(currentLocalState, serverState, completeGroupChange)
return saveGroupUpdate(
timestamp = timestamp,
serverGuid = null,
groupStateDiff = remoteGroupStateDiff
)
}
/**
* @return true iff group exists locally and is at least the specified revision.
*/
private fun localIsAtLeast(groupRecord: Optional<GroupRecord>, revision: Int): Boolean {
if (revision == LATEST || groupRecord.isEmpty || SignalDatabase.groups.isUnknownGroup(groupRecord)) {
return false
}
return revision <= groupRecord.get().requireV2GroupProperties().groupRevision
}
private fun notInGroupAndNotBeingAdded(groupRecord: Optional<GroupRecord>, signedGroupChange: DecryptedGroupChange): Boolean {
val currentlyInGroup = groupRecord.isPresent && groupRecord.get().isActive
val addedAsMember = signedGroupChange
.newMembers
.asSequence()
.mapNotNull { ACI.parseOrNull(it.aciBytes) }
.any { serviceIds.matches(it) }
val addedAsPendingMember = signedGroupChange
.newPendingMembers
.asSequence()
.map { it.serviceIdBytes }
.any { serviceIds.matches(it) }
val addedAsRequestingMember = signedGroupChange
.newRequestingMembers
.asSequence()
.mapNotNull { ACI.parseOrNull(it.aciBytes) }
.any { serviceIds.matches(it) }
return !currentlyInGroup && !addedAsMember && !addedAsPendingMember && !addedAsRequestingMember
}
private fun notHavingInviteRevoked(signedGroupChange: DecryptedGroupChange): Boolean {
val havingInviteRevoked = signedGroupChange
.deletePendingMembers
.asSequence()
.map { it.serviceIdBytes }
.any { serviceIds.matches(it) }
return !havingInviteRevoked
}
@Throws(IOException::class)
private fun getGroupChangeLogs(localState: DecryptedGroup?, logsNeededFromRevision: Int, includeFirstState: Boolean): Pair<GroupStateDiff, GroupHistoryPage.PagingData> {
try {
val groupHistoryPage = groupsApi.getGroupHistoryPage(groupSecretParams, logsNeededFromRevision, groupsV2Authorization.getAuthorizationForToday(serviceIds, groupSecretParams), includeFirstState)
return GroupStateDiff(localState, groupHistoryPage.changeLogs) to groupHistoryPage.pagingData
} catch (e: InvalidGroupStateException) {
throw IOException(e)
} catch (e: VerificationFailedException) {
throw IOException(e)
}
}
private fun saveGroupUpdate(
timestamp: Long,
serverGuid: String?,
groupStateDiff: GroupStateDiff
): InternalUpdateResult {
val currentLocalState: DecryptedGroup? = groupStateDiff.previousGroupState
val applyGroupStateDiffResult = GroupStatePatcher.applyGroupStateDiff(groupStateDiff, GroupStatePatcher.LATEST)
val updatedGroupState = applyGroupStateDiffResult.updatedGroupState
if (updatedGroupState == null || updatedGroupState == groupStateDiff.previousGroupState) {
Log.i(TAG, "$logPrefix Local state and server state are equal")
return InternalUpdateResult.NoUpdateNeeded
} else {
Log.i(TAG, "$logPrefix Local state (revision: ${currentLocalState?.revision}) does not match, updating to ${updatedGroupState.revision}")
}
saveGroupState(groupStateDiff, updatedGroupState)
if (currentLocalState == null || currentLocalState.revision == RESTORE_PLACEHOLDER_REVISION) {
Log.i(TAG, "$logPrefix Inserting single update message for no local state or restore placeholder")
profileAndMessageHelper.insertUpdateMessages(timestamp, null, setOf(AppliedGroupChangeLog(updatedGroupState, null)), null)
} else {
profileAndMessageHelper.insertUpdateMessages(timestamp, currentLocalState, applyGroupStateDiffResult.processedLogEntries, serverGuid)
}
profileAndMessageHelper.persistLearnedProfileKeys(groupStateDiff)
val performCdsLookup = groupStateDiff
.serverHistory
.mapNotNull { it.change }
.any { it.promotePendingPniAciMembers.isNotEmpty() }
if (performCdsLookup) {
ApplicationDependencies.getJobManager().add(DirectoryRefreshJob(false))
}
return InternalUpdateResult.Updated(updatedGroupState)
}
private fun saveGroupState(groupStateDiff: GroupStateDiff, updatedGroupState: DecryptedGroup) {
val previousGroupState = groupStateDiff.previousGroupState
val needsAvatarFetch = if (previousGroupState == null) {
val groupId = SignalDatabase.groups.create(groupMasterKey, updatedGroupState)
if (groupId == null) {
Log.w(TAG, "$logPrefix Group create failed, trying to update")
SignalDatabase.groups.update(groupMasterKey, updatedGroupState)
}
updatedGroupState.avatar.isNotEmpty()
} else {
SignalDatabase.groups.update(groupMasterKey, updatedGroupState)
updatedGroupState.avatar != previousGroupState.avatar
}
if (needsAvatarFetch) {
ApplicationDependencies.getJobManager().add(AvatarGroupsV2DownloadJob(groupId, updatedGroupState.avatar))
}
profileAndMessageHelper.setProfileSharing(groupStateDiff, updatedGroupState)
}
@VisibleForTesting
internal class ProfileAndMessageHelper(private val aci: ACI, private val masterKey: GroupMasterKey, private val groupId: GroupId.V2) {
fun setProfileSharing(groupStateDiff: GroupStateDiff, newLocalState: DecryptedGroup) {
val previousGroupState = groupStateDiff.previousGroupState
if (previousGroupState != null && DecryptedGroupUtil.findMemberByAci(previousGroupState.members, aci).isPresent) {
// Was already a member before update, profile sharing state previously determined
return
}
val selfAsMember = DecryptedGroupUtil.findMemberByAci(newLocalState.members, aci).orNull()
val selfAsPending = DecryptedGroupUtil.findPendingByServiceId(newLocalState.pendingMembers, aci).orNull()
if (selfAsMember != null) {
val revisionJoinedAt = selfAsMember.joinedAtRevision
val addedAtChange = groupStateDiff
.serverHistory
.mapNotNull { it.change }
.firstOrNull { it.revision == revisionJoinedAt }
val addedBy = ServiceId.parseOrNull(addedAtChange?.editorServiceIdBytes)?.let { Recipient.externalPush(it) }
if (addedBy != null) {
Log.i(TAG, "Added as a full member of $groupId by ${addedBy.id}")
if (addedBy.isBlocked && (previousGroupState == null || !DecryptedGroupUtil.isRequesting(previousGroupState, aci))) {
Log.i(TAG, "Added by a blocked user. Leaving group.")
ApplicationDependencies.getJobManager().add(LeaveGroupV2Job(groupId))
return
} else if ((addedBy.isSystemContact || addedBy.isProfileSharing) && !addedBy.isHidden) {
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")
SignalDatabase.recipients.setProfileSharing(Recipient.externalGroupExact(groupId).id, 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 if (selfAsPending != null) {
val addedBy = UuidUtil.fromByteStringOrNull(selfAsPending.addedByAci)?.let { Recipient.externalPush(ACI.from(it)) }
if (addedBy?.isBlocked == true) {
Log.i(TAG, "Added to group $groupId by a blocked user ${addedBy.id}. Leaving group.")
ApplicationDependencies.getJobManager().add(LeaveGroupV2Job(groupId))
return
} else {
Log.i(TAG, "Added to $groupId, but not enabling profile sharing as we are a pending member.")
}
} else {
Log.i(TAG, "Added to $groupId, but not enabling profile sharing as not a fullMember.")
}
}
fun insertUpdateMessages(
timestamp: Long,
previousGroupState: DecryptedGroup?,
processedLogEntries: Collection<AppliedGroupChangeLog>,
serverGuid: String?
): Long {
var runningTimestamp = timestamp
var runningGroupState = previousGroupState
for (entry in processedLogEntries) {
if (entry.change != null && DecryptedGroupUtil.changeIsEmptyExceptForProfileKeyChanges(entry.change) && !DecryptedGroupUtil.changeIsEmpty(entry.change)) {
Log.d(TAG, "Skipping profile key changes only update message")
} else if (entry.change != null && DecryptedGroupUtil.changeIsEmptyExceptForBanChangesAndOptionalProfileKeyChanges(entry.change)) {
Log.d(TAG, "Skipping ban changes only update message")
} else {
if (entry.change != null && DecryptedGroupUtil.changeIsEmpty(entry.change) && runningGroupState != null) {
Log.w(TAG, "Empty group update message seen. Not inserting.")
} else {
storeMessage(GroupProtoUtil.createDecryptedGroupV2Context(masterKey, GroupMutation(runningGroupState, entry.change, entry.group), null), runningTimestamp, serverGuid)
runningTimestamp++
}
}
runningGroupState = entry.group
}
return runningTimestamp
}
fun leaveGroupLocally(serviceIds: ServiceIds) {
if (!SignalDatabase.groups.isActive(groupId)) {
Log.w(TAG, "Group $groupId has already been left.")
return
}
val groupRecipient = Recipient.externalGroupExact(groupId)
val decryptedGroup = SignalDatabase
.groups
.requireGroup(groupId)
.requireV2GroupProperties()
.decryptedGroup
val simulatedGroupState = DecryptedGroupUtil.removeMember(decryptedGroup, serviceIds.aci, decryptedGroup.revision + 1)
val simulatedGroupChange = DecryptedGroupChange(
editorServiceIdBytes = ACI.UNKNOWN.toByteString(),
revision = simulatedGroupState.revision,
deleteMembers = listOf(serviceIds.aci.toByteString())
)
val updateDescription = GroupProtoUtil.createOutgoingGroupV2UpdateDescription(masterKey, GroupMutation(decryptedGroup, simulatedGroupChange, simulatedGroupState), null)
val leaveMessage = OutgoingMessage.groupUpdateMessage(groupRecipient, updateDescription, System.currentTimeMillis())
try {
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(groupRecipient)
val id = SignalDatabase.messages.insertMessageOutbox(leaveMessage, threadId, false, null)
SignalDatabase.messages.markAsSent(id, true)
SignalDatabase.threads.update(threadId, unarchive = false, allowDeletion = false)
} catch (e: MmsException) {
Log.w(TAG, "Failed to insert leave message for $groupId", e)
}
SignalDatabase.groups.setActive(groupId, false)
SignalDatabase.groups.remove(groupId, Recipient.self().id)
}
fun persistLearnedProfileKeys(groupStateDiff: GroupStateDiff) {
val profileKeys = ProfileKeySet()
for (entry in groupStateDiff.serverHistory) {
if (entry.group != null) {
profileKeys.addKeysFromGroupState(entry.group!!)
}
if (entry.change != null) {
profileKeys.addKeysFromGroupChange(entry.change!!)
}
}
persistLearnedProfileKeys(profileKeys)
}
fun persistLearnedProfileKeys(profileKeys: ProfileKeySet) {
val updated = SignalDatabase.recipients.persistProfileKeySet(profileKeys)
if (updated.isNotEmpty()) {
Log.i(TAG, "Learned ${updated.size} new profile keys, fetching profiles")
for (job in RetrieveProfileJob.forRecipients(updated)) {
ApplicationDependencies.getJobManager().runSynchronously(job, 5000)
}
}
}
@VisibleForTesting
fun storeMessage(decryptedGroupV2Context: DecryptedGroupV2Context, timestamp: Long, serverGuid: String?) {
val editor: Optional<ServiceId> = getEditor(decryptedGroupV2Context)
val outgoing = editor.isEmpty || aci == editor.get()
val updateDescription = GV2UpdateDescription(
gv2ChangeDescription = decryptedGroupV2Context,
groupChangeUpdate = translateDecryptedChange(SignalStore.account().getServiceIds(), decryptedGroupV2Context)
)
if (outgoing) {
try {
val recipientId = SignalDatabase.recipients.getOrInsertFromGroupId(groupId)
val recipient = Recipient.resolved(recipientId)
val outgoingMessage = OutgoingMessage.groupUpdateMessage(recipient, updateDescription, timestamp)
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(recipient)
val messageId = SignalDatabase.messages.insertMessageOutbox(outgoingMessage, threadId, false, null)
SignalDatabase.messages.markAsSent(messageId, true)
SignalDatabase.threads.update(threadId, unarchive = false, allowDeletion = false)
} catch (e: MmsException) {
Log.w(TAG, "Failed to insert outgoing update message!", e)
}
} else {
try {
val sender = RecipientId.from(editor.get())
val groupMessage = IncomingMessage.groupUpdate(sender, timestamp, groupId, decryptedGroupV2Context, serverGuid)
val insertResult = SignalDatabase.messages.insertMessageInbox(groupMessage)
if (insertResult.isPresent) {
SignalDatabase.threads.update(insertResult.get().threadId, unarchive = false, allowDeletion = false)
} else {
Log.w(TAG, "Could not insert update message")
}
} catch (e: MmsException) {
Log.w(TAG, "Failed to insert incoming update message!", e)
}
}
}
private fun getEditor(decryptedGroupV2Context: DecryptedGroupV2Context): Optional<ServiceId> {
val changeEditor = DecryptedGroupUtil.editorServiceId(decryptedGroupV2Context.change)
if (changeEditor.isPresent) {
return changeEditor
} else {
val pending = DecryptedGroupUtil.findPendingByServiceId(decryptedGroupV2Context.groupState?.pendingMembers ?: emptyList(), aci)
if (pending.isPresent) {
return Optional.ofNullable(ACI.parseOrNull(pending.get().addedByAci))
}
}
return Optional.empty()
}
companion object {
@VisibleForTesting
fun create(aci: ACI, masterKey: GroupMasterKey, groupId: GroupId.V2): ProfileAndMessageHelper {
return ProfileAndMessageHelper(aci, masterKey, groupId)
}
}
}
private sealed interface InternalUpdateResult {
class Updated(val updatedLocalState: DecryptedGroup) : InternalUpdateResult
object NoUpdateNeeded : InternalUpdateResult
class NotAMember(val exception: GroupNotAMemberException) : InternalUpdateResult
class UpdateFailed(val throwable: Throwable) : InternalUpdateResult
companion object {
fun from(cause: Throwable): InternalUpdateResult {
return when (cause) {
is NotInGroupException,
is GroupNotFoundException -> NotAMember(GroupNotAMemberException(cause))
is IOException -> UpdateFailed(cause)
else -> UpdateFailed(IOException(cause))
}
}
}
}
}

View File

@@ -1,55 +0,0 @@
package org.thoughtcrime.securesms.groups.v2.processing;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.signal.storageservice.protos.groups.local.DecryptedGroup;
import org.signal.storageservice.protos.groups.local.DecryptedGroupChange;
import java.util.Objects;
/**
* Pair of a group state and optionally the corresponding change.
* <p>
* Similar to {@link ServerGroupLogEntry} but guaranteed to have a group state.
* <p>
* Changes are typically not available for pending members.
*/
final class LocalGroupLogEntry {
@NonNull private final DecryptedGroup group;
@Nullable private final DecryptedGroupChange change;
LocalGroupLogEntry(@NonNull DecryptedGroup group, @Nullable DecryptedGroupChange change) {
if (change != null && group.revision != change.revision) {
throw new AssertionError();
}
this.group = group;
this.change = change;
}
@NonNull DecryptedGroup getGroup() {
return group;
}
@Nullable DecryptedGroupChange getChange() {
return change;
}
@Override
public boolean equals(Object o) {
if (!(o instanceof LocalGroupLogEntry)) return false;
LocalGroupLogEntry other = (LocalGroupLogEntry) o;
return group.equals(other.group) && Objects.equals(change, other.change);
}
@Override
public int hashCode() {
int result = group.hashCode();
result = 31 * result + (change != null ? change.hashCode() : 0);
return result;
}
}

View File

@@ -1,50 +0,0 @@
package org.thoughtcrime.securesms.groups.v2.processing;
import androidx.annotation.Nullable;
import org.signal.core.util.logging.Log;
import org.signal.storageservice.protos.groups.local.DecryptedGroup;
import org.signal.storageservice.protos.groups.local.DecryptedGroupChange;
/**
* Pair of a group state and optionally the corresponding change from the server.
* <p>
* Either the group or change may be empty.
* <p>
* Changes are typically not available for pending members.
*/
final class ServerGroupLogEntry {
private static final String TAG = Log.tag(ServerGroupLogEntry.class);
@Nullable private final DecryptedGroup group;
@Nullable private final DecryptedGroupChange change;
ServerGroupLogEntry(@Nullable DecryptedGroup group, @Nullable DecryptedGroupChange change) {
if (change != null && group != null && group.revision != change.revision) {
Log.w(TAG, "Ignoring change with revision number not matching group");
change = null;
}
if (change == null && group == null) {
throw new AssertionError();
}
this.group = group;
this.change = change;
}
@Nullable DecryptedGroup getGroup() {
return group;
}
@Nullable DecryptedGroupChange getChange() {
return change;
}
int getRevision() {
if (group != null) return group.revision;
else if (change != null) return change.revision;
else throw new AssertionError();
}
}

View File

@@ -14,7 +14,6 @@ import java.util.List;
public final class InternalValues extends SignalStoreValues {
public static final String GV2_FORCE_INVITES = "internal.gv2.force_invites";
public static final String GV2_IGNORE_SERVER_CHANGES = "internal.gv2.ignore_server_changes";
public static final String GV2_IGNORE_P2P_CHANGES = "internal.gv2.ignore_p2p_changes";
public static final String RECIPIENT_DETAILS = "internal.recipient_details";
public static final String ALLOW_CENSORSHIP_SETTING = "internal.force_censorship";
@@ -54,19 +53,6 @@ public final class InternalValues extends SignalStoreValues {
return FeatureFlags.internalUser() && getBoolean(GV2_FORCE_INVITES, false);
}
/**
* The Server will leave out changes that can only be described by a future protocol level that
* an older client cannot understand. Ignoring those changes by nulling them out simulates that
* scenario for testing.
* <p>
* In conjunction with {@link #gv2IgnoreP2PChanges()} it means no group changes are coming into
* the client and it will generate changes by group state comparison, and those changes will not
* have an editor and so will be in the passive voice.
*/
public synchronized boolean gv2IgnoreServerChanges() {
return FeatureFlags.internalUser() && getBoolean(GV2_IGNORE_SERVER_CHANGES, false);
}
/**
* Signed group changes are sent P2P, if the client ignores them, it will then ask the server
* directly which allows testing of certain testing scenarios.

View File

@@ -20,7 +20,8 @@ import org.thoughtcrime.securesms.groups.GroupChangeBusyException
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.GroupManager
import org.thoughtcrime.securesms.groups.GroupNotAMemberException
import org.thoughtcrime.securesms.groups.v2.processing.GroupsV2StateProcessor
import org.thoughtcrime.securesms.groups.v2.processing.GroupUpdateResult
import org.thoughtcrime.securesms.groups.v2.processing.GroupUpdateResult.UpdateStatus
import org.thoughtcrime.securesms.jobs.AutomaticSessionResetJob
import org.thoughtcrime.securesms.jobs.NullMessageSendJob
import org.thoughtcrime.securesms.jobs.ResendMessageJob
@@ -238,7 +239,7 @@ open class MessageContentProcessor(private val context: Context) {
return Gv2PreProcessResult.IGNORE
}
val groupRecord = if (groupUpdateResult.groupState == GroupsV2StateProcessor.GroupState.GROUP_CONSISTENT_OR_AHEAD) {
val groupRecord = if (groupUpdateResult.updateStatus == UpdateStatus.GROUP_CONSISTENT_OR_AHEAD) {
preUpdateGroupRecord
} else {
SignalDatabase.groups.getGroup(groupId)
@@ -261,9 +262,9 @@ open class MessageContentProcessor(private val context: Context) {
}
}
return when (groupUpdateResult.groupState) {
GroupsV2StateProcessor.GroupState.GROUP_UPDATED -> Gv2PreProcessResult.GROUP_UPDATE
GroupsV2StateProcessor.GroupState.GROUP_CONSISTENT_OR_AHEAD -> Gv2PreProcessResult.GROUP_UP_TO_DATE
return when (groupUpdateResult.updateStatus) {
UpdateStatus.GROUP_UPDATED -> Gv2PreProcessResult.GROUP_UPDATE
UpdateStatus.GROUP_CONSISTENT_OR_AHEAD -> Gv2PreProcessResult.GROUP_UP_TO_DATE
}
}
@@ -275,7 +276,7 @@ open class MessageContentProcessor(private val context: Context) {
localRecord: Optional<GroupRecord>,
groupSecretParams: GroupSecretParams? = null,
serverGuid: String? = null
): GroupsV2StateProcessor.GroupUpdateResult? {
): GroupUpdateResult? {
return try {
val signedGroupChange: ByteArray? = if (groupV2.hasSignedGroupChange) groupV2.signedGroupChange else null
val updatedTimestamp = if (signedGroupChange != null) timestamp else timestamp - 1

View File

@@ -16,7 +16,7 @@ import org.signal.storageservice.protos.groups.local.EnabledState
import org.thoughtcrime.securesms.database.model.GroupRecord
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.recipients.RecipientId
import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupHistoryEntry
import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupChangeLog
import org.whispersystems.signalservice.api.groupsv2.GroupHistoryPage
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations
import org.whispersystems.signalservice.api.push.DistributionId
@@ -67,7 +67,7 @@ class ChangeSet {
}
fun toApiResponse(): GroupHistoryPage {
return GroupHistoryPage(changeSet.map { DecryptedGroupHistoryEntry(Optional.ofNullable(it.groupSnapshot), Optional.ofNullable(it.groupChange)) }, GroupHistoryPage.PagingData.NONE)
return GroupHistoryPage(changeSet.map { DecryptedGroupChangeLog(it.groupSnapshot, it.groupChange) }, GroupHistoryPage.PagingData.NONE)
}
}
@@ -106,6 +106,9 @@ class GroupStateTestData(private val masterKey: GroupMasterKey, private val grou
var groupChange: GroupChange? = null
var includeFirst: Boolean = false
var requestedRevision: Int = 0
var expectTableCreate: Boolean = false
var expectTableUpdate: Boolean = false
var joinedAtRevision: Int? = null
fun localState(
active: Boolean = true,

View File

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

View File

@@ -1,45 +0,0 @@
package org.thoughtcrime.securesms.groups.v2.processing;
import org.junit.Test;
import org.signal.storageservice.protos.groups.local.DecryptedGroup;
import org.signal.storageservice.protos.groups.local.DecryptedGroupChange;
import static org.junit.Assert.assertEquals;
import static java.util.Arrays.asList;
import static java.util.Collections.emptyList;
public final class GlobalGroupStateTest {
@Test(expected = AssertionError.class)
public void cannot_ask_latestVersionNumber_of_empty_state() {
GlobalGroupState emptyState = new GlobalGroupState(null, emptyList());
emptyState.getLatestRevisionNumber();
}
@Test
public void latestRevisionNumber_of_state_and_empty_list() {
GlobalGroupState emptyState = new GlobalGroupState(state(10), emptyList());
assertEquals(10, emptyState.getLatestRevisionNumber());
}
@Test
public void latestRevisionNumber_of_state_and_list() {
GlobalGroupState emptyState = new GlobalGroupState(state(2), asList(logEntry(3), logEntry(4)));
assertEquals(4, emptyState.getLatestRevisionNumber());
}
private static ServerGroupLogEntry logEntry(int revision) {
return new ServerGroupLogEntry(state(revision), change(revision));
}
private static DecryptedGroup state(int revision) {
return new DecryptedGroup.Builder().revision(revision).build();
}
private static DecryptedGroupChange change(int revision) {
return new DecryptedGroupChange.Builder().revision(revision).build();
}
}

View File

@@ -1,513 +0,0 @@
package org.thoughtcrime.securesms.groups.v2.processing;
import org.junit.Before;
import org.junit.Test;
import org.signal.core.util.logging.Log;
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.DecryptedString;
import org.thoughtcrime.securesms.testutil.LogRecorder;
import org.whispersystems.signalservice.api.push.ServiceId.ACI;
import org.whispersystems.signalservice.api.util.UuidUtil;
import java.util.Collections;
import java.util.UUID;
import kotlin.collections.CollectionsKt;
import static org.hamcrest.CoreMatchers.is;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThat;
import static org.junit.Assert.assertTrue;
import static org.thoughtcrime.securesms.groups.v2.processing.GroupStateMapper.LATEST;
import static java.util.Arrays.asList;
import static java.util.Collections.emptyList;
import static java.util.Collections.singletonList;
public final class GroupStateMapperTest {
private static final UUID KNOWN_EDITOR = UUID.randomUUID();
@Before
public void setup() {
Log.initialize(new LogRecorder());
}
@Test
public void unknown_group_with_no_states_to_update() {
AdvanceGroupStateResult advanceGroupStateResult = GroupStateMapper.partiallyAdvanceGroupState(new GlobalGroupState(null, emptyList()), 10);
assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(emptyList()));
assertTrue(advanceGroupStateResult.getNewGlobalGroupState().getServerHistory().isEmpty());
assertNull(advanceGroupStateResult.getNewGlobalGroupState().getLocalState());
}
@Test
public void known_group_with_no_states_to_update() {
DecryptedGroup currentState = state(0);
AdvanceGroupStateResult advanceGroupStateResult = GroupStateMapper.partiallyAdvanceGroupState(new GlobalGroupState(currentState, emptyList()), 10);
assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(emptyList()));
assertTrue(advanceGroupStateResult.getNewGlobalGroupState().getServerHistory().isEmpty());
assertSame(currentState, advanceGroupStateResult.getNewGlobalGroupState().getLocalState());
}
@Test
public void unknown_group_single_state_to_update() {
ServerGroupLogEntry log0 = serverLogEntry(0);
AdvanceGroupStateResult advanceGroupStateResult = GroupStateMapper.partiallyAdvanceGroupState(new GlobalGroupState(null, singletonList(log0)), 10);
assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(singletonList(asLocal(log0))));
assertTrue(advanceGroupStateResult.getNewGlobalGroupState().getServerHistory().isEmpty());
assertEquals(log0.getGroup(), advanceGroupStateResult.getNewGlobalGroupState().getLocalState());
}
@Test
public void known_group_single_state_to_update() {
DecryptedGroup currentState = state(0);
ServerGroupLogEntry log1 = serverLogEntry(1);
AdvanceGroupStateResult advanceGroupStateResult = GroupStateMapper.partiallyAdvanceGroupState(new GlobalGroupState(currentState, singletonList(log1)), 1);
assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(singletonList(asLocal(log1))));
assertTrue(advanceGroupStateResult.getNewGlobalGroupState().getServerHistory().isEmpty());
assertEquals(log1.getGroup(), advanceGroupStateResult.getNewGlobalGroupState().getLocalState());
}
@Test
public void known_group_two_states_to_update() {
DecryptedGroup currentState = state(0);
ServerGroupLogEntry log1 = serverLogEntry(1);
ServerGroupLogEntry log2 = serverLogEntry(2);
AdvanceGroupStateResult advanceGroupStateResult = GroupStateMapper.partiallyAdvanceGroupState(new GlobalGroupState(currentState, asList(log1, log2)), 2);
assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(asList(asLocal(log1), asLocal(log2))));
assertTrue(advanceGroupStateResult.getNewGlobalGroupState().getServerHistory().isEmpty());
assertEquals(log2.getGroup(), advanceGroupStateResult.getNewGlobalGroupState().getLocalState());
}
@Test
public void known_group_two_states_to_update_already_on_one() {
DecryptedGroup currentState = state(1);
ServerGroupLogEntry log1 = serverLogEntry(1);
ServerGroupLogEntry log2 = serverLogEntry(2);
AdvanceGroupStateResult advanceGroupStateResult = GroupStateMapper.partiallyAdvanceGroupState(new GlobalGroupState(currentState, asList(log1, log2)), 2);
assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(singletonList(asLocal(log2))));
assertTrue(advanceGroupStateResult.getNewGlobalGroupState().getServerHistory().isEmpty());
assertEquals(log2.getGroup(), advanceGroupStateResult.getNewGlobalGroupState().getLocalState());
}
@Test
public void known_group_three_states_to_update_stop_at_2() {
DecryptedGroup currentState = state(0);
ServerGroupLogEntry log1 = serverLogEntry(1);
ServerGroupLogEntry log2 = serverLogEntry(2);
ServerGroupLogEntry log3 = serverLogEntry(3);
AdvanceGroupStateResult advanceGroupStateResult = GroupStateMapper.partiallyAdvanceGroupState(new GlobalGroupState(currentState, asList(log1, log2, log3)), 2);
assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(asList(asLocal(log1), asLocal(log2))));
assertNewState(new GlobalGroupState(log2.getGroup(), singletonList(log3)), advanceGroupStateResult.getNewGlobalGroupState());
assertEquals(log2.getGroup(), advanceGroupStateResult.getNewGlobalGroupState().getLocalState());
}
@Test
public void known_group_three_states_to_update_update_latest() {
DecryptedGroup currentState = state(0);
ServerGroupLogEntry log1 = serverLogEntry(1);
ServerGroupLogEntry log2 = serverLogEntry(2);
ServerGroupLogEntry log3 = serverLogEntry(3);
AdvanceGroupStateResult advanceGroupStateResult = GroupStateMapper.partiallyAdvanceGroupState(new GlobalGroupState(currentState, asList(log1, log2, log3)), LATEST);
assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(asList(asLocal(log1), asLocal(log2), asLocal(log3))));
assertNewState(new GlobalGroupState(log3.getGroup(), emptyList()), advanceGroupStateResult.getNewGlobalGroupState());
assertEquals(log3.getGroup(), advanceGroupStateResult.getNewGlobalGroupState().getLocalState());
}
@Test
public void apply_maximum_group_revisions() {
DecryptedGroup currentState = state(Integer.MAX_VALUE - 2);
ServerGroupLogEntry log1 = serverLogEntry(Integer.MAX_VALUE - 1);
ServerGroupLogEntry log2 = serverLogEntry(Integer.MAX_VALUE);
AdvanceGroupStateResult advanceGroupStateResult = GroupStateMapper.partiallyAdvanceGroupState(new GlobalGroupState(currentState, asList(log1, log2)), LATEST);
assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(asList(asLocal(log1), asLocal(log2))));
assertNewState(new GlobalGroupState(log2.getGroup(), emptyList()), advanceGroupStateResult.getNewGlobalGroupState());
assertEquals(log2.getGroup(), advanceGroupStateResult.getNewGlobalGroupState().getLocalState());
}
@Test
public void unknown_group_single_state_to_update_with_missing_change() {
ServerGroupLogEntry log0 = serverLogEntryWholeStateOnly(0);
AdvanceGroupStateResult advanceGroupStateResult = GroupStateMapper.partiallyAdvanceGroupState(new GlobalGroupState(null, singletonList(log0)), 10);
assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(singletonList(asLocal(log0))));
assertTrue(advanceGroupStateResult.getNewGlobalGroupState().getServerHistory().isEmpty());
assertEquals(log0.getGroup(), advanceGroupStateResult.getNewGlobalGroupState().getLocalState());
}
@Test
public void known_group_single_state_to_update_with_missing_change() {
DecryptedGroup currentState = state(0);
ServerGroupLogEntry log1 = serverLogEntryWholeStateOnly(1);
AdvanceGroupStateResult advanceGroupStateResult = GroupStateMapper.partiallyAdvanceGroupState(new GlobalGroupState(currentState, singletonList(log1)), 1);
assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(singletonList(localLogEntryNoEditor(1))));
assertTrue(advanceGroupStateResult.getNewGlobalGroupState().getServerHistory().isEmpty());
assertEquals(log1.getGroup(), advanceGroupStateResult.getNewGlobalGroupState().getLocalState());
}
@Test
public void known_group_three_states_to_update_update_latest_handle_missing_change() {
DecryptedGroup currentState = state(0);
ServerGroupLogEntry log1 = serverLogEntry(1);
ServerGroupLogEntry log2 = serverLogEntryWholeStateOnly(2);
ServerGroupLogEntry log3 = serverLogEntry(3);
AdvanceGroupStateResult advanceGroupStateResult = GroupStateMapper.partiallyAdvanceGroupState(new GlobalGroupState(currentState, asList(log1, log2, log3)), LATEST);
assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(asList(asLocal(log1), localLogEntryNoEditor(2), asLocal(log3))));
assertNewState(new GlobalGroupState(log3.getGroup(), emptyList()), advanceGroupStateResult.getNewGlobalGroupState());
assertEquals(log3.getGroup(), advanceGroupStateResult.getNewGlobalGroupState().getLocalState());
}
@Test
public void known_group_three_states_to_update_update_latest_handle_gap_with_no_changes() {
DecryptedGroup currentState = state(0);
ServerGroupLogEntry log1 = serverLogEntry(1);
ServerGroupLogEntry log3 = serverLogEntry(3);
AdvanceGroupStateResult advanceGroupStateResult = GroupStateMapper.partiallyAdvanceGroupState(new GlobalGroupState(currentState, asList(log1, log3)), LATEST);
assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(asList(asLocal(log1), asLocal(log3))));
assertNewState(new GlobalGroupState(log3.getGroup(), emptyList()), advanceGroupStateResult.getNewGlobalGroupState());
assertEquals(log3.getGroup(), advanceGroupStateResult.getNewGlobalGroupState().getLocalState());
}
@Test
public void known_group_three_states_to_update_update_latest_handle_gap_with_changes() {
DecryptedGroup currentState = state(0);
ServerGroupLogEntry log1 = serverLogEntry(1);
DecryptedGroup state3a = new DecryptedGroup.Builder()
.revision(3)
.title("Group Revision " + 3)
.build();
DecryptedGroup state3 = new DecryptedGroup.Builder()
.revision(3)
.title("Group Revision " + 3)
.avatar("Lost Avatar Update")
.build();
ServerGroupLogEntry log3 = new ServerGroupLogEntry(state3, change(3));
DecryptedGroup state4 = new DecryptedGroup.Builder()
.revision(4)
.title("Group Revision " + 4)
.avatar("Lost Avatar Update")
.build();
ServerGroupLogEntry log4 = new ServerGroupLogEntry(state4, change(4));
AdvanceGroupStateResult advanceGroupStateResult = GroupStateMapper.partiallyAdvanceGroupState(new GlobalGroupState(currentState, asList(log1, log3, log4)), LATEST);
assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(asList(asLocal(log1),
new LocalGroupLogEntry(state3a, log3.getChange()),
new LocalGroupLogEntry(state3, new DecryptedGroupChange.Builder()
.revision(3)
.newAvatar(new DecryptedString.Builder().value_("Lost Avatar Update").build())
.build()),
asLocal(log4))));
assertNewState(new GlobalGroupState(log4.getGroup(), emptyList()), advanceGroupStateResult.getNewGlobalGroupState());
assertEquals(log4.getGroup(), advanceGroupStateResult.getNewGlobalGroupState().getLocalState());
}
@Test
public void updates_with_all_changes_missing() {
DecryptedGroup currentState = state(5);
ServerGroupLogEntry log6 = serverLogEntryWholeStateOnly(6);
ServerGroupLogEntry log7 = serverLogEntryWholeStateOnly(7);
ServerGroupLogEntry log8 = serverLogEntryWholeStateOnly(8);
AdvanceGroupStateResult advanceGroupStateResult = GroupStateMapper.partiallyAdvanceGroupState(new GlobalGroupState(currentState, asList(log6, log7, log8)), LATEST);
assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(asList(localLogEntryNoEditor(6), localLogEntryNoEditor(7), localLogEntryNoEditor(8))));
assertNewState(new GlobalGroupState(log8.getGroup(), emptyList()), advanceGroupStateResult.getNewGlobalGroupState());
assertEquals(log8.getGroup(), advanceGroupStateResult.getNewGlobalGroupState().getLocalState());
}
@Test
public void updates_with_all_group_states_missing() {
DecryptedGroup currentState = state(6);
ServerGroupLogEntry log7 = logEntryMissingState(7);
ServerGroupLogEntry log8 = logEntryMissingState(8);
ServerGroupLogEntry log9 = logEntryMissingState(9);
AdvanceGroupStateResult advanceGroupStateResult = GroupStateMapper.partiallyAdvanceGroupState(new GlobalGroupState(currentState, asList(log7, log8, log9)), LATEST);
assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(asList(asLocal(serverLogEntry(7)), asLocal(serverLogEntry(8)), asLocal(serverLogEntry(9)))));
assertNewState(new GlobalGroupState(state(9), emptyList()), advanceGroupStateResult.getNewGlobalGroupState());
assertEquals(state(9), advanceGroupStateResult.getNewGlobalGroupState().getLocalState());
}
@Test
public void updates_with_a_server_mismatch_inserts_additional_update() {
DecryptedGroup currentState = state(6);
ServerGroupLogEntry log7 = serverLogEntry(7);
DecryptedMember newMember = new DecryptedMember.Builder()
.aciBytes(ACI.from(UUID.randomUUID()).toByteString())
.build();
DecryptedGroup state7b = new DecryptedGroup.Builder()
.revision(8)
.title("Group Revision " + 8)
.build();
DecryptedGroup state8 = new DecryptedGroup.Builder()
.revision(8)
.title("Group Revision " + 8)
.members(Collections.singletonList(newMember))
.build();
ServerGroupLogEntry log8 = new ServerGroupLogEntry(state8,
change(8));
ServerGroupLogEntry log9 = new ServerGroupLogEntry(new DecryptedGroup.Builder()
.revision(9)
.members(Collections.singletonList(newMember))
.title("Group Revision " + 9)
.build(),
change(9));
AdvanceGroupStateResult advanceGroupStateResult = GroupStateMapper.partiallyAdvanceGroupState(new GlobalGroupState(currentState, asList(log7, log8, log9)), LATEST);
assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(asList(asLocal(log7),
new LocalGroupLogEntry(state7b, log8.getChange()),
new LocalGroupLogEntry(state8, new DecryptedGroupChange.Builder()
.revision(8)
.newMembers(Collections.singletonList(newMember))
.build()),
asLocal(log9))));
assertNewState(new GlobalGroupState(log9.getGroup(), emptyList()), advanceGroupStateResult.getNewGlobalGroupState());
assertEquals(log9.getGroup(), advanceGroupStateResult.getNewGlobalGroupState().getLocalState());
}
@Test
public void local_up_to_date_no_repair_necessary() {
DecryptedGroup currentState = state(6);
ServerGroupLogEntry log6 = serverLogEntryWholeStateOnly(6);
AdvanceGroupStateResult advanceGroupStateResult = GroupStateMapper.partiallyAdvanceGroupState(new GlobalGroupState(currentState, singletonList(log6)), LATEST);
assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(emptyList()));
assertNewState(new GlobalGroupState(state(6), emptyList()), advanceGroupStateResult.getNewGlobalGroupState());
assertEquals(state(6), advanceGroupStateResult.getNewGlobalGroupState().getLocalState());
}
@Test
public void no_repair_change_is_posted_if_the_local_state_is_a_placeholder() {
DecryptedGroup currentState = new DecryptedGroup.Builder()
.revision(GroupStateMapper.PLACEHOLDER_REVISION)
.title("Incorrect group title, Revision " + 6)
.build();
ServerGroupLogEntry log6 = serverLogEntry(6);
AdvanceGroupStateResult advanceGroupStateResult = GroupStateMapper.partiallyAdvanceGroupState(new GlobalGroupState(currentState, singletonList(log6)), LATEST);
assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(singletonList(asLocal(log6))));
assertTrue(advanceGroupStateResult.getNewGlobalGroupState().getServerHistory().isEmpty());
assertEquals(log6.getGroup(), advanceGroupStateResult.getNewGlobalGroupState().getLocalState());
}
@Test
public void clears_changes_duplicated_in_the_placeholder() {
ACI newMemberAci = ACI.from(UUID.randomUUID());
DecryptedMember newMember = new DecryptedMember.Builder()
.aciBytes(newMemberAci.toByteString())
.build();
DecryptedMember existingMember = new DecryptedMember.Builder()
.aciBytes(ACI.from(UUID.randomUUID()).toByteString())
.build();
DecryptedGroup currentState = new DecryptedGroup.Builder()
.revision(GroupStateMapper.PLACEHOLDER_REVISION)
.title("Group Revision " + 8)
.members(Collections.singletonList(newMember))
.build();
ServerGroupLogEntry log8 = new ServerGroupLogEntry(new DecryptedGroup.Builder()
.revision(8)
.members(CollectionsKt.plus(Collections.singletonList(existingMember), newMember))
.title("Group Revision " + 8)
.build(),
new DecryptedGroupChange.Builder()
.revision(8)
.editorServiceIdBytes(newMemberAci.toByteString())
.newMembers(Collections.singletonList(newMember))
.build());
AdvanceGroupStateResult advanceGroupStateResult = GroupStateMapper.partiallyAdvanceGroupState(new GlobalGroupState(currentState, singletonList(log8)), LATEST);
assertNotNull(log8.getGroup());
assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(emptyList()));
assertNewState(new GlobalGroupState(log8.getGroup(), emptyList()), advanceGroupStateResult.getNewGlobalGroupState());
assertEquals(log8.getGroup(), advanceGroupStateResult.getNewGlobalGroupState().getLocalState());
}
@Test
public void clears_changes_duplicated_in_a_non_placeholder() {
ACI editorAci = ACI.from(UUID.randomUUID());
ACI newMemberAci = ACI.from(UUID.randomUUID());
DecryptedMember newMember = new DecryptedMember.Builder()
.aciBytes(newMemberAci.toByteString())
.build();
DecryptedMember existingMember = new DecryptedMember.Builder()
.aciBytes(ACI.from(UUID.randomUUID()).toByteString())
.build();
DecryptedGroup currentState = new DecryptedGroup.Builder()
.revision(8)
.title("Group Revision " + 8)
.members(Collections.singletonList(existingMember))
.build();
ServerGroupLogEntry log8 = new ServerGroupLogEntry(new DecryptedGroup.Builder()
.revision(8)
.members(CollectionsKt.plus(Collections.singletonList(existingMember), newMember))
.title("Group Revision " + 8)
.build(),
new DecryptedGroupChange.Builder()
.revision(8)
.editorServiceIdBytes(editorAci.toByteString())
.newMembers(CollectionsKt.plus(Collections.singletonList(existingMember), newMember))
.build());
DecryptedGroupChange expectedChange = new DecryptedGroupChange.Builder()
.revision(8)
.editorServiceIdBytes(editorAci.toByteString())
.newMembers(Collections.singletonList(newMember))
.build();
AdvanceGroupStateResult advanceGroupStateResult = GroupStateMapper.partiallyAdvanceGroupState(new GlobalGroupState(currentState, singletonList(log8)), LATEST);
assertNotNull(log8.getGroup());
assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(singletonList(new LocalGroupLogEntry(log8.getGroup(), expectedChange))));
assertNewState(new GlobalGroupState(log8.getGroup(), emptyList()), advanceGroupStateResult.getNewGlobalGroupState());
assertEquals(log8.getGroup(), advanceGroupStateResult.getNewGlobalGroupState().getLocalState());
}
@Test
public void notices_changes_in_avatar_and_title_but_not_members_in_placeholder() {
ACI newMemberAci = ACI.from(UUID.randomUUID());
DecryptedMember newMember = new DecryptedMember.Builder()
.aciBytes(newMemberAci.toByteString())
.build();
DecryptedMember existingMember = new DecryptedMember.Builder()
.aciBytes(ACI.from(UUID.randomUUID()).toByteString())
.build();
DecryptedGroup currentState = new DecryptedGroup.Builder()
.revision(GroupStateMapper.PLACEHOLDER_REVISION)
.title("Incorrect group title")
.avatar("Incorrect group avatar")
.members(Collections.singletonList(newMember))
.build();
ServerGroupLogEntry log8 = new ServerGroupLogEntry(new DecryptedGroup.Builder()
.revision(8)
.members(CollectionsKt.plus(Collections.singletonList(existingMember), newMember))
.title("Group Revision " + 8)
.avatar("Group Avatar " + 8)
.build(),
new DecryptedGroupChange.Builder()
.revision(8)
.editorServiceIdBytes(newMemberAci.toByteString())
.newMembers(Collections.singletonList(newMember))
.build());
DecryptedGroupChange expectedChange = new DecryptedGroupChange.Builder()
.revision(8)
.newTitle(new DecryptedString.Builder().value_("Group Revision " + 8).build())
.newAvatar(new DecryptedString.Builder().value_("Group Avatar " + 8).build())
.build();
AdvanceGroupStateResult advanceGroupStateResult = GroupStateMapper.partiallyAdvanceGroupState(new GlobalGroupState(currentState, singletonList(log8)), LATEST);
assertNotNull(log8.getGroup());
assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(singletonList(new LocalGroupLogEntry(log8.getGroup(), expectedChange))));
assertNewState(new GlobalGroupState(log8.getGroup(), emptyList()), advanceGroupStateResult.getNewGlobalGroupState());
assertEquals(log8.getGroup(), advanceGroupStateResult.getNewGlobalGroupState().getLocalState());
}
@Test
public void no_actual_change() {
DecryptedGroup currentState = state(0);
ServerGroupLogEntry log1 = serverLogEntry(1);
ServerGroupLogEntry log2 = new ServerGroupLogEntry(log1.getGroup().newBuilder()
.revision(2)
.build(),
new DecryptedGroupChange.Builder()
.revision(2)
.editorServiceIdBytes(UuidUtil.toByteString(KNOWN_EDITOR))
.newTitle(new DecryptedString.Builder().value_(log1.getGroup().title).build())
.build());
AdvanceGroupStateResult advanceGroupStateResult = GroupStateMapper.partiallyAdvanceGroupState(new GlobalGroupState(currentState, asList(log1, log2)), 2);
assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(asList(asLocal(log1),
new LocalGroupLogEntry(log2.getGroup(), new DecryptedGroupChange.Builder()
.revision(2)
.editorServiceIdBytes(UuidUtil.toByteString(KNOWN_EDITOR))
.build()))));
assertTrue(advanceGroupStateResult.getNewGlobalGroupState().getServerHistory().isEmpty());
assertEquals(log2.getGroup(), advanceGroupStateResult.getNewGlobalGroupState().getLocalState());
}
private static void assertNewState(GlobalGroupState expected, GlobalGroupState actual) {
assertEquals(expected.getLocalState(), actual.getLocalState());
assertThat(actual.getServerHistory(), is(expected.getServerHistory()));
}
private static ServerGroupLogEntry serverLogEntry(int revision) {
return new ServerGroupLogEntry(state(revision), change(revision));
}
private static LocalGroupLogEntry localLogEntryNoEditor(int revision) {
return new LocalGroupLogEntry(state(revision), changeNoEditor(revision));
}
private static ServerGroupLogEntry serverLogEntryWholeStateOnly(int revision) {
return new ServerGroupLogEntry(state(revision), null);
}
private static ServerGroupLogEntry logEntryMissingState(int revision) {
return new ServerGroupLogEntry(null, change(revision));
}
private static DecryptedGroup state(int revision) {
return new DecryptedGroup.Builder()
.revision(revision)
.title("Group Revision " + revision)
.build();
}
private static DecryptedGroupChange change(int revision) {
return new DecryptedGroupChange.Builder()
.revision(revision)
.editorServiceIdBytes(UuidUtil.toByteString(KNOWN_EDITOR))
.newTitle(new DecryptedString.Builder().value_("Group Revision " + revision).build())
.build();
}
private static DecryptedGroupChange changeNoEditor(int revision) {
return new DecryptedGroupChange.Builder()
.revision(revision)
.newTitle(new DecryptedString.Builder().value_("Group Revision " + revision).build())
.build();
}
private static LocalGroupLogEntry asLocal(ServerGroupLogEntry logEntry) {
assertNotNull(logEntry.getGroup());
return new LocalGroupLogEntry(logEntry.getGroup(), logEntry.getChange());
}
}

View File

@@ -0,0 +1,515 @@
package org.thoughtcrime.securesms.groups.v2.processing;
import org.junit.Before;
import org.junit.Test;
import org.signal.core.util.logging.Log;
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.DecryptedString;
import org.thoughtcrime.securesms.testutil.LogRecorder;
import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupChangeLog;
import org.whispersystems.signalservice.api.push.ServiceId.ACI;
import org.whispersystems.signalservice.api.util.UuidUtil;
import java.util.Collections;
import java.util.List;
import java.util.UUID;
import kotlin.collections.CollectionsKt;
import static org.hamcrest.CoreMatchers.is;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThat;
import static org.junit.Assert.assertTrue;
import static org.thoughtcrime.securesms.groups.v2.processing.GroupStatePatcher.LATEST;
import static java.util.Arrays.asList;
import static java.util.Collections.emptyList;
import static java.util.Collections.singletonList;
public final class GroupStatePatcherTest {
private static final UUID KNOWN_EDITOR = UUID.randomUUID();
@Before
public void setup() {
Log.initialize(new LogRecorder());
}
@Test
public void unknown_group_with_no_states_to_update() {
AdvanceGroupStateResult advanceGroupStateResult = GroupStatePatcher.applyGroupStateDiff(new GroupStateDiff(null, emptyList()), 10);
assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(emptyList()));
assertTrue(advanceGroupStateResult.getRemainingRemoteGroupChanges().isEmpty());
assertNull(advanceGroupStateResult.getUpdatedGroupState());
}
@Test
public void known_group_with_no_states_to_update() {
DecryptedGroup currentState = state(0);
AdvanceGroupStateResult advanceGroupStateResult = GroupStatePatcher.applyGroupStateDiff(new GroupStateDiff(currentState, emptyList()), 10);
assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(emptyList()));
assertTrue(advanceGroupStateResult.getRemainingRemoteGroupChanges().isEmpty());
assertSame(currentState, advanceGroupStateResult.getUpdatedGroupState());
}
@Test
public void unknown_group_single_state_to_update() {
DecryptedGroupChangeLog log0 = serverLogEntry(0);
AdvanceGroupStateResult advanceGroupStateResult = GroupStatePatcher.applyGroupStateDiff(new GroupStateDiff(null, singletonList(log0)), 10);
assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(singletonList(asLocal(log0))));
assertTrue(advanceGroupStateResult.getRemainingRemoteGroupChanges().isEmpty());
assertEquals(log0.getGroup(), advanceGroupStateResult.getUpdatedGroupState());
}
@Test
public void known_group_single_state_to_update() {
DecryptedGroup currentState = state(0);
DecryptedGroupChangeLog log1 = serverLogEntry(1);
AdvanceGroupStateResult advanceGroupStateResult = GroupStatePatcher.applyGroupStateDiff(new GroupStateDiff(currentState, singletonList(log1)), 1);
assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(singletonList(asLocal(log1))));
assertTrue(advanceGroupStateResult.getRemainingRemoteGroupChanges().isEmpty());
assertEquals(log1.getGroup(), advanceGroupStateResult.getUpdatedGroupState());
}
@Test
public void known_group_two_states_to_update() {
DecryptedGroup currentState = state(0);
DecryptedGroupChangeLog log1 = serverLogEntry(1);
DecryptedGroupChangeLog log2 = serverLogEntry(2);
AdvanceGroupStateResult advanceGroupStateResult = GroupStatePatcher.applyGroupStateDiff(new GroupStateDiff(currentState, asList(log1, log2)), 2);
assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(asList(asLocal(log1), asLocal(log2))));
assertTrue(advanceGroupStateResult.getRemainingRemoteGroupChanges().isEmpty());
assertEquals(log2.getGroup(), advanceGroupStateResult.getUpdatedGroupState());
}
@Test
public void known_group_two_states_to_update_already_on_one() {
DecryptedGroup currentState = state(1);
DecryptedGroupChangeLog log1 = serverLogEntry(1);
DecryptedGroupChangeLog log2 = serverLogEntry(2);
AdvanceGroupStateResult advanceGroupStateResult = GroupStatePatcher.applyGroupStateDiff(new GroupStateDiff(currentState, asList(log1, log2)), 2);
assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(singletonList(asLocal(log2))));
assertTrue(advanceGroupStateResult.getRemainingRemoteGroupChanges().isEmpty());
assertEquals(log2.getGroup(), advanceGroupStateResult.getUpdatedGroupState());
}
@Test
public void known_group_three_states_to_update_stop_at_2() {
DecryptedGroup currentState = state(0);
DecryptedGroupChangeLog log1 = serverLogEntry(1);
DecryptedGroupChangeLog log2 = serverLogEntry(2);
DecryptedGroupChangeLog log3 = serverLogEntry(3);
AdvanceGroupStateResult advanceGroupStateResult = GroupStatePatcher.applyGroupStateDiff(new GroupStateDiff(currentState, asList(log1, log2, log3)), 2);
assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(asList(asLocal(log1), asLocal(log2))));
assertNewState(log2.getGroup(), singletonList(log3), advanceGroupStateResult.getUpdatedGroupState(), advanceGroupStateResult.getRemainingRemoteGroupChanges());
assertEquals(log2.getGroup(), advanceGroupStateResult.getUpdatedGroupState());
}
@Test
public void known_group_three_states_to_update_update_latest() {
DecryptedGroup currentState = state(0);
DecryptedGroupChangeLog log1 = serverLogEntry(1);
DecryptedGroupChangeLog log2 = serverLogEntry(2);
DecryptedGroupChangeLog log3 = serverLogEntry(3);
AdvanceGroupStateResult advanceGroupStateResult = GroupStatePatcher.applyGroupStateDiff(new GroupStateDiff(currentState, asList(log1, log2, log3)), LATEST);
assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(asList(asLocal(log1), asLocal(log2), asLocal(log3))));
assertNewState(log3.getGroup(), emptyList(), advanceGroupStateResult.getUpdatedGroupState(), advanceGroupStateResult.getRemainingRemoteGroupChanges());
assertEquals(log3.getGroup(), advanceGroupStateResult.getUpdatedGroupState());
}
@Test
public void apply_maximum_group_revisions() {
DecryptedGroup currentState = state(Integer.MAX_VALUE - 2);
DecryptedGroupChangeLog log1 = serverLogEntry(Integer.MAX_VALUE - 1);
DecryptedGroupChangeLog log2 = serverLogEntry(Integer.MAX_VALUE);
AdvanceGroupStateResult advanceGroupStateResult = GroupStatePatcher.applyGroupStateDiff(new GroupStateDiff(currentState, asList(log1, log2)), LATEST);
assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(asList(asLocal(log1), asLocal(log2))));
assertNewState(log2.getGroup(), emptyList(), advanceGroupStateResult.getUpdatedGroupState(), advanceGroupStateResult.getRemainingRemoteGroupChanges());
assertEquals(log2.getGroup(), advanceGroupStateResult.getUpdatedGroupState());
}
@Test
public void unknown_group_single_state_to_update_with_missing_change() {
DecryptedGroupChangeLog log0 = serverLogEntryWholeStateOnly(0);
AdvanceGroupStateResult advanceGroupStateResult = GroupStatePatcher.applyGroupStateDiff(new GroupStateDiff(null, singletonList(log0)), 10);
assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(singletonList(asLocal(log0))));
assertTrue(advanceGroupStateResult.getRemainingRemoteGroupChanges().isEmpty());
assertEquals(log0.getGroup(), advanceGroupStateResult.getUpdatedGroupState());
}
@Test
public void known_group_single_state_to_update_with_missing_change() {
DecryptedGroup currentState = state(0);
DecryptedGroupChangeLog log1 = serverLogEntryWholeStateOnly(1);
AdvanceGroupStateResult advanceGroupStateResult = GroupStatePatcher.applyGroupStateDiff(new GroupStateDiff(currentState, singletonList(log1)), 1);
assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(singletonList(localLogEntryNoEditor(1))));
assertTrue(advanceGroupStateResult.getRemainingRemoteGroupChanges().isEmpty());
assertEquals(log1.getGroup(), advanceGroupStateResult.getUpdatedGroupState());
}
@Test
public void known_group_three_states_to_update_update_latest_handle_missing_change() {
DecryptedGroup currentState = state(0);
DecryptedGroupChangeLog log1 = serverLogEntry(1);
DecryptedGroupChangeLog log2 = serverLogEntryWholeStateOnly(2);
DecryptedGroupChangeLog log3 = serverLogEntry(3);
AdvanceGroupStateResult advanceGroupStateResult = GroupStatePatcher.applyGroupStateDiff(new GroupStateDiff(currentState, asList(log1, log2, log3)), LATEST);
assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(asList(asLocal(log1), localLogEntryNoEditor(2), asLocal(log3))));
assertNewState(log3.getGroup(), emptyList(), advanceGroupStateResult.getUpdatedGroupState(), advanceGroupStateResult.getRemainingRemoteGroupChanges());
assertEquals(log3.getGroup(), advanceGroupStateResult.getUpdatedGroupState());
}
@Test
public void known_group_three_states_to_update_update_latest_handle_gap_with_no_changes() {
DecryptedGroup currentState = state(0);
DecryptedGroupChangeLog log1 = serverLogEntry(1);
DecryptedGroupChangeLog log3 = serverLogEntry(3);
AdvanceGroupStateResult advanceGroupStateResult = GroupStatePatcher.applyGroupStateDiff(new GroupStateDiff(currentState, asList(log1, log3)), LATEST);
assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(asList(asLocal(log1), asLocal(log3))));
assertNewState(log3.getGroup(), emptyList(), advanceGroupStateResult.getUpdatedGroupState(), advanceGroupStateResult.getRemainingRemoteGroupChanges());
assertEquals(log3.getGroup(), advanceGroupStateResult.getUpdatedGroupState());
}
@Test
public void known_group_three_states_to_update_update_latest_handle_gap_with_changes() {
DecryptedGroup currentState = state(0);
DecryptedGroupChangeLog log1 = serverLogEntry(1);
DecryptedGroup state3a = new DecryptedGroup.Builder()
.revision(3)
.title("Group Revision " + 3)
.build();
DecryptedGroup state3 = new DecryptedGroup.Builder()
.revision(3)
.title("Group Revision " + 3)
.avatar("Lost Avatar Update")
.build();
DecryptedGroupChangeLog log3 = new DecryptedGroupChangeLog(state3, change(3));
DecryptedGroup state4 = new DecryptedGroup.Builder()
.revision(4)
.title("Group Revision " + 4)
.avatar("Lost Avatar Update")
.build();
DecryptedGroupChangeLog log4 = new DecryptedGroupChangeLog(state4, change(4));
AdvanceGroupStateResult advanceGroupStateResult = GroupStatePatcher.applyGroupStateDiff(new GroupStateDiff(currentState, asList(log1, log3, log4)), LATEST);
assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(asList(asLocal(log1),
new AppliedGroupChangeLog(state3a, log3.getChange()),
new AppliedGroupChangeLog(state3, new DecryptedGroupChange.Builder()
.revision(3)
.newAvatar(new DecryptedString.Builder().value_("Lost Avatar Update").build())
.build()),
asLocal(log4))));
assertNewState(log4.getGroup(), emptyList(), advanceGroupStateResult.getUpdatedGroupState(), advanceGroupStateResult.getRemainingRemoteGroupChanges());
assertEquals(log4.getGroup(), advanceGroupStateResult.getUpdatedGroupState());
}
@Test
public void updates_with_all_changes_missing() {
DecryptedGroup currentState = state(5);
DecryptedGroupChangeLog log6 = serverLogEntryWholeStateOnly(6);
DecryptedGroupChangeLog log7 = serverLogEntryWholeStateOnly(7);
DecryptedGroupChangeLog log8 = serverLogEntryWholeStateOnly(8);
AdvanceGroupStateResult advanceGroupStateResult = GroupStatePatcher.applyGroupStateDiff(new GroupStateDiff(currentState, asList(log6, log7, log8)), LATEST);
assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(asList(localLogEntryNoEditor(6), localLogEntryNoEditor(7), localLogEntryNoEditor(8))));
assertNewState(log8.getGroup(), emptyList(), advanceGroupStateResult.getUpdatedGroupState(), advanceGroupStateResult.getRemainingRemoteGroupChanges());
assertEquals(log8.getGroup(), advanceGroupStateResult.getUpdatedGroupState());
}
@Test
public void updates_with_all_group_states_missing() {
DecryptedGroup currentState = state(6);
DecryptedGroupChangeLog log7 = logEntryMissingState(7);
DecryptedGroupChangeLog log8 = logEntryMissingState(8);
DecryptedGroupChangeLog log9 = logEntryMissingState(9);
AdvanceGroupStateResult advanceGroupStateResult = GroupStatePatcher.applyGroupStateDiff(new GroupStateDiff(currentState, asList(log7, log8, log9)), LATEST);
assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(asList(asLocal(serverLogEntry(7)), asLocal(serverLogEntry(8)), asLocal(serverLogEntry(9)))));
assertNewState(state(9), emptyList(), advanceGroupStateResult.getUpdatedGroupState(), advanceGroupStateResult.getRemainingRemoteGroupChanges());
assertEquals(state(9), advanceGroupStateResult.getUpdatedGroupState());
}
@Test
public void updates_with_a_server_mismatch_inserts_additional_update() {
DecryptedGroup currentState = state(6);
DecryptedGroupChangeLog log7 = serverLogEntry(7);
DecryptedMember newMember = new DecryptedMember.Builder()
.aciBytes(ACI.from(UUID.randomUUID()).toByteString())
.build();
DecryptedGroup state7b = new DecryptedGroup.Builder()
.revision(8)
.title("Group Revision " + 8)
.build();
DecryptedGroup state8 = new DecryptedGroup.Builder()
.revision(8)
.title("Group Revision " + 8)
.members(Collections.singletonList(newMember))
.build();
DecryptedGroupChangeLog log8 = new DecryptedGroupChangeLog(state8,
change(8));
DecryptedGroupChangeLog log9 = new DecryptedGroupChangeLog(new DecryptedGroup.Builder()
.revision(9)
.members(Collections.singletonList(newMember))
.title("Group Revision " + 9)
.build(),
change(9));
AdvanceGroupStateResult advanceGroupStateResult = GroupStatePatcher.applyGroupStateDiff(new GroupStateDiff(currentState, asList(log7, log8, log9)), LATEST);
assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(asList(asLocal(log7),
new AppliedGroupChangeLog(state7b, log8.getChange()),
new AppliedGroupChangeLog(state8, new DecryptedGroupChange.Builder()
.revision(8)
.newMembers(Collections.singletonList(newMember))
.build()),
asLocal(log9))));
assertNewState(log9.getGroup(), emptyList(), advanceGroupStateResult.getUpdatedGroupState(), advanceGroupStateResult.getRemainingRemoteGroupChanges());
assertEquals(log9.getGroup(), advanceGroupStateResult.getUpdatedGroupState());
}
@Test
public void local_up_to_date_no_repair_necessary() {
DecryptedGroup currentState = state(6);
DecryptedGroupChangeLog log6 = serverLogEntryWholeStateOnly(6);
AdvanceGroupStateResult advanceGroupStateResult = GroupStatePatcher.applyGroupStateDiff(new GroupStateDiff(currentState, singletonList(log6)), LATEST);
assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(emptyList()));
assertNewState(state(6), emptyList(), advanceGroupStateResult.getUpdatedGroupState(), advanceGroupStateResult.getRemainingRemoteGroupChanges());
assertEquals(state(6), advanceGroupStateResult.getUpdatedGroupState());
}
@Test
public void no_repair_change_is_posted_if_the_local_state_is_a_placeholder() {
DecryptedGroup currentState = new DecryptedGroup.Builder()
.revision(GroupStatePatcher.PLACEHOLDER_REVISION)
.title("Incorrect group title, Revision " + 6)
.build();
DecryptedGroupChangeLog log6 = serverLogEntry(6);
AdvanceGroupStateResult advanceGroupStateResult = GroupStatePatcher.applyGroupStateDiff(new GroupStateDiff(currentState, singletonList(log6)), LATEST);
assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(singletonList(asLocal(log6))));
assertTrue(advanceGroupStateResult.getRemainingRemoteGroupChanges().isEmpty());
assertEquals(log6.getGroup(), advanceGroupStateResult.getUpdatedGroupState());
}
@Test
public void clears_changes_duplicated_in_the_placeholder() {
ACI newMemberAci = ACI.from(UUID.randomUUID());
DecryptedMember newMember = new DecryptedMember.Builder()
.aciBytes(newMemberAci.toByteString())
.build();
DecryptedMember existingMember = new DecryptedMember.Builder()
.aciBytes(ACI.from(UUID.randomUUID()).toByteString())
.build();
DecryptedGroup currentState = new DecryptedGroup.Builder()
.revision(GroupStatePatcher.PLACEHOLDER_REVISION)
.title("Group Revision " + 8)
.members(Collections.singletonList(newMember))
.build();
DecryptedGroupChangeLog log8 = new DecryptedGroupChangeLog(new DecryptedGroup.Builder()
.revision(8)
.members(CollectionsKt.plus(Collections.singletonList(existingMember), newMember))
.title("Group Revision " + 8)
.build(),
new DecryptedGroupChange.Builder()
.revision(8)
.editorServiceIdBytes(newMemberAci.toByteString())
.newMembers(Collections.singletonList(newMember))
.build());
AdvanceGroupStateResult advanceGroupStateResult = GroupStatePatcher.applyGroupStateDiff(new GroupStateDiff(currentState, singletonList(log8)), LATEST);
assertNotNull(log8.getGroup());
assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(emptyList()));
assertNewState(log8.getGroup(), emptyList(), advanceGroupStateResult.getUpdatedGroupState(), advanceGroupStateResult.getRemainingRemoteGroupChanges());
assertEquals(log8.getGroup(), advanceGroupStateResult.getUpdatedGroupState());
}
@Test
public void clears_changes_duplicated_in_a_non_placeholder() {
ACI editorAci = ACI.from(UUID.randomUUID());
ACI newMemberAci = ACI.from(UUID.randomUUID());
DecryptedMember newMember = new DecryptedMember.Builder()
.aciBytes(newMemberAci.toByteString())
.build();
DecryptedMember existingMember = new DecryptedMember.Builder()
.aciBytes(ACI.from(UUID.randomUUID()).toByteString())
.build();
DecryptedGroup currentState = new DecryptedGroup.Builder()
.revision(8)
.title("Group Revision " + 8)
.members(Collections.singletonList(existingMember))
.build();
DecryptedGroupChangeLog log8 = new DecryptedGroupChangeLog(new DecryptedGroup.Builder()
.revision(8)
.members(CollectionsKt.plus(Collections.singletonList(existingMember), newMember))
.title("Group Revision " + 8)
.build(),
new DecryptedGroupChange.Builder()
.revision(8)
.editorServiceIdBytes(editorAci.toByteString())
.newMembers(CollectionsKt.plus(Collections.singletonList(existingMember), newMember))
.build());
DecryptedGroupChange expectedChange = new DecryptedGroupChange.Builder()
.revision(8)
.editorServiceIdBytes(editorAci.toByteString())
.newMembers(Collections.singletonList(newMember))
.build();
AdvanceGroupStateResult advanceGroupStateResult = GroupStatePatcher.applyGroupStateDiff(new GroupStateDiff(currentState, singletonList(log8)), LATEST);
assertNotNull(log8.getGroup());
assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(singletonList(new AppliedGroupChangeLog(log8.getGroup(), expectedChange))));
assertNewState(log8.getGroup(), emptyList(), advanceGroupStateResult.getUpdatedGroupState(), advanceGroupStateResult.getRemainingRemoteGroupChanges());
assertEquals(log8.getGroup(), advanceGroupStateResult.getUpdatedGroupState());
}
@Test
public void notices_changes_in_avatar_and_title_but_not_members_in_placeholder() {
ACI newMemberAci = ACI.from(UUID.randomUUID());
DecryptedMember newMember = new DecryptedMember.Builder()
.aciBytes(newMemberAci.toByteString())
.build();
DecryptedMember existingMember = new DecryptedMember.Builder()
.aciBytes(ACI.from(UUID.randomUUID()).toByteString())
.build();
DecryptedGroup currentState = new DecryptedGroup.Builder()
.revision(GroupStatePatcher.PLACEHOLDER_REVISION)
.title("Incorrect group title")
.avatar("Incorrect group avatar")
.members(Collections.singletonList(newMember))
.build();
DecryptedGroupChangeLog log8 = new DecryptedGroupChangeLog(new DecryptedGroup.Builder()
.revision(8)
.members(CollectionsKt.plus(Collections.singletonList(existingMember), newMember))
.title("Group Revision " + 8)
.avatar("Group Avatar " + 8)
.build(),
new DecryptedGroupChange.Builder()
.revision(8)
.editorServiceIdBytes(newMemberAci.toByteString())
.newMembers(Collections.singletonList(newMember))
.build());
DecryptedGroupChange expectedChange = new DecryptedGroupChange.Builder()
.revision(8)
.newTitle(new DecryptedString.Builder().value_("Group Revision " + 8).build())
.newAvatar(new DecryptedString.Builder().value_("Group Avatar " + 8).build())
.build();
AdvanceGroupStateResult advanceGroupStateResult = GroupStatePatcher.applyGroupStateDiff(new GroupStateDiff(currentState, singletonList(log8)), LATEST);
assertNotNull(log8.getGroup());
assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(singletonList(new AppliedGroupChangeLog(log8.getGroup(), expectedChange))));
assertNewState(log8.getGroup(), emptyList(), advanceGroupStateResult.getUpdatedGroupState(), advanceGroupStateResult.getRemainingRemoteGroupChanges());
assertEquals(log8.getGroup(), advanceGroupStateResult.getUpdatedGroupState());
}
@Test
public void no_actual_change() {
DecryptedGroup currentState = state(0);
DecryptedGroupChangeLog log1 = serverLogEntry(1);
DecryptedGroupChangeLog log2 = new DecryptedGroupChangeLog(log1.getGroup().newBuilder()
.revision(2)
.build(),
new DecryptedGroupChange.Builder()
.revision(2)
.editorServiceIdBytes(UuidUtil.toByteString(KNOWN_EDITOR))
.newTitle(new DecryptedString.Builder().value_(log1.getGroup().title).build())
.build());
AdvanceGroupStateResult advanceGroupStateResult = GroupStatePatcher.applyGroupStateDiff(new GroupStateDiff(currentState, asList(log1, log2)), 2);
assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(asList(asLocal(log1),
new AppliedGroupChangeLog(log2.getGroup(), new DecryptedGroupChange.Builder()
.revision(2)
.editorServiceIdBytes(UuidUtil.toByteString(KNOWN_EDITOR))
.build()))));
assertTrue(advanceGroupStateResult.getRemainingRemoteGroupChanges().isEmpty());
assertEquals(log2.getGroup(), advanceGroupStateResult.getUpdatedGroupState());
}
private static void assertNewState(DecryptedGroup expectedUpdatedGroupState, List<DecryptedGroupChangeLog> expectedRemainingLogs, DecryptedGroup updatedGroupState, List<DecryptedGroupChangeLog> remainingLogs) {
assertEquals(expectedUpdatedGroupState, updatedGroupState);
assertThat(remainingLogs, is(expectedRemainingLogs));
}
private static DecryptedGroupChangeLog serverLogEntry(int revision) {
return new DecryptedGroupChangeLog(state(revision), change(revision));
}
private static AppliedGroupChangeLog localLogEntryNoEditor(int revision) {
return new AppliedGroupChangeLog(state(revision), changeNoEditor(revision));
}
private static DecryptedGroupChangeLog serverLogEntryWholeStateOnly(int revision) {
return new DecryptedGroupChangeLog(state(revision), null);
}
private static DecryptedGroupChangeLog logEntryMissingState(int revision) {
return new DecryptedGroupChangeLog(null, change(revision));
}
private static DecryptedGroup state(int revision) {
return new DecryptedGroup.Builder()
.revision(revision)
.title("Group Revision " + revision)
.build();
}
private static DecryptedGroupChange change(int revision) {
return new DecryptedGroupChange.Builder()
.revision(revision)
.editorServiceIdBytes(UuidUtil.toByteString(KNOWN_EDITOR))
.newTitle(new DecryptedString.Builder().value_("Group Revision " + revision).build())
.build();
}
private static DecryptedGroupChange changeNoEditor(int revision) {
return new DecryptedGroupChange.Builder()
.revision(revision)
.newTitle(new DecryptedString.Builder().value_("Group Revision " + revision).build())
.build();
}
private static AppliedGroupChangeLog asLocal(DecryptedGroupChangeLog logEntry) {
assertNotNull(logEntry.getGroup());
return new AppliedGroupChangeLog(logEntry.getGroup(), logEntry.getChange());
}
}

View File

@@ -1,9 +1,15 @@
package org.thoughtcrime.securesms.groups.v2.processing
import android.annotation.SuppressLint
import android.app.Application
import io.mockk.every
import io.mockk.justRun
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.mockkStatic
import io.mockk.spyk
import io.mockk.unmockkObject
import io.mockk.unmockkStatic
import io.mockk.verify
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.hasItem
@@ -18,40 +24,59 @@ import org.robolectric.annotation.Config
import org.signal.core.util.Hex.fromStringCondensed
import org.signal.core.util.logging.Log
import org.signal.libsignal.protocol.logging.SignalProtocolLoggerProvider
import org.signal.libsignal.zkgroup.VerificationFailedException
import org.signal.libsignal.zkgroup.groups.GroupMasterKey
import org.signal.libsignal.zkgroup.groups.GroupSecretParams
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.DecryptedString
import org.signal.storageservice.protos.groups.local.DecryptedTimer
import org.thoughtcrime.securesms.SignalStoreRule
import org.thoughtcrime.securesms.database.GroupStateTestData
import org.thoughtcrime.securesms.database.GroupTable
import org.thoughtcrime.securesms.database.RecipientTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.GroupRecord
import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context
import org.thoughtcrime.securesms.database.model.databaseprotos.member
import org.thoughtcrime.securesms.database.model.databaseprotos.pendingMember
import org.thoughtcrime.securesms.database.model.databaseprotos.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.GroupNotAMemberException
import org.thoughtcrime.securesms.groups.GroupsV2Authorization
import org.thoughtcrime.securesms.groups.v2.ProfileKeySet
import org.thoughtcrime.securesms.groups.v2.processing.GroupsV2StateProcessor.ProfileAndMessageHelper
import org.thoughtcrime.securesms.jobmanager.JobManager
import org.thoughtcrime.securesms.jobs.DirectoryRefreshJob
import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob
import org.thoughtcrime.securesms.logging.CustomSignalProtocolLogger
import org.thoughtcrime.securesms.testutil.SystemOutLogger
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Api
import org.whispersystems.signalservice.api.groupsv2.PartialDecryptedGroup
import org.whispersystems.signalservice.api.groupsv2.NotAbleToApplyGroupV2ChangeException
import org.whispersystems.signalservice.api.push.ServiceId.ACI
import org.whispersystems.signalservice.api.push.ServiceId.PNI
import org.whispersystems.signalservice.api.push.ServiceIds
import org.whispersystems.signalservice.internal.push.exceptions.NotInGroupException
import java.io.IOException
import java.util.Optional
import java.util.UUID
@Suppress("UsePropertyAccessSyntax")
@SuppressLint("CheckResult")
@RunWith(RobolectricTestRunner::class)
@Config(manifest = Config.NONE, application = Application::class)
class GroupsV2StateProcessorTest {
companion object {
private val masterKey = GroupMasterKey(fromStringCondensed("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"))
private val secretParams = GroupSecretParams.deriveFromMasterKey(masterKey)
private val groupId = GroupId.v2(masterKey)
private val selfAci: ACI = ACI.from(UUID.randomUUID())
private val serviceIds: ServiceIds = ServiceIds(selfAci, PNI.from(UUID.randomUUID()))
private val otherAci: ACI = ACI.from(UUID.randomUUID())
@@ -63,10 +88,10 @@ class GroupsV2StateProcessorTest {
private lateinit var recipientTable: RecipientTable
private lateinit var groupsV2API: GroupsV2Api
private lateinit var groupsV2Authorization: GroupsV2Authorization
private lateinit var profileAndMessageHelper: GroupsV2StateProcessor.ProfileAndMessageHelper
private lateinit var profileAndMessageHelper: ProfileAndMessageHelper
private lateinit var jobManager: JobManager
private lateinit var processor: GroupsV2StateProcessor.StateProcessorForGroup
private lateinit var processor: GroupsV2StateProcessor
@get:Rule
val signalStore: SignalStoreRule = SignalStoreRule()
@@ -76,22 +101,34 @@ class GroupsV2StateProcessorTest {
Log.initialize(SystemOutLogger())
SignalProtocolLoggerProvider.setProvider(CustomSignalProtocolLogger())
groupTable = mockk(relaxed = true)
groupTable = mockk()
recipientTable = mockk()
groupsV2API = mockk()
groupsV2Authorization = mockk(relaxed = true)
profileAndMessageHelper = mockk(relaxed = true)
jobManager = mockk(relaxed = true)
groupsV2Authorization = mockk()
profileAndMessageHelper = spyk(ProfileAndMessageHelper(serviceIds.aci, masterKey, groupId))
jobManager = mockk()
mockkStatic(ApplicationDependencies::class)
every { ApplicationDependencies.getJobManager() } returns jobManager
every { ApplicationDependencies.getSignalServiceAccountManager().getGroupsV2Api() } returns groupsV2API
every { ApplicationDependencies.getGroupsV2Authorization() } returns groupsV2Authorization
processor = GroupsV2StateProcessor.StateProcessorForGroup(serviceIds, groupTable, groupsV2API, groupsV2Authorization, masterKey, profileAndMessageHelper)
mockkObject(SignalDatabase)
every { SignalDatabase.groups } returns groupTable
every { SignalDatabase.recipients } returns recipientTable
mockkObject(ProfileAndMessageHelper)
every { ProfileAndMessageHelper.create(any(), any(), any()) } returns profileAndMessageHelper
processor = GroupsV2StateProcessor.forGroup(serviceIds, masterKey, secretParams)
}
@After
fun tearDown() {
// reset(ApplicationDependencies.getJobManager())
unmockkStatic(ApplicationDependencies::class)
unmockkObject(SignalDatabase)
unmockkObject(ProfileAndMessageHelper)
unmockkStatic(DecryptedGroupUtil::class)
}
private fun given(init: GroupStateTestData.() -> Unit) {
@@ -99,21 +136,37 @@ class GroupsV2StateProcessorTest {
every { groupTable.getGroup(any<GroupId.V2>()) } returns data.groupRecord
every { groupTable.isUnknownGroup(any<GroupId>()) } returns !data.groupRecord.isPresent
every { groupTable.isUnknownGroup(any<Optional<GroupRecord>>()) } returns !data.groupRecord.isPresent
every { groupTable.isActive(groupId) } returns data.groupRecord.map { it.isActive }.orElse(false)
every { groupsV2Authorization.getAuthorizationForToday(serviceIds, secretParams) } returns null
if (data.expectTableUpdate) {
justRun { groupTable.update(any<GroupMasterKey>(), any<DecryptedGroup>()) }
}
if (data.expectTableCreate) {
every { groupTable.create(any<GroupMasterKey>(), any<DecryptedGroup>()) } returns groupId
}
if (data.expectTableUpdate || data.expectTableCreate) {
justRun { profileAndMessageHelper.storeMessage(any(), any(), any()) }
justRun { profileAndMessageHelper.persistLearnedProfileKeys(any<ProfileKeySet>()) }
}
data.serverState?.let { serverState ->
val testPartial = object : PartialDecryptedGroup(null, serverState, null, null) {
override fun getFullyDecryptedGroup(): DecryptedGroup {
return serverState
}
}
every { groupsV2API.getPartialDecryptedGroup(any(), any()) } returns testPartial
every { groupsV2API.getGroup(any(), any()) } returns serverState
}
data.changeSet?.let { changeSet ->
every { groupsV2API.getGroupHistoryPage(any(), data.requestedRevision, any(), data.includeFirst) } returns changeSet.toApiResponse()
}
every { groupsV2API.getGroupAsResult(any(), any()) } answers { callOriginal() }
data.joinedAtRevision?.let { joinedAt ->
every { groupsV2API.getGroupJoinedAt(any()) } returns NetworkResult.Success(joinedAt)
}
}
private fun givenData(init: GroupStateTestData.() -> Unit): GroupStateTestData {
@@ -129,14 +182,35 @@ class GroupsV2StateProcessorTest {
revision = 5,
members = selfAndOthers
)
serverState(
changeSet {
}
apiCallParameters(requestedRevision = 5, includeFirst = false)
joinedAtRevision = 0
}
val result = processor.updateLocalGroupToRevision(
targetRevision = GroupsV2StateProcessor.LATEST,
timestamp = 0
)
assertThat("local and server match revisions", result.updateStatus, `is`(GroupUpdateResult.UpdateStatus.GROUP_CONSISTENT_OR_AHEAD))
}
@Test
fun `when local revision matches requested revision, then return consistent or ahead`() {
given {
localState(
revision = 5,
extendGroup = localState
members = selfAndOthers
)
}
val result = processor.updateLocalGroupToRevision(GroupsV2StateProcessor.LATEST, 0, null)
assertThat("local and server match revisions", result.groupState, `is`(GroupsV2StateProcessor.GroupState.GROUP_CONSISTENT_OR_AHEAD))
val result = processor.updateLocalGroupToRevision(
targetRevision = 5,
timestamp = 0
)
assertThat("local and server match revisions", result.updateStatus, `is`(GroupUpdateResult.UpdateStatus.GROUP_CONSISTENT_OR_AHEAD))
}
@Test
@@ -147,11 +221,6 @@ class GroupsV2StateProcessorTest {
title = "Fdsa",
members = selfAndOthers
)
serverState(
revision = 6,
extendGroup = localState,
title = "Asdf"
)
changeSet {
changeLog(6) {
change {
@@ -160,11 +229,19 @@ class GroupsV2StateProcessorTest {
}
}
apiCallParameters(requestedRevision = 5, includeFirst = false)
joinedAtRevision = 0
expectTableUpdate = true
}
val result = processor.updateLocalGroupToRevision(GroupsV2StateProcessor.LATEST, 0, null)
assertThat("local should update to server", result.groupState, `is`(GroupsV2StateProcessor.GroupState.GROUP_UPDATED))
val result = processor.updateLocalGroupToRevision(
targetRevision = GroupsV2StateProcessor.LATEST,
timestamp = 0
)
assertThat("local should update to server", result.updateStatus, `is`(GroupUpdateResult.UpdateStatus.GROUP_UPDATED))
assertThat("title changed to match server", result.latestServer!!.title, `is`("Asdf"))
verify { groupTable.update(masterKey, result.latestServer!!) }
}
@Test
@@ -175,10 +252,6 @@ class GroupsV2StateProcessorTest {
title = "Fdsa",
members = selfAndOthers
)
serverState(
revision = 7,
title = "Asdf!"
)
changeSet {
changeLog(6) {
fullSnapshot(extendGroup = localState, title = "Asdf")
@@ -192,13 +265,21 @@ class GroupsV2StateProcessorTest {
}
}
}
apiCallParameters(requestedRevision = 5, includeFirst = true)
apiCallParameters(requestedRevision = 5, includeFirst = false)
joinedAtRevision = 0
expectTableUpdate = true
}
val result = processor.updateLocalGroupToRevision(GroupsV2StateProcessor.LATEST, 0, null)
assertThat("local should update to server", result.groupState, `is`(GroupsV2StateProcessor.GroupState.GROUP_UPDATED))
val result = processor.updateLocalGroupToRevision(
targetRevision = GroupsV2StateProcessor.LATEST,
timestamp = 0
)
assertThat("local should update to server", result.updateStatus, `is`(GroupUpdateResult.UpdateStatus.GROUP_UPDATED))
assertThat("revision matches server", result.latestServer!!.revision, `is`(7))
assertThat("title changed on server to final result", result.latestServer!!.title, `is`("Asdf!"))
verify { groupTable.update(masterKey, result.latestServer!!) }
}
@Test
@@ -227,13 +308,22 @@ class GroupsV2StateProcessorTest {
}
}
}
apiCallParameters(requestedRevision = 100, includeFirst = false)
joinedAtRevision = 0
expectTableUpdate = true
}
val result = processor.updateLocalGroupToRevision(GroupsV2StateProcessor.LATEST, 0, null)
assertThat("local should update to server", result.groupState, `is`(GroupsV2StateProcessor.GroupState.GROUP_UPDATED))
val result = processor.updateLocalGroupToRevision(
targetRevision = GroupsV2StateProcessor.LATEST,
timestamp = 0
)
assertThat("local should update to server", result.updateStatus, `is`(GroupUpdateResult.UpdateStatus.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"))
verify { groupTable.update(masterKey, result.latestServer!!) }
}
@Test
@@ -241,54 +331,201 @@ class GroupsV2StateProcessorTest {
given {
localState(
revision = 5,
disappearingMessageTimer = DecryptedTimer.Builder().duration(1000).build()
disappearingMessageTimer = DecryptedTimer(1000)
)
expectTableUpdate = true
}
val signedChange = DecryptedGroupChange.Builder().apply {
revision = 6
newTimer(DecryptedTimer.Builder().duration(5000).build())
}
val signedChange = DecryptedGroupChange(
revision = 6,
newTimer = DecryptedTimer(duration = 5000)
)
val result = processor.updateLocalGroupToRevision(
targetRevision = 6,
timestamp = 0,
signedGroupChange = signedChange,
serverGuid = UUID.randomUUID().toString()
)
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))
verify { groupTable.update(masterKey, result.latestServer!!) }
}
@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`() {
fun applyP2PPromotePendingPni() {
given {
localState(
revision = 5,
members = others,
pendingMembers = listOf(pendingMember(serviceIds.pni))
)
expectTableUpdate = true
}
val signedChange = DecryptedGroupChange(
revision = 6,
promotePendingPniAciMembers = listOf(member(selfAci).copy(pniBytes = serviceIds.pni.toByteString()))
)
justRun { jobManager.add(any()) }
val result = processor.updateLocalGroupToRevision(
targetRevision = 6,
timestamp = 0,
signedGroupChange = signedChange,
serverGuid = UUID.randomUUID().toString()
)
assertThat("revision matches peer change", result.latestServer!!.revision, `is`(6))
assertThat("member promoted by peer change", result.latestServer!!.members.map { it.aciBytes }, hasItem(selfAci.toByteString()))
verify { jobManager.add(ofType(DirectoryRefreshJob::class)) }
verify { groupTable.update(masterKey, result.latestServer!!) }
}
@Test
fun updateFromServerIfUnableToApplyP2PChange() {
given {
localState(
revision = 1,
members = selfAndOthers
)
serverState(
revision = 2,
title = "Breaking Signal for Science",
description = "We break stuff, because we must.",
members = listOf(member(otherAci), member(selfAci, joinedAt = 2))
members = selfAndOthers
)
changeSet {
changeLog(2) {
fullSnapshot(serverState)
}
}
apiCallParameters(2, true)
apiCallParameters(1, false)
joinedAtRevision = 0
expectTableUpdate = true
}
val result = processor.updateLocalGroupToRevision(2, 0, DecryptedGroupChange())
assertThat("local should update to server", result.groupState, `is`(GroupsV2StateProcessor.GroupState.GROUP_UPDATED))
mockkStatic(DecryptedGroupUtil::class)
every { DecryptedGroupUtil.apply(any(), any()) } throws NotAbleToApplyGroupV2ChangeException()
val signedChange = DecryptedGroupChange(
revision = 2,
newTitle = DecryptedString("Breaking Signal for Science")
)
val result = processor.updateLocalGroupToRevision(
targetRevision = 2,
timestamp = 0,
signedGroupChange = signedChange,
serverGuid = UUID.randomUUID().toString()
)
assertThat("local should update to server", result.updateStatus, `is`(GroupUpdateResult.UpdateStatus.GROUP_UPDATED))
assertThat("revision matches server", result.latestServer!!.revision, `is`(2))
verify { groupsV2API.getGroupHistoryPage(secretParams, 1, any(), false) }
unmockkStatic(DecryptedGroupUtil::class)
}
@Test(expected = GroupNotAMemberException::class)
fun skipP2PChangeForGroupNotIn() {
given {
localState(
revision = 1,
members = others,
active = false
)
}
every { groupsV2API.getGroupJoinedAt(any()) } returns NetworkResult.StatusCodeError(NotInGroupException())
val signedChange = DecryptedGroupChange(
revision = 2,
newTitle = DecryptedString("Breaking Signal for Science"),
newDescription = DecryptedString("We break stuff, because we must.")
)
processor.updateLocalGroupToRevision(
targetRevision = 2,
timestamp = 0,
signedGroupChange = signedChange,
serverGuid = UUID.randomUUID().toString()
)
}
@Test
fun applyP2PChangeForGroupWeThinkAreIn() {
given {
localState(
revision = 1,
members = others,
active = false
)
expectTableUpdate = true
}
every { groupsV2API.getGroupJoinedAt(any()) } returns NetworkResult.StatusCodeError(NotInGroupException())
val signedChange = DecryptedGroupChange(
revision = 3,
newMembers = listOf(member(selfAci))
)
val result = processor.updateLocalGroupToRevision(
targetRevision = GroupsV2StateProcessor.LATEST,
timestamp = 0,
signedGroupChange = signedChange,
serverGuid = UUID.randomUUID().toString()
)
assertThat("local should update to server", result.updateStatus, `is`(GroupUpdateResult.UpdateStatus.GROUP_UPDATED))
assertThat("revision matches server", result.latestServer!!.revision, `is`(3))
verify { groupTable.update(masterKey, result.latestServer!!) }
}
@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 {
changeSet {
changeLog(2) {
fullSnapshot(
title = "Breaking Signal for Science",
description = "We break stuff, because we must.",
members = listOf(member(otherAci), member(selfAci, joinedAt = 2))
)
}
}
apiCallParameters(2, true)
joinedAtRevision = 2
expectTableCreate = true
}
val result = processor.updateLocalGroupToRevision(
targetRevision = 2,
timestamp = 0
)
assertThat("local should update to server", result.updateStatus, `is`(GroupUpdateResult.UpdateStatus.GROUP_UPDATED))
assertThat("revision matches server", result.latestServer!!.revision, `is`(2))
verify { groupTable.create(masterKey, result.latestServer!!) }
}
@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")
fullSnapshot(
title = "Baking Signal for Science",
description = "We break stuff, because we must.",
members = listOf(member(otherAci), member(selfAci, joinedAt = 2))
)
}
changeLog(3) {
change {
@@ -297,16 +534,24 @@ class GroupsV2StateProcessorTest {
}
}
apiCallParameters(2, true)
joinedAtRevision = 2
expectTableCreate = true
}
every { groupTable.isUnknownGroup(any<GroupId>()) } returns true
justRun { jobManager.add(any()) }
val result = processor.updateLocalGroupToRevision(2, 0, DecryptedGroupChange())
val result = processor.updateLocalGroupToRevision(
targetRevision = 2,
timestamp = 0
)
assertThat("local should update to revision added", result.groupState, `is`(GroupsV2StateProcessor.GroupState.GROUP_UPDATED))
assertThat("local should update to revision added", result.updateStatus, `is`(GroupUpdateResult.UpdateStatus.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 { jobManager.add(ofType(RequestGroupV2InfoJob::class)) }
verify { groupTable.create(masterKey, result.latestServer!!) }
}
@Test
@@ -321,12 +566,18 @@ class GroupsV2StateProcessorTest {
description = "Indeed.",
members = selfAndOthers
)
expectTableUpdate = true
}
val result = processor.updateLocalGroupToRevision(GroupsV2StateProcessor.LATEST, 0, null)
val result = processor.updateLocalGroupToRevision(
targetRevision = GroupsV2StateProcessor.LATEST,
timestamp = 0
)
assertThat("local should update to server", result.groupState, `is`(GroupsV2StateProcessor.GroupState.GROUP_UPDATED))
assertThat("local should update to server", result.updateStatus, `is`(GroupUpdateResult.UpdateStatus.GROUP_UPDATED))
assertThat("revision matches latest server", result.latestServer!!.revision, `is`(10))
verify { groupTable.update(masterKey, result.latestServer!!) }
}
@Test
@@ -337,26 +588,32 @@ class GroupsV2StateProcessorTest {
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)
fullSnapshot(
title = "Beam me up",
members = listOf(member(otherAci), member(selfAci, joinedAt = 3))
)
change {
newMembers += member(selfAci, joinedAt = 3)
}
}
}
apiCallParameters(requestedRevision = 3, includeFirst = true)
joinedAtRevision = 3
expectTableUpdate = true
}
val result = processor.updateLocalGroupToRevision(3, 0, null)
val result = processor.updateLocalGroupToRevision(
targetRevision = 3,
timestamp = 0
)
assertThat("local should update to server", result.groupState, `is`(GroupsV2StateProcessor.GroupState.GROUP_UPDATED))
assertThat("local should update to server", result.updateStatus, `is`(GroupUpdateResult.UpdateStatus.GROUP_UPDATED))
assertThat("revision matches server", result.latestServer!!.revision, `is`(3))
verify { groupTable.update(masterKey, result.latestServer!!) }
}
@Test
@@ -367,14 +624,12 @@ class GroupsV2StateProcessorTest {
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")
fullSnapshot(
title = "Beam me up",
members = listOf(member(otherAci), member(selfAci, joinedAt = 3))
)
change {
newMembers += member(selfAci, joinedAt = 3)
}
@@ -391,47 +646,35 @@ class GroupsV2StateProcessorTest {
}
}
apiCallParameters(requestedRevision = 3, includeFirst = true)
joinedAtRevision = 3
expectTableUpdate = true
}
val result = processor.updateLocalGroupToRevision(3, 0, null)
justRun { jobManager.add(any()) }
assertThat("local should update to server", result.groupState, `is`(GroupsV2StateProcessor.GroupState.GROUP_UPDATED))
val result = processor.updateLocalGroupToRevision(
targetRevision = 3,
timestamp = 0
)
assertThat("local should update to server", result.updateStatus, `is`(GroupUpdateResult.UpdateStatus.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 { jobManager.add(ofType(RequestGroupV2InfoJob::class)) }
verify { groupTable.update(masterKey, result.latestServer!!) }
}
@Test
fun `when failing to update fully to desired revision, then try again forcing inclusion of full group state, and then successfully update from server to latest revision`() {
fun `when local state for same revision does not match server, then successfully update from server to latest revision`() {
val randomMembers = listOf(member(UUID.randomUUID()), member(UUID.randomUUID()))
given {
localState(
revision = 100,
title = "Title",
members = others
)
serverState(
extendGroup = localState,
revision = 101,
members = listOf(others[0], randomMembers[0], member(selfAci, joinedAt = 100))
)
changeSet {
changeLog(100) {
change {
newMembers += member(selfAci, joinedAt = 100)
}
}
changeLog(101) {
change {
deleteMembers += randomMembers[1].aciBytes
modifiedProfileKeys += randomMembers[0]
}
}
}
apiCallParameters(100, false)
}
val secondApiCallChangeSet = GroupStateTestData(masterKey).apply {
changeSet {
changeLog(100) {
fullSnapshot(
@@ -449,13 +692,20 @@ class GroupsV2StateProcessorTest {
}
}
}
apiCallParameters(100, true)
joinedAtRevision = 100
expectTableUpdate = true
}
every { groupsV2API.getGroupHistoryPage(any(), 100, any(), true) } returns secondApiCallChangeSet.changeSet!!.toApiResponse()
val result = processor.updateLocalGroupToRevision(GroupsV2StateProcessor.LATEST, 0, null)
val result = processor.updateLocalGroupToRevision(
targetRevision = GroupsV2StateProcessor.LATEST,
timestamp = 0
)
assertThat("local should update to server", result.groupState, `is`(GroupsV2StateProcessor.GroupState.GROUP_UPDATED))
assertThat("local should update to server", result.updateStatus, `is`(GroupUpdateResult.UpdateStatus.GROUP_UPDATED))
assertThat("revision matches latest revision on server", result.latestServer!!.revision, `is`(101))
verify { groupTable.update(masterKey, result.latestServer!!) }
}
/**
@@ -466,23 +716,12 @@ class GroupsV2StateProcessorTest {
fun missedMemberAddResolvesWithMultipleRevisionUpdate() {
val secondOther = member(ACI.from(UUID.randomUUID()))
profileAndMessageHelper.masterKey = masterKey
val updateMessageContextArgs = mutableListOf<DecryptedGroupV2Context>()
every { profileAndMessageHelper.insertUpdateMessages(any(), any(), any(), any()) } answers { callOriginal() }
every { profileAndMessageHelper.storeMessage(capture(updateMessageContextArgs), any(), any()) } returns Unit
given {
localState(
revision = 8,
title = "Whatever",
members = selfAndOthers
)
serverState(
revision = 10,
title = "Changed",
members = selfAndOthers + secondOther
)
changeSet {
changeLog(9) {
change {
@@ -499,14 +738,25 @@ class GroupsV2StateProcessorTest {
}
}
}
apiCallParameters(requestedRevision = 8, includeFirst = true)
apiCallParameters(requestedRevision = 8, includeFirst = false)
joinedAtRevision = 0
expectTableUpdate = true
}
val result = processor.updateLocalGroupToRevision(GroupsV2StateProcessor.LATEST, 0, null)
assertThat("local should update to server", result.groupState, `is`(GroupsV2StateProcessor.GroupState.GROUP_UPDATED))
val updateMessageContextArgs = mutableListOf<DecryptedGroupV2Context>()
every { profileAndMessageHelper.storeMessage(capture(updateMessageContextArgs), any(), any()) } returns Unit
val result = processor.updateLocalGroupToRevision(
targetRevision = GroupsV2StateProcessor.LATEST,
timestamp = 0
)
assertThat("local should update to server", result.updateStatus, `is`(GroupUpdateResult.UpdateStatus.GROUP_UPDATED))
assertThat("members contains second other", result.latestServer!!.members, hasItem(secondOther))
assertThat("group update messages contains new member add", updateMessageContextArgs.map { it.change!!.newMembers }, hasItem(hasItem(secondOther)))
verify { groupTable.update(masterKey, result.latestServer!!) }
}
/**
@@ -517,12 +767,6 @@ class GroupsV2StateProcessorTest {
fun missedMemberAddResolvesWithForcedUpdate() {
val secondOther = member(ACI.from(UUID.randomUUID()))
profileAndMessageHelper.masterKey = masterKey
val updateMessageContextArgs = mutableListOf<DecryptedGroupV2Context>()
every { profileAndMessageHelper.insertUpdateMessages(any(), any(), any(), any()) } answers { callOriginal() }
every { profileAndMessageHelper.storeMessage(capture(updateMessageContextArgs), any(), any()) } returns Unit
given {
localState(
revision = 10,
@@ -534,14 +778,21 @@ class GroupsV2StateProcessorTest {
title = "Changed",
members = selfAndOthers + secondOther
)
expectTableUpdate = true
}
val updateMessageContextArgs = mutableListOf<DecryptedGroupV2Context>()
every { profileAndMessageHelper.insertUpdateMessages(any(), any(), any(), any()) } answers { callOriginal() }
every { profileAndMessageHelper.storeMessage(capture(updateMessageContextArgs), any(), any()) } returns Unit
val result = processor.forceSanityUpdateFromServer(0)
assertThat("local should update to server", result.groupState, `is`(GroupsV2StateProcessor.GroupState.GROUP_UPDATED))
assertThat("local should update to server", result.updateStatus, `is`(GroupUpdateResult.UpdateStatus.GROUP_UPDATED))
assertThat("members contains second other", result.latestServer!!.members, hasItem(secondOther))
assertThat("title should be updated", result.latestServer!!.title, `is`("Changed"))
assertThat("group update messages contains new member add", updateMessageContextArgs.map { it.change!!.newMembers }, hasItem(hasItem(secondOther)))
assertThat("group update messages contains title change", updateMessageContextArgs.mapNotNull { it.change!!.newTitle }.any { it.value_ == "Changed" })
verify { groupTable.update(masterKey, result.latestServer!!) }
}
/**
@@ -563,6 +814,66 @@ class GroupsV2StateProcessorTest {
}
val result = processor.forceSanityUpdateFromServer(0)
assertThat("local should be unchanged", result.groupState, `is`(GroupsV2StateProcessor.GroupState.GROUP_CONSISTENT_OR_AHEAD))
assertThat("local should be unchanged", result.updateStatus, `is`(GroupUpdateResult.UpdateStatus.GROUP_CONSISTENT_OR_AHEAD))
}
/** No local group state fails gracefully during force update */
@Test
fun missingLocalGroupStateForForcedUpdate() {
given { }
val result = processor.forceSanityUpdateFromServer(0)
assertThat("local should be unchanged", result.updateStatus, `is`(GroupUpdateResult.UpdateStatus.GROUP_CONSISTENT_OR_AHEAD))
}
@Test(expected = GroupNotAMemberException::class)
fun serverNotInGroupFailsForForcedUpdate() {
given {
localState(
revision = 5,
members = selfAndOthers
)
}
every { groupsV2API.getGroup(any(), any()) } throws NotInGroupException()
processor.forceSanityUpdateFromServer(0)
}
@Test(expected = IOException::class)
fun serverVerificationFailedFailsForForcedUpdate() {
given {
localState(
revision = 5,
members = selfAndOthers
)
}
every { groupsV2API.getGroup(any(), any()) } throws VerificationFailedException()
processor.forceSanityUpdateFromServer(0)
}
@Test
fun restoreFromPlaceholderForcedUpdate() {
given {
localState(
revision = GroupsV2StateProcessor.RESTORE_PLACEHOLDER_REVISION
)
serverState(
revision = 10,
members = selfAndOthers,
title = "Asdf!"
)
expectTableUpdate = true
}
val result = processor.forceSanityUpdateFromServer(0)
assertThat("local should update to server", result.updateStatus, `is`(GroupUpdateResult.UpdateStatus.GROUP_UPDATED))
assertThat("revision matches server", result.latestServer!!.revision, `is`(10))
assertThat("title changed on server to final result", result.latestServer!!.title, `is`("Asdf!"))
verify { groupTable.update(masterKey, result.latestServer!!) }
}
}

View File

@@ -30,13 +30,14 @@ sealed class NetworkResult<T>(
companion object {
/**
* A convenience method to capture the common case of making a request.
* Perform the network action in the [fetch] lambda, returning your result.
* Perform the network action in the [fetcher], returning your result.
* Common exceptions will be caught and translated to errors.
*/
fun <T> fromFetch(fetch: () -> T): NetworkResult<T> = try {
Success(fetch())
@JvmStatic
fun <T> fromFetch(fetcher: Fetcher<T>): NetworkResult<T> = try {
Success(fetcher.fetch())
} catch (e: NonSuccessfulResponseCodeException) {
StatusCodeError(e.code, e.body, e)
StatusCodeError(e)
} catch (e: IOException) {
NetworkError(e)
} catch (e: Throwable) {
@@ -51,7 +52,9 @@ sealed class NetworkResult<T>(
data class NetworkError<T>(val exception: IOException) : NetworkResult<T>()
/** Indicates we got a response, but it was a non-2xx response. */
data class StatusCodeError<T>(val code: Int, val body: String?, val exception: NonSuccessfulResponseCodeException) : NetworkResult<T>()
data class StatusCodeError<T>(val code: Int, val body: String?, val exception: NonSuccessfulResponseCodeException) : NetworkResult<T>() {
constructor(e: NonSuccessfulResponseCodeException) : this(e.code, e.body, e)
}
/** Indicates that the application somehow failed in a way unrelated to network activity. Usually a runtime crash. */
data class ApplicationError<T>(val throwable: Throwable) : NetworkResult<T>()
@@ -175,4 +178,9 @@ sealed class NetworkResult<T>(
return this
}
fun interface Fetcher<T> {
@Throws(Exception::class)
fun fetch(): T
}
}

View File

@@ -0,0 +1,30 @@
package org.whispersystems.signalservice.api.groupsv2
import org.signal.storageservice.protos.groups.local.DecryptedGroup
import org.signal.storageservice.protos.groups.local.DecryptedGroupChange
/**
* A changelog from the server representing a specific group state revision. The
* log can contain:
*
* 1. A full group snapshot for the revision
* 2. A full group snapshot and the change from the previous revision to achieve the snapshot
* 3. Only the change from the previous revision to achieve this revision
*
* Most often, it will be the change only (3).
*/
data class DecryptedGroupChangeLog(val group: DecryptedGroup?, val change: DecryptedGroupChange?) {
val revision: Int
get() = group?.revision ?: change!!.revision
init {
if (group == null && change == null) {
throw InvalidGroupStateException("group and change are both null")
}
if (group != null && change != null && group.revision != change.revision) {
throw InvalidGroupStateException("group revision != change revision")
}
}
}

View File

@@ -1,35 +0,0 @@
package org.whispersystems.signalservice.api.groupsv2;
import org.signal.storageservice.protos.groups.local.DecryptedGroup;
import org.signal.storageservice.protos.groups.local.DecryptedGroupChange;
import java.util.Optional;
/**
* Pair of a {@link DecryptedGroup} and the {@link DecryptedGroupChange} for that version.
*/
public final class DecryptedGroupHistoryEntry {
private final Optional<DecryptedGroup> group;
private final Optional<DecryptedGroupChange> change;
public DecryptedGroupHistoryEntry(Optional<DecryptedGroup> group, Optional<DecryptedGroupChange> change)
throws InvalidGroupStateException
{
if (group.isPresent() && change.isPresent() && group.get().revision != change.get().revision) {
throw new InvalidGroupStateException();
}
this.group = group;
this.change = change;
}
public Optional<DecryptedGroup> getGroup() {
return group;
}
public Optional<DecryptedGroupChange> getChange() {
return change;
}
}

View File

@@ -1,52 +0,0 @@
package org.whispersystems.signalservice.api.groupsv2;
import org.whispersystems.signalservice.internal.push.PushServiceSocket;
import java.util.List;
/**
* Wraps result of group history fetch with it's associated paging data.
*/
public final class GroupHistoryPage {
private final List<DecryptedGroupHistoryEntry> results;
private final PagingData pagingData;
public GroupHistoryPage(List<DecryptedGroupHistoryEntry> results, PagingData pagingData) {
this.results = results;
this.pagingData = pagingData;
}
public List<DecryptedGroupHistoryEntry> getResults() {
return results;
}
public PagingData getPagingData() {
return pagingData;
}
public static final class PagingData {
public static final PagingData NONE = new PagingData(false, -1);
private final boolean hasMorePages;
private final int nextPageRevision;
public static PagingData fromGroup(PushServiceSocket.GroupHistory groupHistory) {
return new PagingData(groupHistory.hasMore(), groupHistory.hasMore() ? groupHistory.getNextPageStartGroupRevision() : -1);
}
private PagingData(boolean hasMorePages, int nextPageRevision) {
this.hasMorePages = hasMorePages;
this.nextPageRevision = nextPageRevision;
}
public boolean hasMorePages() {
return hasMorePages;
}
public int getNextPageRevision() {
return nextPageRevision;
}
}
}

View File

@@ -0,0 +1,21 @@
package org.whispersystems.signalservice.api.groupsv2
import org.whispersystems.signalservice.internal.push.PushServiceSocket.GroupHistory
/**
* Wraps result of group history fetch with it's associated paging data.
*/
data class GroupHistoryPage(val changeLogs: List<DecryptedGroupChangeLog>, val pagingData: PagingData) {
data class PagingData(val hasMorePages: Boolean, val nextPageRevision: Int) {
companion object {
@JvmField
val NONE = PagingData(false, -1)
@JvmStatic
fun forGroupHistory(groupHistory: GroupHistory): PagingData {
return PagingData(groupHistory.hasMore(), if (groupHistory.hasMore()) groupHistory.nextPageStartGroupRevision else -1)
}
}
}
}

View File

@@ -19,6 +19,7 @@ import org.signal.storageservice.protos.groups.GroupJoinInfo;
import org.signal.storageservice.protos.groups.local.DecryptedGroup;
import org.signal.storageservice.protos.groups.local.DecryptedGroupChange;
import org.signal.storageservice.protos.groups.local.DecryptedGroupJoinInfo;
import org.whispersystems.signalservice.api.NetworkResult;
import org.whispersystems.signalservice.api.push.ServiceId.ACI;
import org.whispersystems.signalservice.api.push.ServiceId.PNI;
import org.whispersystems.signalservice.internal.push.PushServiceSocket;
@@ -32,6 +33,8 @@ import java.util.List;
import java.util.Map;
import java.util.Optional;
import javax.annotation.Nonnull;
import okio.ByteString;
public class GroupsV2Api {
@@ -87,14 +90,8 @@ public class GroupsV2Api {
socket.putNewGroupsV2Group(group, authorization);
}
public PartialDecryptedGroup getPartialDecryptedGroup(GroupSecretParams groupSecretParams,
GroupsV2AuthorizationString authorization)
throws IOException, InvalidGroupStateException, VerificationFailedException
{
Group group = socket.getGroupsV2Group(authorization);
return groupsOperations.forGroup(groupSecretParams)
.partialDecryptGroup(group);
public NetworkResult<DecryptedGroup> getGroupAsResult(GroupSecretParams groupSecretParams, GroupsV2AuthorizationString authorization) {
return NetworkResult.fromFetch(() -> getGroup(groupSecretParams, authorization));
}
public DecryptedGroup getGroup(GroupSecretParams groupSecretParams,
@@ -114,17 +111,21 @@ public class GroupsV2Api {
throws IOException, InvalidGroupStateException, VerificationFailedException
{
PushServiceSocket.GroupHistory group = socket.getGroupsV2GroupHistory(fromRevision, authorization, GroupsV2Operations.HIGHEST_KNOWN_EPOCH, includeFirstState);
List<DecryptedGroupHistoryEntry> result = new ArrayList<>(group.getGroupChanges().groupChanges.size());
List<DecryptedGroupChangeLog> result = new ArrayList<>(group.getGroupChanges().groupChanges.size());
GroupsV2Operations.GroupOperations groupOperations = groupsOperations.forGroup(groupSecretParams);
for (GroupChanges.GroupChangeState change : group.getGroupChanges().groupChanges) {
Optional<DecryptedGroup> decryptedGroup = change.groupState != null ? Optional.of(groupOperations.decryptGroup(change.groupState)) : Optional.empty();
Optional<DecryptedGroupChange> decryptedChange = change.groupChange != null ? groupOperations.decryptChange(change.groupChange, false) : Optional.empty();
DecryptedGroup decryptedGroup = change.groupState != null ? groupOperations.decryptGroup(change.groupState) : null;
DecryptedGroupChange decryptedChange = change.groupChange != null ? groupOperations.decryptChange(change.groupChange, false).orElse(null) : null;
result.add(new DecryptedGroupHistoryEntry(decryptedGroup, decryptedChange));
result.add(new DecryptedGroupChangeLog(decryptedGroup, decryptedChange));
}
return new GroupHistoryPage(result, GroupHistoryPage.PagingData.fromGroup(group));
return new GroupHistoryPage(result, GroupHistoryPage.PagingData.forGroupHistory(group));
}
public NetworkResult<Integer> getGroupJoinedAt(@Nonnull GroupsV2AuthorizationString authorization) {
return NetworkResult.fromFetch(() -> socket.getGroupJoinedAtRevision(authorization));
}
public DecryptedGroupJoinInfo getGroupJoinInfo(GroupSecretParams groupSecretParams,

View File

@@ -425,35 +425,6 @@ public final class GroupsV2Operations {
return new PendingMember.Builder().member(member);
}
public PartialDecryptedGroup partialDecryptGroup(Group group)
throws VerificationFailedException, InvalidGroupStateException
{
List<Member> membersList = group.members;
List<PendingMember> pendingMembersList = group.pendingMembers;
List<DecryptedMember> decryptedMembers = new ArrayList<>(membersList.size());
List<DecryptedPendingMember> decryptedPendingMembers = new ArrayList<>(pendingMembersList.size());
for (Member member : membersList) {
ACI memberAci = decryptAci(member.userId);
decryptedMembers.add(new DecryptedMember.Builder().aciBytes(memberAci.toByteString())
.joinedAtRevision(member.joinedAtRevision)
.build());
}
for (PendingMember member : pendingMembersList) {
ServiceId pendingMemberServiceId = decryptServiceIdOrUnknown(member.member.userId);
decryptedPendingMembers.add(new DecryptedPendingMember.Builder().serviceIdBytes(pendingMemberServiceId.toByteString()).build());
}
DecryptedGroup decryptedGroup = new DecryptedGroup.Builder()
.revision(group.revision)
.members(decryptedMembers)
.pendingMembers(decryptedPendingMembers)
.build();
return new PartialDecryptedGroup(group, decryptedGroup, GroupsV2Operations.this, groupSecretParams);
}
public DecryptedGroup decryptGroup(Group group)
throws VerificationFailedException, InvalidGroupStateException
{

View File

@@ -12,6 +12,10 @@ public final class InvalidGroupStateException extends Exception {
super(e);
}
InvalidGroupStateException(String message) {
super(message);
}
InvalidGroupStateException() {
}
}

View File

@@ -2,7 +2,7 @@ package org.whispersystems.signalservice.api.groupsv2;
public final class NotAbleToApplyGroupV2ChangeException extends Exception {
NotAbleToApplyGroupV2ChangeException() {
public NotAbleToApplyGroupV2ChangeException() {
}
}

View File

@@ -1,58 +0,0 @@
package org.whispersystems.signalservice.api.groupsv2;
import org.signal.libsignal.zkgroup.VerificationFailedException;
import org.signal.libsignal.zkgroup.groups.GroupSecretParams;
import org.signal.storageservice.protos.groups.Group;
import org.signal.storageservice.protos.groups.local.DecryptedGroup;
import org.signal.storageservice.protos.groups.local.DecryptedMember;
import org.signal.storageservice.protos.groups.local.DecryptedPendingMember;
import java.io.IOException;
import java.util.List;
/**
* Decrypting an entire group can be expensive for large groups. Since not every
* operation requires all data to be decrypted, this class can be populated with only
* the minimalist about of information need to perform an operation. Currently, only
* updating from the server utilizes it.
*/
public class PartialDecryptedGroup {
private final Group group;
private final DecryptedGroup decryptedGroup;
private final GroupsV2Operations groupsOperations;
private final GroupSecretParams groupSecretParams;
public PartialDecryptedGroup(Group group,
DecryptedGroup decryptedGroup,
GroupsV2Operations groupsOperations,
GroupSecretParams groupSecretParams)
{
this.group = group;
this.decryptedGroup = decryptedGroup;
this.groupsOperations = groupsOperations;
this.groupSecretParams = groupSecretParams;
}
public int getRevision() {
return decryptedGroup.revision;
}
public List<DecryptedMember> getMembersList() {
return decryptedGroup.members;
}
public List<DecryptedPendingMember> getPendingMembersList() {
return decryptedGroup.pendingMembers;
}
public DecryptedGroup getFullyDecryptedGroup()
throws IOException
{
try {
return groupsOperations.forGroup(groupSecretParams)
.decryptGroup(group);
} catch (VerificationFailedException | InvalidGroupStateException e) {
throw new IOException(e);
}
}
}

View File

@@ -80,7 +80,7 @@ sealed class ServiceId(val libSignalServiceId: LibSignalServiceId) {
/** Parses a ServiceId serialized as a ByteString. Returns null if the ServiceId is invalid. */
@JvmStatic
fun parseOrNull(bytes: okio.ByteString): ServiceId? = parseOrNull(bytes.toByteArray())
fun parseOrNull(bytes: okio.ByteString?): ServiceId? = parseOrNull(bytes?.toByteArray())
/** Parses a ServiceId serialized as a string. Crashes if the ServiceId is invalid. */
@JvmStatic

View File

@@ -42,6 +42,7 @@ import org.signal.storageservice.protos.groups.GroupChange;
import org.signal.storageservice.protos.groups.GroupChanges;
import org.signal.storageservice.protos.groups.GroupExternalCredential;
import org.signal.storageservice.protos.groups.GroupJoinInfo;
import org.signal.storageservice.protos.groups.Member;
import org.whispersystems.signalservice.api.account.AccountAttributes;
import org.whispersystems.signalservice.api.account.ChangePhoneNumberRequest;
import org.whispersystems.signalservice.api.account.PniKeyDistributionRequest;
@@ -274,6 +275,7 @@ public class PushServiceSocket {
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";
private static final String GROUPSV2_JOINED_AT = "/v1/groups/joined_at_version";
private static final String PAYMENTS_CONVERSIONS = "/v1/payments/conversions";
@@ -2844,6 +2846,19 @@ public class PushServiceSocket {
}
}
public int getGroupJoinedAtRevision(GroupsV2AuthorizationString authorization)
throws IOException
{
try (Response response = makeStorageRequest(authorization.toString(),
GROUPSV2_JOINED_AT,
"GET",
null,
NO_HANDLER))
{
return Member.ADAPTER.decode(readBodyBytes(response)).joinedAtRevision;
}
}
public GroupJoinInfo getGroupJoinInfo(Optional<byte[]> groupLinkPassword, GroupsV2AuthorizationString authorization)
throws NonSuccessfulResponseCodeException, PushNetworkException, IOException, MalformedResponseException
{