diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java index 5514625b6..fe6a692fe 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java @@ -199,6 +199,7 @@ import org.thoughtcrime.securesms.groups.ui.migration.GroupsV1MigrationSuggestio import org.thoughtcrime.securesms.insights.InsightsLauncher; import org.thoughtcrime.securesms.invites.InviteReminderModel; import org.thoughtcrime.securesms.invites.InviteReminderRepository; +import org.thoughtcrime.securesms.jobs.ForceUpdateGroupV2Job; import org.thoughtcrime.securesms.jobs.GroupV1MigrationJob; import org.thoughtcrime.securesms.jobs.GroupV2UpdateSelfProfileKeyJob; import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob; @@ -594,6 +595,8 @@ public class ConversationParentFragment extends Fragment .then(GroupV2UpdateSelfProfileKeyJob.withoutLimits(groupId)) .enqueue(); + ForceUpdateGroupV2Job.enqueueIfNecessary(groupId); + if (viewModel.getArgs().isFirstTimeInSelfCreatedGroup()) { groupViewModel.inviteFriendsOneTimeIfJustSelfInGroup(getChildFragmentManager(), groupId); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java index ed047dabd..2bc18c390 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java @@ -69,24 +69,25 @@ public class GroupDatabase extends Database implements RecipientIdDatabaseRefere private static final String TAG = Log.tag(GroupDatabase.class); - static final String TABLE_NAME = "groups"; - private static final String ID = "_id"; - static final String GROUP_ID = "group_id"; - public static final String RECIPIENT_ID = "recipient_id"; - private static final String TITLE = "title"; - static final String MEMBERS = "members"; - private static final String AVATAR_ID = "avatar_id"; - private static final String AVATAR_KEY = "avatar_key"; - private static final String AVATAR_CONTENT_TYPE = "avatar_content_type"; - private static final String AVATAR_RELAY = "avatar_relay"; - private static final String AVATAR_DIGEST = "avatar_digest"; - private static final String TIMESTAMP = "timestamp"; - static final String ACTIVE = "active"; - static final String MMS = "mms"; - private static final String EXPECTED_V2_ID = "expected_v2_id"; - private static final String UNMIGRATED_V1_MEMBERS = "former_v1_members"; - private static final String DISTRIBUTION_ID = "distribution_id"; - private static final String SHOW_AS_STORY_STATE = "display_as_story"; + static final String TABLE_NAME = "groups"; + private static final String ID = "_id"; + static final String GROUP_ID = "group_id"; + public static final String RECIPIENT_ID = "recipient_id"; + private static final String TITLE = "title"; + static final String MEMBERS = "members"; + private static final String AVATAR_ID = "avatar_id"; + private static final String AVATAR_KEY = "avatar_key"; + private static final String AVATAR_CONTENT_TYPE = "avatar_content_type"; + private static final String AVATAR_RELAY = "avatar_relay"; + private static final String AVATAR_DIGEST = "avatar_digest"; + private static final String TIMESTAMP = "timestamp"; + static final String ACTIVE = "active"; + static final String MMS = "mms"; + private static final String EXPECTED_V2_ID = "expected_v2_id"; + private static final String UNMIGRATED_V1_MEMBERS = "former_v1_members"; + private static final String DISTRIBUTION_ID = "distribution_id"; + private static final String SHOW_AS_STORY_STATE = "display_as_story"; + private static final String LAST_FORCE_UPDATE_TIMESTAMP = "last_force_update_timestamp"; /** Was temporarily used for PNP accept by pni but is no longer needed/updated */ @Deprecated @@ -101,27 +102,28 @@ public class GroupDatabase extends Database implements RecipientIdDatabaseRefere /** Serialized {@link DecryptedGroup} protobuf */ public static final String V2_DECRYPTED_GROUP = "decrypted_group"; - public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" + ID + " INTEGER PRIMARY KEY, " + - GROUP_ID + " TEXT, " + - RECIPIENT_ID + " INTEGER, " + - TITLE + " TEXT, " + - MEMBERS + " TEXT, " + - AVATAR_ID + " INTEGER, " + - AVATAR_KEY + " BLOB, " + - AVATAR_CONTENT_TYPE + " TEXT, " + - AVATAR_RELAY + " TEXT, " + - TIMESTAMP + " INTEGER, " + - ACTIVE + " INTEGER DEFAULT 1, " + - AVATAR_DIGEST + " BLOB, " + - MMS + " INTEGER DEFAULT 0, " + - V2_MASTER_KEY + " BLOB, " + - V2_REVISION + " BLOB, " + - V2_DECRYPTED_GROUP + " BLOB, " + - EXPECTED_V2_ID + " TEXT DEFAULT NULL, " + - UNMIGRATED_V1_MEMBERS + " TEXT DEFAULT NULL, " + - DISTRIBUTION_ID + " TEXT DEFAULT NULL, " + - SHOW_AS_STORY_STATE + " INTEGER DEFAULT 0, " + - AUTH_SERVICE_ID + " TEXT DEFAULT NULL);"; + public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" + ID + " INTEGER PRIMARY KEY, " + + GROUP_ID + " TEXT, " + + RECIPIENT_ID + " INTEGER, " + + TITLE + " TEXT, " + + MEMBERS + " TEXT, " + + AVATAR_ID + " INTEGER, " + + AVATAR_KEY + " BLOB, " + + AVATAR_CONTENT_TYPE + " TEXT, " + + AVATAR_RELAY + " TEXT, " + + TIMESTAMP + " INTEGER, " + + ACTIVE + " INTEGER DEFAULT 1, " + + AVATAR_DIGEST + " BLOB, " + + MMS + " INTEGER DEFAULT 0, " + + V2_MASTER_KEY + " BLOB, " + + V2_REVISION + " BLOB, " + + V2_DECRYPTED_GROUP + " BLOB, " + + EXPECTED_V2_ID + " TEXT DEFAULT NULL, " + + UNMIGRATED_V1_MEMBERS + " TEXT DEFAULT NULL, " + + DISTRIBUTION_ID + " TEXT DEFAULT NULL, " + + SHOW_AS_STORY_STATE + " INTEGER DEFAULT 0, " + + AUTH_SERVICE_ID + " TEXT DEFAULT NULL, " + + LAST_FORCE_UPDATE_TIMESTAMP + " INTEGER DEFAULT 0);"; public static final String[] CREATE_INDEXS = { "CREATE UNIQUE INDEX IF NOT EXISTS group_id_index ON " + TABLE_NAME + " (" + GROUP_ID + ");", @@ -132,7 +134,7 @@ public class GroupDatabase extends Database implements RecipientIdDatabaseRefere private static final String[] GROUP_PROJECTION = { GROUP_ID, RECIPIENT_ID, TITLE, MEMBERS, UNMIGRATED_V1_MEMBERS, AVATAR_ID, AVATAR_KEY, AVATAR_CONTENT_TYPE, AVATAR_RELAY, AVATAR_DIGEST, - TIMESTAMP, ACTIVE, MMS, V2_MASTER_KEY, V2_REVISION, V2_DECRYPTED_GROUP + TIMESTAMP, ACTIVE, MMS, V2_MASTER_KEY, V2_REVISION, V2_DECRYPTED_GROUP, LAST_FORCE_UPDATE_TIMESTAMP }; static final List TYPED_GROUP_PROJECTION = Stream.of(GROUP_PROJECTION).map(columnName -> TABLE_NAME + "." + columnName).toList(); @@ -955,6 +957,12 @@ public class GroupDatabase extends Database implements RecipientIdDatabaseRefere database.update(TABLE_NAME, values, GROUP_ID + " = ?", new String[] {groupId.toString()}); } + public void setLastForceUpdateTimestamp(@NonNull GroupId groupId, long timestamp) { + ContentValues values = new ContentValues(); + values.put(LAST_FORCE_UPDATE_TIMESTAMP, timestamp); + getWritableDatabase().update(TABLE_NAME, values, GROUP_ID + " = ?", SqlUtil.buildArgs(groupId)); + } + @WorkerThread public boolean isCurrentMember(@NonNull GroupId.Push groupId, @NonNull RecipientId recipientId) { SQLiteDatabase database = databaseHelper.getSignalReadableDatabase(); @@ -1114,7 +1122,8 @@ public class GroupDatabase extends Database implements RecipientIdDatabaseRefere CursorUtil.requireBlob(cursor, V2_MASTER_KEY), CursorUtil.requireInt(cursor, V2_REVISION), CursorUtil.requireBlob(cursor, V2_DECRYPTED_GROUP), - CursorUtil.getString(cursor, DISTRIBUTION_ID).map(DistributionId::from).orElse(null)); + CursorUtil.getString(cursor, DISTRIBUTION_ID).map(DistributionId::from).orElse(null), + CursorUtil.requireLong(cursor, LAST_FORCE_UPDATE_TIMESTAMP)); } @Override @@ -1155,6 +1164,7 @@ public class GroupDatabase extends Database implements RecipientIdDatabaseRefere private final boolean mms; @Nullable private final V2GroupProperties v2GroupProperties; private final DistributionId distributionId; + private final long lastForceUpdateTimestamp; public GroupRecord(@NonNull GroupId id, @NonNull RecipientId recipientId, @@ -1171,19 +1181,21 @@ public class GroupDatabase extends Database implements RecipientIdDatabaseRefere @Nullable byte[] groupMasterKeyBytes, int groupRevision, @Nullable byte[] decryptedGroupBytes, - @Nullable DistributionId distributionId) + @Nullable DistributionId distributionId, + long lastForceUpdateTimestamp) { - this.id = id; - this.recipientId = recipientId; - this.title = title; - this.avatarId = avatarId; - this.avatarKey = avatarKey; - this.avatarDigest = avatarDigest; - this.avatarContentType = avatarContentType; - this.relay = relay; - this.active = active; - this.mms = mms; - this.distributionId = distributionId; + this.id = id; + this.recipientId = recipientId; + this.title = title; + this.avatarId = avatarId; + this.avatarKey = avatarKey; + this.avatarDigest = avatarDigest; + this.avatarContentType = avatarContentType; + this.relay = relay; + this.active = active; + this.mms = mms; + this.distributionId = distributionId; + this.lastForceUpdateTimestamp = lastForceUpdateTimestamp; V2GroupProperties v2GroupProperties = null; if (groupMasterKeyBytes != null && decryptedGroupBytes != null) { @@ -1292,6 +1304,10 @@ public class GroupDatabase extends Database implements RecipientIdDatabaseRefere return distributionId; } + public long getLastForceUpdateTimestamp() { + return lastForceUpdateTimestamp; + } + public boolean isV1Group() { return !mms && !isV2Group(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt index 753887e22..6f17717af 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt @@ -12,13 +12,14 @@ import org.thoughtcrime.securesms.database.helpers.migration.V154_PniSignaturesM import org.thoughtcrime.securesms.database.helpers.migration.V155_SmsExporterMigration import org.thoughtcrime.securesms.database.helpers.migration.V156_RecipientUnregisteredTimestampMigration import org.thoughtcrime.securesms.database.helpers.migration.V157_RecipeintHiddenMigration +import org.thoughtcrime.securesms.database.helpers.migration.V158_GroupsLastForceUpdateTimestampMigration /** * Contains all of the database migrations for [SignalDatabase]. Broken into a separate file for cleanliness. */ object SignalDatabaseMigrations { - const val DATABASE_VERSION = 157 + const val DATABASE_VERSION = 158 @JvmStatic fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { @@ -57,6 +58,10 @@ object SignalDatabaseMigrations { if (oldVersion < 157) { V157_RecipeintHiddenMigration.migrate(context, db, oldVersion, newVersion) } + + if (oldVersion < 158) { + V158_GroupsLastForceUpdateTimestampMigration.migrate(context, db, oldVersion, newVersion) + } } @JvmStatic diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V158_GroupsLastForceUpdateTimestampMigration.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V158_GroupsLastForceUpdateTimestampMigration.kt new file mode 100644 index 000000000..8cac5cf3e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V158_GroupsLastForceUpdateTimestampMigration.kt @@ -0,0 +1,13 @@ +package org.thoughtcrime.securesms.database.helpers.migration + +import android.app.Application +import net.zetetic.database.sqlcipher.SQLiteDatabase + +/** + * Track last time we did a forced sanity check for this group with the server. + */ +object V158_GroupsLastForceUpdateTimestampMigration : SignalDatabaseMigration { + override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { + db.execSQL("ALTER TABLE groups ADD COLUMN last_force_update_timestamp INTEGER DEFAULT 0") + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupId.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupId.java index d269665e9..3d4320e09 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupId.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupId.java @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.groups; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import org.signal.core.util.DatabaseId; import org.signal.core.util.Hex; import org.signal.libsignal.protocol.kdf.HKDFv3; import org.signal.libsignal.zkgroup.InvalidInputException; @@ -14,7 +15,7 @@ import org.thoughtcrime.securesms.util.Util; import java.io.IOException; import java.security.SecureRandom; -public abstract class GroupId { +public abstract class GroupId implements DatabaseId { private static final String ENCODED_SIGNAL_GROUP_V1_PREFIX = "__textsecure_group__!"; private static final String ENCODED_SIGNAL_GROUP_V2_PREFIX = "__signal_group__v2__!"; @@ -173,6 +174,11 @@ public abstract class GroupId { return encodedId; } + @Override + public @NonNull String serialize() { + return encodedId; + } + public abstract boolean isMms(); public abstract boolean isV1(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java index 6bf940b85..f24dd70fb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java @@ -188,6 +188,17 @@ public final class GroupManager { } } + @WorkerThread + public static void forceSanityUpdateFromServer(@NonNull Context context, + @NonNull GroupMasterKey groupMasterKey, + long timestamp) + throws GroupChangeBusyException, IOException, GroupNotAMemberException + { + try (GroupManagerV2.GroupUpdater updater = new GroupManagerV2(context).updater(groupMasterKey)) { + updater.forceSanityUpdateFromServer(timestamp); + } + } + @WorkerThread public static V2GroupServerStatus v2GroupStatus(@NonNull Context context, @NonNull ServiceId authServiceId, diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java index d63949ed0..ff895cb31 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java @@ -798,6 +798,14 @@ final class GroupManagerV2 { .updateLocalGroupToRevision(revision, timestamp, getDecryptedGroupChange(signedGroupChange)); } + @WorkerThread + void forceSanityUpdateFromServer(long timestamp) + throws IOException, GroupNotAMemberException + { + new GroupsV2StateProcessor(context).forGroup(serviceIds, groupMasterKey) + .forceSanityUpdateFromServer(timestamp); + } + private DecryptedGroupChange getDecryptedGroupChange(@Nullable byte[] signedGroupChange) { if (signedGroupChange != null) { GroupsV2Operations.GroupOperations groupOperations = groupsV2Operations.forGroup(GroupSecretParams.deriveFromMasterKey(groupMasterKey)); @@ -928,24 +936,6 @@ final class GroupManagerV2 { if (group.isPresent()) { Log.i(TAG, "Group already present locally"); - - DecryptedGroup currentGroupState = group.get() - .requireV2GroupProperties() - .getDecryptedGroup(); - - DecryptedGroup updatedGroup = currentGroupState; - - try { - if (decryptedChange != null) { - updatedGroup = DecryptedGroupUtil.applyWithoutRevisionCheck(updatedGroup, decryptedChange); - } - updatedGroup = resetRevision(updatedGroup, currentGroupState.getRevision()); - } catch (NotAbleToApplyGroupV2ChangeException e) { - Log.w(TAG, e); - updatedGroup = decryptedGroup; - } - - groupDatabase.update(groupId, updatedGroup); } else { groupDatabase.create(groupMasterKey, decryptedGroup); Log.i(TAG, "Created local group with placeholder"); diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.java index 156755bda..c33164ae1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.java @@ -48,6 +48,7 @@ import org.thoughtcrime.securesms.sms.IncomingGroupUpdateMessage; import org.thoughtcrime.securesms.sms.IncomingTextMessage; 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; @@ -170,6 +171,52 @@ public class GroupsV2StateProcessor { this.profileAndMessageHelper = profileAndMessageHelper; } + @WorkerThread + public GroupUpdateResult forceSanityUpdateFromServer(long timestamp) + throws IOException, GroupNotAMemberException + { + Optional localRecord = groupDatabase.getGroup(groupId); + DecryptedGroup localState = localRecord.map(g -> g.requireV2GroupProperties().getDecryptedGroup()).orElse(null); + DecryptedGroup serverState; + + if (localState == null) { + info("No local state to force update"); + return new GroupUpdateResult(GroupState.GROUP_CONSISTENT_OR_AHEAD, null); + } + + try { + serverState = groupsV2Api.getGroup(groupSecretParams, groupsV2Authorization.getAuthorizationForToday(serviceIds, groupSecretParams)); + } catch (NotInGroupException | GroupNotFoundException e) { + throw new GroupNotAMemberException(e); + } catch (VerificationFailedException | InvalidGroupStateException e) { + throw new IOException(e); + } + + DecryptedGroupChange decryptedGroupChange = GroupChangeReconstruct.reconstructGroupChange(localState, serverState); + GlobalGroupState inputGroupState = new GlobalGroupState(localState, Collections.singletonList(new ServerGroupLogEntry(serverState, decryptedGroupChange))); + AdvanceGroupStateResult advanceGroupStateResult = GroupStateMapper.partiallyAdvanceGroupState(inputGroupState, serverState.getRevision()); + 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.getRevision() + ") does not match server state (revision: " + serverState.getRevision() + "), updating"); + } + + updateLocalDatabaseGroupState(inputGroupState, newLocalState); + if (localState.getRevision() == GroupsV2StateProcessor.RESTORE_PLACEHOLDER_REVISION) { + info("Inserting single update message for restore placeholder"); + profileAndMessageHelper.insertUpdateMessages(timestamp, null, Collections.singleton(new LocalGroupLogEntry(newLocalState, null))); + } else { + info("Inserting force update messages"); + profileAndMessageHelper.insertUpdateMessages(timestamp, localState, advanceGroupStateResult.getProcessedLogEntries()); + } + 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. * @@ -560,10 +607,12 @@ public class GroupsV2StateProcessor { private final Context context; private final ServiceId serviceId; - private final GroupMasterKey masterKey; private final GroupId.V2 groupId; private final RecipientDatabase recipientDatabase; + @VisibleForTesting + GroupMasterKey masterKey; + ProfileAndMessageHelper(@NonNull Context context, @NonNull ServiceId serviceId, @NonNull GroupMasterKey masterKey, @NonNull GroupId.V2 groupId, @NonNull RecipientDatabase recipientDatabase) { this.context = context; this.serviceId = serviceId; diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/ForceUpdateGroupV2Job.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/ForceUpdateGroupV2Job.java new file mode 100644 index 000000000..0b427c4ee --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/ForceUpdateGroupV2Job.java @@ -0,0 +1,86 @@ +package org.thoughtcrime.securesms.jobs; + +import androidx.annotation.NonNull; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.thoughtcrime.securesms.database.GroupDatabase; +import org.thoughtcrime.securesms.database.SignalDatabase; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.impl.DecryptionsDrainedConstraint; + +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +/** + * Schedules a {@link ForceUpdateGroupV2WorkerJob} to happen after message queues are drained. + */ +public final class ForceUpdateGroupV2Job extends BaseJob { + + public static final String KEY = "ForceUpdateGroupV2Job"; + + private static final long FORCE_UPDATE_INTERVAL = TimeUnit.DAYS.toMillis(7); + private static final String KEY_GROUP_ID = "group_id"; + + private final GroupId.V2 groupId; + + public static void enqueueIfNecessary(@NonNull GroupId.V2 groupId) { + SignalExecutors.BOUNDED.execute(() -> { + Optional group = SignalDatabase.groups().getGroup(groupId); + if (group.isPresent() && + group.get().isV2Group() && + group.get().getLastForceUpdateTimestamp() + FORCE_UPDATE_INTERVAL < System.currentTimeMillis() + ) { + ApplicationDependencies.getJobManager().add(new ForceUpdateGroupV2Job(groupId)); + } + }); + } + + private ForceUpdateGroupV2Job(@NonNull GroupId.V2 groupId) { + this(new Parameters.Builder().setQueue("ForceUpdateGroupV2Job_" + groupId) + .setMaxInstancesForQueue(1) + .addConstraint(DecryptionsDrainedConstraint.KEY) + .setMaxAttempts(Parameters.UNLIMITED) + .build(), + groupId); + } + + private ForceUpdateGroupV2Job(@NonNull Parameters parameters, @NonNull GroupId.V2 groupId) { + super(parameters); + this.groupId = groupId; + } + + @Override + public @NonNull Data serialize() { + return new Data.Builder().putString(KEY_GROUP_ID, groupId.toString()).build(); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void onRun() { + ApplicationDependencies.getJobManager().add(new ForceUpdateGroupV2WorkerJob(groupId)); + } + + @Override + public boolean onShouldRetry(@NonNull Exception e) { + return false; + } + + @Override + public void onFailure() { + } + + public static final class Factory implements Job.Factory { + + @Override + public @NonNull ForceUpdateGroupV2Job create(@NonNull Parameters parameters, @NonNull Data data) { + return new ForceUpdateGroupV2Job(parameters, GroupId.parseOrThrow(data.getString(KEY_GROUP_ID)).requireV2()); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/ForceUpdateGroupV2WorkerJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/ForceUpdateGroupV2WorkerJob.java new file mode 100644 index 000000000..b84e5c291 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/ForceUpdateGroupV2WorkerJob.java @@ -0,0 +1,103 @@ +package org.thoughtcrime.securesms.jobs; + +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.database.GroupDatabase; +import org.thoughtcrime.securesms.database.SignalDatabase; +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.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.whispersystems.signalservice.api.groupsv2.NoCredentialForRedemptionTimeException; +import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; + +import java.io.IOException; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +/** + * Scheduled by {@link ForceUpdateGroupV2Job} after message queues are drained. + * + * Forces a sanity check between local state and server state, and updates local state + * as necessary. + */ +final class ForceUpdateGroupV2WorkerJob extends BaseJob { + + public static final String KEY = "ForceUpdateGroupV2WorkerJob"; + + private static final String TAG = Log.tag(ForceUpdateGroupV2WorkerJob.class); + + private static final String KEY_GROUP_ID = "group_id"; + + private final GroupId.V2 groupId; + + ForceUpdateGroupV2WorkerJob(@NonNull GroupId.V2 groupId) { + this(new Parameters.Builder().setQueue(PushProcessMessageJob.getQueueName(Recipient.externalGroupExact(groupId).getId())) + .addConstraint(NetworkConstraint.KEY) + .setMaxAttempts(Parameters.UNLIMITED) + .build(), + groupId); + } + + private ForceUpdateGroupV2WorkerJob(@NonNull Parameters parameters, @NonNull GroupId.V2 groupId) { + super(parameters); + this.groupId = groupId; + } + + @Override + public @NonNull Data serialize() { + return new Data.Builder().putString(KEY_GROUP_ID, groupId.toString()) + .build(); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void onRun() throws IOException, GroupNotAMemberException, GroupChangeBusyException { + Optional group = SignalDatabase.groups().getGroup(groupId); + + if (!group.isPresent()) { + Log.w(TAG, "Group not found"); + return; + } + + if (Recipient.externalGroupExact(groupId).isBlocked()) { + Log.i(TAG, "Not fetching group info for blocked group " + groupId); + return; + } + + GroupManager.forceSanityUpdateFromServer(context, group.get().requireV2GroupProperties().getGroupMasterKey(), System.currentTimeMillis()); + + SignalDatabase.groups().setLastForceUpdateTimestamp(group.get().getId(), System.currentTimeMillis()); + } + + @Override + public boolean onShouldRetry(@NonNull Exception e) { + return e instanceof PushNetworkException || + e instanceof NoCredentialForRedemptionTimeException || + e instanceof GroupChangeBusyException; + } + + @Override + public void onFailure() { + } + + public static final class Factory implements Job.Factory { + + @Override + public @NonNull ForceUpdateGroupV2WorkerJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new ForceUpdateGroupV2WorkerJob(parameters, + GroupId.parseOrThrow(data.getString(KEY_GROUP_ID)).requireV2()); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java index 03394d37f..4549772b5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java @@ -97,6 +97,8 @@ public final class JobManagerFactories { put(FcmRefreshJob.KEY, new FcmRefreshJob.Factory()); put(FetchRemoteMegaphoneImageJob.KEY, new FetchRemoteMegaphoneImageJob.Factory()); put(FontDownloaderJob.KEY, new FontDownloaderJob.Factory()); + put(ForceUpdateGroupV2Job.KEY, new ForceUpdateGroupV2Job.Factory()); + put(ForceUpdateGroupV2WorkerJob.KEY, new ForceUpdateGroupV2WorkerJob.Factory()); put(GiftSendJob.KEY, new GiftSendJob.Factory()); put(GroupV1MigrationJob.KEY, new GroupV1MigrationJob.Factory()); put(GroupCallUpdateSendJob.KEY, new GroupCallUpdateSendJob.Factory()); @@ -136,7 +138,7 @@ public final class JobManagerFactories { put(PaymentNotificationSendJob.KEY, new PaymentNotificationSendJob.Factory()); put(PaymentSendJob.KEY, new PaymentSendJob.Factory()); put(PaymentTransactionCheckJob.KEY, new PaymentTransactionCheckJob.Factory()); - put(PnpInitializeDevicesJob.KEY, new PnpInitializeDevicesJob.Factory()); + put(PnpInitializeDevicesJob.KEY, new PnpInitializeDevicesJob.Factory()); put(PreKeysSyncJob.KEY, new PreKeysSyncJob.Factory()); put(ProfileKeySendJob.KEY, new ProfileKeySendJob.Factory()); put(ProfileUploadJob.KEY, new ProfileUploadJob.Factory()); diff --git a/app/src/test/java/org/thoughtcrime/securesms/database/GroupTestUtil.kt b/app/src/test/java/org/thoughtcrime/securesms/database/GroupTestUtil.kt index 83c84a711..d9b06a25b 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/database/GroupTestUtil.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/database/GroupTestUtil.kt @@ -190,7 +190,8 @@ fun groupRecord( masterKey.serialize(), decryptedGroup.revision, decryptedGroup.toByteArray(), - distributionId + distributionId, + System.currentTimeMillis() ) ) } diff --git a/app/src/test/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessorTest.kt b/app/src/test/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessorTest.kt index deca098ce..f28e5a252 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessorTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessorTest.kt @@ -3,19 +3,28 @@ package org.thoughtcrime.securesms.groups.v2.processing import android.app.Application import androidx.test.core.app.ApplicationProvider import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.both +import org.hamcrest.Matchers.hasItem +import org.hamcrest.Matchers.hasProperty import org.hamcrest.Matchers.`is` +import org.hamcrest.Matchers.notNullValue import org.junit.After import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +import org.mockito.ArgumentCaptor +import org.mockito.ArgumentMatchers.anyLong import org.mockito.ArgumentMatchers.eq import org.mockito.Mockito.any +import org.mockito.Mockito.doCallRealMethod import org.mockito.Mockito.doReturn import org.mockito.Mockito.isA import org.mockito.Mockito.mock import org.mockito.Mockito.reset import org.mockito.Mockito.verify +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.doNothing import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import org.signal.core.util.Hex.fromStringCondensed @@ -25,11 +34,13 @@ import org.signal.libsignal.zkgroup.groups.GroupMasterKey import org.signal.storageservice.protos.groups.local.DecryptedGroup import org.signal.storageservice.protos.groups.local.DecryptedGroupChange import org.signal.storageservice.protos.groups.local.DecryptedMember +import org.signal.storageservice.protos.groups.local.DecryptedString import org.signal.storageservice.protos.groups.local.DecryptedTimer import org.thoughtcrime.securesms.SignalStoreRule import org.thoughtcrime.securesms.database.GroupDatabase import org.thoughtcrime.securesms.database.GroupStateTestData import org.thoughtcrime.securesms.database.RecipientDatabase +import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context import org.thoughtcrime.securesms.database.model.databaseprotos.member import org.thoughtcrime.securesms.database.model.databaseprotos.requestingMember import org.thoughtcrime.securesms.database.setNewDescription @@ -104,6 +115,7 @@ class GroupsV2StateProcessorTest { } } doReturn(testPartial).`when`(groupsV2API).getPartialDecryptedGroup(any(), any()) + doReturn(serverState).`when`(groupsV2API).getGroup(any(), any()) } data.changeSet?.let { changeSet -> @@ -453,4 +465,118 @@ class GroupsV2StateProcessorTest { assertThat("local should update to server", result.groupState, `is`(GroupsV2StateProcessor.GroupState.GROUP_UPDATED)) assertThat("revision matches latest revision on server", result.latestServer!!.revision, `is`(101)) } + + /** + * If for some reason we missed a member being added in our local state, and then we preform a multi-revision update, + * we should now know about the member and add update messages to the chat. + */ + @Test + fun missedMemberAddResolvesWithMultipleRevisionUpdate() { + val secondOther = member(ServiceId.from(UUID.randomUUID())) + + val updateMessageContextCapture = ArgumentCaptor.forClass(DecryptedGroupV2Context::class.java) + profileAndMessageHelper.masterKey = masterKey + doCallRealMethod().`when`(profileAndMessageHelper).insertUpdateMessages(anyLong(), anyOrNull(), any()) + doNothing().`when`(profileAndMessageHelper).storeMessage(updateMessageContextCapture.capture(), anyLong()) + + given { + localState( + revision = 8, + title = "Whatever", + members = selfAndOthers + ) + serverState( + revision = 10, + title = "Changed", + members = selfAndOthers + secondOther + ) + changeSet { + changeLog(9) { + change { + setNewTitle("Mid-Change") + } + fullSnapshot( + title = "Mid-Change", + members = selfAndOthers + secondOther + ) + } + changeLog(10) { + change { + setNewTitle("Changed") + } + } + } + apiCallParameters(requestedRevision = 8, includeFirst = true) + } + + val result = processor.updateLocalGroupToRevision(GroupsV2StateProcessor.LATEST, 0, null) + assertThat("local should update to server", result.groupState, `is`(GroupsV2StateProcessor.GroupState.GROUP_UPDATED)) + assertThat("members contains second other", result.latestServer!!.membersList, hasItem(secondOther)) + + val allUpdateMessageContexts = updateMessageContextCapture.allValues + assertThat("group update messages contains new member add", allUpdateMessageContexts.map { it.change.newMembersList }, hasItem(hasItem(secondOther))) + } + + /** + * If for some reason we missed a member being added in our local state, and then we preform a forced sanity update, + * we should now know about the member and any other changes, and add update messages to the chat. + */ + @Test + fun missedMemberAddResolvesWithForcedUpdate() { + val secondOther = member(ServiceId.from(UUID.randomUUID())) + + val updateMessageContextCapture = ArgumentCaptor.forClass(DecryptedGroupV2Context::class.java) + profileAndMessageHelper.masterKey = masterKey + doCallRealMethod().`when`(profileAndMessageHelper).insertUpdateMessages(anyLong(), anyOrNull(), any()) + doNothing().`when`(profileAndMessageHelper).storeMessage(updateMessageContextCapture.capture(), anyLong()) + + given { + localState( + revision = 10, + title = "Title", + members = selfAndOthers + ) + serverState( + revision = 10, + title = "Changed", + members = selfAndOthers + secondOther + ) + } + + val result = processor.forceSanityUpdateFromServer(0) + assertThat("local should update to server", result.groupState, `is`(GroupsV2StateProcessor.GroupState.GROUP_UPDATED)) + assertThat("members contains second other", result.latestServer!!.membersList, hasItem(secondOther)) + assertThat("title should be updated", result.latestServer!!.title, `is`("Changed")) + + val allUpdateMessageContexts = updateMessageContextCapture.allValues + assertThat("group update messages contains new member add", allUpdateMessageContexts.map { it.change.newMembersList }, hasItem(hasItem(secondOther))) + + assertThat( + "group update messages contains title change", + allUpdateMessageContexts.map { it.change.newTitle }, + hasItem(both(notNullValue()).and(hasProperty("value", `is`("Changed")))) + ) + } + + /** + * If we preform a forced sanity update, with no differences between local and server, then it should be no-op. + */ + @Test + fun noDifferencesNoOpsWithForcedUpdate() { + given { + localState( + revision = 10, + title = "Title", + members = selfAndOthers + ) + serverState( + revision = 10, + title = "Title", + members = selfAndOthers + ) + } + + val result = processor.forceSanityUpdateFromServer(0) + assertThat("local should be unchanged", result.groupState, `is`(GroupsV2StateProcessor.GroupState.GROUP_CONSISTENT_OR_AHEAD)) + } }