mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-02-21 18:26:57 +00:00
Refactor group state processing.
This commit is contained in:
@@ -340,15 +340,6 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
|
||||
}
|
||||
)
|
||||
|
||||
switchPref(
|
||||
title = DSLSettingsText.from("Ignore server changes"),
|
||||
summary = DSLSettingsText.from("Changes in server's response will be ignored, causing passive voice update messages if P2P is also ignored."),
|
||||
isChecked = state.gv2ignoreServerChanges,
|
||||
onClick = {
|
||||
viewModel.setGv2IgnoreServerChanges(!state.gv2ignoreServerChanges)
|
||||
}
|
||||
)
|
||||
|
||||
switchPref(
|
||||
title = DSLSettingsText.from("Ignore P2P changes"),
|
||||
summary = DSLSettingsText.from("Changes sent P2P will be ignored. In conjunction with ignoring server changes, will cause passive voice."),
|
||||
|
||||
@@ -7,7 +7,6 @@ data class InternalSettingsState(
|
||||
val seeMoreUserDetails: Boolean,
|
||||
val shakeToReport: Boolean,
|
||||
val gv2forceInvites: Boolean,
|
||||
val gv2ignoreServerChanges: Boolean,
|
||||
val gv2ignoreP2PChanges: Boolean,
|
||||
val allowCensorshipSetting: Boolean,
|
||||
val forceWebsocketMode: Boolean,
|
||||
|
||||
@@ -54,11 +54,6 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun setGv2IgnoreServerChanges(enabled: Boolean) {
|
||||
preferenceDataStore.putBoolean(InternalValues.GV2_IGNORE_SERVER_CHANGES, enabled)
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun setGv2IgnoreP2PChanges(enabled: Boolean) {
|
||||
preferenceDataStore.putBoolean(InternalValues.GV2_IGNORE_P2P_CHANGES, enabled)
|
||||
refresh()
|
||||
@@ -148,7 +143,6 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
|
||||
seeMoreUserDetails = SignalStore.internalValues().recipientDetails(),
|
||||
shakeToReport = SignalStore.internalValues().shakeToReport(),
|
||||
gv2forceInvites = SignalStore.internalValues().gv2ForceInvites(),
|
||||
gv2ignoreServerChanges = SignalStore.internalValues().gv2IgnoreServerChanges(),
|
||||
gv2ignoreP2PChanges = SignalStore.internalValues().gv2IgnoreP2PChanges(),
|
||||
allowCensorshipSetting = SignalStore.internalValues().allowChangingCensorshipSetting(),
|
||||
forceWebsocketMode = SignalStore.internalValues().isWebsocketModeForced,
|
||||
|
||||
@@ -18,7 +18,7 @@ import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||
import org.thoughtcrime.securesms.database.model.GroupRecord;
|
||||
import org.thoughtcrime.securesms.groups.v2.GroupInviteLinkUrl;
|
||||
import org.thoughtcrime.securesms.groups.v2.GroupLinkPassword;
|
||||
import org.thoughtcrime.securesms.groups.v2.processing.GroupsV2StateProcessor;
|
||||
import org.thoughtcrime.securesms.groups.v2.processing.GroupUpdateResult;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.whispersystems.signalservice.api.groupsv2.GroupLinkNotActiveException;
|
||||
@@ -146,14 +146,14 @@ public final class GroupManager {
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
public static GroupsV2StateProcessor.GroupUpdateResult updateGroupFromServer(@NonNull Context context,
|
||||
@NonNull GroupMasterKey groupMasterKey,
|
||||
@NonNull Optional<GroupRecord> groupRecord,
|
||||
@Nullable GroupSecretParams groupSecretParams,
|
||||
int revision,
|
||||
long timestamp,
|
||||
@Nullable byte[] signedGroupChange,
|
||||
@Nullable String serverGuid)
|
||||
public static GroupUpdateResult updateGroupFromServer(@NonNull Context context,
|
||||
@NonNull GroupMasterKey groupMasterKey,
|
||||
@NonNull Optional<GroupRecord> groupRecord,
|
||||
@Nullable GroupSecretParams groupSecretParams,
|
||||
int revision,
|
||||
long timestamp,
|
||||
@Nullable byte[] signedGroupChange,
|
||||
@Nullable String serverGuid)
|
||||
throws GroupChangeBusyException, IOException, GroupNotAMemberException
|
||||
{
|
||||
try (GroupManagerV2.GroupUpdater updater = new GroupManagerV2(context).updater(groupMasterKey)) {
|
||||
|
||||
@@ -36,6 +36,7 @@ import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.groups.v2.GroupCandidateHelper;
|
||||
import org.thoughtcrime.securesms.groups.v2.GroupInviteLinkUrl;
|
||||
import org.thoughtcrime.securesms.groups.v2.GroupLinkPassword;
|
||||
import org.thoughtcrime.securesms.groups.v2.processing.GroupUpdateResult;
|
||||
import org.thoughtcrime.securesms.groups.v2.processing.GroupsV2StateProcessor;
|
||||
import org.thoughtcrime.securesms.jobs.ProfileUploadJob;
|
||||
import org.thoughtcrime.securesms.jobs.PushGroupSilentUpdateSendJob;
|
||||
@@ -603,15 +604,15 @@ final class GroupManagerV2 {
|
||||
private GroupChange.Actions.Builder resolveConflict(@NonNull ServiceId authServiceId, @NonNull GroupChange.Actions.Builder change)
|
||||
throws IOException, GroupNotAMemberException, GroupChangeFailedException
|
||||
{
|
||||
GroupsV2StateProcessor.GroupUpdateResult groupUpdateResult = GroupsV2StateProcessor.forGroup(serviceIds, groupMasterKey)
|
||||
.updateLocalGroupToRevision(GroupsV2StateProcessor.LATEST, System.currentTimeMillis(), null);
|
||||
GroupUpdateResult groupUpdateResult = GroupsV2StateProcessor.forGroup(serviceIds, groupMasterKey)
|
||||
.updateLocalGroupToRevision(GroupsV2StateProcessor.LATEST, System.currentTimeMillis());
|
||||
|
||||
if (groupUpdateResult.getLatestServer() == null) {
|
||||
Log.w(TAG, "Latest server state null.");
|
||||
throw new GroupChangeFailedException();
|
||||
}
|
||||
|
||||
if (groupUpdateResult.getGroupState() != GroupsV2StateProcessor.GroupState.GROUP_UPDATED) {
|
||||
if (groupUpdateResult.getUpdateStatus() != GroupUpdateResult.UpdateStatus.GROUP_UPDATED) {
|
||||
int serverRevision = groupUpdateResult.getLatestServer().revision;
|
||||
int localRevision = groupDatabase.requireGroup(groupId).requireV2GroupProperties().getGroupRevision();
|
||||
int revisionDelta = serverRevision - localRevision;
|
||||
@@ -722,20 +723,20 @@ final class GroupManagerV2 {
|
||||
throws IOException, GroupNotAMemberException
|
||||
{
|
||||
GroupsV2StateProcessor.forGroup(serviceIds, groupMasterKey)
|
||||
.updateLocalGroupToRevision(revision, timestamp, null);
|
||||
.updateLocalGroupToRevision(revision, timestamp);
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
GroupsV2StateProcessor.GroupUpdateResult updateLocalToServerRevision(int revision,
|
||||
long timestamp,
|
||||
@NonNull Optional<GroupRecord> localRecord,
|
||||
@Nullable GroupSecretParams groupSecretParams,
|
||||
@Nullable byte[] signedGroupChange,
|
||||
@Nullable String serverGuid)
|
||||
GroupUpdateResult updateLocalToServerRevision(int revision,
|
||||
long timestamp,
|
||||
@NonNull Optional<GroupRecord> localRecord,
|
||||
@Nullable GroupSecretParams groupSecretParams,
|
||||
@Nullable byte[] signedGroupChange,
|
||||
@Nullable String serverGuid)
|
||||
throws IOException, GroupNotAMemberException
|
||||
{
|
||||
return GroupsV2StateProcessor.forGroup(serviceIds, groupMasterKey, groupSecretParams)
|
||||
.updateLocalGroupToRevision(revision, timestamp, localRecord, getDecryptedGroupChange(signedGroupChange), serverGuid);
|
||||
.updateLocalGroupToRevision(revision, timestamp, getDecryptedGroupChange(signedGroupChange), localRecord, serverGuid);
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
package org.thoughtcrime.securesms.groups;
|
||||
|
||||
public final class GroupNotAMemberException extends GroupChangeException {
|
||||
|
||||
private final boolean likelyPendingMember;
|
||||
|
||||
public GroupNotAMemberException(Throwable throwable) {
|
||||
super(throwable);
|
||||
this.likelyPendingMember = false;
|
||||
}
|
||||
|
||||
public GroupNotAMemberException(GroupNotAMemberException throwable, boolean likelyPendingMember) {
|
||||
super(throwable.getCause() != null ? throwable.getCause() : throwable);
|
||||
this.likelyPendingMember = likelyPendingMember;
|
||||
}
|
||||
|
||||
GroupNotAMemberException() {
|
||||
this.likelyPendingMember = false;
|
||||
}
|
||||
|
||||
public boolean isLikelyPendingMember() {
|
||||
return likelyPendingMember;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package org.thoughtcrime.securesms.groups
|
||||
|
||||
class GroupNotAMemberException : GroupChangeException {
|
||||
constructor()
|
||||
constructor(throwable: Throwable) : super(throwable)
|
||||
constructor(throwable: GroupNotAMemberException) : super(throwable.cause ?: throwable)
|
||||
}
|
||||
@@ -11,15 +11,12 @@ import org.signal.storageservice.protos.groups.local.DecryptedGroupChange;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedMember;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedPendingMember;
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.GroupChangeChatUpdate;
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||
import org.thoughtcrime.securesms.database.model.GroupsV2UpdateMessageConverter;
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context;
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.GV2UpdateDescription;
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExtras;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.whispersystems.signalservice.api.groupsv2.PartialDecryptedGroup;
|
||||
import org.whispersystems.signalservice.api.push.ServiceId;
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.ACI;
|
||||
import org.whispersystems.signalservice.internal.push.GroupContextV2;
|
||||
@@ -33,24 +30,6 @@ public final class GroupProtoUtil {
|
||||
private GroupProtoUtil() {
|
||||
}
|
||||
|
||||
public static int findRevisionWeWereAdded(@NonNull PartialDecryptedGroup partialDecryptedGroup, @NonNull ACI self)
|
||||
throws GroupNotAMemberException
|
||||
{
|
||||
ByteString bytes = self.toByteString();
|
||||
for (DecryptedMember decryptedMember : partialDecryptedGroup.getMembersList()) {
|
||||
if (decryptedMember.aciBytes.equals(bytes)) {
|
||||
return decryptedMember.joinedAtRevision;
|
||||
}
|
||||
}
|
||||
for (DecryptedPendingMember decryptedMember : partialDecryptedGroup.getPendingMembersList()) {
|
||||
if (decryptedMember.serviceIdBytes.equals(bytes)) {
|
||||
// Assume latest, we don't have any information about when pending members were invited
|
||||
return partialDecryptedGroup.getRevision();
|
||||
}
|
||||
}
|
||||
throw new GroupNotAMemberException();
|
||||
}
|
||||
|
||||
public static GV2UpdateDescription createOutgoingGroupV2UpdateDescription(@NonNull GroupMasterKey masterKey,
|
||||
@NonNull GroupMutation groupMutation,
|
||||
@Nullable GroupChange signedServerChange)
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
package org.thoughtcrime.securesms.groups.v2.processing;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import java.util.Collection;
|
||||
|
||||
/**
|
||||
* Pair of log entries applied and a new {@link GlobalGroupState}.
|
||||
*/
|
||||
final class AdvanceGroupStateResult {
|
||||
|
||||
@NonNull private final Collection<LocalGroupLogEntry> processedLogEntries;
|
||||
@NonNull private final GlobalGroupState newGlobalGroupState;
|
||||
|
||||
AdvanceGroupStateResult(@NonNull Collection<LocalGroupLogEntry> processedLogEntries,
|
||||
@NonNull GlobalGroupState newGlobalGroupState)
|
||||
{
|
||||
this.processedLogEntries = processedLogEntries;
|
||||
this.newGlobalGroupState = newGlobalGroupState;
|
||||
}
|
||||
|
||||
@NonNull Collection<LocalGroupLogEntry> getProcessedLogEntries() {
|
||||
return processedLogEntries;
|
||||
}
|
||||
|
||||
@NonNull GlobalGroupState getNewGlobalGroupState() {
|
||||
return newGlobalGroupState;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package org.thoughtcrime.securesms.groups.v2.processing
|
||||
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroup
|
||||
import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupChangeLog
|
||||
|
||||
/**
|
||||
* Result of applying group state changes to a local group state.
|
||||
*
|
||||
* @param updatedGroupState cumulative result of applying changes to the input group state
|
||||
* @param processedLogEntries Local view of logs/changes applied from input group state to [updatedGroupState]
|
||||
* @param remainingRemoteGroupChanges Remote view of logs/changes yet to be applied to the local [updatedGroupState]
|
||||
*/
|
||||
data class AdvanceGroupStateResult @JvmOverloads constructor(
|
||||
val updatedGroupState: DecryptedGroup?,
|
||||
val processedLogEntries: Collection<AppliedGroupChangeLog> = emptyList(),
|
||||
val remainingRemoteGroupChanges: List<DecryptedGroupChangeLog> = emptyList()
|
||||
)
|
||||
@@ -0,0 +1,19 @@
|
||||
package org.thoughtcrime.securesms.groups.v2.processing
|
||||
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroup
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroupChange
|
||||
|
||||
/**
|
||||
* Pair of a group state and optionally the corresponding change.
|
||||
*
|
||||
* Similar to [org.whispersystems.signalservice.api.groupsv2.DecryptedGroupChangeLog] but guaranteed to have a group state.
|
||||
*
|
||||
* Changes are typically not available for pending members.
|
||||
*/
|
||||
data class AppliedGroupChangeLog internal constructor(val group: DecryptedGroup, val change: DecryptedGroupChange?) {
|
||||
init {
|
||||
if (change != null && group.revision != change.revision) {
|
||||
throw AssertionError()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
package org.thoughtcrime.securesms.groups.v2.processing;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroup;
|
||||
import org.whispersystems.signalservice.api.groupsv2.GroupHistoryPage;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Combination of Local and Server group state.
|
||||
*/
|
||||
final class GlobalGroupState {
|
||||
|
||||
@Nullable private final DecryptedGroup localState;
|
||||
@NonNull private final List<ServerGroupLogEntry> serverHistory;
|
||||
@NonNull private final GroupHistoryPage.PagingData pagingData;
|
||||
|
||||
GlobalGroupState(@Nullable DecryptedGroup localState,
|
||||
@NonNull List<ServerGroupLogEntry> serverHistory,
|
||||
@NonNull GroupHistoryPage.PagingData pagingData)
|
||||
{
|
||||
this.localState = localState;
|
||||
this.serverHistory = serverHistory;
|
||||
this.pagingData = pagingData;
|
||||
}
|
||||
|
||||
GlobalGroupState(@Nullable DecryptedGroup localState,
|
||||
@NonNull List<ServerGroupLogEntry> serverHistory)
|
||||
{
|
||||
this(localState, serverHistory, GroupHistoryPage.PagingData.NONE);
|
||||
}
|
||||
|
||||
@Nullable DecryptedGroup getLocalState() {
|
||||
return localState;
|
||||
}
|
||||
|
||||
@NonNull Collection<ServerGroupLogEntry> getServerHistory() {
|
||||
return serverHistory;
|
||||
}
|
||||
|
||||
int getEarliestRevisionNumber() {
|
||||
if (localState != null) {
|
||||
return localState.revision;
|
||||
} else {
|
||||
if (serverHistory.isEmpty()) {
|
||||
throw new AssertionError();
|
||||
}
|
||||
return serverHistory.get(0).getRevision();
|
||||
}
|
||||
}
|
||||
|
||||
int getLatestRevisionNumber() {
|
||||
if (serverHistory.isEmpty()) {
|
||||
if (localState == null) {
|
||||
throw new AssertionError();
|
||||
}
|
||||
return localState.revision;
|
||||
}
|
||||
return serverHistory.get(serverHistory.size() - 1).getRevision();
|
||||
}
|
||||
|
||||
public boolean hasMore() {
|
||||
return pagingData.hasMorePages();
|
||||
}
|
||||
|
||||
public int getNextPageRevision() {
|
||||
if (!pagingData.hasMorePages()) {
|
||||
throw new AssertionError("No paging data available");
|
||||
}
|
||||
return pagingData.getNextPageRevision();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package org.thoughtcrime.securesms.groups.v2.processing
|
||||
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroup
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroupChange
|
||||
import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupChangeLog
|
||||
|
||||
/**
|
||||
* Combination of Local and Server group state.
|
||||
*/
|
||||
class GroupStateDiff(
|
||||
val previousGroupState: DecryptedGroup?,
|
||||
val serverHistory: List<DecryptedGroupChangeLog>
|
||||
) {
|
||||
|
||||
constructor(previousGroupState: DecryptedGroup?, changedGroupState: DecryptedGroup?, change: DecryptedGroupChange?) : this(previousGroupState, listOf(DecryptedGroupChangeLog(changedGroupState, change)))
|
||||
|
||||
val earliestRevisionNumber: Int
|
||||
get() {
|
||||
if (previousGroupState != null) {
|
||||
return previousGroupState.revision
|
||||
} else {
|
||||
if (serverHistory.isEmpty()) {
|
||||
throw AssertionError()
|
||||
}
|
||||
return serverHistory[0].revision
|
||||
}
|
||||
}
|
||||
|
||||
val latestRevisionNumber: Int
|
||||
get() {
|
||||
if (serverHistory.isEmpty()) {
|
||||
if (previousGroupState == null) {
|
||||
throw AssertionError()
|
||||
}
|
||||
return previousGroupState.revision
|
||||
}
|
||||
return serverHistory[serverHistory.size - 1].revision
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import androidx.annotation.Nullable;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroup;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroupChange;
|
||||
import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupChangeLog;
|
||||
import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil;
|
||||
import org.whispersystems.signalservice.api.groupsv2.GroupChangeReconstruct;
|
||||
import org.whispersystems.signalservice.api.groupsv2.GroupChangeUtil;
|
||||
@@ -17,47 +18,47 @@ import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
|
||||
final class GroupStateMapper {
|
||||
final class GroupStatePatcher {
|
||||
|
||||
private static final String TAG = Log.tag(GroupStateMapper.class);
|
||||
private static final String TAG = Log.tag(GroupStatePatcher.class);
|
||||
|
||||
static final int LATEST = Integer.MAX_VALUE;
|
||||
static final int PLACEHOLDER_REVISION = -1;
|
||||
static final int RESTORE_PLACEHOLDER_REVISION = -2;
|
||||
|
||||
private static final Comparator<ServerGroupLogEntry> BY_REVISION = (o1, o2) -> Integer.compare(o1.getRevision(), o2.getRevision());
|
||||
private static final Comparator<DecryptedGroupChangeLog> BY_REVISION = (o1, o2) -> Integer.compare(o1.getRevision(), o2.getRevision());
|
||||
|
||||
private GroupStateMapper() {
|
||||
private GroupStatePatcher() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Given an input {@link GlobalGroupState} and a {@param maximumRevisionToApply}, returns a result
|
||||
* Given an input {@link GroupStateDiff} and a {@param maximumRevisionToApply}, returns a result
|
||||
* containing what the new local group state should be, and any remaining revision history to apply.
|
||||
* <p>
|
||||
* Function is pure.
|
||||
* @param maximumRevisionToApply Use {@link #LATEST} to apply the very latest.
|
||||
*/
|
||||
static @NonNull AdvanceGroupStateResult partiallyAdvanceGroupState(@NonNull GlobalGroupState inputState,
|
||||
int maximumRevisionToApply)
|
||||
static @NonNull AdvanceGroupStateResult applyGroupStateDiff(@NonNull GroupStateDiff inputState,
|
||||
int maximumRevisionToApply)
|
||||
{
|
||||
AdvanceGroupStateResult groupStateResult = processChanges(inputState, maximumRevisionToApply);
|
||||
|
||||
return cleanDuplicatedChanges(groupStateResult, inputState.getLocalState());
|
||||
return cleanDuplicatedChanges(groupStateResult, inputState.getPreviousGroupState());
|
||||
}
|
||||
|
||||
private static @NonNull AdvanceGroupStateResult processChanges(@NonNull GlobalGroupState inputState,
|
||||
private static @NonNull AdvanceGroupStateResult processChanges(@NonNull GroupStateDiff inputState,
|
||||
int maximumRevisionToApply)
|
||||
{
|
||||
HashMap<Integer, ServerGroupLogEntry> statesToApplyNow = new HashMap<>(inputState.getServerHistory().size());
|
||||
ArrayList<ServerGroupLogEntry> statesToApplyLater = new ArrayList<>(inputState.getServerHistory().size());
|
||||
DecryptedGroup current = inputState.getLocalState();
|
||||
HashMap<Integer, DecryptedGroupChangeLog> statesToApplyNow = new HashMap<>(inputState.getServerHistory().size());
|
||||
ArrayList<DecryptedGroupChangeLog> statesToApplyLater = new ArrayList<>(inputState.getServerHistory().size());
|
||||
DecryptedGroup current = inputState.getPreviousGroupState();
|
||||
StateChain<DecryptedGroup, DecryptedGroupChange> stateChain = createNewMapper();
|
||||
|
||||
if (inputState.getServerHistory().isEmpty()) {
|
||||
return new AdvanceGroupStateResult(Collections.emptyList(), new GlobalGroupState(current, Collections.emptyList()));
|
||||
return new AdvanceGroupStateResult(current);
|
||||
}
|
||||
|
||||
for (ServerGroupLogEntry entry : inputState.getServerHistory()) {
|
||||
for (DecryptedGroupChangeLog entry : inputState.getServerHistory()) {
|
||||
if (entry.getRevision() > maximumRevisionToApply) {
|
||||
statesToApplyLater.add(entry);
|
||||
} else {
|
||||
@@ -77,7 +78,7 @@ final class GroupStateMapper {
|
||||
}
|
||||
|
||||
for (int revision = from; revision >= 0 && revision <= to; revision++) {
|
||||
ServerGroupLogEntry entry = statesToApplyNow.get(revision);
|
||||
DecryptedGroupChangeLog entry = statesToApplyNow.get(revision);
|
||||
if (entry == null) {
|
||||
Log.w(TAG, "Could not find group log on server V" + revision);
|
||||
continue;
|
||||
@@ -96,15 +97,15 @@ final class GroupStateMapper {
|
||||
}
|
||||
|
||||
List<StateChain.Pair<DecryptedGroup, DecryptedGroupChange>> mapperList = stateChain.getList();
|
||||
List<LocalGroupLogEntry> appliedChanges = new ArrayList<>(mapperList.size());
|
||||
List<AppliedGroupChangeLog> appliedChanges = new ArrayList<>(mapperList.size());
|
||||
|
||||
for (StateChain.Pair<DecryptedGroup, DecryptedGroupChange> entry : mapperList) {
|
||||
if (current == null || entry.getDelta() != null) {
|
||||
appliedChanges.add(new LocalGroupLogEntry(entry.getState(), entry.getDelta()));
|
||||
appliedChanges.add(new AppliedGroupChangeLog(entry.getState(), entry.getDelta()));
|
||||
}
|
||||
}
|
||||
|
||||
return new AdvanceGroupStateResult(appliedChanges, new GlobalGroupState(stateChain.getLatestState(), statesToApplyLater));
|
||||
return new AdvanceGroupStateResult(stateChain.getLatestState(), appliedChanges, statesToApplyLater);
|
||||
}
|
||||
|
||||
private static AdvanceGroupStateResult cleanDuplicatedChanges(@NonNull AdvanceGroupStateResult groupStateResult,
|
||||
@@ -112,21 +113,21 @@ final class GroupStateMapper {
|
||||
{
|
||||
if (previousGroupState == null) return groupStateResult;
|
||||
|
||||
ArrayList<LocalGroupLogEntry> appliedChanges = new ArrayList<>(groupStateResult.getProcessedLogEntries().size());
|
||||
ArrayList<AppliedGroupChangeLog> appliedChanges = new ArrayList<>(groupStateResult.getProcessedLogEntries().size());
|
||||
|
||||
for (LocalGroupLogEntry entry : groupStateResult.getProcessedLogEntries()) {
|
||||
for (AppliedGroupChangeLog entry : groupStateResult.getProcessedLogEntries()) {
|
||||
DecryptedGroupChange change = entry.getChange();
|
||||
|
||||
if (change != null) {
|
||||
change = GroupChangeUtil.resolveConflict(previousGroupState, change).build();
|
||||
}
|
||||
|
||||
appliedChanges.add(new LocalGroupLogEntry(entry.getGroup(), change));
|
||||
appliedChanges.add(new AppliedGroupChangeLog(entry.getGroup(), change));
|
||||
|
||||
previousGroupState = entry.getGroup();
|
||||
}
|
||||
|
||||
return new AdvanceGroupStateResult(appliedChanges, groupStateResult.getNewGlobalGroupState());
|
||||
return new AdvanceGroupStateResult(groupStateResult.getUpdatedGroupState(), appliedChanges, groupStateResult.getRemainingRemoteGroupChanges());
|
||||
}
|
||||
|
||||
private static StateChain<DecryptedGroup, DecryptedGroupChange> createNewMapper() {
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
package org.thoughtcrime.securesms.groups.v2.processing
|
||||
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroup
|
||||
|
||||
/**
|
||||
* Result of updating a local group state via P2P group change or a server pull.
|
||||
*/
|
||||
data class GroupUpdateResult(val updateStatus: UpdateStatus, val latestServer: DecryptedGroup?) {
|
||||
|
||||
companion object {
|
||||
val CONSISTENT_OR_AHEAD = GroupUpdateResult(UpdateStatus.GROUP_CONSISTENT_OR_AHEAD, null)
|
||||
|
||||
fun updated(updatedGroupState: DecryptedGroup): GroupUpdateResult {
|
||||
return GroupUpdateResult(UpdateStatus.GROUP_UPDATED, updatedGroupState)
|
||||
}
|
||||
}
|
||||
|
||||
enum class UpdateStatus {
|
||||
/** The local group was successfully updated to be consistent with the message revision */
|
||||
GROUP_UPDATED,
|
||||
|
||||
/** The local group is already consistent with the message revision or is ahead of the message revision */
|
||||
GROUP_CONSISTENT_OR_AHEAD
|
||||
}
|
||||
}
|
||||
@@ -1,799 +0,0 @@
|
||||
package org.thoughtcrime.securesms.groups.v2.processing;
|
||||
|
||||
import android.text.TextUtils;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.VisibleForTesting;
|
||||
import androidx.annotation.WorkerThread;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.signal.libsignal.zkgroup.VerificationFailedException;
|
||||
import org.signal.libsignal.zkgroup.groups.GroupMasterKey;
|
||||
import org.signal.libsignal.zkgroup.groups.GroupSecretParams;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroup;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroupChange;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedMember;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedPendingMember;
|
||||
import org.thoughtcrime.securesms.database.GroupTable;
|
||||
import org.thoughtcrime.securesms.database.MessageTable;
|
||||
import org.thoughtcrime.securesms.database.RecipientTable;
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||
import org.thoughtcrime.securesms.database.ThreadTable;
|
||||
import org.thoughtcrime.securesms.database.model.GroupRecord;
|
||||
import org.thoughtcrime.securesms.database.model.GroupsV2UpdateMessageConverter;
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context;
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.GV2UpdateDescription;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.groups.GroupId;
|
||||
import org.thoughtcrime.securesms.groups.GroupMutation;
|
||||
import org.thoughtcrime.securesms.groups.GroupNotAMemberException;
|
||||
import org.thoughtcrime.securesms.groups.GroupProtoUtil;
|
||||
import org.thoughtcrime.securesms.groups.GroupsV2Authorization;
|
||||
import org.thoughtcrime.securesms.groups.v2.ProfileKeySet;
|
||||
import org.thoughtcrime.securesms.jobmanager.Job;
|
||||
import org.thoughtcrime.securesms.jobs.AvatarGroupsV2DownloadJob;
|
||||
import org.thoughtcrime.securesms.jobs.DirectoryRefreshJob;
|
||||
import org.thoughtcrime.securesms.jobs.LeaveGroupV2Job;
|
||||
import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob;
|
||||
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.mms.IncomingMessage;
|
||||
import org.thoughtcrime.securesms.mms.MmsException;
|
||||
import org.thoughtcrime.securesms.mms.OutgoingMessage;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupHistoryEntry;
|
||||
import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil;
|
||||
import org.whispersystems.signalservice.api.groupsv2.GroupChangeReconstruct;
|
||||
import org.whispersystems.signalservice.api.groupsv2.GroupHistoryPage;
|
||||
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Api;
|
||||
import org.whispersystems.signalservice.api.groupsv2.InvalidGroupStateException;
|
||||
import org.whispersystems.signalservice.api.groupsv2.NotAbleToApplyGroupV2ChangeException;
|
||||
import org.whispersystems.signalservice.api.groupsv2.PartialDecryptedGroup;
|
||||
import org.whispersystems.signalservice.api.push.ServiceId;
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.ACI;
|
||||
import org.whispersystems.signalservice.api.push.ServiceIds;
|
||||
import org.whispersystems.signalservice.api.util.UuidUtil;
|
||||
import org.whispersystems.signalservice.internal.push.exceptions.GroupNotFoundException;
|
||||
import org.whispersystems.signalservice.internal.push.exceptions.NotInGroupException;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Advances a groups state to a specified revision.
|
||||
*/
|
||||
public class GroupsV2StateProcessor {
|
||||
|
||||
private static final String TAG = Log.tag(GroupsV2StateProcessor.class);
|
||||
|
||||
public static final int LATEST = GroupStateMapper.LATEST;
|
||||
|
||||
/**
|
||||
* Used to mark a group state as a placeholder when there is partial knowledge (title and avater)
|
||||
* gathered from a group join link.
|
||||
*/
|
||||
public static final int PLACEHOLDER_REVISION = GroupStateMapper.PLACEHOLDER_REVISION;
|
||||
|
||||
/**
|
||||
* Used to mark a group state as a placeholder when you have no knowledge at all of the group
|
||||
* e.g. from a group master key from a storage service restore.
|
||||
*/
|
||||
public static final int RESTORE_PLACEHOLDER_REVISION = GroupStateMapper.RESTORE_PLACEHOLDER_REVISION;
|
||||
|
||||
private GroupsV2StateProcessor() {
|
||||
}
|
||||
|
||||
public static StateProcessorForGroup forGroup(@NonNull ServiceIds serviceIds, @NonNull GroupMasterKey groupMasterKey) {
|
||||
return forGroup(serviceIds, groupMasterKey, null);
|
||||
}
|
||||
|
||||
public static StateProcessorForGroup forGroup(@NonNull ServiceIds serviceIds, @NonNull GroupMasterKey groupMasterKey, @Nullable GroupSecretParams groupSecretParams) {
|
||||
groupSecretParams = groupSecretParams != null ? groupSecretParams : GroupSecretParams.deriveFromMasterKey(groupMasterKey);
|
||||
|
||||
return new StateProcessorForGroup(
|
||||
serviceIds,
|
||||
SignalDatabase.groups(),
|
||||
ApplicationDependencies.getSignalServiceAccountManager().getGroupsV2Api(),
|
||||
ApplicationDependencies.getGroupsV2Authorization(),
|
||||
groupMasterKey,
|
||||
groupSecretParams,
|
||||
SignalDatabase.recipients()
|
||||
);
|
||||
}
|
||||
|
||||
public enum GroupState {
|
||||
/**
|
||||
* The local group was successfully updated to be consistent with the message revision
|
||||
*/
|
||||
GROUP_UPDATED,
|
||||
|
||||
/**
|
||||
* The local group is already consistent with the message revision or is ahead of the message revision
|
||||
*/
|
||||
GROUP_CONSISTENT_OR_AHEAD
|
||||
}
|
||||
|
||||
public static class GroupUpdateResult {
|
||||
private final GroupState groupState;
|
||||
private final DecryptedGroup latestServer;
|
||||
|
||||
GroupUpdateResult(@NonNull GroupState groupState, @Nullable DecryptedGroup latestServer) {
|
||||
this.groupState = groupState;
|
||||
this.latestServer = latestServer;
|
||||
}
|
||||
|
||||
public @NonNull GroupState getGroupState() {
|
||||
return groupState;
|
||||
}
|
||||
|
||||
public @Nullable DecryptedGroup getLatestServer() {
|
||||
return latestServer;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class StateProcessorForGroup {
|
||||
private final ServiceIds serviceIds;
|
||||
private final GroupTable groupDatabase;
|
||||
private final GroupsV2Api groupsV2Api;
|
||||
private final GroupsV2Authorization groupsV2Authorization;
|
||||
private final GroupMasterKey masterKey;
|
||||
private final GroupId.V2 groupId;
|
||||
private final GroupSecretParams groupSecretParams;
|
||||
private final ProfileAndMessageHelper profileAndMessageHelper;
|
||||
|
||||
private StateProcessorForGroup(@NonNull ServiceIds serviceIds,
|
||||
@NonNull GroupTable groupDatabase,
|
||||
@NonNull GroupsV2Api groupsV2Api,
|
||||
@NonNull GroupsV2Authorization groupsV2Authorization,
|
||||
@NonNull GroupMasterKey groupMasterKey,
|
||||
@NonNull GroupSecretParams groupSecretParams,
|
||||
@NonNull RecipientTable recipientTable)
|
||||
{
|
||||
this.serviceIds = serviceIds;
|
||||
this.groupDatabase = groupDatabase;
|
||||
this.groupsV2Api = groupsV2Api;
|
||||
this.groupsV2Authorization = groupsV2Authorization;
|
||||
this.masterKey = groupMasterKey;
|
||||
this.groupSecretParams = groupSecretParams;
|
||||
this.groupId = GroupId.v2(groupSecretParams.getPublicParams().getGroupIdentifier());
|
||||
this.profileAndMessageHelper = new ProfileAndMessageHelper(serviceIds.getAci(), groupMasterKey, groupId, recipientTable);
|
||||
}
|
||||
|
||||
@VisibleForTesting StateProcessorForGroup(@NonNull ServiceIds serviceIds,
|
||||
@NonNull GroupTable groupDatabase,
|
||||
@NonNull GroupsV2Api groupsV2Api,
|
||||
@NonNull GroupsV2Authorization groupsV2Authorization,
|
||||
@NonNull GroupMasterKey groupMasterKey,
|
||||
@NonNull ProfileAndMessageHelper profileAndMessageHelper)
|
||||
{
|
||||
this.serviceIds = serviceIds;
|
||||
this.groupDatabase = groupDatabase;
|
||||
this.groupsV2Api = groupsV2Api;
|
||||
this.groupsV2Authorization = groupsV2Authorization;
|
||||
this.masterKey = groupMasterKey;
|
||||
this.groupSecretParams = GroupSecretParams.deriveFromMasterKey(groupMasterKey);
|
||||
this.groupId = GroupId.v2(groupSecretParams.getPublicParams().getGroupIdentifier());
|
||||
this.profileAndMessageHelper = profileAndMessageHelper;
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
public GroupUpdateResult forceSanityUpdateFromServer(long timestamp)
|
||||
throws IOException, GroupNotAMemberException
|
||||
{
|
||||
Optional<GroupRecord> localRecord = groupDatabase.getGroup(groupId);
|
||||
DecryptedGroup localState = localRecord.map(g -> g.requireV2GroupProperties().getDecryptedGroup()).orElse(null);
|
||||
DecryptedGroup serverState;
|
||||
|
||||
if (localState == null) {
|
||||
info("No local state to force update");
|
||||
return new GroupUpdateResult(GroupState.GROUP_CONSISTENT_OR_AHEAD, null);
|
||||
}
|
||||
|
||||
try {
|
||||
serverState = groupsV2Api.getGroup(groupSecretParams, groupsV2Authorization.getAuthorizationForToday(serviceIds, groupSecretParams));
|
||||
} catch (NotInGroupException | GroupNotFoundException e) {
|
||||
throw new GroupNotAMemberException(e);
|
||||
} catch (VerificationFailedException | InvalidGroupStateException e) {
|
||||
throw new IOException(e);
|
||||
}
|
||||
|
||||
DecryptedGroupChange decryptedGroupChange = GroupChangeReconstruct.reconstructGroupChange(localState, serverState);
|
||||
GlobalGroupState inputGroupState = new GlobalGroupState(localState, Collections.singletonList(new ServerGroupLogEntry(serverState, decryptedGroupChange)));
|
||||
AdvanceGroupStateResult advanceGroupStateResult = GroupStateMapper.partiallyAdvanceGroupState(inputGroupState, serverState.revision);
|
||||
DecryptedGroup newLocalState = advanceGroupStateResult.getNewGlobalGroupState().getLocalState();
|
||||
|
||||
if (newLocalState == null || newLocalState == inputGroupState.getLocalState()) {
|
||||
info("Local state and server state are equal");
|
||||
return new GroupUpdateResult(GroupState.GROUP_CONSISTENT_OR_AHEAD, null);
|
||||
} else {
|
||||
info("Local state (revision: " + localState.revision + ") does not match server state (revision: " + serverState.revision + "), updating");
|
||||
}
|
||||
|
||||
updateLocalDatabaseGroupState(inputGroupState, newLocalState);
|
||||
if (localState.revision == GroupsV2StateProcessor.RESTORE_PLACEHOLDER_REVISION) {
|
||||
info("Inserting single update message for restore placeholder");
|
||||
profileAndMessageHelper.insertUpdateMessages(timestamp, null, Collections.singleton(new LocalGroupLogEntry(newLocalState, null)), null);
|
||||
} else {
|
||||
info("Inserting force update messages");
|
||||
profileAndMessageHelper.insertUpdateMessages(timestamp, localState, advanceGroupStateResult.getProcessedLogEntries(), null);
|
||||
}
|
||||
profileAndMessageHelper.persistLearnedProfileKeys(inputGroupState);
|
||||
|
||||
return new GroupUpdateResult(GroupState.GROUP_UPDATED, newLocalState);
|
||||
}
|
||||
|
||||
/**
|
||||
* Using network where required, will attempt to bring the local copy of the group up to the revision specified.
|
||||
*
|
||||
* @param revision use {@link #LATEST} to get latest.
|
||||
*/
|
||||
@WorkerThread
|
||||
public GroupUpdateResult updateLocalGroupToRevision(final int revision,
|
||||
final long timestamp,
|
||||
@Nullable DecryptedGroupChange signedGroupChange)
|
||||
throws IOException, GroupNotAMemberException
|
||||
{
|
||||
return updateLocalGroupToRevision(revision, timestamp, groupDatabase.getGroup(groupId), signedGroupChange, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Using network where required, will attempt to bring the local copy of the group up to the revision specified.
|
||||
*
|
||||
* @param revision use {@link #LATEST} to get latest.
|
||||
*/
|
||||
@WorkerThread
|
||||
public GroupUpdateResult updateLocalGroupToRevision(final int revision,
|
||||
final long timestamp,
|
||||
@NonNull Optional<GroupRecord> localRecord,
|
||||
@Nullable DecryptedGroupChange signedGroupChange,
|
||||
@Nullable String serverGuid)
|
||||
throws IOException, GroupNotAMemberException
|
||||
{
|
||||
if (localIsAtLeast(localRecord, revision)) {
|
||||
return new GroupUpdateResult(GroupState.GROUP_CONSISTENT_OR_AHEAD, null);
|
||||
}
|
||||
|
||||
GlobalGroupState inputGroupState = null;
|
||||
|
||||
DecryptedGroup localState = localRecord.map(g -> g.requireV2GroupProperties().getDecryptedGroup()).orElse(null);
|
||||
|
||||
if (signedGroupChange != null &&
|
||||
localState != null &&
|
||||
localState.revision + 1 == signedGroupChange.revision &&
|
||||
revision == signedGroupChange.revision)
|
||||
{
|
||||
if (notInGroupAndNotBeingAdded(localRecord, signedGroupChange) && notHavingInviteRevoked(signedGroupChange)) {
|
||||
warn("Ignoring P2P group change because we're not currently in the group and this change doesn't add us in. Falling back to a server fetch.");
|
||||
} else if (SignalStore.internalValues().gv2IgnoreP2PChanges()) {
|
||||
warn( "Ignoring P2P group change by setting");
|
||||
} else {
|
||||
try {
|
||||
info("Applying P2P group change");
|
||||
DecryptedGroup newState = DecryptedGroupUtil.apply(localState, signedGroupChange);
|
||||
|
||||
inputGroupState = new GlobalGroupState(localState, Collections.singletonList(new ServerGroupLogEntry(newState, signedGroupChange)));
|
||||
} catch (NotAbleToApplyGroupV2ChangeException e) {
|
||||
warn( "Unable to apply P2P group change", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (inputGroupState == null) {
|
||||
try {
|
||||
return updateLocalGroupFromServerPaged(revision, localState, timestamp, false, serverGuid);
|
||||
} catch (GroupNotAMemberException e) {
|
||||
if (localState != null && signedGroupChange != null) {
|
||||
try {
|
||||
if (notInGroupAndNotBeingAdded(localRecord, signedGroupChange)) {
|
||||
warn( "Server says we're not a member. Ignoring P2P group change because we're not currently in the group and this change doesn't add us in.");
|
||||
} else {
|
||||
info("Server says we're not a member. Applying P2P group change.");
|
||||
DecryptedGroup newState = DecryptedGroupUtil.applyWithoutRevisionCheck(localState, signedGroupChange);
|
||||
|
||||
inputGroupState = new GlobalGroupState(localState, Collections.singletonList(new ServerGroupLogEntry(newState, signedGroupChange)));
|
||||
}
|
||||
} catch (NotAbleToApplyGroupV2ChangeException failed) {
|
||||
warn( "Unable to apply P2P group change when not a member", failed);
|
||||
}
|
||||
}
|
||||
|
||||
if (inputGroupState == null) {
|
||||
if (localState != null && DecryptedGroupUtil.isPendingOrRequesting(localState, serviceIds)) {
|
||||
warn( "Unable to query server for group " + groupId + " server says we're not in group, but we think we are a pending or requesting member");
|
||||
throw new GroupNotAMemberException(e, true);
|
||||
} else {
|
||||
warn( "Unable to query server for group " + groupId + " server says we're not in group, inserting leave message");
|
||||
insertGroupLeave();
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AdvanceGroupStateResult advanceGroupStateResult = GroupStateMapper.partiallyAdvanceGroupState(inputGroupState, revision);
|
||||
DecryptedGroup newLocalState = advanceGroupStateResult.getNewGlobalGroupState().getLocalState();
|
||||
|
||||
if (newLocalState == null || newLocalState == inputGroupState.getLocalState()) {
|
||||
return new GroupUpdateResult(GroupState.GROUP_CONSISTENT_OR_AHEAD, null);
|
||||
}
|
||||
|
||||
updateLocalDatabaseGroupState(inputGroupState, newLocalState);
|
||||
if (localState != null && localState.revision == GroupsV2StateProcessor.RESTORE_PLACEHOLDER_REVISION) {
|
||||
info("Inserting single update message for restore placeholder");
|
||||
profileAndMessageHelper.insertUpdateMessages(timestamp, null, Collections.singleton(new LocalGroupLogEntry(newLocalState, null)), null);
|
||||
} else {
|
||||
profileAndMessageHelper.insertUpdateMessages(timestamp, localState, advanceGroupStateResult.getProcessedLogEntries(), serverGuid);
|
||||
}
|
||||
profileAndMessageHelper.persistLearnedProfileKeys(inputGroupState);
|
||||
|
||||
if (!signedGroupChange.promotePendingPniAciMembers.isEmpty()) {
|
||||
ApplicationDependencies.getJobManager().add(new DirectoryRefreshJob(false));
|
||||
}
|
||||
|
||||
GlobalGroupState remainingWork = advanceGroupStateResult.getNewGlobalGroupState();
|
||||
if (remainingWork.getServerHistory().size() > 0) {
|
||||
info(String.format(Locale.US, "There are more revisions on the server for this group, scheduling for later, V[%d..%d]", newLocalState.revision + 1, remainingWork.getLatestRevisionNumber()));
|
||||
ApplicationDependencies.getJobManager().add(new RequestGroupV2InfoJob(groupId, remainingWork.getLatestRevisionNumber()));
|
||||
}
|
||||
|
||||
return new GroupUpdateResult(GroupState.GROUP_UPDATED, newLocalState);
|
||||
}
|
||||
|
||||
private boolean notInGroupAndNotBeingAdded(@NonNull Optional<GroupRecord> localRecord, @NonNull DecryptedGroupChange signedGroupChange) {
|
||||
boolean currentlyInGroup = localRecord.isPresent() && localRecord.get().isActive();
|
||||
|
||||
boolean addedAsMember = signedGroupChange.newMembers
|
||||
.stream()
|
||||
.map(m -> m.aciBytes)
|
||||
.map(ACI::parseOrNull)
|
||||
.filter(Objects::nonNull)
|
||||
.anyMatch(serviceIds::matches);
|
||||
|
||||
boolean addedAsPendingMember = signedGroupChange.newPendingMembers
|
||||
.stream()
|
||||
.map(m -> m.serviceIdBytes)
|
||||
.anyMatch(serviceIds::matches);
|
||||
|
||||
boolean addedAsRequestingMember = signedGroupChange.newRequestingMembers
|
||||
.stream()
|
||||
.map(m -> m.aciBytes)
|
||||
.map(ACI::parseOrNull)
|
||||
.filter(Objects::nonNull)
|
||||
.anyMatch(serviceIds::matches);
|
||||
|
||||
return !currentlyInGroup && !addedAsMember && !addedAsPendingMember && !addedAsRequestingMember;
|
||||
}
|
||||
|
||||
private boolean notHavingInviteRevoked(@NonNull DecryptedGroupChange signedGroupChange) {
|
||||
boolean havingInviteRevoked = signedGroupChange.deletePendingMembers
|
||||
.stream()
|
||||
.map(m -> m.serviceIdBytes)
|
||||
.anyMatch(serviceIds::matches);
|
||||
|
||||
return !havingInviteRevoked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Using network, attempt to bring the local copy of the group up to the revision specified via paging.
|
||||
*/
|
||||
private GroupUpdateResult updateLocalGroupFromServerPaged(int revision, DecryptedGroup localState, long timestamp, boolean forceIncludeFirst, @Nullable String serverGuid) throws IOException, GroupNotAMemberException {
|
||||
boolean latestRevisionOnly = revision == LATEST && (localState == null || localState.revision == GroupsV2StateProcessor.RESTORE_PLACEHOLDER_REVISION);
|
||||
|
||||
info("Paging from server revision: " + (revision == LATEST ? "latest" : revision) + ", latestOnly: " + latestRevisionOnly);
|
||||
|
||||
PartialDecryptedGroup latestServerGroup;
|
||||
GlobalGroupState inputGroupState;
|
||||
|
||||
try {
|
||||
latestServerGroup = groupsV2Api.getPartialDecryptedGroup(groupSecretParams, groupsV2Authorization.getAuthorizationForToday(serviceIds, groupSecretParams));
|
||||
} catch (NotInGroupException | GroupNotFoundException e) {
|
||||
throw new GroupNotAMemberException(e);
|
||||
} catch (VerificationFailedException | InvalidGroupStateException e) {
|
||||
throw new IOException(e);
|
||||
}
|
||||
|
||||
if (localState != null && localState.revision >= latestServerGroup.getRevision() && GroupProtoUtil.isMember(serviceIds.getAci(), localState.members)) {
|
||||
info("Local state is at or later than server");
|
||||
return new GroupUpdateResult(GroupState.GROUP_CONSISTENT_OR_AHEAD, null);
|
||||
}
|
||||
|
||||
if (latestRevisionOnly || !GroupProtoUtil.isMember(serviceIds.getAci(), latestServerGroup.getMembersList())) {
|
||||
info("Latest revision or not a member, use latest only");
|
||||
inputGroupState = new GlobalGroupState(localState, Collections.singletonList(new ServerGroupLogEntry(latestServerGroup.getFullyDecryptedGroup(), null)));
|
||||
} else {
|
||||
int revisionWeWereAdded = GroupProtoUtil.findRevisionWeWereAdded(latestServerGroup, serviceIds.getAci());
|
||||
int logsNeededFrom = localState != null ? Math.max(localState.revision, revisionWeWereAdded) : revisionWeWereAdded;
|
||||
|
||||
boolean includeFirstState = forceIncludeFirst ||
|
||||
localState == null ||
|
||||
localState.revision < 0 ||
|
||||
localState.revision == revisionWeWereAdded ||
|
||||
!GroupProtoUtil.isMember(serviceIds.getAci(), localState.members) ||
|
||||
(revision == LATEST && localState.revision + 1 < latestServerGroup.getRevision());
|
||||
|
||||
info("Requesting from server currentRevision: " + (localState != null ? localState.revision : "null") +
|
||||
" logsNeededFrom: " + logsNeededFrom +
|
||||
" includeFirstState: " + includeFirstState +
|
||||
" forceIncludeFirst: " + forceIncludeFirst);
|
||||
inputGroupState = getFullMemberHistoryPage(localState, logsNeededFrom, includeFirstState);
|
||||
}
|
||||
|
||||
ProfileKeySet profileKeys = new ProfileKeySet();
|
||||
DecryptedGroup finalState = localState;
|
||||
GlobalGroupState finalGlobalGroupState = inputGroupState;
|
||||
boolean performCdsLookup = false;
|
||||
|
||||
boolean hasMore = true;
|
||||
|
||||
while (hasMore) {
|
||||
AdvanceGroupStateResult advanceGroupStateResult = GroupStateMapper.partiallyAdvanceGroupState(inputGroupState, revision);
|
||||
DecryptedGroup newLocalState = advanceGroupStateResult.getNewGlobalGroupState().getLocalState();
|
||||
info("Advanced group to revision: " + (newLocalState != null ? newLocalState.revision : "null"));
|
||||
|
||||
if (newLocalState != null && !inputGroupState.hasMore() && !forceIncludeFirst) {
|
||||
int newLocalRevision = newLocalState.revision;
|
||||
int requestRevision = (revision == LATEST) ? latestServerGroup.getRevision() : revision;
|
||||
if (newLocalRevision < requestRevision) {
|
||||
warn( "Paging again with force first snapshot enabled due to error processing changes. New local revision [" + newLocalRevision + "] hasn't reached our desired level [" + requestRevision + "]");
|
||||
return updateLocalGroupFromServerPaged(revision, localState, timestamp, true, serverGuid);
|
||||
}
|
||||
}
|
||||
|
||||
if (newLocalState == null || newLocalState == inputGroupState.getLocalState()) {
|
||||
return new GroupUpdateResult(GroupState.GROUP_CONSISTENT_OR_AHEAD, null);
|
||||
}
|
||||
|
||||
updateLocalDatabaseGroupState(inputGroupState, newLocalState);
|
||||
|
||||
if (localState == null || localState.revision != GroupsV2StateProcessor.RESTORE_PLACEHOLDER_REVISION) {
|
||||
timestamp = profileAndMessageHelper.insertUpdateMessages(timestamp, localState, advanceGroupStateResult.getProcessedLogEntries(), serverGuid);
|
||||
}
|
||||
|
||||
for (ServerGroupLogEntry entry : inputGroupState.getServerHistory()) {
|
||||
if (entry.getGroup() != null) {
|
||||
profileKeys.addKeysFromGroupState(entry.getGroup());
|
||||
}
|
||||
|
||||
if (entry.getChange() != null) {
|
||||
profileKeys.addKeysFromGroupChange(entry.getChange());
|
||||
|
||||
if (!entry.getChange().promotePendingPniAciMembers.isEmpty()) {
|
||||
performCdsLookup = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
finalState = newLocalState;
|
||||
finalGlobalGroupState = advanceGroupStateResult.getNewGlobalGroupState();
|
||||
hasMore = inputGroupState.hasMore();
|
||||
|
||||
if (hasMore) {
|
||||
info("Request next page from server revision: " + finalState.revision + " nextPageRevision: " + inputGroupState.getNextPageRevision());
|
||||
inputGroupState = getFullMemberHistoryPage(finalState, inputGroupState.getNextPageRevision(), false);
|
||||
}
|
||||
}
|
||||
|
||||
if (localState != null && localState.revision == GroupsV2StateProcessor.RESTORE_PLACEHOLDER_REVISION) {
|
||||
info("Inserting single update message for restore placeholder");
|
||||
profileAndMessageHelper.insertUpdateMessages(timestamp, null, Collections.singleton(new LocalGroupLogEntry(finalState, null)), serverGuid);
|
||||
}
|
||||
|
||||
profileAndMessageHelper.persistLearnedProfileKeys(profileKeys);
|
||||
|
||||
if (performCdsLookup) {
|
||||
ApplicationDependencies.getJobManager().add(new DirectoryRefreshJob(false));
|
||||
}
|
||||
|
||||
if (finalGlobalGroupState.getServerHistory().size() > 0) {
|
||||
info(String.format(Locale.US, "There are more revisions on the server for this group, scheduling for later, V[%d..%d]", finalState.revision + 1, finalGlobalGroupState.getLatestRevisionNumber()));
|
||||
ApplicationDependencies.getJobManager().add(new RequestGroupV2InfoJob(groupId, finalGlobalGroupState.getLatestRevisionNumber()));
|
||||
}
|
||||
|
||||
return new GroupUpdateResult(GroupState.GROUP_UPDATED, finalState);
|
||||
}
|
||||
|
||||
private void insertGroupLeave() {
|
||||
if (!groupDatabase.isActive(groupId)) {
|
||||
warn("Group has already been left.");
|
||||
return;
|
||||
}
|
||||
|
||||
Recipient groupRecipient = Recipient.externalGroupExact(groupId);
|
||||
|
||||
DecryptedGroup decryptedGroup = groupDatabase.requireGroup(groupId)
|
||||
.requireV2GroupProperties()
|
||||
.getDecryptedGroup();
|
||||
|
||||
DecryptedGroup simulatedGroupState = DecryptedGroupUtil.removeMember(decryptedGroup, serviceIds.getAci(), decryptedGroup.revision + 1);
|
||||
|
||||
DecryptedGroupChange simulatedGroupChange = new DecryptedGroupChange.Builder()
|
||||
.editorServiceIdBytes(ACI.UNKNOWN.toByteString())
|
||||
.revision(simulatedGroupState.revision)
|
||||
.deleteMembers(Collections.singletonList(serviceIds.getAci().toByteString()))
|
||||
.build();
|
||||
|
||||
GV2UpdateDescription updateDescription = GroupProtoUtil.createOutgoingGroupV2UpdateDescription(masterKey, new GroupMutation(decryptedGroup, simulatedGroupChange, simulatedGroupState), null);
|
||||
OutgoingMessage leaveMessage = OutgoingMessage.groupUpdateMessage(groupRecipient, updateDescription, System.currentTimeMillis());
|
||||
|
||||
try {
|
||||
MessageTable mmsDatabase = SignalDatabase.messages();
|
||||
ThreadTable threadTable = SignalDatabase.threads();
|
||||
long threadId = threadTable.getOrCreateThreadIdFor(groupRecipient);
|
||||
long id = mmsDatabase.insertMessageOutbox(leaveMessage, threadId, false, null);
|
||||
mmsDatabase.markAsSent(id, true);
|
||||
threadTable.update(threadId, false, false);
|
||||
} catch (MmsException e) {
|
||||
warn( "Failed to insert leave message.", e);
|
||||
}
|
||||
|
||||
groupDatabase.setActive(groupId, false);
|
||||
groupDatabase.remove(groupId, Recipient.self().getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true iff group exists locally and is at least the specified revision.
|
||||
*/
|
||||
private boolean localIsAtLeast(Optional<GroupRecord> localRecord, int revision) {
|
||||
if (revision == LATEST || localRecord.isEmpty() || groupDatabase.isUnknownGroup(localRecord)) {
|
||||
return false;
|
||||
}
|
||||
int dbRevision = localRecord.get().requireV2GroupProperties().getGroupRevision();
|
||||
return revision <= dbRevision;
|
||||
}
|
||||
|
||||
private void updateLocalDatabaseGroupState(@NonNull GlobalGroupState inputGroupState,
|
||||
@NonNull DecryptedGroup newLocalState)
|
||||
{
|
||||
boolean needsAvatarFetch;
|
||||
|
||||
if (inputGroupState.getLocalState() == null) {
|
||||
GroupId.V2 groupId = groupDatabase.create(masterKey, newLocalState);
|
||||
if (groupId == null) {
|
||||
Log.w(TAG, "Group create failed, trying to update");
|
||||
groupDatabase.update(masterKey, newLocalState);
|
||||
}
|
||||
needsAvatarFetch = !TextUtils.isEmpty(newLocalState.avatar);
|
||||
} else {
|
||||
groupDatabase.update(masterKey, newLocalState);
|
||||
needsAvatarFetch = !newLocalState.avatar.equals(inputGroupState.getLocalState().avatar);
|
||||
}
|
||||
|
||||
if (needsAvatarFetch) {
|
||||
ApplicationDependencies.getJobManager().add(new AvatarGroupsV2DownloadJob(groupId, newLocalState.avatar));
|
||||
}
|
||||
|
||||
profileAndMessageHelper.determineProfileSharing(inputGroupState, newLocalState);
|
||||
}
|
||||
|
||||
private GlobalGroupState getFullMemberHistoryPage(DecryptedGroup localState, int logsNeededFromRevision, boolean includeFirstState) throws IOException {
|
||||
try {
|
||||
GroupHistoryPage groupHistoryPage = groupsV2Api.getGroupHistoryPage(groupSecretParams, logsNeededFromRevision, groupsV2Authorization.getAuthorizationForToday(serviceIds, groupSecretParams), includeFirstState);
|
||||
ArrayList<ServerGroupLogEntry> history = new ArrayList<>(groupHistoryPage.getResults().size());
|
||||
boolean ignoreServerChanges = SignalStore.internalValues().gv2IgnoreServerChanges();
|
||||
|
||||
if (ignoreServerChanges) {
|
||||
warn( "Server change logs are ignored by setting");
|
||||
}
|
||||
|
||||
for (DecryptedGroupHistoryEntry entry : groupHistoryPage.getResults()) {
|
||||
DecryptedGroup group = entry.getGroup().orElse(null);
|
||||
DecryptedGroupChange change = ignoreServerChanges ? null : entry.getChange().orElse(null);
|
||||
|
||||
if (group != null || change != null) {
|
||||
history.add(new ServerGroupLogEntry(group, change));
|
||||
}
|
||||
}
|
||||
|
||||
return new GlobalGroupState(localState, history, groupHistoryPage.getPagingData());
|
||||
} catch (InvalidGroupStateException | VerificationFailedException e) {
|
||||
throw new IOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private void info(String message) {
|
||||
Log.i(TAG, "[" + groupId.toString() + "] " + message);
|
||||
}
|
||||
|
||||
private void warn(String message) {
|
||||
warn(message, null);
|
||||
}
|
||||
|
||||
private void warn(String message, Throwable e) {
|
||||
Log.w(TAG, "[" + groupId.toString() + "] " + message, e);
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
static class ProfileAndMessageHelper {
|
||||
|
||||
private final ACI aci;
|
||||
private final GroupId.V2 groupId;
|
||||
private final RecipientTable recipientTable;
|
||||
|
||||
@VisibleForTesting
|
||||
GroupMasterKey masterKey;
|
||||
|
||||
ProfileAndMessageHelper(@NonNull ACI aci, @NonNull GroupMasterKey masterKey, @NonNull GroupId.V2 groupId, @NonNull RecipientTable recipientTable) {
|
||||
this.aci = aci;
|
||||
this.masterKey = masterKey;
|
||||
this.groupId = groupId;
|
||||
this.recipientTable = recipientTable;
|
||||
}
|
||||
|
||||
void determineProfileSharing(@NonNull GlobalGroupState inputGroupState, @NonNull DecryptedGroup newLocalState) {
|
||||
if (inputGroupState.getLocalState() != null) {
|
||||
boolean wasAMemberAlready = DecryptedGroupUtil.findMemberByAci(inputGroupState.getLocalState().members, aci).isPresent();
|
||||
|
||||
if (wasAMemberAlready) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Optional<DecryptedMember> selfAsMemberOptional = DecryptedGroupUtil.findMemberByAci(newLocalState.members, aci);
|
||||
Optional<DecryptedPendingMember> selfAsPendingOptional = DecryptedGroupUtil.findPendingByServiceId(newLocalState.pendingMembers, aci);
|
||||
|
||||
if (selfAsMemberOptional.isPresent()) {
|
||||
DecryptedMember selfAsMember = selfAsMemberOptional.get();
|
||||
int revisionJoinedAt = selfAsMember.joinedAtRevision;
|
||||
|
||||
Optional<Recipient> addedByOptional = inputGroupState.getServerHistory()
|
||||
.stream()
|
||||
.map(ServerGroupLogEntry::getChange)
|
||||
.filter(c -> c != null && c.revision == revisionJoinedAt)
|
||||
.findFirst()
|
||||
.flatMap(c -> Optional.ofNullable(ServiceId.parseOrNull(c.editorServiceIdBytes))
|
||||
.map(Recipient::externalPush));
|
||||
|
||||
if (addedByOptional.isPresent()) {
|
||||
Recipient addedBy = addedByOptional.get();
|
||||
|
||||
Log.i(TAG, String.format("Added as a full member of %s by %s", groupId, addedBy.getId()));
|
||||
|
||||
if (addedBy.isBlocked() && (inputGroupState.getLocalState() == null || !DecryptedGroupUtil.isRequesting(inputGroupState.getLocalState(), aci))) {
|
||||
Log.i(TAG, "Added by a blocked user. Leaving group.");
|
||||
ApplicationDependencies.getJobManager().add(new LeaveGroupV2Job(groupId));
|
||||
//noinspection UnnecessaryReturnStatement
|
||||
return;
|
||||
} else if ((addedBy.isSystemContact() || addedBy.isProfileSharing()) && !addedBy.isHidden()) {
|
||||
Log.i(TAG, "Group 'adder' is trusted. contact: " + addedBy.isSystemContact() + ", profileSharing: " + addedBy.isProfileSharing());
|
||||
Log.i(TAG, "Added to a group and auto-enabling profile sharing");
|
||||
recipientTable.setProfileSharing(Recipient.externalGroupExact(groupId).getId(), true);
|
||||
} else {
|
||||
Log.i(TAG, "Added to a group, but not enabling profile sharing, as 'adder' is not trusted");
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "Could not find founding member during gv2 create. Not enabling profile sharing.");
|
||||
}
|
||||
} else if (selfAsPendingOptional.isPresent()) {
|
||||
Optional<Recipient> addedBy = selfAsPendingOptional.flatMap(adder -> Optional.ofNullable(UuidUtil.fromByteStringOrNull(adder.addedByAci))
|
||||
.map(uuid -> Recipient.externalPush(ACI.from(uuid))));
|
||||
|
||||
if (addedBy.isPresent() && addedBy.get().isBlocked()) {
|
||||
Log.i(TAG, String.format("Added to group %s by a blocked user %s. Leaving group.", groupId, addedBy.get().getId()));
|
||||
ApplicationDependencies.getJobManager().add(new LeaveGroupV2Job(groupId));
|
||||
//noinspection UnnecessaryReturnStatement
|
||||
return;
|
||||
} else {
|
||||
Log.i(TAG, String.format("Added to %s, but not enabling profile sharing as we are a pending member.", groupId));
|
||||
}
|
||||
} else {
|
||||
Log.i(TAG, String.format("Added to %s, but not enabling profile sharing as not a fullMember.", groupId));
|
||||
}
|
||||
}
|
||||
|
||||
long insertUpdateMessages(long timestamp,
|
||||
@Nullable DecryptedGroup previousGroupState,
|
||||
Collection<LocalGroupLogEntry> processedLogEntries,
|
||||
@Nullable String serverGuid)
|
||||
{
|
||||
for (LocalGroupLogEntry entry : processedLogEntries) {
|
||||
if (entry.getChange() != null && DecryptedGroupUtil.changeIsEmptyExceptForProfileKeyChanges(entry.getChange()) && !DecryptedGroupUtil.changeIsEmpty(entry.getChange())) {
|
||||
Log.d(TAG, "Skipping profile key changes only update message");
|
||||
} else if (entry.getChange() != null && DecryptedGroupUtil.changeIsEmptyExceptForBanChangesAndOptionalProfileKeyChanges(entry.getChange())) {
|
||||
Log.d(TAG, "Skipping ban changes only update message");
|
||||
} else {
|
||||
if (entry.getChange() != null && DecryptedGroupUtil.changeIsEmpty(entry.getChange()) && previousGroupState != null) {
|
||||
Log.w(TAG, "Empty group update message seen. Not inserting.");
|
||||
} else {
|
||||
storeMessage(GroupProtoUtil.createDecryptedGroupV2Context(masterKey, new GroupMutation(previousGroupState, entry.getChange(), entry.getGroup()), null), timestamp, serverGuid);
|
||||
timestamp++;
|
||||
}
|
||||
}
|
||||
previousGroupState = entry.getGroup();
|
||||
}
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
void persistLearnedProfileKeys(@NonNull GlobalGroupState globalGroupState) {
|
||||
final ProfileKeySet profileKeys = new ProfileKeySet();
|
||||
|
||||
for (ServerGroupLogEntry entry : globalGroupState.getServerHistory()) {
|
||||
if (entry.getGroup() != null) {
|
||||
profileKeys.addKeysFromGroupState(entry.getGroup());
|
||||
}
|
||||
if (entry.getChange() != null) {
|
||||
profileKeys.addKeysFromGroupChange(entry.getChange());
|
||||
}
|
||||
}
|
||||
|
||||
persistLearnedProfileKeys(profileKeys);
|
||||
}
|
||||
|
||||
void persistLearnedProfileKeys(@NonNull ProfileKeySet profileKeys) {
|
||||
Set<RecipientId> updated = recipientTable.persistProfileKeySet(profileKeys);
|
||||
|
||||
if (!updated.isEmpty()) {
|
||||
Log.i(TAG, String.format(Locale.US, "Learned %d new profile keys, fetching profiles", updated.size()));
|
||||
|
||||
for (Job job : RetrieveProfileJob.forRecipients(updated)) {
|
||||
ApplicationDependencies.getJobManager().runSynchronously(job, 5000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void storeMessage(@NonNull DecryptedGroupV2Context decryptedGroupV2Context, long timestamp, @Nullable String serverGuid) {
|
||||
Optional<ServiceId> editor = getEditor(decryptedGroupV2Context);
|
||||
|
||||
boolean outgoing = editor.isEmpty() || aci.equals(editor.get());
|
||||
|
||||
GV2UpdateDescription updateDescription = new GV2UpdateDescription.Builder()
|
||||
.gv2ChangeDescription(decryptedGroupV2Context)
|
||||
.groupChangeUpdate(GroupsV2UpdateMessageConverter.translateDecryptedChange(SignalStore.account().getServiceIds(), decryptedGroupV2Context))
|
||||
.build();
|
||||
|
||||
if (outgoing) {
|
||||
try {
|
||||
MessageTable mmsDatabase = SignalDatabase.messages();
|
||||
ThreadTable threadTable = SignalDatabase.threads();
|
||||
RecipientId recipientId = recipientTable.getOrInsertFromGroupId(groupId);
|
||||
Recipient recipient = Recipient.resolved(recipientId);
|
||||
OutgoingMessage outgoingMessage = OutgoingMessage.groupUpdateMessage(recipient, updateDescription, timestamp);
|
||||
long threadId = threadTable.getOrCreateThreadIdFor(recipient);
|
||||
long messageId = mmsDatabase.insertMessageOutbox(outgoingMessage, threadId, false, null);
|
||||
|
||||
mmsDatabase.markAsSent(messageId, true);
|
||||
threadTable.update(threadId, false, false);
|
||||
} catch (MmsException e) {
|
||||
Log.w(TAG, "Failed to insert outgoing update message!", e);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
MessageTable smsDatabase = SignalDatabase.messages();
|
||||
RecipientId sender = RecipientId.from(editor.get());
|
||||
IncomingMessage groupMessage = IncomingMessage.groupUpdate(sender, timestamp, groupId, decryptedGroupV2Context, serverGuid);
|
||||
Optional<MessageTable.InsertResult> insertResult = smsDatabase.insertMessageInbox(groupMessage);
|
||||
|
||||
if (insertResult.isPresent()) {
|
||||
SignalDatabase.threads().update(insertResult.get().getThreadId(), false, false);
|
||||
} else {
|
||||
Log.w(TAG, "Could not insert update message");
|
||||
}
|
||||
} catch (MmsException e) {
|
||||
Log.w(TAG, "Failed to insert incoming update message!", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Optional<ServiceId> getEditor(@NonNull DecryptedGroupV2Context decryptedGroupV2Context) {
|
||||
DecryptedGroupChange change = decryptedGroupV2Context.change;
|
||||
Optional<ServiceId> changeEditor = DecryptedGroupUtil.editorServiceId(change);
|
||||
if (changeEditor.isPresent()) {
|
||||
return changeEditor;
|
||||
} else {
|
||||
Optional<DecryptedPendingMember> pending = DecryptedGroupUtil.findPendingByServiceId(decryptedGroupV2Context.groupState.pendingMembers, aci);
|
||||
if (pending.isPresent()) {
|
||||
return Optional.ofNullable(ACI.parseOrNull(pending.get().addedByAci));
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,714 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.groups.v2.processing
|
||||
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.annotation.WorkerThread
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.orNull
|
||||
import org.signal.libsignal.zkgroup.VerificationFailedException
|
||||
import org.signal.libsignal.zkgroup.groups.GroupMasterKey
|
||||
import org.signal.libsignal.zkgroup.groups.GroupSecretParams
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroup
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroupChange
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.GroupRecord
|
||||
import org.thoughtcrime.securesms.database.model.GroupsV2UpdateMessageConverter.translateDecryptedChange
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.GV2UpdateDescription
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.groups.GroupMutation
|
||||
import org.thoughtcrime.securesms.groups.GroupNotAMemberException
|
||||
import org.thoughtcrime.securesms.groups.GroupProtoUtil
|
||||
import org.thoughtcrime.securesms.groups.v2.ProfileKeySet
|
||||
import org.thoughtcrime.securesms.groups.v2.processing.GroupsV2StateProcessor.Companion.LATEST
|
||||
import org.thoughtcrime.securesms.jobs.AvatarGroupsV2DownloadJob
|
||||
import org.thoughtcrime.securesms.jobs.DirectoryRefreshJob
|
||||
import org.thoughtcrime.securesms.jobs.LeaveGroupV2Job
|
||||
import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob
|
||||
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.mms.IncomingMessage
|
||||
import org.thoughtcrime.securesms.mms.MmsException
|
||||
import org.thoughtcrime.securesms.mms.OutgoingMessage
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.whispersystems.signalservice.api.NetworkResult
|
||||
import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil
|
||||
import org.whispersystems.signalservice.api.groupsv2.GroupChangeReconstruct
|
||||
import org.whispersystems.signalservice.api.groupsv2.GroupHistoryPage
|
||||
import org.whispersystems.signalservice.api.groupsv2.InvalidGroupStateException
|
||||
import org.whispersystems.signalservice.api.groupsv2.NotAbleToApplyGroupV2ChangeException
|
||||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.ACI
|
||||
import org.whispersystems.signalservice.api.push.ServiceIds
|
||||
import org.whispersystems.signalservice.api.util.UuidUtil
|
||||
import org.whispersystems.signalservice.internal.push.exceptions.GroupNotFoundException
|
||||
import org.whispersystems.signalservice.internal.push.exceptions.NotInGroupException
|
||||
import java.io.IOException
|
||||
import java.util.Optional
|
||||
import kotlin.math.max
|
||||
|
||||
/**
|
||||
* Given a group, provide various state operations to update from the current local revision to a
|
||||
* specific number or [LATEST] revision via a P2P update and/or by fetching changes from the server.
|
||||
*/
|
||||
class GroupsV2StateProcessor private constructor(
|
||||
private val serviceIds: ServiceIds,
|
||||
private val groupMasterKey: GroupMasterKey,
|
||||
private val groupSecretParams: GroupSecretParams
|
||||
) {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(GroupsV2StateProcessor::class.java)
|
||||
|
||||
const val LATEST = GroupStatePatcher.LATEST
|
||||
|
||||
/**
|
||||
* Used to mark a group state as a placeholder when there is partial knowledge (title and avater)
|
||||
* gathered from a group join link.
|
||||
*/
|
||||
const val PLACEHOLDER_REVISION = GroupStatePatcher.PLACEHOLDER_REVISION
|
||||
|
||||
/**
|
||||
* Used to mark a group state as a placeholder when you have no knowledge at all of the group
|
||||
* e.g. from a group master key from a storage service restore.
|
||||
*/
|
||||
const val RESTORE_PLACEHOLDER_REVISION = GroupStatePatcher.RESTORE_PLACEHOLDER_REVISION
|
||||
|
||||
@JvmStatic
|
||||
@JvmOverloads
|
||||
fun forGroup(
|
||||
serviceIds: ServiceIds,
|
||||
groupMasterKey: GroupMasterKey,
|
||||
groupSecretParams: GroupSecretParams? = null
|
||||
): GroupsV2StateProcessor {
|
||||
return GroupsV2StateProcessor(
|
||||
serviceIds = serviceIds,
|
||||
groupMasterKey = groupMasterKey,
|
||||
groupSecretParams = groupSecretParams ?: GroupSecretParams.deriveFromMasterKey(groupMasterKey)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val groupsApi = ApplicationDependencies.getSignalServiceAccountManager().getGroupsV2Api()
|
||||
private val groupsV2Authorization = ApplicationDependencies.getGroupsV2Authorization()
|
||||
private val groupId = GroupId.v2(groupSecretParams.getPublicParams().getGroupIdentifier())
|
||||
private val profileAndMessageHelper = ProfileAndMessageHelper.create(serviceIds.getAci(), groupMasterKey, groupId)
|
||||
|
||||
private val logPrefix = "[$groupId]"
|
||||
|
||||
/**
|
||||
* Forces the local state to be updated directly to the latest available from the server without
|
||||
* processing individual changes and instead inserting one large change to go from local to server.
|
||||
*/
|
||||
@WorkerThread
|
||||
@Throws(IOException::class, GroupNotAMemberException::class)
|
||||
fun forceSanityUpdateFromServer(timestamp: Long): GroupUpdateResult {
|
||||
val currentLocalState: DecryptedGroup? = SignalDatabase.groups.getGroup(groupId).map { it.requireV2GroupProperties().decryptedGroup }.orNull()
|
||||
|
||||
if (currentLocalState == null) {
|
||||
Log.i(TAG, "$logPrefix No local state to force update")
|
||||
return GroupUpdateResult.CONSISTENT_OR_AHEAD
|
||||
}
|
||||
|
||||
return when (val result = updateToLatestViaServer(timestamp, currentLocalState, reconstructChange = true)) {
|
||||
InternalUpdateResult.NoUpdateNeeded -> GroupUpdateResult.CONSISTENT_OR_AHEAD
|
||||
is InternalUpdateResult.Updated -> GroupUpdateResult(GroupUpdateResult.UpdateStatus.GROUP_UPDATED, result.updatedLocalState)
|
||||
is InternalUpdateResult.NotAMember -> throw result.exception
|
||||
is InternalUpdateResult.UpdateFailed -> throw result.throwable
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Using network where required, will attempt to bring the local copy of the group up to the revision specified.
|
||||
*
|
||||
* @param targetRevision use [.LATEST] to get latest.
|
||||
*/
|
||||
@JvmOverloads
|
||||
@WorkerThread
|
||||
@Throws(IOException::class, GroupNotAMemberException::class)
|
||||
fun updateLocalGroupToRevision(
|
||||
targetRevision: Int,
|
||||
timestamp: Long,
|
||||
signedGroupChange: DecryptedGroupChange? = null,
|
||||
groupRecord: Optional<GroupRecord> = SignalDatabase.groups.getGroup(groupId),
|
||||
serverGuid: String? = null
|
||||
): GroupUpdateResult {
|
||||
if (localIsAtLeast(groupRecord, targetRevision)) {
|
||||
return GroupUpdateResult(GroupUpdateResult.UpdateStatus.GROUP_CONSISTENT_OR_AHEAD, null)
|
||||
}
|
||||
|
||||
val currentLocalState: DecryptedGroup? = groupRecord.map { it.requireV2GroupProperties().decryptedGroup }.orNull()
|
||||
|
||||
if (signedGroupChange != null && canApplyP2pChange(targetRevision, signedGroupChange, currentLocalState, groupRecord)) {
|
||||
when (val p2pUpdateResult = updateViaPeerGroupChange(timestamp, serverGuid, signedGroupChange, currentLocalState!!, forceApply = false)) {
|
||||
InternalUpdateResult.NoUpdateNeeded -> return GroupUpdateResult.CONSISTENT_OR_AHEAD
|
||||
is InternalUpdateResult.Updated -> return GroupUpdateResult.updated(p2pUpdateResult.updatedLocalState)
|
||||
else -> Log.w(TAG, "$logPrefix P2P update was not successfully processed, falling back to server update")
|
||||
}
|
||||
}
|
||||
|
||||
val serverUpdateResult = updateViaServer(targetRevision, timestamp, serverGuid, groupRecord)
|
||||
|
||||
when (serverUpdateResult) {
|
||||
InternalUpdateResult.NoUpdateNeeded -> return GroupUpdateResult.CONSISTENT_OR_AHEAD
|
||||
is InternalUpdateResult.Updated -> return GroupUpdateResult.updated(serverUpdateResult.updatedLocalState)
|
||||
is InternalUpdateResult.UpdateFailed -> throw serverUpdateResult.throwable
|
||||
is InternalUpdateResult.NotAMember -> Unit
|
||||
}
|
||||
|
||||
Log.w(TAG, "$logPrefix Unable to query server for group, says we're not in group, trying to resolve locally")
|
||||
|
||||
if (currentLocalState != null && signedGroupChange != null) {
|
||||
if (notInGroupAndNotBeingAdded(groupRecord, signedGroupChange)) {
|
||||
Log.w(TAG, "$logPrefix Server says we're not a member. Ignoring P2P group change because we're not currently in the group and this change doesn't add us in.")
|
||||
} else {
|
||||
Log.i(TAG, "$logPrefix Server says we're not a member. Force applying P2P group change.")
|
||||
when (val forcedP2pUpdateResult = updateViaPeerGroupChange(timestamp, serverGuid, signedGroupChange, currentLocalState, forceApply = true)) {
|
||||
is InternalUpdateResult.Updated -> return GroupUpdateResult.updated(forcedP2pUpdateResult.updatedLocalState)
|
||||
InternalUpdateResult.NoUpdateNeeded -> return GroupUpdateResult.CONSISTENT_OR_AHEAD
|
||||
is InternalUpdateResult.NotAMember, is InternalUpdateResult.UpdateFailed -> Log.w(TAG, "$logPrefix Unable to apply P2P group change when not a member: $forcedP2pUpdateResult")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentLocalState != null && DecryptedGroupUtil.isPendingOrRequesting(currentLocalState, serviceIds)) {
|
||||
Log.w(TAG, "$logPrefix Unable to query server for group. Server says we're not in group, but we think we are a pending or requesting member")
|
||||
} else {
|
||||
Log.w(TAG, "$logPrefix Unable to query server for group $groupId server says we're not in group, we agree, inserting leave message")
|
||||
profileAndMessageHelper.leaveGroupLocally(serviceIds)
|
||||
}
|
||||
|
||||
throw GroupNotAMemberException(serverUpdateResult.exception)
|
||||
}
|
||||
|
||||
private fun canApplyP2pChange(
|
||||
targetRevision: Int,
|
||||
signedGroupChange: DecryptedGroupChange,
|
||||
currentLocalState: DecryptedGroup?,
|
||||
groupRecord: Optional<GroupRecord>
|
||||
): Boolean {
|
||||
if (SignalStore.internalValues().gv2IgnoreP2PChanges()) {
|
||||
Log.w(TAG, "$logPrefix Ignoring P2P group change by setting")
|
||||
return false
|
||||
}
|
||||
|
||||
if (currentLocalState == null || currentLocalState.revision + 1 != signedGroupChange.revision || targetRevision != signedGroupChange.revision) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (notInGroupAndNotBeingAdded(groupRecord, signedGroupChange) && notHavingInviteRevoked(signedGroupChange)) {
|
||||
Log.w(TAG, "$logPrefix Ignoring P2P group change because we're not currently in the group and this change doesn't add us in.")
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private fun updateViaPeerGroupChange(
|
||||
timestamp: Long,
|
||||
serverGuid: String?,
|
||||
signedGroupChange: DecryptedGroupChange,
|
||||
currentLocalState: DecryptedGroup,
|
||||
forceApply: Boolean
|
||||
): InternalUpdateResult {
|
||||
val updatedGroupState = try {
|
||||
if (forceApply) {
|
||||
DecryptedGroupUtil.applyWithoutRevisionCheck(currentLocalState, signedGroupChange)
|
||||
} else {
|
||||
DecryptedGroupUtil.apply(currentLocalState, signedGroupChange)
|
||||
}
|
||||
} catch (e: NotAbleToApplyGroupV2ChangeException) {
|
||||
Log.w(TAG, "$logPrefix Unable to apply P2P group change", e)
|
||||
return InternalUpdateResult.UpdateFailed(e)
|
||||
}
|
||||
|
||||
val groupStateDiff = GroupStateDiff(currentLocalState, updatedGroupState, signedGroupChange)
|
||||
|
||||
return saveGroupUpdate(
|
||||
timestamp = timestamp,
|
||||
serverGuid = serverGuid,
|
||||
groupStateDiff = groupStateDiff
|
||||
)
|
||||
}
|
||||
|
||||
private fun updateViaServer(
|
||||
targetRevision: Int,
|
||||
timestamp: Long,
|
||||
serverGuid: String?,
|
||||
groupRecord: Optional<GroupRecord> = SignalDatabase.groups.getGroup(groupId)
|
||||
): InternalUpdateResult {
|
||||
var currentLocalState: DecryptedGroup? = groupRecord.map { it.requireV2GroupProperties().decryptedGroup }.orNull()
|
||||
|
||||
val latestRevisionOnly = targetRevision == LATEST && (currentLocalState == null || currentLocalState.revision == RESTORE_PLACEHOLDER_REVISION)
|
||||
|
||||
if (latestRevisionOnly) {
|
||||
Log.i(TAG, "$logPrefix Latest revision or not a member, update to latest directly")
|
||||
return updateToLatestViaServer(timestamp, currentLocalState, reconstructChange = false)
|
||||
}
|
||||
|
||||
Log.i(TAG, "$logPrefix Paging from server targetRevision: ${if (targetRevision == LATEST) "latest" else targetRevision}")
|
||||
|
||||
val joinedAtRevision = when (val result = groupsApi.getGroupJoinedAt(groupsV2Authorization.getAuthorizationForToday(serviceIds, groupSecretParams))) {
|
||||
is NetworkResult.Success -> result.result
|
||||
else -> return InternalUpdateResult.from(result.getCause()!!)
|
||||
}
|
||||
|
||||
var includeFirstState = currentLocalState == null ||
|
||||
currentLocalState.revision < 0 ||
|
||||
currentLocalState.revision == joinedAtRevision ||
|
||||
!GroupProtoUtil.isMember(serviceIds.aci, currentLocalState.members)
|
||||
|
||||
val profileKeys = ProfileKeySet()
|
||||
val addMessagesForAllUpdates = currentLocalState == null || currentLocalState.revision != RESTORE_PLACEHOLDER_REVISION
|
||||
var logsNeededFrom = if (currentLocalState != null) max(currentLocalState.revision, joinedAtRevision) else joinedAtRevision
|
||||
var hasMore = true
|
||||
var runningTimestamp = timestamp
|
||||
var performCdsLookup = false
|
||||
var hasRemainingRemoteChanges = false
|
||||
|
||||
while (hasMore) {
|
||||
Log.i(TAG, "$logPrefix Requesting change logs from server, currentRevision=${currentLocalState?.revision ?: "null"} logsNeededFrom=$logsNeededFrom includeFirstState=$includeFirstState")
|
||||
|
||||
val (remoteGroupStateDiff, pagingData) = getGroupChangeLogs(currentLocalState, logsNeededFrom, includeFirstState)
|
||||
val applyGroupStateDiffResult: AdvanceGroupStateResult = GroupStatePatcher.applyGroupStateDiff(remoteGroupStateDiff, targetRevision)
|
||||
val updatedGroupState: DecryptedGroup? = applyGroupStateDiffResult.updatedGroupState
|
||||
|
||||
if (updatedGroupState == null || updatedGroupState == remoteGroupStateDiff.previousGroupState) {
|
||||
Log.i(TAG, "$logPrefix Local state is at or later than server revision: ${currentLocalState?.revision ?: "null"}")
|
||||
return InternalUpdateResult.NoUpdateNeeded
|
||||
}
|
||||
|
||||
Log.i(TAG, "$logPrefix Saving updated group state at revision: ${updatedGroupState.revision}")
|
||||
|
||||
saveGroupState(remoteGroupStateDiff, updatedGroupState)
|
||||
|
||||
if (addMessagesForAllUpdates) {
|
||||
Log.d(TAG, "$logPrefix Inserting group changes into chat history")
|
||||
runningTimestamp = profileAndMessageHelper.insertUpdateMessages(runningTimestamp, currentLocalState, applyGroupStateDiffResult.processedLogEntries, serverGuid)
|
||||
}
|
||||
|
||||
remoteGroupStateDiff
|
||||
.serverHistory
|
||||
.forEach { entry ->
|
||||
entry.group?.let { profileKeys.addKeysFromGroupState(it) }
|
||||
|
||||
entry.change?.let { change ->
|
||||
profileKeys.addKeysFromGroupChange(change)
|
||||
if (change.promotePendingPniAciMembers.isNotEmpty()) {
|
||||
performCdsLookup = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
currentLocalState = updatedGroupState
|
||||
hasMore = pagingData.hasMorePages
|
||||
|
||||
if (hasMore) {
|
||||
includeFirstState = false
|
||||
logsNeededFrom = pagingData.nextPageRevision
|
||||
} else {
|
||||
if (applyGroupStateDiffResult.remainingRemoteGroupChanges.isNotEmpty()) {
|
||||
hasRemainingRemoteChanges = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!addMessagesForAllUpdates) {
|
||||
Log.i(TAG, "Inserting single update message for restore placeholder")
|
||||
profileAndMessageHelper.insertUpdateMessages(runningTimestamp, null, setOf(AppliedGroupChangeLog(currentLocalState!!, null)), serverGuid)
|
||||
}
|
||||
|
||||
profileAndMessageHelper.persistLearnedProfileKeys(profileKeys)
|
||||
|
||||
if (performCdsLookup) {
|
||||
ApplicationDependencies.getJobManager().add(DirectoryRefreshJob(false))
|
||||
}
|
||||
|
||||
if (hasRemainingRemoteChanges) {
|
||||
Log.i(TAG, "$logPrefix There are more revisions on the server for this group, scheduling for later")
|
||||
ApplicationDependencies.getJobManager().add(RequestGroupV2InfoJob(groupId))
|
||||
}
|
||||
|
||||
return InternalUpdateResult.Updated(currentLocalState!!)
|
||||
}
|
||||
|
||||
private fun updateToLatestViaServer(timestamp: Long, currentLocalState: DecryptedGroup?, reconstructChange: Boolean): InternalUpdateResult {
|
||||
val result = groupsApi.getGroupAsResult(groupSecretParams, groupsV2Authorization.getAuthorizationForToday(serviceIds, groupSecretParams))
|
||||
|
||||
val serverState = if (result is NetworkResult.Success) {
|
||||
result.result
|
||||
} else {
|
||||
return InternalUpdateResult.from(result.getCause()!!)
|
||||
}
|
||||
|
||||
val completeGroupChange = if (reconstructChange) GroupChangeReconstruct.reconstructGroupChange(currentLocalState, serverState) else null
|
||||
val remoteGroupStateDiff = GroupStateDiff(currentLocalState, serverState, completeGroupChange)
|
||||
|
||||
return saveGroupUpdate(
|
||||
timestamp = timestamp,
|
||||
serverGuid = null,
|
||||
groupStateDiff = remoteGroupStateDiff
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true iff group exists locally and is at least the specified revision.
|
||||
*/
|
||||
private fun localIsAtLeast(groupRecord: Optional<GroupRecord>, revision: Int): Boolean {
|
||||
if (revision == LATEST || groupRecord.isEmpty || SignalDatabase.groups.isUnknownGroup(groupRecord)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return revision <= groupRecord.get().requireV2GroupProperties().groupRevision
|
||||
}
|
||||
|
||||
private fun notInGroupAndNotBeingAdded(groupRecord: Optional<GroupRecord>, signedGroupChange: DecryptedGroupChange): Boolean {
|
||||
val currentlyInGroup = groupRecord.isPresent && groupRecord.get().isActive
|
||||
|
||||
val addedAsMember = signedGroupChange
|
||||
.newMembers
|
||||
.asSequence()
|
||||
.mapNotNull { ACI.parseOrNull(it.aciBytes) }
|
||||
.any { serviceIds.matches(it) }
|
||||
|
||||
val addedAsPendingMember = signedGroupChange
|
||||
.newPendingMembers
|
||||
.asSequence()
|
||||
.map { it.serviceIdBytes }
|
||||
.any { serviceIds.matches(it) }
|
||||
|
||||
val addedAsRequestingMember = signedGroupChange
|
||||
.newRequestingMembers
|
||||
.asSequence()
|
||||
.mapNotNull { ACI.parseOrNull(it.aciBytes) }
|
||||
.any { serviceIds.matches(it) }
|
||||
|
||||
return !currentlyInGroup && !addedAsMember && !addedAsPendingMember && !addedAsRequestingMember
|
||||
}
|
||||
|
||||
private fun notHavingInviteRevoked(signedGroupChange: DecryptedGroupChange): Boolean {
|
||||
val havingInviteRevoked = signedGroupChange
|
||||
.deletePendingMembers
|
||||
.asSequence()
|
||||
.map { it.serviceIdBytes }
|
||||
.any { serviceIds.matches(it) }
|
||||
|
||||
return !havingInviteRevoked
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
private fun getGroupChangeLogs(localState: DecryptedGroup?, logsNeededFromRevision: Int, includeFirstState: Boolean): Pair<GroupStateDiff, GroupHistoryPage.PagingData> {
|
||||
try {
|
||||
val groupHistoryPage = groupsApi.getGroupHistoryPage(groupSecretParams, logsNeededFromRevision, groupsV2Authorization.getAuthorizationForToday(serviceIds, groupSecretParams), includeFirstState)
|
||||
|
||||
return GroupStateDiff(localState, groupHistoryPage.changeLogs) to groupHistoryPage.pagingData
|
||||
} catch (e: InvalidGroupStateException) {
|
||||
throw IOException(e)
|
||||
} catch (e: VerificationFailedException) {
|
||||
throw IOException(e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveGroupUpdate(
|
||||
timestamp: Long,
|
||||
serverGuid: String?,
|
||||
groupStateDiff: GroupStateDiff
|
||||
): InternalUpdateResult {
|
||||
val currentLocalState: DecryptedGroup? = groupStateDiff.previousGroupState
|
||||
val applyGroupStateDiffResult = GroupStatePatcher.applyGroupStateDiff(groupStateDiff, GroupStatePatcher.LATEST)
|
||||
val updatedGroupState = applyGroupStateDiffResult.updatedGroupState
|
||||
|
||||
if (updatedGroupState == null || updatedGroupState == groupStateDiff.previousGroupState) {
|
||||
Log.i(TAG, "$logPrefix Local state and server state are equal")
|
||||
return InternalUpdateResult.NoUpdateNeeded
|
||||
} else {
|
||||
Log.i(TAG, "$logPrefix Local state (revision: ${currentLocalState?.revision}) does not match, updating to ${updatedGroupState.revision}")
|
||||
}
|
||||
|
||||
saveGroupState(groupStateDiff, updatedGroupState)
|
||||
|
||||
if (currentLocalState == null || currentLocalState.revision == RESTORE_PLACEHOLDER_REVISION) {
|
||||
Log.i(TAG, "$logPrefix Inserting single update message for no local state or restore placeholder")
|
||||
profileAndMessageHelper.insertUpdateMessages(timestamp, null, setOf(AppliedGroupChangeLog(updatedGroupState, null)), null)
|
||||
} else {
|
||||
profileAndMessageHelper.insertUpdateMessages(timestamp, currentLocalState, applyGroupStateDiffResult.processedLogEntries, serverGuid)
|
||||
}
|
||||
profileAndMessageHelper.persistLearnedProfileKeys(groupStateDiff)
|
||||
|
||||
val performCdsLookup = groupStateDiff
|
||||
.serverHistory
|
||||
.mapNotNull { it.change }
|
||||
.any { it.promotePendingPniAciMembers.isNotEmpty() }
|
||||
|
||||
if (performCdsLookup) {
|
||||
ApplicationDependencies.getJobManager().add(DirectoryRefreshJob(false))
|
||||
}
|
||||
|
||||
return InternalUpdateResult.Updated(updatedGroupState)
|
||||
}
|
||||
|
||||
private fun saveGroupState(groupStateDiff: GroupStateDiff, updatedGroupState: DecryptedGroup) {
|
||||
val previousGroupState = groupStateDiff.previousGroupState
|
||||
|
||||
val needsAvatarFetch = if (previousGroupState == null) {
|
||||
val groupId = SignalDatabase.groups.create(groupMasterKey, updatedGroupState)
|
||||
|
||||
if (groupId == null) {
|
||||
Log.w(TAG, "$logPrefix Group create failed, trying to update")
|
||||
SignalDatabase.groups.update(groupMasterKey, updatedGroupState)
|
||||
}
|
||||
|
||||
updatedGroupState.avatar.isNotEmpty()
|
||||
} else {
|
||||
SignalDatabase.groups.update(groupMasterKey, updatedGroupState)
|
||||
|
||||
updatedGroupState.avatar != previousGroupState.avatar
|
||||
}
|
||||
|
||||
if (needsAvatarFetch) {
|
||||
ApplicationDependencies.getJobManager().add(AvatarGroupsV2DownloadJob(groupId, updatedGroupState.avatar))
|
||||
}
|
||||
|
||||
profileAndMessageHelper.setProfileSharing(groupStateDiff, updatedGroupState)
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
internal class ProfileAndMessageHelper(private val aci: ACI, private val masterKey: GroupMasterKey, private val groupId: GroupId.V2) {
|
||||
|
||||
fun setProfileSharing(groupStateDiff: GroupStateDiff, newLocalState: DecryptedGroup) {
|
||||
val previousGroupState = groupStateDiff.previousGroupState
|
||||
|
||||
if (previousGroupState != null && DecryptedGroupUtil.findMemberByAci(previousGroupState.members, aci).isPresent) {
|
||||
// Was already a member before update, profile sharing state previously determined
|
||||
return
|
||||
}
|
||||
|
||||
val selfAsMember = DecryptedGroupUtil.findMemberByAci(newLocalState.members, aci).orNull()
|
||||
val selfAsPending = DecryptedGroupUtil.findPendingByServiceId(newLocalState.pendingMembers, aci).orNull()
|
||||
|
||||
if (selfAsMember != null) {
|
||||
val revisionJoinedAt = selfAsMember.joinedAtRevision
|
||||
|
||||
val addedAtChange = groupStateDiff
|
||||
.serverHistory
|
||||
.mapNotNull { it.change }
|
||||
.firstOrNull { it.revision == revisionJoinedAt }
|
||||
|
||||
val addedBy = ServiceId.parseOrNull(addedAtChange?.editorServiceIdBytes)?.let { Recipient.externalPush(it) }
|
||||
|
||||
if (addedBy != null) {
|
||||
Log.i(TAG, "Added as a full member of $groupId by ${addedBy.id}")
|
||||
|
||||
if (addedBy.isBlocked && (previousGroupState == null || !DecryptedGroupUtil.isRequesting(previousGroupState, aci))) {
|
||||
Log.i(TAG, "Added by a blocked user. Leaving group.")
|
||||
ApplicationDependencies.getJobManager().add(LeaveGroupV2Job(groupId))
|
||||
return
|
||||
} else if ((addedBy.isSystemContact || addedBy.isProfileSharing) && !addedBy.isHidden) {
|
||||
Log.i(TAG, "Group 'adder' is trusted. contact: " + addedBy.isSystemContact + ", profileSharing: " + addedBy.isProfileSharing)
|
||||
Log.i(TAG, "Added to a group and auto-enabling profile sharing")
|
||||
SignalDatabase.recipients.setProfileSharing(Recipient.externalGroupExact(groupId).id, true)
|
||||
} else {
|
||||
Log.i(TAG, "Added to a group, but not enabling profile sharing, as 'adder' is not trusted")
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "Could not find founding member during gv2 create. Not enabling profile sharing.")
|
||||
}
|
||||
} else if (selfAsPending != null) {
|
||||
val addedBy = UuidUtil.fromByteStringOrNull(selfAsPending.addedByAci)?.let { Recipient.externalPush(ACI.from(it)) }
|
||||
|
||||
if (addedBy?.isBlocked == true) {
|
||||
Log.i(TAG, "Added to group $groupId by a blocked user ${addedBy.id}. Leaving group.")
|
||||
ApplicationDependencies.getJobManager().add(LeaveGroupV2Job(groupId))
|
||||
return
|
||||
} else {
|
||||
Log.i(TAG, "Added to $groupId, but not enabling profile sharing as we are a pending member.")
|
||||
}
|
||||
} else {
|
||||
Log.i(TAG, "Added to $groupId, but not enabling profile sharing as not a fullMember.")
|
||||
}
|
||||
}
|
||||
|
||||
fun insertUpdateMessages(
|
||||
timestamp: Long,
|
||||
previousGroupState: DecryptedGroup?,
|
||||
processedLogEntries: Collection<AppliedGroupChangeLog>,
|
||||
serverGuid: String?
|
||||
): Long {
|
||||
var runningTimestamp = timestamp
|
||||
var runningGroupState = previousGroupState
|
||||
|
||||
for (entry in processedLogEntries) {
|
||||
if (entry.change != null && DecryptedGroupUtil.changeIsEmptyExceptForProfileKeyChanges(entry.change) && !DecryptedGroupUtil.changeIsEmpty(entry.change)) {
|
||||
Log.d(TAG, "Skipping profile key changes only update message")
|
||||
} else if (entry.change != null && DecryptedGroupUtil.changeIsEmptyExceptForBanChangesAndOptionalProfileKeyChanges(entry.change)) {
|
||||
Log.d(TAG, "Skipping ban changes only update message")
|
||||
} else {
|
||||
if (entry.change != null && DecryptedGroupUtil.changeIsEmpty(entry.change) && runningGroupState != null) {
|
||||
Log.w(TAG, "Empty group update message seen. Not inserting.")
|
||||
} else {
|
||||
storeMessage(GroupProtoUtil.createDecryptedGroupV2Context(masterKey, GroupMutation(runningGroupState, entry.change, entry.group), null), runningTimestamp, serverGuid)
|
||||
runningTimestamp++
|
||||
}
|
||||
}
|
||||
|
||||
runningGroupState = entry.group
|
||||
}
|
||||
|
||||
return runningTimestamp
|
||||
}
|
||||
|
||||
fun leaveGroupLocally(serviceIds: ServiceIds) {
|
||||
if (!SignalDatabase.groups.isActive(groupId)) {
|
||||
Log.w(TAG, "Group $groupId has already been left.")
|
||||
return
|
||||
}
|
||||
|
||||
val groupRecipient = Recipient.externalGroupExact(groupId)
|
||||
|
||||
val decryptedGroup = SignalDatabase
|
||||
.groups
|
||||
.requireGroup(groupId)
|
||||
.requireV2GroupProperties()
|
||||
.decryptedGroup
|
||||
|
||||
val simulatedGroupState = DecryptedGroupUtil.removeMember(decryptedGroup, serviceIds.aci, decryptedGroup.revision + 1)
|
||||
|
||||
val simulatedGroupChange = DecryptedGroupChange(
|
||||
editorServiceIdBytes = ACI.UNKNOWN.toByteString(),
|
||||
revision = simulatedGroupState.revision,
|
||||
deleteMembers = listOf(serviceIds.aci.toByteString())
|
||||
)
|
||||
|
||||
val updateDescription = GroupProtoUtil.createOutgoingGroupV2UpdateDescription(masterKey, GroupMutation(decryptedGroup, simulatedGroupChange, simulatedGroupState), null)
|
||||
val leaveMessage = OutgoingMessage.groupUpdateMessage(groupRecipient, updateDescription, System.currentTimeMillis())
|
||||
|
||||
try {
|
||||
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(groupRecipient)
|
||||
val id = SignalDatabase.messages.insertMessageOutbox(leaveMessage, threadId, false, null)
|
||||
SignalDatabase.messages.markAsSent(id, true)
|
||||
SignalDatabase.threads.update(threadId, unarchive = false, allowDeletion = false)
|
||||
} catch (e: MmsException) {
|
||||
Log.w(TAG, "Failed to insert leave message for $groupId", e)
|
||||
}
|
||||
|
||||
SignalDatabase.groups.setActive(groupId, false)
|
||||
SignalDatabase.groups.remove(groupId, Recipient.self().id)
|
||||
}
|
||||
|
||||
fun persistLearnedProfileKeys(groupStateDiff: GroupStateDiff) {
|
||||
val profileKeys = ProfileKeySet()
|
||||
|
||||
for (entry in groupStateDiff.serverHistory) {
|
||||
if (entry.group != null) {
|
||||
profileKeys.addKeysFromGroupState(entry.group!!)
|
||||
}
|
||||
if (entry.change != null) {
|
||||
profileKeys.addKeysFromGroupChange(entry.change!!)
|
||||
}
|
||||
}
|
||||
|
||||
persistLearnedProfileKeys(profileKeys)
|
||||
}
|
||||
|
||||
fun persistLearnedProfileKeys(profileKeys: ProfileKeySet) {
|
||||
val updated = SignalDatabase.recipients.persistProfileKeySet(profileKeys)
|
||||
|
||||
if (updated.isNotEmpty()) {
|
||||
Log.i(TAG, "Learned ${updated.size} new profile keys, fetching profiles")
|
||||
|
||||
for (job in RetrieveProfileJob.forRecipients(updated)) {
|
||||
ApplicationDependencies.getJobManager().runSynchronously(job, 5000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
fun storeMessage(decryptedGroupV2Context: DecryptedGroupV2Context, timestamp: Long, serverGuid: String?) {
|
||||
val editor: Optional<ServiceId> = getEditor(decryptedGroupV2Context)
|
||||
|
||||
val outgoing = editor.isEmpty || aci == editor.get()
|
||||
|
||||
val updateDescription = GV2UpdateDescription(
|
||||
gv2ChangeDescription = decryptedGroupV2Context,
|
||||
groupChangeUpdate = translateDecryptedChange(SignalStore.account().getServiceIds(), decryptedGroupV2Context)
|
||||
)
|
||||
|
||||
if (outgoing) {
|
||||
try {
|
||||
val recipientId = SignalDatabase.recipients.getOrInsertFromGroupId(groupId)
|
||||
val recipient = Recipient.resolved(recipientId)
|
||||
val outgoingMessage = OutgoingMessage.groupUpdateMessage(recipient, updateDescription, timestamp)
|
||||
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(recipient)
|
||||
val messageId = SignalDatabase.messages.insertMessageOutbox(outgoingMessage, threadId, false, null)
|
||||
|
||||
SignalDatabase.messages.markAsSent(messageId, true)
|
||||
SignalDatabase.threads.update(threadId, unarchive = false, allowDeletion = false)
|
||||
} catch (e: MmsException) {
|
||||
Log.w(TAG, "Failed to insert outgoing update message!", e)
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
val sender = RecipientId.from(editor.get())
|
||||
val groupMessage = IncomingMessage.groupUpdate(sender, timestamp, groupId, decryptedGroupV2Context, serverGuid)
|
||||
val insertResult = SignalDatabase.messages.insertMessageInbox(groupMessage)
|
||||
|
||||
if (insertResult.isPresent) {
|
||||
SignalDatabase.threads.update(insertResult.get().threadId, unarchive = false, allowDeletion = false)
|
||||
} else {
|
||||
Log.w(TAG, "Could not insert update message")
|
||||
}
|
||||
} catch (e: MmsException) {
|
||||
Log.w(TAG, "Failed to insert incoming update message!", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getEditor(decryptedGroupV2Context: DecryptedGroupV2Context): Optional<ServiceId> {
|
||||
val changeEditor = DecryptedGroupUtil.editorServiceId(decryptedGroupV2Context.change)
|
||||
|
||||
if (changeEditor.isPresent) {
|
||||
return changeEditor
|
||||
} else {
|
||||
val pending = DecryptedGroupUtil.findPendingByServiceId(decryptedGroupV2Context.groupState?.pendingMembers ?: emptyList(), aci)
|
||||
|
||||
if (pending.isPresent) {
|
||||
return Optional.ofNullable(ACI.parseOrNull(pending.get().addedByAci))
|
||||
}
|
||||
}
|
||||
|
||||
return Optional.empty()
|
||||
}
|
||||
|
||||
companion object {
|
||||
@VisibleForTesting
|
||||
fun create(aci: ACI, masterKey: GroupMasterKey, groupId: GroupId.V2): ProfileAndMessageHelper {
|
||||
return ProfileAndMessageHelper(aci, masterKey, groupId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed interface InternalUpdateResult {
|
||||
class Updated(val updatedLocalState: DecryptedGroup) : InternalUpdateResult
|
||||
object NoUpdateNeeded : InternalUpdateResult
|
||||
class NotAMember(val exception: GroupNotAMemberException) : InternalUpdateResult
|
||||
class UpdateFailed(val throwable: Throwable) : InternalUpdateResult
|
||||
|
||||
companion object {
|
||||
fun from(cause: Throwable): InternalUpdateResult {
|
||||
return when (cause) {
|
||||
is NotInGroupException,
|
||||
is GroupNotFoundException -> NotAMember(GroupNotAMemberException(cause))
|
||||
|
||||
is IOException -> UpdateFailed(cause)
|
||||
else -> UpdateFailed(IOException(cause))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
package org.thoughtcrime.securesms.groups.v2.processing;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroup;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroupChange;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Pair of a group state and optionally the corresponding change.
|
||||
* <p>
|
||||
* Similar to {@link ServerGroupLogEntry} but guaranteed to have a group state.
|
||||
* <p>
|
||||
* Changes are typically not available for pending members.
|
||||
*/
|
||||
final class LocalGroupLogEntry {
|
||||
|
||||
@NonNull private final DecryptedGroup group;
|
||||
@Nullable private final DecryptedGroupChange change;
|
||||
|
||||
LocalGroupLogEntry(@NonNull DecryptedGroup group, @Nullable DecryptedGroupChange change) {
|
||||
if (change != null && group.revision != change.revision) {
|
||||
throw new AssertionError();
|
||||
}
|
||||
|
||||
this.group = group;
|
||||
this.change = change;
|
||||
}
|
||||
|
||||
@NonNull DecryptedGroup getGroup() {
|
||||
return group;
|
||||
}
|
||||
|
||||
@Nullable DecryptedGroupChange getChange() {
|
||||
return change;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (!(o instanceof LocalGroupLogEntry)) return false;
|
||||
|
||||
LocalGroupLogEntry other = (LocalGroupLogEntry) o;
|
||||
|
||||
return group.equals(other.group) && Objects.equals(change, other.change);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
int result = group.hashCode();
|
||||
result = 31 * result + (change != null ? change.hashCode() : 0);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
package org.thoughtcrime.securesms.groups.v2.processing;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroup;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroupChange;
|
||||
|
||||
/**
|
||||
* Pair of a group state and optionally the corresponding change from the server.
|
||||
* <p>
|
||||
* Either the group or change may be empty.
|
||||
* <p>
|
||||
* Changes are typically not available for pending members.
|
||||
*/
|
||||
final class ServerGroupLogEntry {
|
||||
|
||||
private static final String TAG = Log.tag(ServerGroupLogEntry.class);
|
||||
|
||||
@Nullable private final DecryptedGroup group;
|
||||
@Nullable private final DecryptedGroupChange change;
|
||||
|
||||
ServerGroupLogEntry(@Nullable DecryptedGroup group, @Nullable DecryptedGroupChange change) {
|
||||
if (change != null && group != null && group.revision != change.revision) {
|
||||
Log.w(TAG, "Ignoring change with revision number not matching group");
|
||||
change = null;
|
||||
}
|
||||
|
||||
if (change == null && group == null) {
|
||||
throw new AssertionError();
|
||||
}
|
||||
|
||||
this.group = group;
|
||||
this.change = change;
|
||||
}
|
||||
|
||||
@Nullable DecryptedGroup getGroup() {
|
||||
return group;
|
||||
}
|
||||
|
||||
@Nullable DecryptedGroupChange getChange() {
|
||||
return change;
|
||||
}
|
||||
|
||||
int getRevision() {
|
||||
if (group != null) return group.revision;
|
||||
else if (change != null) return change.revision;
|
||||
else throw new AssertionError();
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,6 @@ import java.util.List;
|
||||
public final class InternalValues extends SignalStoreValues {
|
||||
|
||||
public static final String GV2_FORCE_INVITES = "internal.gv2.force_invites";
|
||||
public static final String GV2_IGNORE_SERVER_CHANGES = "internal.gv2.ignore_server_changes";
|
||||
public static final String GV2_IGNORE_P2P_CHANGES = "internal.gv2.ignore_p2p_changes";
|
||||
public static final String RECIPIENT_DETAILS = "internal.recipient_details";
|
||||
public static final String ALLOW_CENSORSHIP_SETTING = "internal.force_censorship";
|
||||
@@ -54,19 +53,6 @@ public final class InternalValues extends SignalStoreValues {
|
||||
return FeatureFlags.internalUser() && getBoolean(GV2_FORCE_INVITES, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* The Server will leave out changes that can only be described by a future protocol level that
|
||||
* an older client cannot understand. Ignoring those changes by nulling them out simulates that
|
||||
* scenario for testing.
|
||||
* <p>
|
||||
* In conjunction with {@link #gv2IgnoreP2PChanges()} it means no group changes are coming into
|
||||
* the client and it will generate changes by group state comparison, and those changes will not
|
||||
* have an editor and so will be in the passive voice.
|
||||
*/
|
||||
public synchronized boolean gv2IgnoreServerChanges() {
|
||||
return FeatureFlags.internalUser() && getBoolean(GV2_IGNORE_SERVER_CHANGES, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Signed group changes are sent P2P, if the client ignores them, it will then ask the server
|
||||
* directly which allows testing of certain testing scenarios.
|
||||
|
||||
@@ -20,7 +20,8 @@ import org.thoughtcrime.securesms.groups.GroupChangeBusyException
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.groups.GroupManager
|
||||
import org.thoughtcrime.securesms.groups.GroupNotAMemberException
|
||||
import org.thoughtcrime.securesms.groups.v2.processing.GroupsV2StateProcessor
|
||||
import org.thoughtcrime.securesms.groups.v2.processing.GroupUpdateResult
|
||||
import org.thoughtcrime.securesms.groups.v2.processing.GroupUpdateResult.UpdateStatus
|
||||
import org.thoughtcrime.securesms.jobs.AutomaticSessionResetJob
|
||||
import org.thoughtcrime.securesms.jobs.NullMessageSendJob
|
||||
import org.thoughtcrime.securesms.jobs.ResendMessageJob
|
||||
@@ -238,7 +239,7 @@ open class MessageContentProcessor(private val context: Context) {
|
||||
return Gv2PreProcessResult.IGNORE
|
||||
}
|
||||
|
||||
val groupRecord = if (groupUpdateResult.groupState == GroupsV2StateProcessor.GroupState.GROUP_CONSISTENT_OR_AHEAD) {
|
||||
val groupRecord = if (groupUpdateResult.updateStatus == UpdateStatus.GROUP_CONSISTENT_OR_AHEAD) {
|
||||
preUpdateGroupRecord
|
||||
} else {
|
||||
SignalDatabase.groups.getGroup(groupId)
|
||||
@@ -261,9 +262,9 @@ open class MessageContentProcessor(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
return when (groupUpdateResult.groupState) {
|
||||
GroupsV2StateProcessor.GroupState.GROUP_UPDATED -> Gv2PreProcessResult.GROUP_UPDATE
|
||||
GroupsV2StateProcessor.GroupState.GROUP_CONSISTENT_OR_AHEAD -> Gv2PreProcessResult.GROUP_UP_TO_DATE
|
||||
return when (groupUpdateResult.updateStatus) {
|
||||
UpdateStatus.GROUP_UPDATED -> Gv2PreProcessResult.GROUP_UPDATE
|
||||
UpdateStatus.GROUP_CONSISTENT_OR_AHEAD -> Gv2PreProcessResult.GROUP_UP_TO_DATE
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,7 +276,7 @@ open class MessageContentProcessor(private val context: Context) {
|
||||
localRecord: Optional<GroupRecord>,
|
||||
groupSecretParams: GroupSecretParams? = null,
|
||||
serverGuid: String? = null
|
||||
): GroupsV2StateProcessor.GroupUpdateResult? {
|
||||
): GroupUpdateResult? {
|
||||
return try {
|
||||
val signedGroupChange: ByteArray? = if (groupV2.hasSignedGroupChange) groupV2.signedGroupChange else null
|
||||
val updatedTimestamp = if (signedGroupChange != null) timestamp else timestamp - 1
|
||||
|
||||
Reference in New Issue
Block a user