From 6362da7a5098bc0db41e252ec752ba9a3763b861 Mon Sep 17 00:00:00 2001 From: Cody Henthorne Date: Wed, 22 May 2024 10:00:17 -0400 Subject: [PATCH] Refactor group state processing. --- .../app/internal/InternalSettingsFragment.kt | 9 - .../app/internal/InternalSettingsState.kt | 1 - .../app/internal/InternalSettingsViewModel.kt | 6 - .../securesms/groups/GroupManager.java | 18 +- .../securesms/groups/GroupManagerV2.java | 23 +- .../groups/GroupNotAMemberException.java | 24 - .../groups/GroupNotAMemberException.kt | 7 + .../securesms/groups/GroupProtoUtil.java | 21 - .../processing/AdvanceGroupStateResult.java | 29 - .../v2/processing/AdvanceGroupStateResult.kt | 17 + .../v2/processing/AppliedGroupChangeLog.kt | 19 + .../v2/processing/GlobalGroupState.java | 75 -- .../groups/v2/processing/GroupStateDiff.kt | 39 + ...tateMapper.java => GroupStatePatcher.java} | 45 +- .../groups/v2/processing/GroupUpdateResult.kt | 29 + .../v2/processing/GroupsV2StateProcessor.java | 799 ------------------ .../v2/processing/GroupsV2StateProcessor.kt | 714 ++++++++++++++++ .../v2/processing/LocalGroupLogEntry.java | 55 -- .../v2/processing/ServerGroupLogEntry.java | 50 -- .../securesms/keyvalue/InternalValues.java | 14 - .../messages/MessageContentProcessor.kt | 13 +- .../securesms/database/GroupTestUtil.kt | 7 +- .../MockApplicationDependencyProvider.java | 2 +- .../v2/processing/GlobalGroupStateTest.java | 45 - .../v2/processing/GroupStateMapperTest.java | 513 ----------- .../v2/processing/GroupStatePatcherTest.java | 515 +++++++++++ .../processing/GroupsV2StateProcessorTest.kt | 557 +++++++++--- .../signalservice/api/NetworkResult.kt | 18 +- .../api/groupsv2/DecryptedGroupChangeLog.kt | 30 + .../groupsv2/DecryptedGroupHistoryEntry.java | 35 - .../api/groupsv2/GroupHistoryPage.java | 52 -- .../api/groupsv2/GroupHistoryPage.kt | 21 + .../api/groupsv2/GroupsV2Api.java | 27 +- .../api/groupsv2/GroupsV2Operations.java | 29 - .../groupsv2/InvalidGroupStateException.java | 4 + .../NotAbleToApplyGroupV2ChangeException.java | 2 +- .../api/groupsv2/PartialDecryptedGroup.java | 58 -- .../signalservice/api/push/ServiceId.kt | 2 +- .../internal/push/PushServiceSocket.java | 15 + 39 files changed, 1930 insertions(+), 2009 deletions(-) delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/GroupNotAMemberException.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/GroupNotAMemberException.kt delete 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/AdvanceGroupStateResult.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/AppliedGroupChangeLog.kt delete 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/GroupStateDiff.kt rename app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/{GroupStateMapper.java => GroupStatePatcher.java} (72%) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupUpdateResult.kt delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.kt delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/LocalGroupLogEntry.java delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/ServerGroupLogEntry.java delete mode 100644 app/src/test/java/org/thoughtcrime/securesms/groups/v2/processing/GlobalGroupStateTest.java delete mode 100644 app/src/test/java/org/thoughtcrime/securesms/groups/v2/processing/GroupStateMapperTest.java create mode 100644 app/src/test/java/org/thoughtcrime/securesms/groups/v2/processing/GroupStatePatcherTest.java create mode 100644 libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupChangeLog.kt delete mode 100644 libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupHistoryEntry.java delete mode 100644 libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupHistoryPage.java create mode 100644 libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupHistoryPage.kt delete mode 100644 libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/PartialDecryptedGroup.java diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt index 431b513102..d3256f7b12 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt @@ -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."), diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsState.kt index db9d1f5ecb..3adedb3d21 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsState.kt @@ -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, diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsViewModel.kt index ad9332741c..143c42ff02 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsViewModel.kt @@ -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, diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java index 53d9c9927c..eb6c5a3f85 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java @@ -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, - @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, + @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)) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java index acd65337e3..11184aa4b2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java @@ -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 localRecord, - @Nullable GroupSecretParams groupSecretParams, - @Nullable byte[] signedGroupChange, - @Nullable String serverGuid) + GroupUpdateResult updateLocalToServerRevision(int revision, + long timestamp, + @NonNull Optional 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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupNotAMemberException.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupNotAMemberException.java deleted file mode 100644 index 5c8bc02157..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupNotAMemberException.java +++ /dev/null @@ -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; - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupNotAMemberException.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupNotAMemberException.kt new file mode 100644 index 0000000000..a7f0918d04 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupNotAMemberException.kt @@ -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) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupProtoUtil.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupProtoUtil.java index fe6ef4edf1..4ddfa07e96 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupProtoUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupProtoUtil.java @@ -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) 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 deleted file mode 100644 index d0a9c4509c..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/AdvanceGroupStateResult.java +++ /dev/null @@ -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 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/AdvanceGroupStateResult.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/AdvanceGroupStateResult.kt new file mode 100644 index 0000000000..014efdc425 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/AdvanceGroupStateResult.kt @@ -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 = emptyList(), + val remainingRemoteGroupChanges: List = emptyList() +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/AppliedGroupChangeLog.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/AppliedGroupChangeLog.kt new file mode 100644 index 0000000000..a67c6effbe --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/AppliedGroupChangeLog.kt @@ -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() + } + } +} 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 deleted file mode 100644 index 800775a088..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GlobalGroupState.java +++ /dev/null @@ -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 serverHistory; - @NonNull private final GroupHistoryPage.PagingData pagingData; - - GlobalGroupState(@Nullable DecryptedGroup localState, - @NonNull List serverHistory, - @NonNull GroupHistoryPage.PagingData pagingData) - { - this.localState = localState; - this.serverHistory = serverHistory; - this.pagingData = pagingData; - } - - GlobalGroupState(@Nullable DecryptedGroup localState, - @NonNull List serverHistory) - { - this(localState, serverHistory, GroupHistoryPage.PagingData.NONE); - } - - @Nullable DecryptedGroup getLocalState() { - return localState; - } - - @NonNull Collection 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(); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupStateDiff.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupStateDiff.kt new file mode 100644 index 0000000000..1d4ce43461 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupStateDiff.kt @@ -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 +) { + + 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 + } +} 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/GroupStatePatcher.java similarity index 72% rename from app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupStateMapper.java rename to app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupStatePatcher.java index 874ddea4fc..c586e96604 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/GroupStatePatcher.java @@ -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 BY_REVISION = (o1, o2) -> Integer.compare(o1.getRevision(), o2.getRevision()); + private static final Comparator 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. *

