mirror of
https://github.com/signalapp/Signal-Android.git
synced 2025-12-24 13:08:46 +00:00
Refactor group state processing.
This commit is contained in:
@@ -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."),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -82,7 +82,7 @@ public class MockApplicationDependencyProvider implements ApplicationDependencie
|
||||
|
||||
@Override
|
||||
public @NonNull JobManager provideJobManager() {
|
||||
return mock(JobManager.class);
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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!!) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -12,6 +12,10 @@ public final class InvalidGroupStateException extends Exception {
|
||||
super(e);
|
||||
}
|
||||
|
||||
InvalidGroupStateException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
InvalidGroupStateException() {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ package org.whispersystems.signalservice.api.groupsv2;
|
||||
|
||||
public final class NotAbleToApplyGroupV2ChangeException extends Exception {
|
||||
|
||||
NotAbleToApplyGroupV2ChangeException() {
|
||||
public NotAbleToApplyGroupV2ChangeException() {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user