From 7bf090fdab3fdcaed8cf205a5f4ac119e050f33e Mon Sep 17 00:00:00 2001 From: Alan Evans Date: Thu, 9 Apr 2020 18:09:47 -0300 Subject: [PATCH] GroupsV2 state mapping. --- .../processing/AdvanceGroupStateResult.java | 29 ++++ .../v2/processing/GlobalGroupState.java | 43 ++++++ .../groups/v2/processing/GroupLogEntry.java | 35 +++++ .../v2/processing/GroupStateMapper.java | 63 ++++++++ .../v2/processing/GlobalGroupStateTest.java | 45 ++++++ .../v2/processing/GroupStateMapperTest.java | 146 ++++++++++++++++++ 6 files changed, 361 insertions(+) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/AdvanceGroupStateResult.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GlobalGroupState.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupLogEntry.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupStateMapper.java create mode 100644 app/src/test/java/org/thoughtcrime/securesms/groups/v2/processing/GlobalGroupStateTest.java create mode 100644 app/src/test/java/org/thoughtcrime/securesms/groups/v2/processing/GroupStateMapperTest.java 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 new file mode 100644 index 0000000000..9f7150055d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/AdvanceGroupStateResult.java @@ -0,0 +1,29 @@ +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 processedLogEntries; + @NonNull private final GlobalGroupState newGlobalGroupState; + + AdvanceGroupStateResult(@NonNull Collection processedLogEntries, + @NonNull GlobalGroupState newGlobalGroupState) + { + this.processedLogEntries = processedLogEntries; + this.newGlobalGroupState = newGlobalGroupState; + } + + @NonNull Collection getProcessedLogEntries() { + return processedLogEntries; + } + + @NonNull GlobalGroupState getNewGlobalGroupState() { + return newGlobalGroupState; + } +} 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 new file mode 100644 index 0000000000..a7966c4cb4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GlobalGroupState.java @@ -0,0 +1,43 @@ +package org.thoughtcrime.securesms.groups.v2.processing; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.signal.storageservice.protos.groups.local.DecryptedGroup; + +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 history; + + GlobalGroupState(@Nullable DecryptedGroup localState, + @NonNull List serverStates) + { + this.localState = localState; + this.history = serverStates; + } + + @Nullable DecryptedGroup getLocalState() { + return localState; + } + + @NonNull Collection getHistory() { + return history; + } + + int getLatestVersionNumber() { + if (history.isEmpty()) { + if (localState == null) { + throw new AssertionError(); + } + return localState.getVersion(); + } + return history.get(history.size() - 1).getGroup().getVersion(); + } +} 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/GroupLogEntry.java new file mode 100644 index 0000000000..d12476154c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupLogEntry.java @@ -0,0 +1,35 @@ +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; + +/** + * Pair of a group state and optionally the corresponding change. + *

+ * Changes are typically not available for pending members. + */ +final class GroupLogEntry { + + @NonNull private final DecryptedGroup group; + @Nullable private final DecryptedGroupChange change; + + GroupLogEntry(@NonNull DecryptedGroup group, @Nullable DecryptedGroupChange change) { + if (change != null && group.getVersion() != change.getVersion()) { + throw new AssertionError(); + } + + this.group = group; + this.change = change; + } + + @NonNull DecryptedGroup getGroup() { + return group; + } + + @Nullable DecryptedGroupChange getChange() { + return change; + } +} 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 new file mode 100644 index 0000000000..e4f327bd31 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupStateMapper.java @@ -0,0 +1,63 @@ +package org.thoughtcrime.securesms.groups.v2.processing; + +import androidx.annotation.NonNull; + +import org.signal.storageservice.protos.groups.local.DecryptedGroup; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; + +final class GroupStateMapper { + + static final int LATEST = Integer.MAX_VALUE; + + private static final Comparator BY_VERSION = (o1, o2) -> Integer.compare(o1.getGroup().getVersion(), o2.getGroup().getVersion()); + + private GroupStateMapper() { + } + + /** + * Given an input {@link GlobalGroupState} and a {@param maximumVersionToApply}, returns a result + * containing what the new local group state should be, and any remaining version history to apply. + *

+ * Function is pure. + * @param maximumVersionToApply Use {@link #LATEST} to apply the very latest. + */ + static @NonNull AdvanceGroupStateResult partiallyAdvanceGroupState(@NonNull GlobalGroupState inputState, + int maximumVersionToApply) + { + final ArrayList statesToApplyNow = new ArrayList<>(inputState.getHistory().size()); + final ArrayList statesToApplyLater = new ArrayList<>(inputState.getHistory().size()); + final DecryptedGroup newLocalState; + final GlobalGroupState newGlobalGroupState; + + for (GroupLogEntry entry : inputState.getHistory()) { + if (inputState.getLocalState() != null && + inputState.getLocalState().getVersion() >= entry.getGroup().getVersion()) + { + continue; + } + + if (entry.getGroup().getVersion() > maximumVersionToApply) { + statesToApplyLater.add(entry); + } else { + statesToApplyNow.add(entry); + } + } + + Collections.sort(statesToApplyNow, BY_VERSION); + Collections.sort(statesToApplyLater, BY_VERSION); + + 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); + } +} 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 new file mode 100644 index 0000000000..377b85158a --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/groups/v2/processing/GlobalGroupStateTest.java @@ -0,0 +1,45 @@ +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 java.util.Arrays.asList; +import static java.util.Collections.emptyList; +import static org.junit.Assert.assertEquals; + +public final class GlobalGroupStateTest { + + @Test(expected = AssertionError.class) + public void cannot_ask_latestVersionNumber_of_empty_state() { + GlobalGroupState emptyState = new GlobalGroupState(null, emptyList()); + + emptyState.getLatestVersionNumber(); + } + + @Test + public void latestVersionNumber_of_state_and_empty_list() { + GlobalGroupState emptyState = new GlobalGroupState(state(10), emptyList()); + + assertEquals(10, emptyState.getLatestVersionNumber()); + } + + @Test + public void latestVersionNumber_of_state_and_list() { + GlobalGroupState emptyState = new GlobalGroupState(state(2), asList(logEntry(3), logEntry(4))); + + assertEquals(4, emptyState.getLatestVersionNumber()); + } + + private static GroupLogEntry logEntry(int version) { + return new GroupLogEntry(state(version), change(version)); + } + + private static DecryptedGroup state(int version) { + return DecryptedGroup.newBuilder().setVersion(version).build(); + } + + private static DecryptedGroupChange change(int version) { + return DecryptedGroupChange.newBuilder().setVersion(version).build(); + } +} 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 new file mode 100644 index 0000000000..940f625f80 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/groups/v2/processing/GroupStateMapperTest.java @@ -0,0 +1,146 @@ +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 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.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; + +public final class GroupStateMapperTest { + + @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()); + 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().getHistory().isEmpty()); + assertSame(currentState, advanceGroupStateResult.getNewGlobalGroupState().getLocalState()); + } + + @Test + public void unknown_group_single_state_to_update() { + GroupLogEntry log0 = logEntry(0); + + AdvanceGroupStateResult advanceGroupStateResult = GroupStateMapper.partiallyAdvanceGroupState(new GlobalGroupState(null, singletonList(log0)), 10); + + assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(singletonList(log0))); + assertTrue(advanceGroupStateResult.getNewGlobalGroupState().getHistory().isEmpty()); + assertEquals(log0.getGroup(), advanceGroupStateResult.getNewGlobalGroupState().getLocalState()); + } + + @Test + public void known_group_single_state_to_update() { + DecryptedGroup currentState = state(0); + GroupLogEntry log1 = logEntry(1); + + AdvanceGroupStateResult advanceGroupStateResult = GroupStateMapper.partiallyAdvanceGroupState(new GlobalGroupState(currentState, singletonList(log1)), 1); + + assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(singletonList(log1))); + assertTrue(advanceGroupStateResult.getNewGlobalGroupState().getHistory().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); + + AdvanceGroupStateResult advanceGroupStateResult = GroupStateMapper.partiallyAdvanceGroupState(new GlobalGroupState(currentState, asList(log1, log2)), 2); + + assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(asList(log1, log2))); + assertTrue(advanceGroupStateResult.getNewGlobalGroupState().getHistory().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); + + AdvanceGroupStateResult advanceGroupStateResult = GroupStateMapper.partiallyAdvanceGroupState(new GlobalGroupState(currentState, asList(log1, log2)), 2); + + assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(singletonList(log2))); + assertTrue(advanceGroupStateResult.getNewGlobalGroupState().getHistory().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); + + AdvanceGroupStateResult advanceGroupStateResult = GroupStateMapper.partiallyAdvanceGroupState(new GlobalGroupState(currentState, asList(log1, log2, log3)), 2); + + assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(asList(log1, 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); + + AdvanceGroupStateResult advanceGroupStateResult = GroupStateMapper.partiallyAdvanceGroupState(new GlobalGroupState(currentState, asList(log1, log2, log3)), LATEST); + + assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(asList(log1, log2, log3))); + assertNewState(new GlobalGroupState(log3.getGroup(), emptyList()), advanceGroupStateResult.getNewGlobalGroupState()); + assertEquals(log3.getGroup(), advanceGroupStateResult.getNewGlobalGroupState().getLocalState()); + } + + @Test + public void apply_maximum_group_versions() { + DecryptedGroup currentState = state(Integer.MAX_VALUE - 2); + GroupLogEntry log1 = logEntry(Integer.MAX_VALUE - 1); + GroupLogEntry log2 = logEntry(Integer.MAX_VALUE); + + AdvanceGroupStateResult advanceGroupStateResult = GroupStateMapper.partiallyAdvanceGroupState(new GlobalGroupState(currentState, asList(log1, log2)), LATEST); + + assertThat(advanceGroupStateResult.getProcessedLogEntries(), is(asList(log1, 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())); + } + + private static GroupLogEntry logEntry(int version) { + return new GroupLogEntry(state(version), change(version)); + } + + private static DecryptedGroup state(int version) { + return DecryptedGroup.newBuilder().setVersion(version).build(); + } + + private static DecryptedGroupChange change(int version) { + return DecryptedGroupChange.newBuilder().setVersion(version).build(); + } +} \ No newline at end of file