diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/AdvanceGroupStateResult.java b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/AdvanceGroupStateResult.java index 9f7150055d..d0a9c4509c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/AdvanceGroupStateResult.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/AdvanceGroupStateResult.java @@ -9,17 +9,17 @@ import java.util.Collection; */ final class AdvanceGroupStateResult { - @NonNull private final Collection processedLogEntries; - @NonNull private final GlobalGroupState newGlobalGroupState; + @NonNull private final Collection processedLogEntries; + @NonNull private final GlobalGroupState newGlobalGroupState; - AdvanceGroupStateResult(@NonNull Collection processedLogEntries, + AdvanceGroupStateResult(@NonNull Collection processedLogEntries, @NonNull GlobalGroupState newGlobalGroupState) { this.processedLogEntries = processedLogEntries; this.newGlobalGroupState = newGlobalGroupState; } - @NonNull Collection getProcessedLogEntries() { + @NonNull Collection getProcessedLogEntries() { return processedLogEntries; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GlobalGroupState.java b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GlobalGroupState.java index 48529b18a7..ad534fe423 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GlobalGroupState.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GlobalGroupState.java @@ -13,31 +13,42 @@ import java.util.List; */ final class GlobalGroupState { - @Nullable private final DecryptedGroup localState; - @NonNull private final List history; + @Nullable private final DecryptedGroup localState; + @NonNull private final List serverHistory; GlobalGroupState(@Nullable DecryptedGroup localState, - @NonNull List serverStates) + @NonNull List serverHistory) { - this.localState = localState; - this.history = serverStates; + this.localState = localState; + this.serverHistory = serverHistory; } @Nullable DecryptedGroup getLocalState() { return localState; } - @NonNull Collection getHistory() { - return history; + @NonNull Collection getServerHistory() { + return serverHistory; + } + + int getEarliestRevisionNumber() { + if (localState != null) { + return localState.getRevision(); + } else { + if (serverHistory.isEmpty()) { + throw new AssertionError(); + } + return serverHistory.get(0).getRevision(); + } } int getLatestRevisionNumber() { - if (history.isEmpty()) { + if (serverHistory.isEmpty()) { if (localState == null) { throw new AssertionError(); } return localState.getRevision(); } - return history.get(history.size() - 1).getGroup().getRevision(); + return serverHistory.get(serverHistory.size() - 1).getRevision(); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupStateMapper.java b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupStateMapper.java index 8ab8d4894f..c6ff92ea76 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupStateMapper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupStateMapper.java @@ -3,16 +3,24 @@ package org.thoughtcrime.securesms.groups.v2.processing; import androidx.annotation.NonNull; import org.signal.storageservice.protos.groups.local.DecryptedGroup; +import org.signal.storageservice.protos.groups.local.DecryptedGroupChange; +import org.thoughtcrime.securesms.logging.Log; +import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil; +import org.whispersystems.signalservice.api.groupsv2.GroupChangeReconstruct; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; +import java.util.HashMap; +import java.util.Objects; final class GroupStateMapper { + private static final String TAG = Log.tag(GroupStateMapper.class); + static final int LATEST = Integer.MAX_VALUE; - private static final Comparator BY_REVISION = (o1, o2) -> Integer.compare(o1.getGroup().getRevision(), o2.getGroup().getRevision()); + private static final Comparator BY_REVISION = (o1, o2) -> Integer.compare(o1.getRevision(), o2.getRevision()); private GroupStateMapper() { } @@ -27,37 +35,88 @@ final class GroupStateMapper { static @NonNull AdvanceGroupStateResult partiallyAdvanceGroupState(@NonNull GlobalGroupState inputState, int maximumRevisionToApply) { - final ArrayList statesToApplyNow = new ArrayList<>(inputState.getHistory().size()); - final ArrayList statesToApplyLater = new ArrayList<>(inputState.getHistory().size()); - final DecryptedGroup newLocalState; - final GlobalGroupState newGlobalGroupState; + ArrayList appliedChanges = new ArrayList<>(inputState.getServerHistory().size()); + HashMap statesToApplyNow = new HashMap<>(inputState.getServerHistory().size()); + ArrayList statesToApplyLater = new ArrayList<>(inputState.getServerHistory().size()); + DecryptedGroup current = inputState.getLocalState(); - for (GroupLogEntry entry : inputState.getHistory()) { - if (inputState.getLocalState() != null && - inputState.getLocalState().getRevision() >= entry.getGroup().getRevision()) - { + if (inputState.getServerHistory().isEmpty()) { + return new AdvanceGroupStateResult(Collections.emptyList(), new GlobalGroupState(current, Collections.emptyList())); + } + + for (ServerGroupLogEntry entry : inputState.getServerHistory()) { + if (entry.getRevision() > maximumRevisionToApply) { + statesToApplyLater.add(entry); + } else { + statesToApplyNow.put(entry.getRevision(), entry); + } + } + + Collections.sort(statesToApplyLater, BY_REVISION); + + final int from = inputState.getEarliestRevisionNumber(); + final int to = Math.min(inputState.getLatestRevisionNumber(), maximumRevisionToApply); + + for (int revision = from; revision >= 0 && revision <= to; revision++) { + ServerGroupLogEntry entry = statesToApplyNow.get(revision); + if (entry == null) { + Log.w(TAG, "Could not find group log on server V" + revision); continue; } - if (entry.getGroup().getRevision() > maximumRevisionToApply) { - statesToApplyLater.add(entry); - } else { - statesToApplyNow.add(entry); + DecryptedGroup groupAtRevision = entry.getGroup(); + DecryptedGroupChange changeAtRevision = entry.getChange(); + + if (current == null) { + Log.w(TAG, "No local state, accepting server state for V" + revision); + current = groupAtRevision; + if (groupAtRevision != null) { + appliedChanges.add(new LocalGroupLogEntry(groupAtRevision, changeAtRevision)); + } + continue; } + + if (current.getRevision() + 1 != revision) { + Log.w(TAG, "Detected gap V" + revision); + } + + if (changeAtRevision == null) { + Log.w(TAG, "Reconstructing change for V" + revision); + changeAtRevision = GroupChangeReconstruct.reconstructGroupChange(current, Objects.requireNonNull(groupAtRevision)); + } + + DecryptedGroup groupWithChangeApplied; + try { + groupWithChangeApplied = DecryptedGroupUtil.applyWithoutRevisionCheck(current, changeAtRevision); + } catch (DecryptedGroupUtil.NotAbleToApplyChangeException e) { + Log.w(TAG, "Unable to apply V" + revision, e); + continue; + } + + if (groupAtRevision == null) { + Log.w(TAG, "Reconstructing state for V" + revision); + groupAtRevision = groupWithChangeApplied; + } + + if (current.getRevision() != groupAtRevision.getRevision()) { + appliedChanges.add(new LocalGroupLogEntry(groupAtRevision, changeAtRevision)); + } else { + DecryptedGroupChange sameRevisionDelta = GroupChangeReconstruct.reconstructGroupChange(current, groupAtRevision); + if (!DecryptedGroupUtil.changeIsEmpty(sameRevisionDelta)) { + appliedChanges.add(new LocalGroupLogEntry(groupAtRevision, sameRevisionDelta)); + Log.w(TAG, "Inserted repair change for mismatch V" + revision); + } + } + + DecryptedGroupChange missingChanges = GroupChangeReconstruct.reconstructGroupChange(groupWithChangeApplied, groupAtRevision); + if (!DecryptedGroupUtil.changeIsEmpty(missingChanges)) { + appliedChanges.add(new LocalGroupLogEntry(groupAtRevision, missingChanges)); + Log.w(TAG, "Inserted repair change for gap V" + revision); + } + + current = groupAtRevision; } - Collections.sort(statesToApplyNow, BY_REVISION); - Collections.sort(statesToApplyLater, BY_REVISION); - - if (statesToApplyNow.size() > 0) { - newLocalState = statesToApplyNow.get(statesToApplyNow.size() - 1) - .getGroup(); - } else { - newLocalState = inputState.getLocalState(); - } - - newGlobalGroupState = new GlobalGroupState(newLocalState, statesToApplyLater); - - return new AdvanceGroupStateResult(statesToApplyNow, newGlobalGroupState); + return new AdvanceGroupStateResult(appliedChanges, new GlobalGroupState(current, statesToApplyLater)); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.java index 1bada6f464..4f296a357f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.java @@ -28,6 +28,7 @@ import org.thoughtcrime.securesms.groups.v2.ProfileKeySet; import org.thoughtcrime.securesms.jobmanager.JobManager; import org.thoughtcrime.securesms.jobs.AvatarGroupsV2DownloadJob; import org.thoughtcrime.securesms.jobs.RetrieveProfileJob; +import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.mms.MmsException; import org.thoughtcrime.securesms.mms.OutgoingGroupUpdateMessage; @@ -99,7 +100,7 @@ public final class GroupsV2StateProcessor { public static class GroupUpdateResult { private final GroupState groupState; - @Nullable private DecryptedGroup latestServer; + @Nullable private final DecryptedGroup latestServer; GroupUpdateResult(@NonNull GroupState groupState, @Nullable DecryptedGroup latestServer) { this.groupState = groupState; @@ -152,13 +153,17 @@ public final class GroupsV2StateProcessor { localState.getRevision() + 1 == signedGroupChange.getRevision() && revision == signedGroupChange.getRevision()) { - try { - Log.i(TAG, "Applying P2P group change"); - DecryptedGroup newState = DecryptedGroupUtil.apply(localState, signedGroupChange); + if (SignalStore.internalValues().gv2IgnoreP2PChanges()) { + Log.w(TAG, "Ignoring P2P group change by setting"); + } else { + try { + Log.i(TAG, "Applying P2P group change"); + DecryptedGroup newState = DecryptedGroupUtil.apply(localState, signedGroupChange); - inputGroupState = new GlobalGroupState(localState, Collections.singletonList(new GroupLogEntry(newState, signedGroupChange))); - } catch (DecryptedGroupUtil.NotAbleToApplyChangeException e) { - Log.w(TAG, "Unable to apply P2P group change", e); + inputGroupState = new GlobalGroupState(localState, Collections.singletonList(new ServerGroupLogEntry(newState, signedGroupChange))); + } catch (DecryptedGroupUtil.NotAbleToApplyChangeException e) { + Log.w(TAG, "Unable to apply P2P group change", e); + } } } @@ -186,7 +191,7 @@ public final class GroupsV2StateProcessor { persistLearnedProfileKeys(inputGroupState); GlobalGroupState remainingWork = advanceGroupStateResult.getNewGlobalGroupState(); - if (remainingWork.getHistory().size() > 0) { + if (remainingWork.getServerHistory().size() > 0) { Log.i(TAG, String.format(Locale.US, "There are more revisions on the server for this group, not applying at this time, V[%d..%d]", newLocalState.getRevision() + 1, remainingWork.getLatestRevisionNumber())); } @@ -270,9 +275,9 @@ public final class GroupsV2StateProcessor { } } - private void insertUpdateMessages(long timestamp, Collection processedLogEntries) { - for (GroupLogEntry entry : processedLogEntries) { - if (entry.getChange() != null && DecryptedGroupUtil.changeIsEmptyExceptForProfileKeyChanges(entry.getChange())) { + private void insertUpdateMessages(long timestamp, Collection processedLogEntries) { + for (LocalGroupLogEntry entry : processedLogEntries) { + if (entry.getChange() != null && DecryptedGroupUtil.changeIsEmptyExceptForProfileKeyChanges(entry.getChange()) && !DecryptedGroupUtil.changeIsEmpty(entry.getChange())) { Log.d(TAG, "Skipping profile key changes only update message"); } else { storeMessage(GroupProtoUtil.createDecryptedGroupV2Context(masterKey, entry.getGroup(), entry.getChange(), null), timestamp); @@ -283,9 +288,9 @@ public final class GroupsV2StateProcessor { private void persistLearnedProfileKeys(@NonNull GlobalGroupState globalGroupState) { final ProfileKeySet profileKeys = new ProfileKeySet(); - for (GroupLogEntry entry : globalGroupState.getHistory()) { + for (ServerGroupLogEntry entry : globalGroupState.getServerHistory()) { Optional editor = DecryptedGroupUtil.editorUuid(entry.getChange()); - if (editor.isPresent()) { + if (editor.isPresent() && entry.getGroup() != null) { profileKeys.addKeysFromGroupState(entry.getGroup(), editor.get()); } } @@ -301,9 +306,9 @@ public final class GroupsV2StateProcessor { private @NonNull GlobalGroupState queryServer(@Nullable DecryptedGroup localState, boolean latestOnly) throws IOException, GroupNotAMemberException { - DecryptedGroup latestServerGroup; - List history; - UUID selfUuid = Recipient.self().getUuid().get(); + UUID selfUuid = Recipient.self().getUuid().get(); + DecryptedGroup latestServerGroup; + List history; try { latestServerGroup = groupsV2Api.getGroup(groupSecretParams, groupsV2Authorization.getAuthorizationForToday(selfUuid, groupSecretParams)); @@ -314,7 +319,7 @@ public final class GroupsV2StateProcessor { } if (latestOnly || !GroupProtoUtil.isMember(selfUuid, latestServerGroup.getMembersList())) { - history = Collections.singletonList(new GroupLogEntry(latestServerGroup, null)); + history = Collections.singletonList(new ServerGroupLogEntry(latestServerGroup, null)); } else { int revisionWeWereAdded = GroupProtoUtil.findRevisionWeWereAdded(latestServerGroup, selfUuid); int logsNeededFrom = localState != null ? Math.max(localState.getRevision(), revisionWeWereAdded) : revisionWeWereAdded; @@ -325,13 +330,18 @@ public final class GroupsV2StateProcessor { return new GlobalGroupState(localState, history); } - private List getFullMemberHistory(@NonNull UUID selfUuid, int logsNeededFromRevision) throws IOException { + private List getFullMemberHistory(@NonNull UUID selfUuid, int logsNeededFromRevision) throws IOException { try { Collection groupStatesFromRevision = groupsV2Api.getGroupHistory(groupSecretParams, logsNeededFromRevision, groupsV2Authorization.getAuthorizationForToday(selfUuid, groupSecretParams)); - ArrayList history = new ArrayList<>(groupStatesFromRevision.size()); + ArrayList history = new ArrayList<>(groupStatesFromRevision.size()); + boolean ignoreServerChanges = SignalStore.internalValues().gv2IgnoreServerChanges(); + + if (ignoreServerChanges) { + Log.w(TAG, "Server change logs are ignored by setting"); + } for (DecryptedGroupHistoryEntry entry : groupStatesFromRevision) { - history.add(new GroupLogEntry(entry.getGroup(), entry.getChange())); + history.add(new ServerGroupLogEntry(entry.getGroup(), ignoreServerChanges ? null : entry.getChange())); } return history; @@ -343,12 +353,7 @@ public final class GroupsV2StateProcessor { private void storeMessage(@NonNull DecryptedGroupV2Context decryptedGroupV2Context, long timestamp) { Optional editor = getEditor(decryptedGroupV2Context); - if (!editor.isPresent() || UuidUtil.UNKNOWN_UUID.equals(editor.get())) { - Log.w(TAG, "Cannot determine editor of change, can't insert message"); - return; - } - - boolean outgoing = Recipient.self().requireUuid().equals(editor.get()); + boolean outgoing = !editor.isPresent() || Recipient.self().requireUuid().equals(editor.get()); if (outgoing) { try { diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupLogEntry.java b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/LocalGroupLogEntry.java similarity index 56% rename from app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupLogEntry.java rename to app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/LocalGroupLogEntry.java index b211ab3f24..5692340ec1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupLogEntry.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/LocalGroupLogEntry.java @@ -6,17 +6,21 @@ 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. *

+ * Similar to {@link ServerGroupLogEntry} but guaranteed to have a group state. + *

* Changes are typically not available for pending members. */ -final class GroupLogEntry { +final class LocalGroupLogEntry { @NonNull private final DecryptedGroup group; @Nullable private final DecryptedGroupChange change; - GroupLogEntry(@NonNull DecryptedGroup group, @Nullable DecryptedGroupChange change) { + LocalGroupLogEntry(@NonNull DecryptedGroup group, @Nullable DecryptedGroupChange change) { if (change != null && group.getRevision() != change.getRevision()) { throw new AssertionError(); } @@ -32,4 +36,20 @@ final class GroupLogEntry { @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; + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/ServerGroupLogEntry.java b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/ServerGroupLogEntry.java new file mode 100644 index 0000000000..37f7354e30 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/ServerGroupLogEntry.java @@ -0,0 +1,50 @@ +package org.thoughtcrime.securesms.groups.v2.processing; + +import androidx.annotation.Nullable; + +import org.signal.storageservice.protos.groups.local.DecryptedGroup; +import org.signal.storageservice.protos.groups.local.DecryptedGroupChange; +import org.thoughtcrime.securesms.logging.Log; + +/** + * Pair of a group state and optionally the corresponding change from the server. + *

+ * Either the group or change may be empty. + *

+ * 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.getRevision() != change.getRevision()) { + 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.getRevision(); + else if (change != null) return change.getRevision(); + else throw new AssertionError(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/InternalValues.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/InternalValues.java index e768c8ec9b..e89b00fca2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/InternalValues.java +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/InternalValues.java @@ -4,7 +4,9 @@ import org.thoughtcrime.securesms.util.FeatureFlags; public final class InternalValues extends SignalStoreValues { - public static final String GV2_FORCE_INVITES = "internal.gv2.force_invites"; + 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"; InternalValues(KeyValueStore store) { super(store); @@ -17,4 +19,25 @@ public final class InternalValues extends SignalStoreValues { public synchronized boolean forceGv2Invites() { 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. + *

+ * 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. + */ + public synchronized boolean gv2IgnoreP2PChanges() { + return FeatureFlags.internalUser() && getBoolean(GV2_IGNORE_P2P_CHANGES, false); + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/InternalOptionsPreferenceFragment.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/InternalOptionsPreferenceFragment.java index b8625d5163..f743cc40a8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/InternalOptionsPreferenceFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/InternalOptionsPreferenceFragment.java @@ -1,13 +1,19 @@ package org.thoughtcrime.securesms.preferences; import android.os.Bundle; +import android.widget.Toast; +import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.preference.PreferenceDataStore; import org.thoughtcrime.securesms.ApplicationPreferencesActivity; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.components.SwitchPreferenceCompat; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobs.RefreshAttributesJob; +import org.thoughtcrime.securesms.jobs.RefreshOwnProfileJob; +import org.thoughtcrime.securesms.jobs.RotateProfileKeyJob; import org.thoughtcrime.securesms.keyvalue.InternalValues; import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.logging.Log; @@ -26,14 +32,39 @@ public class InternalOptionsPreferenceFragment extends CorrectedPreferenceFragme PreferenceDataStore preferenceDataStore = SignalStore.getPreferenceDataStore(); - SwitchPreferenceCompat forceGv2Preference = (SwitchPreferenceCompat) this.findPreference(InternalValues.GV2_FORCE_INVITES); + initializeSwitchPreference(preferenceDataStore, InternalValues.GV2_FORCE_INVITES, SignalStore.internalValues().forceGv2Invites()); + initializeSwitchPreference(preferenceDataStore, InternalValues.GV2_IGNORE_SERVER_CHANGES, SignalStore.internalValues().gv2IgnoreServerChanges()); + initializeSwitchPreference(preferenceDataStore, InternalValues.GV2_IGNORE_P2P_CHANGES, SignalStore.internalValues().gv2IgnoreP2PChanges()); + + findPreference("pref_refresh_attributes").setOnPreferenceClickListener(preference -> { + ApplicationDependencies.getJobManager() + .startChain(new RefreshAttributesJob()) + .then(new RefreshOwnProfileJob()) + .enqueue(); + Toast.makeText(getContext(), "Scheduled attribute refresh", Toast.LENGTH_SHORT).show(); + return true; + }); + + findPreference("pref_rotate_profile_key").setOnPreferenceClickListener(preference -> { + ApplicationDependencies.getJobManager().add(new RotateProfileKeyJob()); + Toast.makeText(getContext(), "Scheduled profile key rotation", Toast.LENGTH_SHORT).show(); + return true; + }); + } + + private void initializeSwitchPreference(@NonNull PreferenceDataStore preferenceDataStore, + @NonNull String key, + boolean checked) + { + SwitchPreferenceCompat forceGv2Preference = (SwitchPreferenceCompat) findPreference(key); forceGv2Preference.setPreferenceDataStore(preferenceDataStore); - forceGv2Preference.setChecked(SignalStore.internalValues().forceGv2Invites()); + forceGv2Preference.setChecked(checked); } @Override public void onResume() { super.onResume(); + //noinspection ConstantConditions ((ApplicationPreferencesActivity) getActivity()).getSupportActionBar().setTitle(R.string.preferences__internal_preferences); } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a5d7f43372..9be1cae528 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1947,6 +1947,15 @@ Groups V2 Force Invites Members will not be added directly to a GV2 even if they could be. + Ignore server changes + Changes in server\'s response will be ignored, causing passive voice update messages if P2P is also ignored. + Ignore P2P changes + Changes sent P2P will be ignored. In conjunction with ignoring server changes, will cause passive voice. + Account + Refresh attributes + Forces a write of capabilities on to the server followed by a read. + Rotate profile key + Creates a new versioned profile, and triggers an update of any GV2 group you belong to. diff --git a/app/src/main/res/xml/preferences_internal.xml b/app/src/main/res/xml/preferences_internal.xml index a36cfa866e..d11f7f589e 100644 --- a/app/src/main/res/xml/preferences_internal.xml +++ b/app/src/main/res/xml/preferences_internal.xml @@ -1,6 +1,22 @@ + + + + + + + + @@ -11,6 +27,18 @@ android:summary="@string/preferences__internal_force_gv2_invites_description" android:title="@string/preferences__internal_force_gv2_invites" /> + + + + diff --git a/app/src/test/java/org/thoughtcrime/securesms/groups/v2/processing/GlobalGroupStateTest.java b/app/src/test/java/org/thoughtcrime/securesms/groups/v2/processing/GlobalGroupStateTest.java index 6b36da0696..6f06b07083 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/groups/v2/processing/GlobalGroupStateTest.java +++ b/app/src/test/java/org/thoughtcrime/securesms/groups/v2/processing/GlobalGroupStateTest.java @@ -31,8 +31,8 @@ public final class GlobalGroupStateTest { assertEquals(4, emptyState.getLatestRevisionNumber()); } - private static GroupLogEntry logEntry(int revision) { - return new GroupLogEntry(state(revision), change(revision)); + private static ServerGroupLogEntry logEntry(int revision) { + return new ServerGroupLogEntry(state(revision), change(revision)); } private static DecryptedGroup state(int revision) { diff --git a/app/src/test/java/org/thoughtcrime/securesms/groups/v2/processing/GroupStateMapperTest.java b/app/src/test/java/org/thoughtcrime/securesms/groups/v2/processing/GroupStateMapperTest.java index a06ad449ec..60ab941306 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/groups/v2/processing/GroupStateMapperTest.java +++ b/app/src/test/java/org/thoughtcrime/securesms/groups/v2/processing/GroupStateMapperTest.java @@ -1,14 +1,23 @@ package org.thoughtcrime.securesms.groups.v2.processing; +import org.junit.Before; import org.junit.Test; 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.logging.Log; +import org.thoughtcrime.securesms.testutil.LogRecorder; +import org.whispersystems.signalservice.api.util.UuidUtil; + +import java.util.UUID; import static java.util.Arrays.asList; import static java.util.Collections.emptyList; import static java.util.Collections.singletonList; 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; @@ -17,12 +26,19 @@ import static org.thoughtcrime.securesms.groups.v2.processing.GroupStateMapper.L 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().getHistory().isEmpty()); + assertTrue(advanceGroupStateResult.getNewGlobalGroupState().getServerHistory().isEmpty()); assertNull(advanceGroupStateResult.getNewGlobalGroupState().getLocalState()); } @@ -33,114 +49,314 @@ public final class GroupStateMapperTest { AdvanceGroupStateResult advanceGroupStateResult = GroupStateMapper.partiallyAdvanceGroupState(new GlobalGroupState(currentState, emptyList()), 10); assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(emptyList())); - assertTrue(advanceGroupStateResult.getNewGlobalGroupState().getHistory().isEmpty()); + assertTrue(advanceGroupStateResult.getNewGlobalGroupState().getServerHistory().isEmpty()); assertSame(currentState, advanceGroupStateResult.getNewGlobalGroupState().getLocalState()); } @Test public void unknown_group_single_state_to_update() { - GroupLogEntry log0 = logEntry(0); + ServerGroupLogEntry log0 = serverLogEntry(0); AdvanceGroupStateResult advanceGroupStateResult = GroupStateMapper.partiallyAdvanceGroupState(new GlobalGroupState(null, singletonList(log0)), 10); - assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(singletonList(log0))); - assertTrue(advanceGroupStateResult.getNewGlobalGroupState().getHistory().isEmpty()); + 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); - GroupLogEntry log1 = logEntry(1); + DecryptedGroup currentState = state(0); + ServerGroupLogEntry log1 = serverLogEntry(1); AdvanceGroupStateResult advanceGroupStateResult = GroupStateMapper.partiallyAdvanceGroupState(new GlobalGroupState(currentState, singletonList(log1)), 1); - assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(singletonList(log1))); - assertTrue(advanceGroupStateResult.getNewGlobalGroupState().getHistory().isEmpty()); + 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); - GroupLogEntry log1 = logEntry(1); - GroupLogEntry log2 = logEntry(2); + 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(log1, log2))); - assertTrue(advanceGroupStateResult.getNewGlobalGroupState().getHistory().isEmpty()); + 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); - GroupLogEntry log1 = logEntry(1); - GroupLogEntry log2 = logEntry(2); + 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(log2))); - assertTrue(advanceGroupStateResult.getNewGlobalGroupState().getHistory().isEmpty()); + 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); - GroupLogEntry log1 = logEntry(1); - GroupLogEntry log2 = logEntry(2); - GroupLogEntry log3 = logEntry(3); + 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(log1, log2))); + 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); - GroupLogEntry log1 = logEntry(1); - GroupLogEntry log2 = logEntry(2); - GroupLogEntry log3 = logEntry(3); + 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(log1, log2, log3))); + 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); - GroupLogEntry log1 = logEntry(Integer.MAX_VALUE - 1); - GroupLogEntry log2 = logEntry(Integer.MAX_VALUE); + 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(log1, log2))); + assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(asList(asLocal(log1), asLocal(log2)))); assertNewState(new GlobalGroupState(log2.getGroup(), emptyList()), advanceGroupStateResult.getNewGlobalGroupState()); assertEquals(log2.getGroup(), advanceGroupStateResult.getNewGlobalGroupState().getLocalState()); } - private static void assertNewState(GlobalGroupState expected, GlobalGroupState actual) { - assertEquals(expected.getLocalState(), actual.getLocalState()); - assertThat(actual.getHistory(), is(expected.getHistory())); + @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()); } - private static GroupLogEntry logEntry(int revision) { - return new GroupLogEntry(state(revision), change(revision)); + @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 state3 = DecryptedGroup.newBuilder() + .setRevision(3) + .setTitle("Group Revision " + 3) + .setAvatar("Lost Avatar Update") + .build(); + ServerGroupLogEntry log3 = new ServerGroupLogEntry(state3, change(3)); + DecryptedGroup state4 = DecryptedGroup.newBuilder() + .setRevision(4) + .setTitle("Group Revision " + 4) + .setAvatar("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), + asLocal(log3), + new LocalGroupLogEntry(state3, DecryptedGroupChange.newBuilder() + .setRevision(3) + .setNewAvatar(DecryptedString.newBuilder().setValue("Lost Avatar Update")) + .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 = DecryptedMember.newBuilder() + .setUuid(UuidUtil.toByteString(UUID.randomUUID())) + .build(); + ServerGroupLogEntry log8 = new ServerGroupLogEntry(DecryptedGroup.newBuilder() + .setRevision(8) + .addMembers(newMember) + .setTitle("Group Revision " + 8) + .build(), + change(8) ); + ServerGroupLogEntry log9 = new ServerGroupLogEntry(DecryptedGroup.newBuilder() + .setRevision(9) + .addMembers(newMember) + .setTitle("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), + asLocal(log8), + asLocal(new ServerGroupLogEntry(log8.getGroup(), DecryptedGroupChange.newBuilder() + .setRevision(8) + .addNewMembers(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 local_on_same_revision_but_incorrect_repair_necessary() { + DecryptedGroup currentState = DecryptedGroup.newBuilder() + .setRevision(6) + .setTitle("Incorrect group title, Revision " + 6) + .build(); + ServerGroupLogEntry log6 = serverLogEntryWholeStateOnly(6); + + AdvanceGroupStateResult advanceGroupStateResult = GroupStateMapper.partiallyAdvanceGroupState(new GlobalGroupState(currentState, singletonList(log6)), LATEST); + + assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(singletonList(localLogEntryNoEditor(6)))); + assertNewState(new GlobalGroupState(state(6), emptyList()), advanceGroupStateResult.getNewGlobalGroupState()); + assertEquals(state(6), 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 DecryptedGroup.newBuilder().setRevision(revision).build(); + return DecryptedGroup.newBuilder() + .setRevision(revision) + .setTitle("Group Revision " + revision) + .build(); } private static DecryptedGroupChange change(int revision) { - return DecryptedGroupChange.newBuilder().setRevision(revision).build(); + return DecryptedGroupChange.newBuilder() + .setRevision(revision) + .setEditor(UuidUtil.toByteString(KNOWN_EDITOR)) + .setNewTitle(DecryptedString.newBuilder().setValue("Group Revision " + revision)) + .build(); + } + + private static DecryptedGroupChange changeNoEditor(int revision) { + return DecryptedGroupChange.newBuilder() + .setRevision(revision) + .setNewTitle(DecryptedString.newBuilder().setValue("Group Revision " + revision)) + .build(); + } + + private static LocalGroupLogEntry asLocal(ServerGroupLogEntry logEntry) { + assertNotNull(logEntry.getGroup()); + return new LocalGroupLogEntry(logEntry.getGroup(), logEntry.getChange()); } } \ No newline at end of file diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupUtil.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupUtil.java index 370937b286..2650939b89 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupUtil.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupUtil.java @@ -197,6 +197,12 @@ public final class DecryptedGroupUtil { throw new NotAbleToApplyChangeException(); } + return applyWithoutRevisionCheck(group, change); + } + + public static DecryptedGroup applyWithoutRevisionCheck(DecryptedGroup group, DecryptedGroupChange change) + throws NotAbleToApplyChangeException + { DecryptedGroup.Builder builder = DecryptedGroup.newBuilder(group); builder.addAllMembers(change.getNewMembersList()); @@ -228,7 +234,7 @@ public final class DecryptedGroupUtil { throw new NotAbleToApplyChangeException(); } - builder.setMembers(index, modifyProfileKey); + builder.setMembers(index, DecryptedMember.newBuilder(builder.getMembers(index)).setProfileKey(modifyProfileKey.getProfileKey()).build()); } for (DecryptedPendingMemberRemoval removedMember : change.getDeletePendingMembersList()) { diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeReconstruct.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeReconstruct.java new file mode 100644 index 0000000000..065a151f6e --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeReconstruct.java @@ -0,0 +1,160 @@ +package org.whispersystems.signalservice.api.groupsv2; + +import com.google.protobuf.ByteString; + +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.DecryptedModifyMemberRole; +import org.signal.storageservice.protos.groups.local.DecryptedPendingMember; +import org.signal.storageservice.protos.groups.local.DecryptedPendingMemberRemoval; +import org.signal.storageservice.protos.groups.local.DecryptedString; + +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +public final class GroupChangeReconstruct { + + /** + * Given a {@param fromState} and a {@param toState} creates a {@link DecryptedGroupChange} that would take the {@param fromState} to the {@param toState}. + */ + public static DecryptedGroupChange reconstructGroupChange(DecryptedGroup fromState, DecryptedGroup toState) { + DecryptedGroupChange.Builder builder = DecryptedGroupChange.newBuilder() + .setRevision(toState.getRevision()); + + if (!fromState.getTitle().equals(toState.getTitle())) { + builder.setNewTitle(DecryptedString.newBuilder().setValue(toState.getTitle())); + } + + if (!fromState.getAvatar().equals(toState.getAvatar())) { + builder.setNewAvatar(DecryptedString.newBuilder().setValue(toState.getAvatar())); + } + + if (!fromState.getDisappearingMessagesTimer().equals(toState.getDisappearingMessagesTimer())) { + builder.setNewTimer(toState.getDisappearingMessagesTimer()); + } + + if (!fromState.getAccessControl().getAttributes().equals(toState.getAccessControl().getAttributes())) { + builder.setNewAttributeAccess(toState.getAccessControl().getAttributes()); + } + + if (!fromState.getAccessControl().getMembers().equals(toState.getAccessControl().getMembers())) { + builder.setNewMemberAccess(toState.getAccessControl().getMembers()); + } + + Set fromStateMemberUuids = membersToSetOfUuids(fromState.getMembersList()); + Set toStateMemberUuids = membersToSetOfUuids(toState.getMembersList()); + + Set pendingMembersListA = pendingMembersToSetOfUuids(fromState.getPendingMembersList()); + Set pendingMembersListB = pendingMembersToSetOfUuids(toState.getPendingMembersList()); + + Set removedPendingMemberUuids = subtract(pendingMembersListA, pendingMembersListB); + Set newPendingMemberUuids = subtract(pendingMembersListB, pendingMembersListA); + Set removedMemberUuids = subtract(fromStateMemberUuids, toStateMemberUuids); + Set newMemberUuids = subtract(toStateMemberUuids, fromStateMemberUuids); + + Set addedByInvitationUuids = intersect(newMemberUuids, removedPendingMemberUuids); + Set addedMembersByInvitation = intersectByUUID(toState.getMembersList(), addedByInvitationUuids); + Set addedMembers = intersectByUUID(toState.getMembersList(), subtract(newMemberUuids, addedByInvitationUuids)); + Set uninvitedMembers = intersectPendingByUUID(fromState.getPendingMembersList(), subtract(removedPendingMemberUuids, addedByInvitationUuids)); + + for (DecryptedMember member : intersectByUUID(fromState.getMembersList(), removedMemberUuids)) { + builder.addDeleteMembers(member.getUuid()); + } + + for (DecryptedMember member : addedMembers) { + builder.addNewMembers(member); + } + + for (DecryptedMember member : addedMembersByInvitation) { + builder.addPromotePendingMembers(member); + } + + for (DecryptedPendingMember uninvitedMember : uninvitedMembers) { + builder.addDeletePendingMembers(DecryptedPendingMemberRemoval.newBuilder() + .setUuid(uninvitedMember.getUuid()) + .setUuidCipherText(uninvitedMember.getUuidCipherText())); + } + + for (DecryptedPendingMember invitedMember : intersectPendingByUUID(toState.getPendingMembersList(), newPendingMemberUuids)) { + builder.addNewPendingMembers(invitedMember); + } + + Set consistentMemberUuids = intersect(fromStateMemberUuids, toStateMemberUuids); + Set changedMembers = intersectByUUID(subtract(toState.getMembersList(), fromState.getMembersList()), consistentMemberUuids); + Map membersUuidMap = uuidMap(fromState.getMembersList()); + + for (DecryptedMember newState : changedMembers) { + DecryptedMember oldState = membersUuidMap.get(newState.getUuid()); + if (oldState.getRole() != newState.getRole()) { + builder.addModifyMemberRoles(DecryptedModifyMemberRole.newBuilder() + .setUuid(newState.getUuid()) + .setRole(newState.getRole())); + } + + if (oldState.getProfileKey() != newState.getProfileKey()) { + builder.addModifiedProfileKeys(newState); + } + } + + return builder.build(); + } + + private static Map uuidMap(List membersList) { + HashMap map = new HashMap<>(membersList.size()); + for (DecryptedMember member : membersList) { + map.put(member.getUuid(), member); + } + return map; + } + + private static Set intersectByUUID(Collection members, Set uuids) { + Set result = new HashSet<>(members.size()); + for (DecryptedMember member : members) { + if (uuids.contains(member.getUuid())) + result.add(member); + } + return result; + } + + private static Set intersectPendingByUUID(Collection members, Set uuids) { + Set result = new HashSet<>(members.size()); + for (DecryptedPendingMember member : members) { + if (uuids.contains(member.getUuid())) + result.add(member); + } + return result; + } + + private static Set pendingMembersToSetOfUuids(Collection pendingMembers) { + HashSet uuids = new HashSet<>(pendingMembers.size()); + for (DecryptedPendingMember pendingMember : pendingMembers) { + uuids.add(pendingMember.getUuid()); + } + return uuids; + } + + private static Set membersToSetOfUuids(Collection members) { + HashSet uuids = new HashSet<>(members.size()); + for (DecryptedMember member : members) { + uuids.add(member.getUuid()); + } + return uuids; + } + + private static Set subtract(Collection a, Collection b) { + Set result = new HashSet<>(a); + result.removeAll(b); + return result; + } + + private static Set intersect(Collection a, Collection b) { + Set result = new HashSet<>(a); + result.retainAll(b); + return result; + } +} diff --git a/libsignal/service/src/test/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupUtil_apply_Test.java b/libsignal/service/src/test/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupUtil_apply_Test.java index f0f77d670b..df0a8970c2 100644 --- a/libsignal/service/src/test/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupUtil_apply_Test.java +++ b/libsignal/service/src/test/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupUtil_apply_Test.java @@ -1,5 +1,7 @@ package org.whispersystems.signalservice.api.groupsv2; +import com.google.protobuf.ByteString; + import org.junit.Test; import org.signal.storageservice.protos.groups.AccessControl; import org.signal.storageservice.protos.groups.Member; @@ -12,6 +14,7 @@ import org.signal.storageservice.protos.groups.local.DecryptedPendingMemberRemov import org.signal.storageservice.protos.groups.local.DecryptedString; import org.signal.storageservice.protos.groups.local.DecryptedTimer; import org.signal.zkgroup.profiles.ProfileKey; +import org.whispersystems.signalservice.api.util.UuidUtil; import java.util.UUID; @@ -170,6 +173,36 @@ public final class DecryptedGroupUtil_apply_Test { newGroup); } + @Test + public void apply_modify_admin_profile_keys() throws DecryptedGroupUtil.NotAbleToApplyChangeException { + UUID adminUuid = UUID.randomUUID(); + ProfileKey profileKey1 = randomProfileKey(); + ProfileKey profileKey2a = randomProfileKey(); + ProfileKey profileKey2b = randomProfileKey(); + DecryptedMember member1 = member(UUID.randomUUID(), profileKey1); + DecryptedMember admin2a = admin(adminUuid, profileKey2a); + + DecryptedGroup newGroup = DecryptedGroupUtil.apply(DecryptedGroup.newBuilder() + .setRevision(13) + .addMembers(member1) + .addMembers(admin2a) + .build(), + DecryptedGroupChange.newBuilder() + .setRevision(14) + .addModifiedProfileKeys(DecryptedMember.newBuilder(DecryptedMember.newBuilder() + .setUuid(UuidUtil.toByteString(adminUuid)) + .build()) + .setProfileKey(ByteString.copyFrom(profileKey2b.serialize()))) + .build()); + + assertEquals(DecryptedGroup.newBuilder() + .setRevision(14) + .addMembers(member1) + .addMembers(admin(adminUuid, profileKey2b)) + .build(), + newGroup); + } + @Test public void apply_new_pending_member() throws DecryptedGroupUtil.NotAbleToApplyChangeException { DecryptedMember member1 = member(UUID.randomUUID()); diff --git a/libsignal/service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeReconstructTest.java b/libsignal/service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeReconstructTest.java new file mode 100644 index 0000000000..9603b53427 --- /dev/null +++ b/libsignal/service/src/test/java/org/whispersystems/signalservice/api/groupsv2/GroupChangeReconstructTest.java @@ -0,0 +1,222 @@ +package org.whispersystems.signalservice.api.groupsv2; + +import org.junit.Test; +import org.signal.storageservice.protos.groups.AccessControl; +import org.signal.storageservice.protos.groups.local.DecryptedGroup; +import org.signal.storageservice.protos.groups.local.DecryptedGroupChange; +import org.signal.storageservice.protos.groups.local.DecryptedString; +import org.signal.storageservice.protos.groups.local.DecryptedTimer; +import org.signal.zkgroup.profiles.ProfileKey; +import org.whispersystems.signalservice.api.util.UuidUtil; + +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.admin; +import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.demoteAdmin; +import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.member; +import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.pendingMember; +import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.pendingMemberRemoval; +import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.promoteAdmin; +import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.randomProfileKey; +import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.withProfileKey; + +public final class GroupChangeReconstructTest { + + @Test + public void empty_to_empty() { + DecryptedGroup from = DecryptedGroup.newBuilder().build(); + DecryptedGroup to = DecryptedGroup.newBuilder().build(); + + DecryptedGroupChange decryptedGroupChange = GroupChangeReconstruct.reconstructGroupChange(from, to); + + assertEquals(DecryptedGroupChange.newBuilder().build(), decryptedGroupChange); + } + + @Test + public void revision_set_to_the_target() { + DecryptedGroup from = DecryptedGroup.newBuilder().setRevision(10).build(); + DecryptedGroup to = DecryptedGroup.newBuilder().setRevision(20).build(); + + DecryptedGroupChange decryptedGroupChange = GroupChangeReconstruct.reconstructGroupChange(from, to); + + assertEquals(20, decryptedGroupChange.getRevision()); + } + + @Test + public void title_change() { + DecryptedGroup from = DecryptedGroup.newBuilder().setTitle("A").build(); + DecryptedGroup to = DecryptedGroup.newBuilder().setTitle("B").build(); + + DecryptedGroupChange decryptedGroupChange = GroupChangeReconstruct.reconstructGroupChange(from, to); + + assertEquals(DecryptedGroupChange.newBuilder().setNewTitle(DecryptedString.newBuilder().setValue("B")).build(), decryptedGroupChange); + } + + @Test + public void avatar_change() { + DecryptedGroup from = DecryptedGroup.newBuilder().setAvatar("A").build(); + DecryptedGroup to = DecryptedGroup.newBuilder().setAvatar("B").build(); + + DecryptedGroupChange decryptedGroupChange = GroupChangeReconstruct.reconstructGroupChange(from, to); + + assertEquals(DecryptedGroupChange.newBuilder().setNewAvatar(DecryptedString.newBuilder().setValue("B")).build(), decryptedGroupChange); + } + + @Test + public void timer_change() { + DecryptedGroup from = DecryptedGroup.newBuilder().setDisappearingMessagesTimer(DecryptedTimer.newBuilder().setDuration(100)).build(); + DecryptedGroup to = DecryptedGroup.newBuilder().setDisappearingMessagesTimer(DecryptedTimer.newBuilder().setDuration(200)).build(); + + DecryptedGroupChange decryptedGroupChange = GroupChangeReconstruct.reconstructGroupChange(from, to); + + assertEquals(DecryptedGroupChange.newBuilder().setNewTimer(DecryptedTimer.newBuilder().setDuration(200)).build(), decryptedGroupChange); + } + + @Test + public void access_control_change_attributes() { + DecryptedGroup from = DecryptedGroup.newBuilder().setAccessControl(AccessControl.newBuilder().setAttributes(AccessControl.AccessRequired.MEMBER)).build(); + DecryptedGroup to = DecryptedGroup.newBuilder().setAccessControl(AccessControl.newBuilder().setAttributes(AccessControl.AccessRequired.ADMINISTRATOR)).build(); + + DecryptedGroupChange decryptedGroupChange = GroupChangeReconstruct.reconstructGroupChange(from, to); + + assertEquals(DecryptedGroupChange.newBuilder().setNewAttributeAccess(AccessControl.AccessRequired.ADMINISTRATOR).build(), decryptedGroupChange); + } + + @Test + public void access_control_change_membership() { + DecryptedGroup from = DecryptedGroup.newBuilder().setAccessControl(AccessControl.newBuilder().setMembers(AccessControl.AccessRequired.ADMINISTRATOR)).build(); + DecryptedGroup to = DecryptedGroup.newBuilder().setAccessControl(AccessControl.newBuilder().setMembers(AccessControl.AccessRequired.MEMBER)).build(); + + DecryptedGroupChange decryptedGroupChange = GroupChangeReconstruct.reconstructGroupChange(from, to); + + assertEquals(DecryptedGroupChange.newBuilder().setNewMemberAccess(AccessControl.AccessRequired.MEMBER).build(), decryptedGroupChange); + } + + @Test + public void access_control_change_membership_and_attributes() { + DecryptedGroup from = DecryptedGroup.newBuilder().setAccessControl(AccessControl.newBuilder().setMembers(AccessControl.AccessRequired.MEMBER) + .setAttributes(AccessControl.AccessRequired.ADMINISTRATOR)).build(); + DecryptedGroup to = DecryptedGroup.newBuilder().setAccessControl(AccessControl.newBuilder().setMembers(AccessControl.AccessRequired.ADMINISTRATOR) + .setAttributes(AccessControl.AccessRequired.MEMBER)).build(); + + DecryptedGroupChange decryptedGroupChange = GroupChangeReconstruct.reconstructGroupChange(from, to); + + assertEquals(DecryptedGroupChange.newBuilder().setNewMemberAccess(AccessControl.AccessRequired.ADMINISTRATOR) + .setNewAttributeAccess(AccessControl.AccessRequired.MEMBER).build(), decryptedGroupChange); + } + + @Test + public void new_member() { + UUID uuidNew = UUID.randomUUID(); + DecryptedGroup from = DecryptedGroup.newBuilder().build(); + DecryptedGroup to = DecryptedGroup.newBuilder().addMembers(member(uuidNew)).build(); + + DecryptedGroupChange decryptedGroupChange = GroupChangeReconstruct.reconstructGroupChange(from, to); + + assertEquals(DecryptedGroupChange.newBuilder().addNewMembers(member(uuidNew)).build(), decryptedGroupChange); + } + + @Test + public void removed_member() { + UUID uuidOld = UUID.randomUUID(); + DecryptedGroup from = DecryptedGroup.newBuilder().addMembers(member(uuidOld)).build(); + DecryptedGroup to = DecryptedGroup.newBuilder().build(); + + DecryptedGroupChange decryptedGroupChange = GroupChangeReconstruct.reconstructGroupChange(from, to); + + assertEquals(DecryptedGroupChange.newBuilder().addDeleteMembers(UuidUtil.toByteString(uuidOld)).build(), decryptedGroupChange); + } + + @Test + public void new_member_and_existing_member() { + UUID uuidOld = UUID.randomUUID(); + UUID uuidNew = UUID.randomUUID(); + DecryptedGroup from = DecryptedGroup.newBuilder().addMembers(member(uuidOld)).build(); + DecryptedGroup to = DecryptedGroup.newBuilder().addMembers(member(uuidOld)).addMembers(member(uuidNew)).build(); + + DecryptedGroupChange decryptedGroupChange = GroupChangeReconstruct.reconstructGroupChange(from, to); + + assertEquals(DecryptedGroupChange.newBuilder().addNewMembers(member(uuidNew)).build(), decryptedGroupChange); + } + + @Test + public void removed_member_and_remaining_member() { + UUID uuidOld = UUID.randomUUID(); + UUID uuidRemaining = UUID.randomUUID(); + DecryptedGroup from = DecryptedGroup.newBuilder().addMembers(member(uuidOld)).addMembers(member(uuidRemaining)).build(); + DecryptedGroup to = DecryptedGroup.newBuilder().addMembers(member(uuidRemaining)).build(); + + DecryptedGroupChange decryptedGroupChange = GroupChangeReconstruct.reconstructGroupChange(from, to); + + assertEquals(DecryptedGroupChange.newBuilder().addDeleteMembers(UuidUtil.toByteString(uuidOld)).build(), decryptedGroupChange); + } + + @Test + public void new_member_by_invite() { + UUID uuidNew = UUID.randomUUID(); + DecryptedGroup from = DecryptedGroup.newBuilder().addPendingMembers(pendingMember(uuidNew)).build(); + DecryptedGroup to = DecryptedGroup.newBuilder().addMembers(member(uuidNew)).build(); + + DecryptedGroupChange decryptedGroupChange = GroupChangeReconstruct.reconstructGroupChange(from, to); + + assertEquals(DecryptedGroupChange.newBuilder().addPromotePendingMembers(member(uuidNew)).build(), decryptedGroupChange); + } + + @Test + public void uninvited_member_by_invite() { + UUID uuidNew = UUID.randomUUID(); + DecryptedGroup from = DecryptedGroup.newBuilder().addPendingMembers(pendingMember(uuidNew)).build(); + DecryptedGroup to = DecryptedGroup.newBuilder().build(); + + DecryptedGroupChange decryptedGroupChange = GroupChangeReconstruct.reconstructGroupChange(from, to); + + assertEquals(DecryptedGroupChange.newBuilder().addDeletePendingMembers(pendingMemberRemoval(uuidNew)).build(), decryptedGroupChange); + } + + @Test + public void new_invite() { + UUID uuidNew = UUID.randomUUID(); + DecryptedGroup from = DecryptedGroup.newBuilder().build(); + DecryptedGroup to = DecryptedGroup.newBuilder().addPendingMembers(pendingMember(uuidNew)).build(); + + DecryptedGroupChange decryptedGroupChange = GroupChangeReconstruct.reconstructGroupChange(from, to); + + assertEquals(DecryptedGroupChange.newBuilder().addNewPendingMembers(pendingMember(uuidNew)).build(), decryptedGroupChange); + } + + @Test + public void to_admin() { + UUID uuid = UUID.randomUUID(); + DecryptedGroup from = DecryptedGroup.newBuilder().addMembers(member(uuid)).build(); + DecryptedGroup to = DecryptedGroup.newBuilder().addMembers(admin(uuid)).build(); + + DecryptedGroupChange decryptedGroupChange = GroupChangeReconstruct.reconstructGroupChange(from, to); + + assertEquals(DecryptedGroupChange.newBuilder().addModifyMemberRoles(promoteAdmin(uuid)).build(), decryptedGroupChange); + } + + @Test + public void to_member() { + UUID uuid = UUID.randomUUID(); + DecryptedGroup from = DecryptedGroup.newBuilder().addMembers(admin(uuid)).build(); + DecryptedGroup to = DecryptedGroup.newBuilder().addMembers(member(uuid)).build(); + + DecryptedGroupChange decryptedGroupChange = GroupChangeReconstruct.reconstructGroupChange(from, to); + + assertEquals(DecryptedGroupChange.newBuilder().addModifyMemberRoles(demoteAdmin(uuid)).build(), decryptedGroupChange); + } + + @Test + public void profile_key_change_member() { + UUID uuid = UUID.randomUUID(); + ProfileKey profileKey1 = randomProfileKey(); + ProfileKey profileKey2 = randomProfileKey(); + DecryptedGroup from = DecryptedGroup.newBuilder().addMembers(withProfileKey(admin(uuid),profileKey1)).build(); + DecryptedGroup to = DecryptedGroup.newBuilder().addMembers(withProfileKey(admin(uuid),profileKey2)).build(); + + DecryptedGroupChange decryptedGroupChange = GroupChangeReconstruct.reconstructGroupChange(from, to); + + assertEquals(DecryptedGroupChange.newBuilder().addModifiedProfileKeys(withProfileKey(admin(uuid),profileKey2)).build(), decryptedGroupChange); + } +} \ No newline at end of file diff --git a/libsignal/service/src/test/java/org/whispersystems/signalservice/api/groupsv2/ProtoTestUtils.java b/libsignal/service/src/test/java/org/whispersystems/signalservice/api/groupsv2/ProtoTestUtils.java index 2cbb21892d..16ddeb016f 100644 --- a/libsignal/service/src/test/java/org/whispersystems/signalservice/api/groupsv2/ProtoTestUtils.java +++ b/libsignal/service/src/test/java/org/whispersystems/signalservice/api/groupsv2/ProtoTestUtils.java @@ -96,6 +96,10 @@ final class ProtoTestUtils { return withProfileKey(member(uuid), profileKey); } + static DecryptedMember admin(UUID uuid, ProfileKey profileKey) { + return withProfileKey(admin(uuid), profileKey); + } + static DecryptedMember admin(UUID uuid) { return DecryptedMember.newBuilder() .setUuid(UuidUtil.toByteString(uuid))