* 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 statesToApplyNow = new HashMap<>(inputState.getServerHistory().size()); - ArrayList statesToApplyLater = new ArrayList<>(inputState.getServerHistory().size()); - DecryptedGroup current = inputState.getLocalState(); + HashMap statesToApplyNow = new HashMap<>(inputState.getServerHistory().size()); + ArrayList statesToApplyLater = new ArrayList<>(inputState.getServerHistory().size()); + DecryptedGroup current = inputState.getPreviousGroupState(); StateChain 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> mapperList = stateChain.getList(); - List appliedChanges = new ArrayList<>(mapperList.size()); + List appliedChanges = new ArrayList<>(mapperList.size()); for (StateChain.Pair 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 appliedChanges = new ArrayList<>(groupStateResult.getProcessedLogEntries().size()); + ArrayList 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 createNewMapper() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupUpdateResult.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupUpdateResult.kt new file mode 100644 index 0000000000..30eac77de9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupUpdateResult.kt @@ -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 + } +} 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 deleted file mode 100644 index b7e87faa6b..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.java +++ /dev/null @@ -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 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 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 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 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 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 selfAsMemberOptional = DecryptedGroupUtil.findMemberByAci(newLocalState.members, aci); - Optional selfAsPendingOptional = DecryptedGroupUtil.findPendingByServiceId(newLocalState.pendingMembers, aci); - - if (selfAsMemberOptional.isPresent()) { - DecryptedMember selfAsMember = selfAsMemberOptional.get(); - int revisionJoinedAt = selfAsMember.joinedAtRevision; - - Optional 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 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 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 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 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 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 getEditor(@NonNull DecryptedGroupV2Context decryptedGroupV2Context) { - DecryptedGroupChange change = decryptedGroupV2Context.change; - Optional changeEditor = DecryptedGroupUtil.editorServiceId(change); - if (changeEditor.isPresent()) { - return changeEditor; - } else { - Optional pending = DecryptedGroupUtil.findPendingByServiceId(decryptedGroupV2Context.groupState.pendingMembers, aci); - if (pending.isPresent()) { - return Optional.ofNullable(ACI.parseOrNull(pending.get().addedByAci)); - } - } - return Optional.empty(); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.kt new file mode 100644 index 0000000000..8f6b5c58d8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.kt @@ -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 = 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 + ): 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 = 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, 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, 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 { + 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, + 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 = 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 { + 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)) + } + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/LocalGroupLogEntry.java b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/LocalGroupLogEntry.java deleted file mode 100644 index 3e31884dce..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/LocalGroupLogEntry.java +++ /dev/null @@ -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. - *

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

