From 6bb9d27d4e185581a820be88ec0c2df4f1ace350 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Thu, 15 Oct 2020 15:49:09 -0400 Subject: [PATCH] Add the ability to migrate GV1 groups to GV2. Co-authored-by: Alan Evans --- .../securesms/AppCapabilities.java | 8 +- .../securesms/ApplicationContext.java | 2 + .../conversation/ConversationActivity.java | 6 + .../securesms/database/GroupDatabase.java | 42 +++ .../securesms/database/MessageDatabase.java | 1 + .../securesms/database/MmsDatabase.java | 7 +- .../securesms/database/MmsSmsColumns.java | 1 + .../securesms/database/RecipientDatabase.java | 73 +++- .../securesms/database/SmsDatabase.java | 20 +- .../securesms/database/ThreadDatabase.java | 33 +- .../securesms/groups/GroupManager.java | 23 +- .../securesms/groups/GroupManagerV1.java | 24 +- .../securesms/groups/GroupManagerV2.java | 45 ++- .../groups/GroupV1MessageProcessor.java | 7 +- .../securesms/groups/LiveGroup.java | 2 +- .../chooseadmin/ChooseNewAdminActivity.java | 2 +- .../ui/managegroup/ManageGroupRepository.java | 43 +-- .../ui/managegroup/ManageGroupViewModel.java | 45 ++- .../v2/processing/GroupsV2StateProcessor.java | 24 +- .../PushProcessMessageQueueJobMigration.java | 4 +- .../securesms/jobs/GroupV1MigrationJob.java | 352 ++++++++++++++++++ .../securesms/jobs/JobManagerFactories.java | 1 + .../jobs/PushGroupSilentUpdateSendJob.java | 3 +- .../securesms/jobs/PushProcessMessageJob.java | 34 +- .../jobs/RequestGroupV2InfoWorkerJob.java | 2 +- .../securesms/keyvalue/InternalValues.java | 30 +- .../keyvalue/MiscellaneousValues.java | 23 +- .../InternalOptionsPreferenceFragment.java | 2 + .../edit/EditPushGroupProfileRepository.java | 2 +- .../securesms/recipients/Recipient.java | 22 +- .../securesms/util/FeatureFlags.java | 18 +- .../securesms/util/GroupUtil.java | 26 ++ app/src/main/res/values/strings.xml | 5 + app/src/main/res/xml/preferences_internal.xml | 18 + 34 files changed, 818 insertions(+), 132 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/jobs/GroupV1MigrationJob.java diff --git a/app/src/main/java/org/thoughtcrime/securesms/AppCapabilities.java b/app/src/main/java/org/thoughtcrime/securesms/AppCapabilities.java index 60fe02f632..d9ce463578 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/AppCapabilities.java +++ b/app/src/main/java/org/thoughtcrime/securesms/AppCapabilities.java @@ -1,5 +1,6 @@ package org.thoughtcrime.securesms; +import org.thoughtcrime.securesms.util.FeatureFlags; import org.whispersystems.signalservice.api.account.AccountAttributes; public final class AppCapabilities { @@ -7,15 +8,14 @@ public final class AppCapabilities { private AppCapabilities() { } - private static final boolean UUID_CAPABLE = false; - private static final boolean GV2_CAPABLE = true; - private static final boolean GV1_MIGRATION_CAPABLE = false; + private static final boolean UUID_CAPABLE = false; + private static final boolean GV2_CAPABLE = true; /** * @param storageCapable Whether or not the user can use storage service. This is another way of * asking if the user has set a Signal PIN or not. */ public static AccountAttributes.Capabilities getCapabilities(boolean storageCapable) { - return new AccountAttributes.Capabilities(UUID_CAPABLE, GV2_CAPABLE, storageCapable, GV1_MIGRATION_CAPABLE); + return new AccountAttributes.Capabilities(UUID_CAPABLE, GV2_CAPABLE, storageCapable, FeatureFlags.groupsV1AutoMigration()); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java index 0b2e8595de..58c274c13b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java @@ -44,6 +44,7 @@ import org.thoughtcrime.securesms.dependencies.ApplicationDependencyProvider; import org.thoughtcrime.securesms.gcm.FcmJobService; import org.thoughtcrime.securesms.jobs.CreateSignedPreKeyJob; import org.thoughtcrime.securesms.jobs.FcmRefreshJob; +import org.thoughtcrime.securesms.jobs.GroupV1MigrationJob; import org.thoughtcrime.securesms.jobs.MultiDeviceContactUpdateJob; import org.thoughtcrime.securesms.jobs.PushNotificationReceiveJob; import org.thoughtcrime.securesms.jobs.RefreshPreKeysJob; @@ -155,6 +156,7 @@ public class ApplicationContext extends MultiDexApplication implements DefaultLi FeatureFlags.refreshIfNecessary(); ApplicationDependencies.getRecipientCache().warmUp(); RetrieveProfileJob.enqueueRoutineFetchIfNecessary(this); + GroupV1MigrationJob.enqueueRoutineMigrationsIfNecessary(this); executePendingContactSync(); KeyCachingService.onAppForegrounded(this); ApplicationDependencies.getFrameRateTracker().begin(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java index 64e858cf6d..b4268a550e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java @@ -167,6 +167,7 @@ import org.thoughtcrime.securesms.groups.ui.managegroup.ManageGroupActivity; import org.thoughtcrime.securesms.insights.InsightsLauncher; import org.thoughtcrime.securesms.invites.InviteReminderModel; import org.thoughtcrime.securesms.invites.InviteReminderRepository; +import org.thoughtcrime.securesms.jobs.GroupV1MigrationJob; import org.thoughtcrime.securesms.jobs.GroupV2UpdateSelfProfileKeyJob; import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob; import org.thoughtcrime.securesms.jobs.RetrieveProfileJob; @@ -451,6 +452,7 @@ public class ConversationActivity extends PassphraseRequiredActivity @Override public void onSuccess(Boolean result) { initializeProfiles(); + initializeGv1Migration(); initializeDraft().addListener(new AssertedSuccessListener() { @Override public void onSuccess(Boolean loadedDraft) { @@ -2123,6 +2125,10 @@ public class ConversationActivity extends PassphraseRequiredActivity RetrieveProfileJob.enqueueAsync(recipient.getId()); } + private void initializeGv1Migration() { + GroupV1MigrationJob.enqueuePossibleAutoMigrate(recipient.getId()); + } + private void onRecipientChanged(@NonNull Recipient recipient) { Log.i(TAG, "onModified(" + recipient.getId() + ") " + recipient.getRegistered()); titleView.setTitle(glideRequests, recipient); 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 305997278a..28277f998b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java @@ -468,6 +468,48 @@ public final class GroupDatabase extends Database { notifyConversationListListeners(); } + /** + * Migrates a V1 group to a V2 group. + */ + public @NonNull GroupId.V2 migrateToV2(@NonNull GroupId.V1 groupIdV1, @NonNull DecryptedGroup decryptedGroup) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + GroupId.V2 groupIdV2 = groupIdV1.deriveV2MigrationGroupId(); + GroupMasterKey groupMasterKey = groupIdV1.deriveV2MigrationMasterKey(); + + db.beginTransaction(); + try { + GroupRecord record = getGroup(groupIdV1).get(); + + ContentValues contentValues = new ContentValues(); + contentValues.put(GROUP_ID, groupIdV2.toString()); + contentValues.put(V2_MASTER_KEY, groupMasterKey.serialize()); + contentValues.putNull(EXPECTED_V2_ID); + + List newMembers = Stream.of(DecryptedGroupUtil.membersToUuidList(decryptedGroup.getMembersList())).map(u -> RecipientId.from(u, null)).toList(); + newMembers.addAll(Stream.of(DecryptedGroupUtil.pendingToUuidList(decryptedGroup.getPendingMembersList())).map(u -> RecipientId.from(u, null)).toList()); + + if (record.getMembers().size() > newMembers.size() || !newMembers.containsAll(record.getMembers())) { + contentValues.put(FORMER_V1_MEMBERS, RecipientId.toSerializedList(record.getMembers())); + } + + int updated = db.update(TABLE_NAME, contentValues, GROUP_ID + " = ?", SqlUtil.buildArgs(groupIdV1.toString())); + + if (updated != 1) { + throw new AssertionError(); + } + + DatabaseFactory.getRecipientDatabase(context).updateGroupId(groupIdV1, groupIdV2); + + update(groupMasterKey, decryptedGroup); + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + return groupIdV2; + } + public void update(@NonNull GroupMasterKey groupMasterKey, @NonNull DecryptedGroup decryptedGroup) { update(GroupId.v2(groupMasterKey), decryptedGroup); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MessageDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MessageDatabase.java index 4e238171e5..24b88f30ec 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessageDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessageDatabase.java @@ -137,6 +137,7 @@ public abstract class MessageDatabase extends Database implements MmsSmsColumns public abstract long insertMessageOutbox(@NonNull OutgoingMediaMessage message, long threadId, boolean forceSms, @Nullable SmsDatabase.InsertListener insertListener) throws MmsException; public abstract long insertMessageOutbox(@NonNull OutgoingMediaMessage message, long threadId, boolean forceSms, int defaultReceiptStatus, @Nullable SmsDatabase.InsertListener insertListener) throws MmsException; public abstract void insertProfileNameChangeMessages(@NonNull Recipient recipient, @NonNull String newProfileName, @NonNull String previousProfileName); + public abstract void insertGroupV1MigrationEvent(@NonNull RecipientId recipientId, long threadId, List pendingRecipients); public abstract boolean deleteMessage(long messageId); abstract void deleteThread(long threadId); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java index 98564e2837..5fd012b3b1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java @@ -414,6 +414,11 @@ public class MmsDatabase extends MessageDatabase { throw new UnsupportedOperationException(); } + @Override + public void insertGroupV1MigrationEvent(@NonNull RecipientId recipientId, long threadId, List pendingRecipients) { + throw new UnsupportedOperationException(); + } + @Override public void endTransaction(SQLiteDatabase database) { database.endTransaction(); @@ -588,7 +593,7 @@ public class MmsDatabase extends MessageDatabase { private long getThreadIdFor(@NonNull IncomingMediaMessage retrieved) { if (retrieved.getGroupId() != null) { - RecipientId groupRecipientId = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(retrieved.getGroupId()); + RecipientId groupRecipientId = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromPossiblyMigratedGroupId(retrieved.getGroupId()); Recipient groupRecipients = Recipient.resolved(groupRecipientId); return DatabaseFactory.getThreadDatabase(context).getThreadIdFor(groupRecipients); } else { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsColumns.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsColumns.java index bc848489ed..e909e06c40 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsColumns.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsColumns.java @@ -40,6 +40,7 @@ public interface MmsSmsColumns { protected static final long INVALID_MESSAGE_TYPE = 6; protected static final long PROFILE_CHANGE_TYPE = 7; protected static final long MISSED_VIDEO_CALL_TYPE = 8; + protected static final long GV1_MIGRATION_TYPE = 9; protected static final long BASE_INBOX_TYPE = 20; protected static final long BASE_OUTBOX_TYPE = 21; diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java index 6da868fa5c..26d7709451 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java @@ -35,6 +35,7 @@ import org.thoughtcrime.securesms.jobs.RetrieveProfileJob; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.profiles.AvatarHelper; import org.thoughtcrime.securesms.profiles.ProfileName; +import org.thoughtcrime.securesms.recipients.LiveRecipient; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.storage.StorageSyncHelper; @@ -377,8 +378,8 @@ public class RecipientDatabase extends Database { return getByColumn(EMAIL, email); } - public @NonNull Optional getByGroupId(@NonNull String groupId) { - return getByColumn(GROUP_ID, groupId); + public @NonNull Optional getByGroupId(@NonNull GroupId groupId) { + return getByColumn(GROUP_ID, groupId.toString()); } @@ -554,7 +555,7 @@ public class RecipientDatabase extends Database { } public @NonNull RecipientId getOrInsertFromGroupId(@NonNull GroupId groupId) { - Optional existing = getByColumn(GROUP_ID, groupId.toString()); + Optional existing = getByGroupId(groupId); if (existing.isPresent()) { return existing.get(); @@ -604,6 +605,44 @@ public class RecipientDatabase extends Database { } } + /** + * See {@link Recipient#externalPossiblyMigratedGroup(Context, GroupId)}. + */ + public @NonNull RecipientId getOrInsertFromPossiblyMigratedGroupId(@NonNull GroupId groupId) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + + db.beginTransaction(); + try { + Optional existing = getByColumn(GROUP_ID, groupId.toString()); + + if (existing.isPresent()) { + return existing.get(); + } + + if (groupId.isV1()) { + Optional v2 = getByGroupId(groupId.requireV1().deriveV2MigrationGroupId()); + if (v2.isPresent()) { + return v2.get(); + } + } + + if (groupId.isV2()) { + Optional v1 = DatabaseFactory.getGroupDatabase(context).getGroupV1ByExpectedV2(groupId.requireV2()); + if (v1.isPresent()) { + return v1.get().getRecipientId(); + } + } + + RecipientId id = getOrInsertFromGroupId(groupId); + + db.setTransactionSuccessful(); + + return id; + } finally { + db.endTransaction(); + } + } + public Cursor getBlocked() { SQLiteDatabase database = databaseHelper.getReadableDatabase(); @@ -877,7 +916,7 @@ public class RecipientDatabase extends Database { for (SignalGroupV1Record insert : groupV1Inserts) { db.insertOrThrow(TABLE_NAME, null, getValuesForStorageGroupV1(insert)); - Recipient recipient = Recipient.externalGroup(context, GroupId.v1orThrow(insert.getGroupId())); + Recipient recipient = Recipient.externalGroupExact(context, GroupId.v1orThrow(insert.getGroupId())); threadDatabase.applyStorageSyncUpdate(recipient.getId(), insert); needsRefresh.add(recipient.getId()); @@ -891,7 +930,7 @@ public class RecipientDatabase extends Database { throw new AssertionError("Had an update, but it didn't match any rows!"); } - Recipient recipient = Recipient.externalGroup(context, GroupId.v1orThrow(update.getOld().getGroupId())); + Recipient recipient = Recipient.externalGroupExact(context, GroupId.v1orThrow(update.getOld().getGroupId())); threadDatabase.applyStorageSyncUpdate(recipient.getId(), update.getNew()); needsRefresh.add(recipient.getId()); @@ -902,7 +941,7 @@ public class RecipientDatabase extends Database { GroupId.V2 groupId = GroupId.v2(masterKey); ContentValues values = getValuesForStorageGroupV2(insert); long id = db.insertWithOnConflict(TABLE_NAME, null, values, SQLiteDatabase.CONFLICT_IGNORE); - Recipient recipient = Recipient.externalGroup(context, groupId); + Recipient recipient = Recipient.externalGroupExact(context, groupId); if (id < 0) { Log.w(TAG, String.format("Recipient %s is already linked to group %s", recipient.getId(), groupId)); @@ -934,7 +973,7 @@ public class RecipientDatabase extends Database { } GroupMasterKey masterKey = update.getOld().getMasterKeyOrThrow(); - Recipient recipient = Recipient.externalGroup(context, GroupId.v2(masterKey)); + Recipient recipient = Recipient.externalGroupExact(context, GroupId.v2(masterKey)); threadDatabase.applyStorageSyncUpdate(recipient.getId(), update.getNew()); needsRefresh.add(recipient.getId()); @@ -1155,7 +1194,7 @@ public class RecipientDatabase extends Database { } for (GroupId.V2 id : DatabaseFactory.getGroupDatabase(context).getAllGroupV2Ids()) { - Recipient recipient = Recipient.externalGroup(context, id); + Recipient recipient = Recipient.externalGroupExact(context, id); RecipientId recipientId = recipient.getId(); RecipientSettings recipientSettingsForSync = getRecipientSettingsForSync(recipientId); @@ -2300,6 +2339,24 @@ public class RecipientDatabase extends Database { databaseHelper.getWritableDatabase().update(TABLE_NAME, contentValues, query, args); } + /** + * Updates a group recipient with a new V2 group ID. Should only be done as a part of GV1->GV2 + * migration. + */ + void updateGroupId(@NonNull GroupId.V1 v1Id, @NonNull GroupId.V2 v2Id) { + ContentValues values = new ContentValues(); + values.put(GROUP_ID, v2Id.toString()); + values.put(GROUP_TYPE, GroupType.SIGNAL_V2.getId()); + + SqlUtil.Query query = SqlUtil.buildTrueUpdateQuery(GROUP_ID + " = ?", SqlUtil.buildArgs(v1Id), values); + + if (update(query, values)) { + RecipientId id = getByGroupId(v2Id).get(); + markDirty(id, DirtyState.UPDATE); + Recipient.live(id).refresh(); + } + } + /** * Will update the database with the content values you specified. It will make an intelligent * query such that this will only return true if a row was *actually* updated. diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java index eecd5adebe..94810dfa15 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java @@ -739,6 +739,24 @@ public class SmsDatabase extends MessageDatabase { } } + @Override + public void insertGroupV1MigrationEvent(@NonNull RecipientId recipientId, long threadId, List pendingRecipients) { + ContentValues values = new ContentValues(); + values.put(RECIPIENT_ID, recipientId.serialize()); + values.put(ADDRESS_DEVICE_ID, 1); + values.put(DATE_RECEIVED, System.currentTimeMillis()); + values.put(DATE_SENT, System.currentTimeMillis()); + values.put(READ, 1); + values.put(TYPE, Types.GV1_MIGRATION_TYPE); + values.put(THREAD_ID, threadId); + values.put(BODY, RecipientId.toSerializedList(pendingRecipients)); + + databaseHelper.getWritableDatabase().insert(TABLE_NAME, null, values); + + notifyConversationListeners(threadId); + ApplicationDependencies.getJobManager().add(new TrimThreadJob(threadId)); + } + @Override public Optional insertMessageInbox(IncomingTextMessage message, long type) { if (message.isJoined()) { @@ -775,7 +793,7 @@ public class SmsDatabase extends MessageDatabase { if (message.getGroupId() == null) { groupRecipient = null; } else { - RecipientId id = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(message.getGroupId()); + RecipientId id = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromPossiblyMigratedGroupId(message.getGroupId()); groupRecipient = Recipient.resolved(id); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java index ae4f740a2e..57d035bf57 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java @@ -62,7 +62,6 @@ import org.whispersystems.signalservice.api.storage.SignalAccountRecord; import org.whispersystems.signalservice.api.storage.SignalContactRecord; import org.whispersystems.signalservice.api.storage.SignalGroupV1Record; import org.whispersystems.signalservice.api.storage.SignalGroupV2Record; -import org.whispersystems.signalservice.api.storage.SignalStorageRecord; import java.io.Closeable; import java.io.IOException; @@ -577,6 +576,28 @@ public class ThreadDatabase extends Database { return db.rawQuery(query, null); } + public @NonNull List getRecentV1Groups(int limit) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + String where = MESSAGE_COUNT + " != 0 AND " + + "(" + + GroupDatabase.TABLE_NAME + "." + GroupDatabase.ACTIVE + " = 1 AND " + + GroupDatabase.TABLE_NAME + "." + GroupDatabase.V2_MASTER_KEY + " IS NULL AND " + + GroupDatabase.TABLE_NAME + "." + GroupDatabase.MMS + " = 0" + + ")"; + String query = createQuery(where, 0, limit, true); + + List threadRecords = new ArrayList<>(); + + try (Reader reader = readerFor(db.rawQuery(query, null))) { + ThreadRecord record; + + while ((record = reader.getNext()) != null) { + threadRecords.add(record); + } + } + return threadRecords; + } + public Cursor getConversationList() { return getConversationList("0"); } @@ -697,7 +718,7 @@ public class ThreadDatabase extends Database { final String query; if (pinned) { - query = createQuery(where, PINNED + " ASC", offset, limit, false); + query = createQuery(where, PINNED + " ASC", offset, limit); } else { query = createQuery(where, offset, limit, false); } @@ -1076,14 +1097,14 @@ public class ThreadDatabase extends Database { pinnedRecipient = Recipient.externalPush(context, pinned.getContact().get()); } else if (pinned.getGroupV1Id().isPresent()) { try { - pinnedRecipient = Recipient.externalGroup(context, GroupId.v1Exact(pinned.getGroupV1Id().get())); + pinnedRecipient = Recipient.externalGroupExact(context, GroupId.v1Exact(pinned.getGroupV1Id().get())); } catch (BadGroupIdException e) { Log.w(TAG, "Failed to parse pinned groupV1 ID!", e); pinnedRecipient = null; } } else if (pinned.getGroupV2MasterKey().isPresent()) { try { - pinnedRecipient = Recipient.externalGroup(context, GroupId.v2(new GroupMasterKey(pinned.getGroupV2MasterKey().get()))); + pinnedRecipient = Recipient.externalGroupExact(context, GroupId.v2(new GroupMasterKey(pinned.getGroupV2MasterKey().get()))); } catch (InvalidInputException e) { Log.w(TAG, "Failed to parse pinned groupV2 master key!", e); pinnedRecipient = null; @@ -1321,10 +1342,10 @@ public class ThreadDatabase extends Database { private @NonNull String createQuery(@NonNull String where, long offset, long limit, boolean preferPinned) { String orderBy = (preferPinned ? TABLE_NAME + "." + PINNED + " DESC, " : "") + TABLE_NAME + "." + DATE + " DESC"; - return createQuery(where, orderBy, offset, limit, preferPinned); + return createQuery(where, orderBy, offset, limit); } - private @NonNull String createQuery(@NonNull String where, @NonNull String orderBy, long offset, long limit, boolean preferPinned) { + private @NonNull String createQuery(@NonNull String where, @NonNull String orderBy, long offset, long limit) { String projection = Util.join(COMBINED_THREAD_RECIPIENT_GROUP_PROJECTION, ","); String query = 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 ff6236ed9f..fce73d69f5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java @@ -6,6 +6,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.WorkerThread; +import org.signal.storageservice.protos.groups.local.DecryptedGroup; import org.signal.storageservice.protos.groups.local.DecryptedGroupJoinInfo; import org.signal.zkgroup.VerificationFailedException; import org.signal.zkgroup.groups.GroupMasterKey; @@ -81,10 +82,11 @@ public final class GroupManager { @WorkerThread public static void migrateGroupToServer(@NonNull Context context, - @NonNull GroupId.V1 groupIdV1) + @NonNull GroupId.V1 groupIdV1, + @NonNull Collection members) throws IOException, GroupChangeFailedException, MembershipNotSuitableForV2Exception, GroupAlreadyExistsException { - new GroupManagerV2(context).migrateGroupOnToServer(groupIdV1); + new GroupManagerV2(context).migrateGroupOnToServer(groupIdV1, members); } private static Set getMemberIds(Collection recipients) { @@ -186,6 +188,19 @@ public final class GroupManager { } } + /** + * Tries to gets the exact version of the group at the time you joined. + *

+ * If it fails to get the exact version, it will give the latest. + */ + @WorkerThread + public static DecryptedGroup addedGroupVersion(@NonNull Context context, + @NonNull GroupMasterKey groupMasterKey) + throws IOException, GroupDoesNotExistException, GroupNotAMemberException + { + return new GroupManagerV2(context).addedGroupVersion(groupMasterKey); + } + @WorkerThread public static void setMemberAdmin(@NonNull Context context, @NonNull GroupId.V2 groupId, @@ -371,6 +386,10 @@ public final class GroupManager { } } + public static void sendNoopUpdate(@NonNull Context context, @NonNull GroupMasterKey groupMasterKey, @NonNull DecryptedGroup currentState) { + new GroupManagerV2(context).sendNoopGroupUpdate(groupMasterKey, currentState); + } + public static class GroupActionResult { private final Recipient groupRecipient; private final long threadId; diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV1.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV1.java index 882f7c470d..0c68dfc5cc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV1.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV1.java @@ -27,8 +27,8 @@ import org.thoughtcrime.securesms.profiles.AvatarHelper; import org.thoughtcrime.securesms.providers.BlobProvider; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; -import org.thoughtcrime.securesms.recipients.RecipientUtil; import org.thoughtcrime.securesms.sms.MessageSender; +import org.thoughtcrime.securesms.util.GroupUtil; import org.thoughtcrime.securesms.util.MediaUtil; import org.whispersystems.libsignal.util.guava.Optional; import org.whispersystems.signalservice.internal.push.SignalServiceProtos.GroupContext; @@ -157,7 +157,7 @@ final class GroupManagerV1 { @WorkerThread static boolean leaveGroup(@NonNull Context context, @NonNull GroupId.V1 groupId) { - Recipient groupRecipient = Recipient.externalGroup(context, groupId); + Recipient groupRecipient = Recipient.externalGroupExact(context, groupId); long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(groupRecipient); Optional leaveMessage = createGroupLeaveMessage(context, groupId, groupRecipient); @@ -183,7 +183,7 @@ final class GroupManagerV1 { @WorkerThread static boolean silentLeaveGroup(@NonNull Context context, @NonNull GroupId.V1 groupId) { if (DatabaseFactory.getGroupDatabase(context).isActive(groupId)) { - Recipient groupRecipient = Recipient.externalGroup(context, groupId); + Recipient groupRecipient = Recipient.externalGroupExact(context, groupId); long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(groupRecipient); Optional leaveMessage = createGroupLeaveMessage(context, groupId, groupRecipient); @@ -208,7 +208,7 @@ final class GroupManagerV1 { static void updateGroupTimer(@NonNull Context context, @NonNull GroupId.V1 groupId, int expirationTime) { RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context); ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(context); - Recipient recipient = Recipient.externalGroup(context, groupId); + Recipient recipient = Recipient.externalGroupExact(context, groupId); long threadId = threadDatabase.getThreadIdFor(recipient); recipientDatabase.setExpireMessages(recipient.getId(), expirationTime); @@ -228,20 +228,6 @@ final class GroupManagerV1 { return Optional.absent(); } - GroupContext groupContext = GroupContext.newBuilder() - .setId(ByteString.copyFrom(groupId.getDecodedId())) - .setType(GroupContext.Type.QUIT) - .build(); - - return Optional.of(new OutgoingGroupUpdateMessage(groupRecipient, - groupContext, - null, - System.currentTimeMillis(), - 0, - false, - null, - Collections.emptyList(), - Collections.emptyList(), - Collections.emptyList())); + return Optional.of(GroupUtil.createGroupV1LeaveMessage(groupId, groupRecipient)); } } 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 1790a3a189..b1f624b64c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java @@ -30,6 +30,7 @@ import org.signal.zkgroup.profiles.ProfileKeyCredential; import org.thoughtcrime.securesms.crypto.ProfileKeyUtil; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.GroupDatabase; +import org.thoughtcrime.securesms.database.RecipientDatabase; import org.thoughtcrime.securesms.database.ThreadDatabase; import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; @@ -147,7 +148,34 @@ final class GroupManagerV2 { } @WorkerThread - void migrateGroupOnToServer(@NonNull GroupId.V1 groupIdV1) + @NonNull DecryptedGroup addedGroupVersion(@NonNull GroupMasterKey groupMasterKey) + throws GroupNotAMemberException, IOException, GroupDoesNotExistException + { + GroupsV2StateProcessor.StateProcessorForGroup stateProcessorForGroup = new GroupsV2StateProcessor(context).forGroup(groupMasterKey); + DecryptedGroup latest = stateProcessorForGroup.getCurrentGroupStateFromServer(); + + if (latest.getRevision() == 0) { + return latest; + } + + Optional selfInFullMemberList = DecryptedGroupUtil.findMemberByUuid(latest.getMembersList(), Recipient.self().requireUuid()); + + if (!selfInFullMemberList.isPresent()) { + return latest; + } + + DecryptedGroup joinedVersion = stateProcessorForGroup.getSpecificVersionFromServer(selfInFullMemberList.get().getJoinedAtRevision()); + + if (joinedVersion != null) { + return joinedVersion; + } else { + Log.w(TAG, "Unable to retreive exact version joined at, using latest"); + return latest; + } + } + + @WorkerThread + void migrateGroupOnToServer(@NonNull GroupId.V1 groupIdV1, @NonNull Collection members) throws IOException, MembershipNotSuitableForV2Exception, GroupAlreadyExistsException, GroupChangeFailedException { GroupMasterKey groupMasterKey = groupIdV1.deriveV2MigrationMasterKey(); @@ -156,13 +184,20 @@ final class GroupManagerV2 { String name = groupRecord.getTitle(); byte[] avatar = groupRecord.hasAvatar() ? AvatarHelper.getAvatarBytes(context, groupRecord.getRecipientId()) : null; int messageTimer = Recipient.resolved(groupRecord.getRecipientId()).getExpireMessages(); - Set memberIds = Stream.of(groupRecord.getMembers()) + Set memberIds = Stream.of(members) + .map(Recipient::getId) .filterNot(m -> m.equals(Recipient.self().getId())) .collect(Collectors.toSet()); createGroupOnServer(groupSecretParams, name, avatar, memberIds, Member.Role.ADMINISTRATOR, messageTimer); } + @WorkerThread + void sendNoopGroupUpdate(@NonNull GroupMasterKey masterKey, @NonNull DecryptedGroup currentState) { + sendGroupUpdate(masterKey, new GroupMutation(currentState, DecryptedGroupChange.newBuilder().build(), currentState), null); + } + + final class GroupCreator extends LockOwner { GroupCreator(@NonNull Closeable lock) { @@ -290,7 +325,7 @@ final class GroupManagerV2 { GroupManager.GroupActionResult groupActionResult = commitChangeWithConflictResolution(change); if (avatarChanged) { - AvatarHelper.setAvatar(context, Recipient.externalGroup(context, groupId).getId(), avatarBytes != null ? new ByteArrayInputStream(avatarBytes) : null); + AvatarHelper.setAvatar(context, Recipient.externalGroupExact(context, groupId).getId(), avatarBytes != null ? new ByteArrayInputStream(avatarBytes) : null); groupDatabase.onAvatarUpdated(groupId, avatarBytes != null); } @@ -479,7 +514,7 @@ final class GroupManagerV2 { if (GroupChangeUtil.changeIsEmpty(change.build())) { Log.i(TAG, "Change is empty after conflict resolution"); - Recipient groupRecipient = Recipient.externalGroup(context, groupId); + Recipient groupRecipient = Recipient.externalGroupExact(context, groupId); long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(groupRecipient); return new GroupManager.GroupActionResult(groupRecipient, threadId, 0, Collections.emptyList()); @@ -1026,7 +1061,7 @@ final class GroupManagerV2 { @Nullable GroupChange signedGroupChange) { GroupId.V2 groupId = GroupId.v2(masterKey); - Recipient groupRecipient = Recipient.externalGroup(context, groupId); + Recipient groupRecipient = Recipient.externalGroupExact(context, groupId); DecryptedGroupV2Context decryptedGroupV2Context = GroupProtoUtil.createDecryptedGroupV2Context(masterKey, groupMutation, signedGroupChange); OutgoingGroupUpdateMessage outgoingMessage = new OutgoingGroupUpdateMessage(groupRecipient, decryptedGroupV2Context, diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupV1MessageProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupV1MessageProcessor.java index c31fd940d8..7b05b74008 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupV1MessageProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupV1MessageProcessor.java @@ -9,13 +9,10 @@ import androidx.annotation.Nullable; import com.annimon.stream.Stream; import com.google.protobuf.ByteString; -import org.thoughtcrime.securesms.database.Database; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.GroupDatabase; import org.thoughtcrime.securesms.database.MessageDatabase; import org.thoughtcrime.securesms.database.MessageDatabase.InsertResult; -import org.thoughtcrime.securesms.database.MmsDatabase; -import org.thoughtcrime.securesms.database.SmsDatabase; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.jobs.AvatarGroupsV1DownloadJob; import org.thoughtcrime.securesms.jobs.PushGroupUpdateJob; @@ -24,11 +21,9 @@ import org.thoughtcrime.securesms.mms.MmsException; import org.thoughtcrime.securesms.mms.OutgoingGroupUpdateMessage; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; -import org.thoughtcrime.securesms.recipients.RecipientUtil; import org.thoughtcrime.securesms.sms.IncomingGroupUpdateMessage; import org.thoughtcrime.securesms.sms.IncomingTextMessage; import org.thoughtcrime.securesms.util.Base64; -import org.thoughtcrime.securesms.util.FeatureFlags; import org.whispersystems.libsignal.util.guava.Optional; import org.whispersystems.signalservice.api.messages.SignalServiceAttachment; import org.whispersystems.signalservice.api.messages.SignalServiceContent; @@ -114,7 +109,7 @@ public final class GroupV1MessageProcessor { if (sender.isSystemContact() || sender.isProfileSharing()) { Log.i(TAG, "Auto-enabling profile sharing because 'adder' is trusted. contact: " + sender.isSystemContact() + ", profileSharing: " + sender.isProfileSharing()); - DatabaseFactory.getRecipientDatabase(context).setProfileSharing(Recipient.externalGroup(context, id).getId(), true); + DatabaseFactory.getRecipientDatabase(context).setProfileSharing(Recipient.externalGroupExact(context, id).getId(), true); } return storeMessage(context, content, group, builder.build(), outgoing); diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/LiveGroup.java b/app/src/main/java/org/thoughtcrime/securesms/groups/LiveGroup.java index c2a9794ad8..2800c886cb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/LiveGroup.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/LiveGroup.java @@ -82,7 +82,7 @@ public final class LiveGroup { this.groupLink = new MutableLiveData<>(GroupLinkUrlAndStatus.NONE); } - SignalExecutors.BOUNDED.execute(() -> liveRecipient.postValue(Recipient.externalGroup(context, groupId).live())); + SignalExecutors.BOUNDED.execute(() -> liveRecipient.postValue(Recipient.externalGroupExact(context, groupId).live())); } protected static LiveData> mapToFullMembers(@NonNull LiveData groupRecord) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/chooseadmin/ChooseNewAdminActivity.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/chooseadmin/ChooseNewAdminActivity.java index 61f161f828..9762e8da03 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/chooseadmin/ChooseNewAdminActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/chooseadmin/ChooseNewAdminActivity.java @@ -110,7 +110,7 @@ public final class ChooseNewAdminActivity extends PassphraseRequiredActivity { private void handleUpdateAndLeaveResult(@NonNull GroupChangeResult updateResult) { if (updateResult.isSuccess()) { - String title = Recipient.externalGroup(this, groupId).getDisplayName(this); + String title = Recipient.externalGroupExact(this, groupId).getDisplayName(this); Toast.makeText(this, getString(R.string.ChooseNewAdminActivity_you_left, title), Toast.LENGTH_LONG).show(); startActivity(new Intent(this, MainActivity.class)); finish(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupRepository.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupRepository.java index b453e2cccf..1809dbdf58 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupRepository.java @@ -42,22 +42,16 @@ final class ManageGroupRepository { private static final String TAG = Log.tag(ManageGroupRepository.class); private final Context context; - private final GroupId groupId; - ManageGroupRepository(@NonNull Context context, @NonNull GroupId groupId) { + ManageGroupRepository(@NonNull Context context) { this.context = context; - this.groupId = groupId; } - public GroupId getGroupId() { - return groupId; + void getGroupState(@NonNull GroupId groupId, @NonNull Consumer onGroupStateLoaded) { + SignalExecutors.BOUNDED.execute(() -> onGroupStateLoaded.accept(getGroupState(groupId))); } - void getGroupState(@NonNull Consumer onGroupStateLoaded) { - SignalExecutors.BOUNDED.execute(() -> onGroupStateLoaded.accept(getGroupState())); - } - - void getGroupCapacity(@NonNull Consumer onGroupCapacityLoaded) { + void getGroupCapacity(@NonNull GroupId groupId, @NonNull Consumer onGroupCapacityLoaded) { SimpleTask.run(SignalExecutors.BOUNDED, () -> { GroupDatabase.GroupRecord groupRecord = DatabaseFactory.getGroupDatabase(context).getGroup(groupId).get(); if (groupRecord.isV2Group()) { @@ -77,15 +71,15 @@ final class ManageGroupRepository { } @WorkerThread - private GroupStateResult getGroupState() { + private GroupStateResult getGroupState(@NonNull GroupId groupId) { ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(context); - Recipient groupRecipient = Recipient.externalGroup(context, groupId); + Recipient groupRecipient = Recipient.externalGroupExact(context, groupId); long threadId = threadDatabase.getThreadIdFor(groupRecipient); return new GroupStateResult(threadId, groupRecipient); } - void setExpiration(int newExpirationTime, @NonNull GroupChangeErrorCallback error) { + void setExpiration(@NonNull GroupId groupId, int newExpirationTime, @NonNull GroupChangeErrorCallback error) { SignalExecutors.UNBOUNDED.execute(() -> { try { GroupManager.updateGroupTimer(context, groupId.requirePush(), newExpirationTime); @@ -96,7 +90,7 @@ final class ManageGroupRepository { }); } - void applyMembershipRightsChange(@NonNull GroupAccessControl newRights, @NonNull GroupChangeErrorCallback error) { + void applyMembershipRightsChange(@NonNull GroupId groupId, @NonNull GroupAccessControl newRights, @NonNull GroupChangeErrorCallback error) { SignalExecutors.UNBOUNDED.execute(() -> { try { GroupManager.applyMembershipAdditionRightsChange(context, groupId.requireV2(), newRights); @@ -107,7 +101,7 @@ final class ManageGroupRepository { }); } - void applyAttributesRightsChange(@NonNull GroupAccessControl newRights, @NonNull GroupChangeErrorCallback error) { + void applyAttributesRightsChange(@NonNull GroupId groupId, @NonNull GroupAccessControl newRights, @NonNull GroupChangeErrorCallback error) { SignalExecutors.UNBOUNDED.execute(() -> { try { GroupManager.applyAttributesRightsChange(context, groupId.requireV2(), newRights); @@ -118,20 +112,21 @@ final class ManageGroupRepository { }); } - public void getRecipient(@NonNull Consumer recipientCallback) { + public void getRecipient(@NonNull GroupId groupId, @NonNull Consumer recipientCallback) { SimpleTask.run(SignalExecutors.BOUNDED, - () -> Recipient.externalGroup(context, groupId), + () -> Recipient.externalGroupExact(context, groupId), recipientCallback::accept); } - void setMuteUntil(long until) { + void setMuteUntil(@NonNull GroupId groupId, long until) { SignalExecutors.BOUNDED.execute(() -> { - RecipientId recipientId = Recipient.externalGroup(context, groupId).getId(); + RecipientId recipientId = Recipient.externalGroupExact(context, groupId).getId(); DatabaseFactory.getRecipientDatabase(context).setMuted(recipientId, until); }); } - void addMembers(@NonNull List selected, + void addMembers(@NonNull GroupId groupId, + @NonNull List selected, @NonNull AsynchronousCallback.WorkerThread callback) { SignalExecutors.UNBOUNDED.execute(() -> { @@ -145,10 +140,10 @@ final class ManageGroupRepository { }); } - void blockAndLeaveGroup(@NonNull GroupChangeErrorCallback error, @NonNull Runnable onSuccess) { + void blockAndLeaveGroup(@NonNull GroupId groupId, @NonNull GroupChangeErrorCallback error, @NonNull Runnable onSuccess) { SignalExecutors.UNBOUNDED.execute(() -> { try { - RecipientUtil.block(context, Recipient.externalGroup(context, groupId)); + RecipientUtil.block(context, Recipient.externalGroupExact(context, groupId)); onSuccess.run(); } catch (GroupChangeException | IOException e) { Log.w(TAG, e); @@ -157,9 +152,9 @@ final class ManageGroupRepository { }); } - void setMentionSetting(RecipientDatabase.MentionSetting mentionSetting) { + void setMentionSetting(@NonNull GroupId groupId, RecipientDatabase.MentionSetting mentionSetting) { SignalExecutors.BOUNDED.execute(() -> { - RecipientId recipientId = Recipient.externalGroup(context, groupId).getId(); + RecipientId recipientId = Recipient.externalGroupExact(context, groupId).getId(); DatabaseFactory.getRecipientDatabase(context).setMentionSetting(recipientId, mentionSetting); }); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupViewModel.java index e040549c14..ce7c25d17e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupViewModel.java @@ -81,18 +81,18 @@ public class ManageGroupViewModel extends ViewModel { private final LiveData groupLinkOn; private final LiveData groupInfoMessage; - private ManageGroupViewModel(@NonNull Context context, @NonNull ManageGroupRepository manageGroupRepository) { + private ManageGroupViewModel(@NonNull Context context, @NonNull GroupId groupId, @NonNull ManageGroupRepository manageGroupRepository) { this.context = context; this.manageGroupRepository = manageGroupRepository; - manageGroupRepository.getGroupState(this::groupStateLoaded); + manageGroupRepository.getGroupState(groupId, this::groupStateLoaded); - GroupId groupId = manageGroupRepository.getGroupId(); LiveGroup liveGroup = new LiveGroup(groupId); this.title = Transformations.map(liveGroup.getTitle(), title -> TextUtils.isEmpty(title) ? context.getString(R.string.Recipient_unknown) : title); + this.groupRecipient = liveGroup.getGroupRecipient(); this.isAdmin = liveGroup.isSelfAdmin(); this.canCollapseMemberList = LiveDataUtil.combineLatest(memberListCollapseState, Transformations.map(liveGroup.getFullMembers(), m -> m.size() > MAX_UNCOLLAPSED_MEMBERS), @@ -102,7 +102,7 @@ public class ManageGroupViewModel extends ViewModel { ManageGroupViewModel::filterMemberList); this.pendingMemberCount = liveGroup.getPendingMemberCount(); this.pendingAndRequestingCount = liveGroup.getPendingAndRequestingMemberCount(); - this.showLegacyIndicator = new MutableLiveData<>(groupId.isV1()); + this.showLegacyIndicator = Transformations.map(groupRecipient, recipient -> recipient.requireGroupId().isV1()); this.memberCountSummary = LiveDataUtil.combineLatest(liveGroup.getMembershipCountDescription(context.getResources()), this.showLegacyIndicator, (description, legacy) -> legacy ? String.format("%s ยท %s", description, context.getString(R.string.ManageGroupActivity_legacy_group)) @@ -113,7 +113,6 @@ public class ManageGroupViewModel extends ViewModel { this.disappearingMessageTimer = Transformations.map(liveGroup.getExpireMessages(), expiration -> ExpirationUtil.getExpirationDisplayValue(context, expiration)); this.canEditGroupAttributes = liveGroup.selfCanEditGroupAttributes(); this.canAddMembers = liveGroup.selfCanAddMembers(); - this.groupRecipient = liveGroup.getGroupRecipient(); this.muteState = Transformations.map(this.groupRecipient, recipient -> new MuteState(recipient.getMuteUntil(), recipient.isMuted())); this.hasCustomNotifications = Transformations.map(this.groupRecipient, @@ -231,44 +230,47 @@ public class ManageGroupViewModel extends ViewModel { } void handleExpirationSelection() { - manageGroupRepository.getRecipient(groupRecipient -> + manageGroupRepository.getRecipient(getGroupId(), + groupRecipient -> ExpirationDialog.show(context, groupRecipient.getExpireMessages(), - expirationTime -> manageGroupRepository.setExpiration(expirationTime, this::showErrorToast))); + expirationTime -> manageGroupRepository.setExpiration(getGroupId(), expirationTime, this::showErrorToast))); } void applyMembershipRightsChange(@NonNull GroupAccessControl newRights) { - manageGroupRepository.applyMembershipRightsChange(newRights, this::showErrorToast); + manageGroupRepository.applyMembershipRightsChange(getGroupId(), newRights, this::showErrorToast); } void applyAttributesRightsChange(@NonNull GroupAccessControl newRights) { - manageGroupRepository.applyAttributesRightsChange(newRights, this::showErrorToast); + manageGroupRepository.applyAttributesRightsChange(getGroupId(), newRights, this::showErrorToast); } void blockAndLeave(@NonNull FragmentActivity activity) { - manageGroupRepository.getRecipient(recipient -> BlockUnblockDialog.showBlockFor(activity, + manageGroupRepository.getRecipient(getGroupId(), + recipient -> BlockUnblockDialog.showBlockFor(activity, activity.getLifecycle(), recipient, this::onBlockAndLeaveConfirmed)); } void unblock(@NonNull FragmentActivity activity) { - manageGroupRepository.getRecipient(recipient -> BlockUnblockDialog.showUnblockFor(activity, activity.getLifecycle(), recipient, + manageGroupRepository.getRecipient(getGroupId(), + recipient -> BlockUnblockDialog.showUnblockFor(activity, activity.getLifecycle(), recipient, () -> RecipientUtil.unblock(context, recipient))); } void onAddMembers(@NonNull List selected, @NonNull AsynchronousCallback.MainThread callback) { - manageGroupRepository.addMembers(selected, callback.toWorkerCallback()); + manageGroupRepository.addMembers(getGroupId(), selected, callback.toWorkerCallback()); } void setMuteUntil(long muteUntil) { - manageGroupRepository.setMuteUntil(muteUntil); + manageGroupRepository.setMuteUntil(getGroupId(), muteUntil); } void clearMuteUntil() { - manageGroupRepository.setMuteUntil(0); + manageGroupRepository.setMuteUntil(getGroupId(), 0); } void revealCollapsedMembers() { @@ -276,19 +278,24 @@ public class ManageGroupViewModel extends ViewModel { } void handleMentionNotificationSelection() { - manageGroupRepository.getRecipient(r -> GroupMentionSettingDialog.show(context, r.getMentionSetting(), manageGroupRepository::setMentionSetting)); + manageGroupRepository.getRecipient(getGroupId(), r -> GroupMentionSettingDialog.show(context, r.getMentionSetting(), setting -> manageGroupRepository.setMentionSetting(getGroupId(), setting))); } private void onBlockAndLeaveConfirmed() { SimpleProgressDialog.DismissibleDialog dismissibleDialog = SimpleProgressDialog.showDelayed(context); - manageGroupRepository.blockAndLeaveGroup(e -> { + manageGroupRepository.blockAndLeaveGroup(getGroupId(), + e -> { dismissibleDialog.dismiss(); showErrorToast(e); }, dismissibleDialog::dismiss); } + private @NonNull GroupId getGroupId() { + return groupRecipient.getValue().requireGroupId(); + } + private static @NonNull List filterMemberList(@NonNull List members, @NonNull CollapseState collapseState) { @@ -305,13 +312,13 @@ public class ManageGroupViewModel extends ViewModel { } public void onAddMembersClick(@NonNull Fragment fragment, int resultCode) { - manageGroupRepository.getGroupCapacity(capacity -> { + manageGroupRepository.getGroupCapacity(getGroupId(), capacity -> { int remainingCapacity = capacity.getRemainingCapacity(); if (remainingCapacity <= 0) { GroupLimitDialog.showHardLimitMessage(fragment.requireContext()); } else { Intent intent = new Intent(fragment.requireActivity(), AddMembersActivity.class); - intent.putExtra(AddMembersActivity.GROUP_ID, manageGroupRepository.getGroupId().toString()); + intent.putExtra(AddMembersActivity.GROUP_ID, getGroupId().toString()); intent.putExtra(ContactSelectionListFragment.DISPLAY_MODE, ContactsCursorLoader.DisplayMode.FLAG_PUSH); intent.putExtra(ContactSelectionListFragment.SELECTION_LIMITS, new SelectionLimits(capacity.getSelectionWarning(), capacity.getSelectionLimit())); intent.putParcelableArrayListExtra(ContactSelectionListFragment.CURRENT_SELECTION, capacity.getMembersWithoutSelf()); @@ -410,7 +417,7 @@ public class ManageGroupViewModel extends ViewModel { @Override public @NonNull T create(@NonNull Class modelClass) { //noinspection unchecked - return (T) new ManageGroupViewModel(context, new ManageGroupRepository(context.getApplicationContext(), groupId)); + return (T) new ManageGroupViewModel(context, groupId, new ManageGroupRepository(context.getApplicationContext())); } } } 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 cfbdc31abf..e0591decb0 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 @@ -245,7 +245,7 @@ public final class GroupsV2StateProcessor { } @WorkerThread - public DecryptedGroup getCurrentGroupStateFromServer() + public @NonNull DecryptedGroup getCurrentGroupStateFromServer() throws IOException, GroupNotAMemberException, GroupDoesNotExistException { try { @@ -259,13 +259,31 @@ public final class GroupsV2StateProcessor { } } + @WorkerThread + public @Nullable DecryptedGroup getSpecificVersionFromServer(int revision) + throws IOException, GroupNotAMemberException, GroupDoesNotExistException + { + try { + return groupsV2Api.getGroupHistory(groupSecretParams, revision, groupsV2Authorization.getAuthorizationForToday(Recipient.self().requireUuid(), groupSecretParams)) + .get(0) + .getGroup() + .orNull(); + } catch (GroupNotFoundException e) { + throw new GroupDoesNotExistException(e); + } catch (NotInGroupException e) { + throw new GroupNotAMemberException(e); + } catch (VerificationFailedException | InvalidGroupStateException e) { + throw new IOException(e); + } + } + private void insertGroupLeave() { if (!groupDatabase.isActive(groupId)) { Log.w(TAG, "Group has already been left."); return; } - Recipient groupRecipient = Recipient.externalGroup(context, groupId); + Recipient groupRecipient = Recipient.externalGroupExact(context, groupId); UUID selfUuid = Recipient.self().getUuid().get(); DecryptedGroup decryptedGroup = groupDatabase.requireGroup(groupId) .requireV2GroupProperties() @@ -368,7 +386,7 @@ public final class GroupsV2StateProcessor { if (addedBy.isSystemContact() || addedBy.isProfileSharing()) { 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"); - recipientDatabase.setProfileSharing(Recipient.externalGroup(context, groupId).getId(), true); + recipientDatabase.setProfileSharing(Recipient.externalGroupExact(context, groupId).getId(), true); } else { Log.i(TAG, "Added to a group, but not enabling profile sharing, as 'adder' is not trusted"); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/migrations/PushProcessMessageQueueJobMigration.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/migrations/PushProcessMessageQueueJobMigration.java index 521487950d..497abce882 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/migrations/PushProcessMessageQueueJobMigration.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/migrations/PushProcessMessageQueueJobMigration.java @@ -58,7 +58,7 @@ public class PushProcessMessageQueueJobMigration extends JobMigration { Log.i(TAG, "Migrating a group message."); try { GroupId groupId = GroupUtil.idFromGroupContext(content.getDataMessage().get().getGroupContext().get()); - Recipient recipient = Recipient.externalGroup(context, groupId); + Recipient recipient = Recipient.externalGroupExact(context, groupId); suffix = recipient.getId().toQueueKey(); } catch (BadGroupIdException e) { @@ -75,7 +75,7 @@ public class PushProcessMessageQueueJobMigration extends JobMigration { GroupId exceptionGroup = GroupId.parseNullableOrThrow(data.getStringOrDefault("exception_groupId", null)); if (exceptionGroup != null) { - suffix = Recipient.externalGroup(context, exceptionGroup).getId().toQueueKey(); + suffix = Recipient.externalGroupExact(context, exceptionGroup).getId().toQueueKey(); } else if (exceptionSender != null) { suffix = Recipient.external(context, exceptionSender).getId().toQueueKey(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/GroupV1MigrationJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/GroupV1MigrationJob.java new file mode 100644 index 0000000000..8f34790ed2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/GroupV1MigrationJob.java @@ -0,0 +1,352 @@ +package org.thoughtcrime.securesms.jobs; + +import android.app.Application; +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.annimon.stream.Stream; + +import org.signal.storageservice.protos.groups.local.DecryptedGroup; +import org.signal.zkgroup.groups.GroupMasterKey; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.model.ThreadRecord; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.groups.GroupAlreadyExistsException; +import org.thoughtcrime.securesms.groups.GroupChangeBusyException; +import org.thoughtcrime.securesms.groups.GroupChangeFailedException; +import org.thoughtcrime.securesms.groups.GroupDoesNotExistException; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.groups.GroupManager; +import org.thoughtcrime.securesms.groups.GroupNotAMemberException; +import org.thoughtcrime.securesms.groups.MembershipNotSuitableForV2Exception; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.JobManager; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.mms.MmsException; +import org.thoughtcrime.securesms.mms.OutgoingMediaMessage; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.recipients.RecipientUtil; +import org.thoughtcrime.securesms.transport.RetryLaterException; +import org.thoughtcrime.securesms.util.FeatureFlags; +import org.thoughtcrime.securesms.util.GroupUtil; +import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.concurrent.SignalExecutors; +import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil; +import org.whispersystems.signalservice.api.groupsv2.NoCredentialForRedemptionTimeException; +import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; + +import java.io.IOException; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +import static org.thoughtcrime.securesms.groups.v2.processing.GroupsV2StateProcessor.LATEST; + +public class GroupV1MigrationJob extends BaseJob { + + private static final String TAG = Log.tag(GroupV1MigrationJob.class); + + public static final String KEY = "GroupV1MigrationJob"; + + private static final String KEY_RECIPIENT_ID = "recipient_id"; + private static final String KEY_FORCED = "forced"; + + private static final int ROUTINE_LIMIT = 50; + private static final long REFRESH_INTERVAL = TimeUnit.HOURS.toMillis(3); + + private final RecipientId recipientId; + private final boolean forced; + + private GroupV1MigrationJob(@NonNull RecipientId recipientId, boolean forced) { + this(updateParameters(new Parameters.Builder() + .setQueue(recipientId.toQueueKey()) + .addConstraint(NetworkConstraint.KEY), + forced), + recipientId, + forced); + } + + private static Parameters updateParameters(@NonNull Parameters.Builder builder, boolean forced) { + if (forced) { + return builder.setMaxAttempts(Parameters.UNLIMITED) + .setLifespan(TimeUnit.DAYS.toMillis(7)) + .build(); + } else { + return builder.setMaxAttempts(3) + .setLifespan(TimeUnit.MINUTES.toMillis(20)) + .build(); + } + } + + private GroupV1MigrationJob(@NonNull Parameters parameters, @NonNull RecipientId recipientId, boolean forced) { + super(parameters); + this.recipientId = recipientId; + this.forced = forced; + } + + public static void enqueuePossibleAutoMigrate(@NonNull RecipientId recipientId) { + SignalExecutors.BOUNDED.execute(() -> { + if (Recipient.resolved(recipientId).isPushV1Group()) { + ApplicationDependencies.getJobManager().add(new GroupV1MigrationJob(recipientId, false)); + } + }); + } + + public static void enqueueRoutineMigrationsIfNecessary(@NonNull Application application) { + if (!SignalStore.registrationValues().isRegistrationComplete() || + !TextSecurePreferences.isPushRegistered(application) || + TextSecurePreferences.getLocalUuid(application) == null) + { + Log.i(TAG, "Registration not complete. Skipping."); + return; + } + + if (!FeatureFlags.groupsV1AutoMigration()) { + Log.i(TAG, "Auto-migration disabled. Not proactively searching for groups."); + return; + } + + long timeSinceRefresh = System.currentTimeMillis() - SignalStore.misc().getLastProfileRefreshTime(); + if (timeSinceRefresh < REFRESH_INTERVAL) { + Log.i(TAG, "Too soon to refresh. Did the last refresh " + timeSinceRefresh + " ms ago."); + return; + } + + SignalExecutors.BOUNDED.execute(() -> { + JobManager jobManager = ApplicationDependencies.getJobManager(); + List threads = DatabaseFactory.getThreadDatabase(application).getRecentV1Groups(ROUTINE_LIMIT); + Set needsRefresh = new HashSet<>(); + + if (threads.size() > 0) { + Log.d(TAG, "About to enqueue refreshes for " + threads.size() + " groups."); + } + + for (ThreadRecord thread : threads) { + jobManager.add(new GroupV1MigrationJob(thread.getRecipient().getId(), false)); + + needsRefresh.addAll(Stream.of(thread.getRecipient().getParticipants()) + .filter(r -> r.getGroupsV2Capability() != Recipient.Capability.SUPPORTED || + r.getGroupsV1MigrationCapability() != Recipient.Capability.SUPPORTED) + .map(Recipient::getId) + .toList()); + } + + if (needsRefresh.size() > 0) { + Log.w(TAG, "Enqueuing profile refreshes for " + needsRefresh.size() + " GV1 participants."); + RetrieveProfileJob.enqueue(needsRefresh); + } + }); + } + + @Override + public @NonNull Data serialize() { + return new Data.Builder().putString(KEY_RECIPIENT_ID, recipientId.serialize()) + .putBoolean(KEY_FORCED, forced) + .build(); + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + protected void onRun() throws IOException, RetryLaterException { + Recipient groupRecipient = Recipient.resolved(recipientId); + Long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(recipientId); + + if (threadId == null) { + warn(TAG, "No thread found!"); + return; + } + + if (!groupRecipient.isPushV1Group()) { + warn(TAG, "Not a V1 group!"); + return; + } + + if (groupRecipient.getParticipants().size() > FeatureFlags.groupLimits().getHardLimit()) { + warn(TAG, "Too many members! Size: " + groupRecipient.getParticipants().size()); + return; + } + + GroupId.V1 gv1Id = groupRecipient.requireGroupId().requireV1(); + GroupId.V2 gv2Id = gv1Id.deriveV2MigrationGroupId(); + GroupMasterKey gv2MasterKey = gv1Id.deriveV2MigrationMasterKey(); + boolean newlyCreated = false; + + switch (GroupManager.v2GroupStatus(context, gv2MasterKey)) { + case DOES_NOT_EXIST: + log(TAG, "Group does not exist on the service."); + + if (!groupRecipient.isActiveGroup()) { + warn(TAG, "Group is inactive! Can't migrate."); + return; + } + + if (!groupRecipient.isProfileSharing()) { + warn(TAG, "Profile sharing is disabled! Can't migrate."); + return; + } + + if (!forced && SignalStore.internalValues().disableGv1AutoMigrateInitiation()) { + warn(TAG, "Auto migration initiation has been disabled! Skipping."); + return; + } + + if (!forced && !FeatureFlags.groupsV1AutoMigration()) { + warn(TAG, "Auto migration is not enabled! Skipping."); + return; + } + + if (forced && !FeatureFlags.groupsV1ManualMigration()) { + warn(TAG, "Manual migration is not enabled! Skipping."); + return; + } + + RecipientUtil.ensureUuidsAreAvailable(context, groupRecipient.getParticipants()); + groupRecipient = groupRecipient.fresh(); + + List registeredMembers = RecipientUtil.getEligibleForSending(groupRecipient.getParticipants()); + List possibleMembers = forced ? getMigratableManualMigrationMembers(registeredMembers) + : getMigratableAutoMigrationMembers(registeredMembers); + + if (!forced && possibleMembers.size() != registeredMembers.size()) { + warn(TAG, "Not allowed to invite or leave registered users behind in an auto-migration! Skipping."); + return; + } + + log(TAG, "Attempting to create group."); + + try { + GroupManager.migrateGroupToServer(context, gv1Id, possibleMembers); + newlyCreated = true; + log(TAG, "Successfully created!"); + } catch (GroupChangeFailedException e) { + warn(TAG, "Failed to migrate group. Retrying.", e); + throw new RetryLaterException(); + } catch (MembershipNotSuitableForV2Exception e) { + warn(TAG, "Failed to migrate job due to the membership not yet being suitable for GV2. Aborting.", e); + return; + } catch (GroupAlreadyExistsException e) { + warn(TAG, "Someone else created the group while we were trying to do the same! It exists now. Continuing on.", e); + } + break; + case NOT_A_MEMBER: + warn(TAG, "The migrated group already exists, but we are not a member. Doing a local leave."); + handleLeftBehind(context, gv1Id, groupRecipient, threadId); + return; + case FULL_OR_PENDING_MEMBER: + warn(TAG, "The migrated group already exists, and we're in it. Continuing on."); + break; + default: throw new AssertionError(); + } + + log(TAG, "Migrating local group " + gv1Id + " to " + gv2Id); + + DecryptedGroup decryptedGroup = performLocalMigration(context, gv1Id, threadId, groupRecipient); + + if (newlyCreated && decryptedGroup != null && !SignalStore.internalValues().disableGv1AutoMigrateNotification()) { + GroupManager.sendNoopUpdate(context, gv2MasterKey, decryptedGroup); + } + } + + public static void performLocalMigration(@NonNull Context context, @NonNull GroupId.V1 gv1Id) throws IOException { + Recipient recipient = Recipient.externalGroupExact(context, gv1Id); + long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(recipient); + + performLocalMigration(context, gv1Id, threadId, recipient); + } + + private static @Nullable DecryptedGroup performLocalMigration(@NonNull Context context, @NonNull GroupId.V1 gv1Id, long threadId, @NonNull Recipient groupRecipient) throws IOException { + DecryptedGroup decryptedGroup; + try { + decryptedGroup = GroupManager.addedGroupVersion(context, gv1Id.deriveV2MigrationMasterKey()); + } catch (GroupDoesNotExistException e) { + throw new IOException("[Local] The group should exist already!"); + } catch (GroupNotAMemberException e) { + Log.w(TAG, "[Local] We are not in the group. Doing a local leave."); + handleLeftBehind(context, gv1Id, groupRecipient, threadId); + return null; + } + + List pendingRecipients = Stream.of(DecryptedGroupUtil.pendingToUuidList(decryptedGroup.getPendingMembersList())) + .map(uuid -> Recipient.externalPush(context, uuid, null, false)) + .filterNot(Recipient::isSelf) + .map(Recipient::getId) + .toList(); + + Log.i(TAG, "[Local] Migrating group over to the version we were added to: V" + decryptedGroup.getRevision()); + DatabaseFactory.getGroupDatabase(context).migrateToV2(gv1Id, decryptedGroup); + DatabaseFactory.getSmsDatabase(context).insertGroupV1MigrationEvent(groupRecipient.getId(), threadId, pendingRecipients); + + Log.i(TAG, "[Local] Applying all changes since V" + decryptedGroup.getRevision()); + try { + GroupManager.updateGroupFromServer(context, gv1Id.deriveV2MigrationMasterKey(), LATEST, System.currentTimeMillis(), null); + } catch (GroupChangeBusyException | GroupNotAMemberException e) { + Log.w(TAG, e); + } + + return decryptedGroup; + } + + private static void handleLeftBehind(@NonNull Context context, @NonNull GroupId.V1 gv1Id, @NonNull Recipient groupRecipient, long threadId) { + DatabaseFactory.getGroupDatabase(context).setActive(gv1Id, false); + + OutgoingMediaMessage leaveMessage = GroupUtil.createGroupV1LeaveMessage(gv1Id, groupRecipient); + try { + long id = DatabaseFactory.getMmsDatabase(context).insertMessageOutbox(leaveMessage, threadId, false, null); + DatabaseFactory.getMmsDatabase(context).markAsSent(id, true); + } catch (MmsException e) { + Log.w(TAG, "Failed to insert group leave message!", e); + } + } + + /** + * In addition to meeting traditional requirements, you must also have a profile key for a member + * to consider them migratable in an auto-migration. + */ + private static @NonNull List getMigratableAutoMigrationMembers(@NonNull List registeredMembers) { + return Stream.of(getMigratableManualMigrationMembers(registeredMembers)) + .filter(r -> r.getProfileKey() != null) + .toList(); + } + + /** + * You can only migrate users that have the required capabilities. + */ + private static @NonNull List getMigratableManualMigrationMembers(@NonNull List registeredMembers) { + return Stream.of(registeredMembers) + .filter(r -> r.getGroupsV2Capability() == Recipient.Capability.SUPPORTED && + r.getGroupsV1MigrationCapability() == Recipient.Capability.SUPPORTED) + .toList(); + } + + @Override + protected boolean onShouldRetry(@NonNull Exception e) { + return e instanceof PushNetworkException || + e instanceof NoCredentialForRedemptionTimeException || + e instanceof GroupChangeBusyException || + e instanceof RetryLaterException; + } + + @Override + public void onFailure() { + } + + public static final class Factory implements Job.Factory { + @Override + public @NonNull GroupV1MigrationJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new GroupV1MigrationJob(parameters, + RecipientId.from(data.getString(KEY_RECIPIENT_ID)), + data.getBoolean(KEY_FORCED)); + } + } +} 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 145bb4e436..bd3c49ca7d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java @@ -69,6 +69,7 @@ public final class JobManagerFactories { put(CreateSignedPreKeyJob.KEY, new CreateSignedPreKeyJob.Factory()); put(DirectoryRefreshJob.KEY, new DirectoryRefreshJob.Factory()); put(FcmRefreshJob.KEY, new FcmRefreshJob.Factory()); + put(GroupV1MigrationJob.KEY, new GroupV1MigrationJob.Factory()); put(KbsEnclaveMigrationWorkerJob.KEY, new KbsEnclaveMigrationWorkerJob.Factory()); put(LeaveGroupJob.KEY, new LeaveGroupJob.Factory()); put(LocalBackupJob.KEY, new LocalBackupJob.Factory()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSilentUpdateSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSilentUpdateSendJob.java index ad7b154ad4..e5c3b89737 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSilentUpdateSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSilentUpdateSendJob.java @@ -24,7 +24,6 @@ import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.recipients.RecipientUtil; import org.thoughtcrime.securesms.transport.RetryLaterException; import org.thoughtcrime.securesms.util.Base64; -import org.thoughtcrime.securesms.util.FeatureFlags; import org.whispersystems.libsignal.util.guava.Optional; import org.whispersystems.signalservice.api.SignalServiceMessageSender; import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair; @@ -85,7 +84,7 @@ public final class PushGroupSilentUpdateSendJob extends BaseJob { MessageGroupContext.GroupV2Properties properties = groupMessage.requireGroupV2Properties(); SignalServiceProtos.GroupContextV2 groupContext = properties.getGroupContext(); - String queue = Recipient.externalGroup(context, groupId).getId().toQueueKey(); + String queue = Recipient.externalGroupExact(context, groupId).getId().toQueueKey(); return new PushGroupSilentUpdateSendJob(new ArrayList<>(recipients), recipients.size(), diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushProcessMessageJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushProcessMessageJob.java index ec6f355213..e8891bb7f8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushProcessMessageJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushProcessMessageJob.java @@ -236,15 +236,17 @@ public final class PushProcessMessageJob extends BaseJob { try { SignalServiceGroupContext signalServiceGroupContext = content.getDataMessage().get().getGroupContext().get(); GroupId groupId = GroupUtil.idFromGroupContext(signalServiceGroupContext); - Recipient recipient = Recipient.externalGroup(context, groupId); - queueName = getQueueName(recipient.getId()); + queueName = getQueueName(Recipient.externalPossiblyMigratedGroup(context, groupId).getId()); if (groupId.isV2()) { int localRevision = DatabaseFactory.getGroupDatabase(context) .getGroupV2Revision(groupId.requireV2()); - if (signalServiceGroupContext.getGroupV2().get().getRevision() > localRevision) { + if (signalServiceGroupContext.getGroupV2().get().getRevision() > localRevision || + DatabaseFactory.getGroupDatabase(context).getGroupV1ByExpectedV2(groupId.requireV2()).isPresent()) + { + Log.i(TAG, "Adding network constraint to group-related job."); builder.addConstraint(NetworkConstraint.KEY) .setLifespan(TimeUnit.DAYS.toMillis(30)); } @@ -256,7 +258,7 @@ public final class PushProcessMessageJob extends BaseJob { queueName = getQueueName(RecipientId.fromHighTrust(content.getSender())); } } else if (exceptionMetadata != null) { - Recipient recipient = exceptionMetadata.groupId != null ? Recipient.externalGroup(context, exceptionMetadata.groupId) + Recipient recipient = exceptionMetadata.groupId != null ? Recipient.externalPossiblyMigratedGroup(context, exceptionMetadata.groupId) : Recipient.external(context, exceptionMetadata.sender); queueName = getQueueName(recipient.getId()); } @@ -348,6 +350,11 @@ public final class PushProcessMessageJob extends BaseJob { if (isGv2Message) { GroupId.V2 groupIdV2 = groupId.get().requireV2(); + Optional possibleGv1 = groupDatabase.getGroupV1ByExpectedV2(groupIdV2); + if (possibleGv1.isPresent()) { + GroupV1MigrationJob.performLocalMigration(context, possibleGv1.get().getId().requireV1()); + } + if (!updateGv2GroupFromServerOrP2PChange(content, message.getGroupContext().get().getGroupV2().get())) { log(TAG, String.valueOf(content.getTimestamp()), "Ignoring GV2 message for group we are not currently in " + groupIdV2); return; @@ -877,7 +884,7 @@ public final class PushProcessMessageJob extends BaseJob { recipient = Recipient.externalPush(context, response.getPerson().get()); } else if (response.getGroupId().isPresent()) { GroupId groupId = GroupId.v1(response.getGroupId().get()); - recipient = Recipient.externalGroup(context, groupId); + recipient = Recipient.externalPossiblyMigratedGroup(context, groupId); } else { warn(TAG, "Message request response was missing a thread recipient! Skipping."); return; @@ -1323,7 +1330,7 @@ public final class PushProcessMessageJob extends BaseJob { boolean isGroup = recipient.isGroup(); MessageDatabase database; - long messageId; + long messageId; if (isGroup) { OutgoingMediaMessage outgoingMediaMessage = new OutgoingMediaMessage(recipient, @@ -1552,7 +1559,7 @@ public final class PushProcessMessageJob extends BaseJob { return; } - Recipient groupRecipient = Recipient.externalGroup(context, groupId); + Recipient groupRecipient = Recipient.externalPossiblyMigratedGroup(context, groupId); threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(groupRecipient); } else { @@ -1779,7 +1786,7 @@ public final class PushProcessMessageJob extends BaseJob { throws BadGroupIdException { if (message.isPresent()) { - return Optional.of(Recipient.externalGroup(context, GroupUtil.idFromGroupContext(message.get()))); + return Optional.of(Recipient.externalPossiblyMigratedGroup(context, GroupUtil.idFromGroupContext(message.get()))); } return Optional.absent(); } @@ -1814,6 +1821,15 @@ public final class PushProcessMessageJob extends BaseJob { GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context); Optional groupId = GroupUtil.idFromGroupContext(message.getGroupContext()); + if (groupId.isPresent() && + groupId.get().isV1() && + message.isGroupV1Update() && + groupDatabase.groupExists(groupId.get().requireV1().deriveV2MigrationGroupId())) + { + warn(TAG, String.valueOf(content.getTimestamp()), "Ignoring V1 update for a group we've already migrated to V2."); + return true; + } + if (groupId.isPresent() && groupDatabase.isUnknownGroup(groupId.get())) { return sender.isBlocked(); } @@ -1839,7 +1855,7 @@ public final class PushProcessMessageJob extends BaseJob { if (content.getTypingMessage().get().getGroupId().isPresent()) { GroupId groupId = GroupId.push(content.getTypingMessage().get().getGroupId().get()); - Recipient groupRecipient = Recipient.externalGroup(context, groupId); + Recipient groupRecipient = Recipient.externalPossiblyMigratedGroup(context, groupId); return groupRecipient.isBlocked() || !groupRecipient.isActiveGroup(); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RequestGroupV2InfoWorkerJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/RequestGroupV2InfoWorkerJob.java index b5f7cac4a4..d42eef13bf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RequestGroupV2InfoWorkerJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RequestGroupV2InfoWorkerJob.java @@ -41,7 +41,7 @@ final class RequestGroupV2InfoWorkerJob extends BaseJob { @WorkerThread RequestGroupV2InfoWorkerJob(@NonNull GroupId.V2 groupId, int toRevision) { this(new Parameters.Builder() - .setQueue(PushProcessMessageJob.getQueueName(Recipient.externalGroup(ApplicationDependencies.getApplication(), groupId).getId())) + .setQueue(PushProcessMessageJob.getQueueName(Recipient.externalGroupExact(ApplicationDependencies.getApplication(), groupId).getId())) .addConstraint(NetworkConstraint.KEY) .setLifespan(TimeUnit.DAYS.toMillis(1)) .setMaxAttempts(Parameters.UNLIMITED) diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/InternalValues.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/InternalValues.java index 5676123bea..71753244e4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/InternalValues.java +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/InternalValues.java @@ -4,12 +4,14 @@ import org.thoughtcrime.securesms.util.FeatureFlags; public final class InternalValues extends SignalStoreValues { - public static final String GV2_DO_NOT_CREATE_GV2 = "internal.gv2.do_not_create_gv2"; - 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 FORCE_CENSORSHIP = "internal.force_censorship"; + public static final String GV2_DO_NOT_CREATE_GV2 = "internal.gv2.do_not_create_gv2"; + 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 GV2_DISABLE_AUTOMIGRATE_INITIATION = "internal.gv2.disable_automigrate_initiation"; + public static final String GV2_DISABLE_AUTOMIGRATE_NOTIFICATION = "internal.gv2.disable_automigrate_notification"; + public static final String RECIPIENT_DETAILS = "internal.recipient_details"; + public static final String FORCE_CENSORSHIP = "internal.force_censorship"; InternalValues(KeyValueStore store) { super(store); @@ -67,4 +69,20 @@ public final class InternalValues extends SignalStoreValues { public synchronized boolean forcedCensorship() { return FeatureFlags.internalUser() && getBoolean(FORCE_CENSORSHIP, false); } + + /** + * Disable initiating a GV1->GV2 auto-migration. You can still recognize a group has been + * auto-migrated. + */ + public synchronized boolean disableGv1AutoMigrateInitiation() { + return FeatureFlags.internalUser() && getBoolean(GV2_DISABLE_AUTOMIGRATE_INITIATION, false); + } + + /** + * Disable sending a group update after an automigration. This will force other group members to + * have to discover the migration on their own. + */ + public synchronized boolean disableGv1AutoMigrateNotification() { + return FeatureFlags.internalUser() && getBoolean(GV2_DISABLE_AUTOMIGRATE_NOTIFICATION, false); + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/MiscellaneousValues.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/MiscellaneousValues.java index c7116a6167..b64475d4a2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/MiscellaneousValues.java +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/MiscellaneousValues.java @@ -1,18 +1,15 @@ package org.thoughtcrime.securesms.keyvalue; -import android.net.Uri; -import android.text.TextUtils; - import androidx.annotation.NonNull; -import androidx.annotation.Nullable; public final class MiscellaneousValues extends SignalStoreValues { - private static final String LAST_PREKEY_REFRESH_TIME = "last_prekey_refresh_time"; - private static final String MESSAGE_REQUEST_ENABLE_TIME = "message_request_enable_time"; - private static final String LAST_PROFILE_REFRESH_TIME = "misc.last_profile_refresh_time"; - private static final String USERNAME_SHOW_REMINDER = "username.show.reminder"; - private static final String CLIENT_DEPRECATED = "misc.client_deprecated"; + private static final String LAST_PREKEY_REFRESH_TIME = "last_prekey_refresh_time"; + private static final String MESSAGE_REQUEST_ENABLE_TIME = "message_request_enable_time"; + private static final String LAST_PROFILE_REFRESH_TIME = "misc.last_profile_refresh_time"; + private static final String LAST_GV1_ROUTINE_MIGRATION_TIME = "misc.last_gv1_routine_migration_time"; + private static final String USERNAME_SHOW_REMINDER = "username.show.reminder"; + private static final String CLIENT_DEPRECATED = "misc.client_deprecated"; MiscellaneousValues(@NonNull KeyValueStore store) { super(store); @@ -43,6 +40,14 @@ public final class MiscellaneousValues extends SignalStoreValues { putLong(LAST_PROFILE_REFRESH_TIME, time); } + public long getLastGv1RoutineMigrationTime() { + return getLong(LAST_GV1_ROUTINE_MIGRATION_TIME, 0); + } + + public void setLastGv1RoutineMigrationTime(long time) { + putLong(LAST_GV1_ROUTINE_MIGRATION_TIME, time); + } + public void hideUsernameReminder() { putBoolean(USERNAME_SHOW_REMINDER, false); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/InternalOptionsPreferenceFragment.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/InternalOptionsPreferenceFragment.java index b2a7e77171..3966993418 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/InternalOptionsPreferenceFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/InternalOptionsPreferenceFragment.java @@ -39,6 +39,8 @@ public class InternalOptionsPreferenceFragment extends CorrectedPreferenceFragme initializeSwitchPreference(preferenceDataStore, InternalValues.GV2_FORCE_INVITES, SignalStore.internalValues().gv2ForceInvites()); initializeSwitchPreference(preferenceDataStore, InternalValues.GV2_IGNORE_SERVER_CHANGES, SignalStore.internalValues().gv2IgnoreServerChanges()); initializeSwitchPreference(preferenceDataStore, InternalValues.GV2_IGNORE_P2P_CHANGES, SignalStore.internalValues().gv2IgnoreP2PChanges()); + initializeSwitchPreference(preferenceDataStore, InternalValues.GV2_DISABLE_AUTOMIGRATE_INITIATION, SignalStore.internalValues().disableGv1AutoMigrateInitiation()); + initializeSwitchPreference(preferenceDataStore, InternalValues.GV2_DISABLE_AUTOMIGRATE_NOTIFICATION, SignalStore.internalValues().disableGv1AutoMigrateNotification()); initializeSwitchPreference(preferenceDataStore, InternalValues.FORCE_CENSORSHIP, SignalStore.internalValues().forcedCensorship()); findPreference("pref_refresh_attributes").setOnPreferenceClickListener(preference -> { diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditPushGroupProfileRepository.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditPushGroupProfileRepository.java index e6c9e738e0..2e87f4a565 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditPushGroupProfileRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditPushGroupProfileRepository.java @@ -94,7 +94,7 @@ class EditPushGroupProfileRepository implements EditProfileRepository { @WorkerThread private RecipientId getRecipientId() { - return DatabaseFactory.getRecipientDatabase(context).getByGroupId(groupId.toString()) + return DatabaseFactory.getRecipientDatabase(context).getByGroupId(groupId) .or(() -> { throw new AssertionError("Recipient ID for Group ID does not exist."); }); diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java index 70fb272760..1cd3d5bc2d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java @@ -234,12 +234,32 @@ public class Recipient { /** * A version of {@link #external(Context, String)} that should be used when you know the * identifier is a groupId. + * + * Important: This will throw an exception if the groupId you're using could have been migrated. + * If you're dealing with inbound data, you should be using + * {@link #externalPossiblyMigratedGroup(Context, GroupId)}, or checking the database before + * calling this method. */ @WorkerThread - public static @NonNull Recipient externalGroup(@NonNull Context context, @NonNull GroupId groupId) { + public static @NonNull Recipient externalGroupExact(@NonNull Context context, @NonNull GroupId groupId) { return Recipient.resolved(DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(groupId)); } + /** + * Will give you one of: + * - The recipient that matches the groupId specified exactly + * - The recipient whose V1 ID would map to the provided V2 ID + * - The recipient whose V2 ID would be derived from the provided V1 ID + * - A newly-created recipient for the provided ID if none of the above match + * + * Important: You could get back a recipient with a different groupId than the one you provided. + * You should be very cautious when using the groupId on the returned recipient. + */ + @WorkerThread + public static @NonNull Recipient externalPossiblyMigratedGroup(@NonNull Context context, @NonNull GroupId groupId) { + return Recipient.resolved(DatabaseFactory.getRecipientDatabase(context).getOrInsertFromPossiblyMigratedGroupId(groupId)); + } + /** * Returns a fully-populated {@link Recipient} based off of a string identifier, creating one in * the database if necessary. The identifier may be a uuid, phone number, email, diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java index 9e039b615a..0f0ec4c248 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java @@ -13,6 +13,7 @@ import org.json.JSONObject; import org.thoughtcrime.securesms.BuildConfig; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.groups.SelectionLimits; +import org.thoughtcrime.securesms.jobs.RefreshAttributesJob; import org.thoughtcrime.securesms.jobs.RemoteConfigRefreshJob; import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.logging.Log; @@ -61,6 +62,8 @@ public final class FeatureFlags { public static final String MODERN_PROFILE_SHARING = "android.modernProfileSharing"; private static final String VIEWED_RECEIPTS = "android.viewed.receipts"; private static final String MAX_ENVELOPE_SIZE = "android.maxEnvelopeSize"; + private static final String GV1_AUTO_MIGRATE_VERSION = "android.groupsv2.autoMigrateVersion"; + private static final String GV1_MANUAL_MIGRATE_VERSION = "android.groupsv2.manualMigrateVersion"; /** * We will only store remote values for flags in this set. If you want a flag to be controllable @@ -79,7 +82,9 @@ public final class FeatureFlags { RESEARCH_MEGAPHONE_1, MODERN_PROFILE_SHARING, VIEWED_RECEIPTS, - MAX_ENVELOPE_SIZE + MAX_ENVELOPE_SIZE, + GV1_AUTO_MIGRATE_VERSION, + GV1_MANUAL_MIGRATE_VERSION ); /** @@ -124,6 +129,7 @@ public final class FeatureFlags { * desired test state. */ private static final Map FLAG_CHANGE_LISTENERS = new HashMap() {{ + put(GV1_AUTO_MIGRATE_VERSION, change -> ApplicationDependencies.getJobManager().add(new RefreshAttributesJob())); }}; private static final Map REMOTE_VALUES = new TreeMap<>(); @@ -257,6 +263,16 @@ public final class FeatureFlags { return getInteger(MAX_ENVELOPE_SIZE, 0); } + /** Whether or not auto-migration from GV1->GV2 is enabled. */ + public static boolean groupsV1AutoMigration() { + return getVersionFlag(GV1_AUTO_MIGRATE_VERSION) == VersionFlag.ON; + } + + /** Whether or not manual migration from GV1->GV2 is enabled. */ + public static boolean groupsV1ManualMigration() { + return groupsV1AutoMigration() && getVersionFlag(GV1_MANUAL_MIGRATE_VERSION) == VersionFlag.ON; + } + /** Only for rendering debug info. */ public static synchronized @NonNull Map getMemoryValues() { return new TreeMap<>(REMOTE_VALUES); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/GroupUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/GroupUtil.java index 7a48332f75..1c290a4a55 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/GroupUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/GroupUtil.java @@ -6,6 +6,8 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.WorkerThread; +import com.google.protobuf.ByteString; + import org.signal.zkgroup.InvalidInputException; import org.signal.zkgroup.groups.GroupMasterKey; import org.thoughtcrime.securesms.R; @@ -15,6 +17,7 @@ import org.thoughtcrime.securesms.groups.BadGroupIdException; import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.mms.MessageGroupContext; +import org.thoughtcrime.securesms.mms.OutgoingGroupUpdateMessage; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; import org.whispersystems.libsignal.util.guava.Optional; @@ -22,8 +25,11 @@ import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; import org.whispersystems.signalservice.api.messages.SignalServiceGroup; import org.whispersystems.signalservice.api.messages.SignalServiceGroupContext; import org.whispersystems.signalservice.api.messages.SignalServiceGroupV2; +import org.whispersystems.signalservice.internal.push.SignalServiceProtos; +import org.whispersystems.signalservice.internal.push.SignalServiceProtos.GroupContext; import java.io.IOException; +import java.util.Collections; import java.util.List; public final class GroupUtil { @@ -108,6 +114,26 @@ public final class GroupUtil { } } + public static OutgoingGroupUpdateMessage createGroupV1LeaveMessage(@NonNull GroupId.V1 groupId, + @NonNull Recipient groupRecipient) + { + GroupContext groupContext = GroupContext.newBuilder() + .setId(ByteString.copyFrom(groupId.getDecodedId())) + .setType(GroupContext.Type.QUIT) + .build(); + + return new OutgoingGroupUpdateMessage(groupRecipient, + groupContext, + null, + System.currentTimeMillis(), + 0, + false, + null, + Collections.emptyList(), + Collections.emptyList(), + Collections.emptyList()); + } + public static class GroupDescription { @NonNull private final Context context; diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 06f9c15749..41a3a3eb37 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2258,6 +2258,7 @@ Internal Preferences Groups V2 + Groups V1 Migration Do not create GV2 groups Do not attempt to create GV2 groups, i.e. will force creation of GV1 or MMS groups. Force Invites @@ -2266,6 +2267,10 @@ Changes in server\'s response will be ignored, causing passive voice update messages if P2P is also ignored. Ignore P2P changes Changes sent P2P will be ignored. In conjunction with ignoring server changes, will cause passive voice. + Disable Auto-Migration Initiation + Do not attempt to initiate an auto-migration. You will still recognize migrated groups. + Disable Auto-Migration Notification + Do not attempt to notify other users of an auto-migration. They will have to discover it on their own. Account Refresh attributes Forces a write of capabilities on to the server followed by a read. diff --git a/app/src/main/res/xml/preferences_internal.xml b/app/src/main/res/xml/preferences_internal.xml index eff7e8f66c..9f31a6f432 100644 --- a/app/src/main/res/xml/preferences_internal.xml +++ b/app/src/main/res/xml/preferences_internal.xml @@ -75,6 +75,24 @@ + + + + + + + +