- * 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; - } -} 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 deleted file mode 100644 index 28479bf1ca..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/ServerGroupLogEntry.java +++ /dev/null @@ -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. - *

- * 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.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(); - } -} 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 0ebc80fd68..fca0bdb1ce 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/InternalValues.java +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/InternalValues.java @@ -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. - *

- * 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. diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.kt index b5fea4052f..afecbad986 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.kt @@ -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, 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 diff --git a/app/src/test/java/org/thoughtcrime/securesms/database/GroupTestUtil.kt b/app/src/test/java/org/thoughtcrime/securesms/database/GroupTestUtil.kt index 56aa4bca63..9487b485a9 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/database/GroupTestUtil.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/database/GroupTestUtil.kt @@ -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, diff --git a/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.java b/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.java index a166f99256..25373af140 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.java +++ b/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.java @@ -82,7 +82,7 @@ public class MockApplicationDependencyProvider implements ApplicationDependencie @Override public @NonNull JobManager provideJobManager() { - return mock(JobManager.class); + return null; } @Override 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 deleted file mode 100644 index a9565ec8c0..0000000000 --- a/app/src/test/java/org/thoughtcrime/securesms/groups/v2/processing/GlobalGroupStateTest.java +++ /dev/null @@ -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(); - } -} 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 deleted file mode 100644 index 35d81b1cb9..0000000000 --- a/app/src/test/java/org/thoughtcrime/securesms/groups/v2/processing/GroupStateMapperTest.java +++ /dev/null @@ -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()); - } -} \ No newline at end of file diff --git a/app/src/test/java/org/thoughtcrime/securesms/groups/v2/processing/GroupStatePatcherTest.java b/app/src/test/java/org/thoughtcrime/securesms/groups/v2/processing/GroupStatePatcherTest.java new file mode 100644 index 0000000000..6f36d06430 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/groups/v2/processing/GroupStatePatcherTest.java @@ -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 expectedRemainingLogs, DecryptedGroup updatedGroupState, List 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()); + } +} \ No newline at end of file diff --git a/app/src/test/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessorTest.kt b/app/src/test/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessorTest.kt index 686a26f6e4..c9cbe64f57 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessorTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessorTest.kt @@ -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()) } returns data.groupRecord every { groupTable.isUnknownGroup(any()) } returns !data.groupRecord.isPresent + every { groupTable.isUnknownGroup(any>()) } 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(), any()) } + } + + if (data.expectTableCreate) { + every { groupTable.create(any(), any()) } returns groupId + } + + if (data.expectTableUpdate || data.expectTableCreate) { + justRun { profileAndMessageHelper.storeMessage(any(), any(), any()) } + justRun { profileAndMessageHelper.persistLearnedProfileKeys(any()) } + } 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()) } 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() - 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() + 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() - 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() + 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!!) } } } diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/NetworkResult.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/NetworkResult.kt index 700d07603c..2063dfd192 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/NetworkResult.kt +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/NetworkResult.kt @@ -30,13 +30,14 @@ sealed class NetworkResult( 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 fromFetch(fetch: () -> T): NetworkResult = try { - Success(fetch()) + @JvmStatic + fun fromFetch(fetcher: Fetcher): NetworkResult = 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( data class NetworkError(val exception: IOException) : NetworkResult() /** Indicates we got a response, but it was a non-2xx response. */ - data class StatusCodeError(val code: Int, val body: String?, val exception: NonSuccessfulResponseCodeException) : NetworkResult() + data class StatusCodeError(val code: Int, val body: String?, val exception: NonSuccessfulResponseCodeException) : NetworkResult() { + 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(val throwable: Throwable) : NetworkResult() @@ -175,4 +178,9 @@ sealed class NetworkResult( return this } + + fun interface Fetcher { + @Throws(Exception::class) + fun fetch(): T + } } diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupChangeLog.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupChangeLog.kt new file mode 100644 index 0000000000..01278f4319 --- /dev/null +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupChangeLog.kt @@ -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") + } + } +} diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupHistoryEntry.java b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupHistoryEntry.java deleted file mode 100644 index 4ca34c221e..0000000000 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/DecryptedGroupHistoryEntry.java +++ /dev/null @@ -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 group; - private final Optional change; - - public DecryptedGroupHistoryEntry(Optional group, Optional 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 getGroup() { - return group; - } - - public Optional getChange() { - return change; - } -} diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupHistoryPage.java b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupHistoryPage.java deleted file mode 100644 index 5c2f6b1196..0000000000 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupHistoryPage.java +++ /dev/null @@ -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 results; - private final PagingData pagingData; - - - public GroupHistoryPage(List results, PagingData pagingData) { - this.results = results; - this.pagingData = pagingData; - } - - public List 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; - } - } -} diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupHistoryPage.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupHistoryPage.kt new file mode 100644 index 0000000000..0a07259d77 --- /dev/null +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupHistoryPage.kt @@ -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, 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) + } + } + } +} diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Api.java b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Api.java index 42b87b7946..58f1c54c70 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Api.java +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Api.java @@ -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 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 result = new ArrayList<>(group.getGroupChanges().groupChanges.size()); + List result = new ArrayList<>(group.getGroupChanges().groupChanges.size()); GroupsV2Operations.GroupOperations groupOperations = groupsOperations.forGroup(groupSecretParams); for (GroupChanges.GroupChangeState change : group.getGroupChanges().groupChanges) { - Optional decryptedGroup = change.groupState != null ? Optional.of(groupOperations.decryptGroup(change.groupState)) : Optional.empty(); - Optional 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 getGroupJoinedAt(@Nonnull GroupsV2AuthorizationString authorization) { + return NetworkResult.fromFetch(() -> socket.getGroupJoinedAtRevision(authorization)); } public DecryptedGroupJoinInfo getGroupJoinInfo(GroupSecretParams groupSecretParams, diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Operations.java b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Operations.java index 07bed200cf..d63b3ded56 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Operations.java +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Operations.java @@ -425,35 +425,6 @@ public final class GroupsV2Operations { return new PendingMember.Builder().member(member); } - public PartialDecryptedGroup partialDecryptGroup(Group group) - throws VerificationFailedException, InvalidGroupStateException - { - List membersList = group.members; - List pendingMembersList = group.pendingMembers; - List decryptedMembers = new ArrayList<>(membersList.size()); - List 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 { diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/InvalidGroupStateException.java b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/InvalidGroupStateException.java index 3b05f55652..3a07b81a8c 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/InvalidGroupStateException.java +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/InvalidGroupStateException.java @@ -12,6 +12,10 @@ public final class InvalidGroupStateException extends Exception { super(e); } + InvalidGroupStateException(String message) { + super(message); + } + InvalidGroupStateException() { } } diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/NotAbleToApplyGroupV2ChangeException.java b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/NotAbleToApplyGroupV2ChangeException.java index 91aa2361c6..0e6433f117 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/NotAbleToApplyGroupV2ChangeException.java +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/NotAbleToApplyGroupV2ChangeException.java @@ -2,7 +2,7 @@ package org.whispersystems.signalservice.api.groupsv2; public final class NotAbleToApplyGroupV2ChangeException extends Exception { - NotAbleToApplyGroupV2ChangeException() { + public NotAbleToApplyGroupV2ChangeException() { } } diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/PartialDecryptedGroup.java b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/PartialDecryptedGroup.java deleted file mode 100644 index c603e356dc..0000000000 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/groupsv2/PartialDecryptedGroup.java +++ /dev/null @@ -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 getMembersList() { - return decryptedGroup.members; - } - - public List getPendingMembersList() { - return decryptedGroup.pendingMembers; - } - - public DecryptedGroup getFullyDecryptedGroup() - throws IOException - { - try { - return groupsOperations.forGroup(groupSecretParams) - .decryptGroup(group); - } catch (VerificationFailedException | InvalidGroupStateException e) { - throw new IOException(e); - } - } -} diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/push/ServiceId.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/push/ServiceId.kt index 4e2fafdf74..5f8588f7ec 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/push/ServiceId.kt +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/push/ServiceId.kt @@ -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 diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java b/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java index cc38b0a654..30ad3d5735 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java @@ -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 groupLinkPassword, GroupsV2AuthorizationString authorization) throws NonSuccessfulResponseCodeException, PushNetworkException, IOException, MalformedResponseException {