From 7e347f5cce02bb05c7403b91f6c8d517c695dc2c Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Thu, 12 Nov 2020 09:52:21 -0500 Subject: [PATCH] Add support for manual initiation of GV1->GV2 migrations. --- .../GroupsV1MigrationInitiationReminder.java | 27 ++ .../conversation/ConversationActivity.java | 19 +- .../conversation/ConversationFragment.java | 4 +- .../ConversationGroupViewModel.java | 44 ++- .../securesms/database/MmsSmsDatabase.java | 2 +- .../securesms/database/RecipientDatabase.java | 184 +++++++------ .../securesms/database/ThreadDatabase.java | 7 +- .../database/helpers/SQLCipherOpenHelper.java | 7 +- .../securesms/groups/GroupId.java | 3 +- .../groups/GroupsV1MigrationUtil.java | 244 +++++++++++++++++ .../ui/managegroup/ManageGroupFragment.java | 12 + .../ui/managegroup/ManageGroupViewModel.java | 15 +- ...grationInfoBottomSheetDialogFragment.java} | 36 ++- ...va => GroupsV1MigrationInfoViewModel.java} | 6 +- ...onInitiationBottomSheetDialogFragment.java | 133 ++++++++++ .../GroupsV1MigrationInitiationViewModel.java | 51 ++++ .../GroupsV1MigrationRepository.java | 108 ++++++++ .../groups/ui/migration/MigrationResult.java | 5 + .../groups/ui/migration/MigrationState.java | 30 +++ .../securesms/jobs/GroupV1MigrationJob.java | 250 ++---------------- .../securesms/jobs/PushProcessMessageJob.java | 3 +- .../securesms/jobs/StorageSyncJob.java | 6 +- .../securesms/util/FeatureFlags.java | 9 +- .../groupsv1_migration_bottom_sheet.xml | 182 +++++++++++++ ...psv1_migration_learn_more_bottom_sheet.xml | 169 +++++++----- app/src/main/res/values/ids.xml | 3 + app/src/main/res/values/strings.xml | 26 +- 27 files changed, 1161 insertions(+), 424 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/reminder/GroupsV1MigrationInitiationReminder.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/GroupsV1MigrationUtil.java rename app/src/main/java/org/thoughtcrime/securesms/groups/ui/migration/{GroupsV1MigrationBottomSheetDialogFragment.java => GroupsV1MigrationInfoBottomSheetDialogFragment.java} (60%) rename app/src/main/java/org/thoughtcrime/securesms/groups/ui/migration/{GroupsV1MigrationViewModel.java => GroupsV1MigrationInfoViewModel.java} (83%) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/ui/migration/GroupsV1MigrationInitiationBottomSheetDialogFragment.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/ui/migration/GroupsV1MigrationInitiationViewModel.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/ui/migration/GroupsV1MigrationRepository.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/ui/migration/MigrationResult.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/ui/migration/MigrationState.java create mode 100644 app/src/main/res/layout/groupsv1_migration_bottom_sheet.xml diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/reminder/GroupsV1MigrationInitiationReminder.java b/app/src/main/java/org/thoughtcrime/securesms/components/reminder/GroupsV1MigrationInitiationReminder.java new file mode 100644 index 0000000000..1672d410c4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/reminder/GroupsV1MigrationInitiationReminder.java @@ -0,0 +1,27 @@ +package org.thoughtcrime.securesms.components.reminder; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.recipients.RecipientId; + +import java.util.List; + +/** + * Shows a reminder to upgrade a group to GV2. + */ +public class GroupsV1MigrationInitiationReminder extends Reminder { + + public GroupsV1MigrationInitiationReminder(@NonNull Context context) { + super(null, context.getString(R.string.GroupsV1MigrationInitiationReminder_to_access_new_features_like_mentions)); + addAction(new Action(context.getString(R.string.GroupsV1MigrationInitiationReminder_update_group), R.id.reminder_action_gv1_initiation_update_group)); + addAction(new Action(context.getResources().getString(R.string.GroupsV1MigrationInitiationReminder_not_now), R.id.reminder_action_gv1_initiation_not_now)); + } + + @Override + public boolean isDismissable() { + return false; + } +} 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 de37cee3be..743b20c483 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java @@ -116,6 +116,7 @@ import org.thoughtcrime.securesms.components.identity.UnverifiedBannerView; import org.thoughtcrime.securesms.components.location.SignalPlace; import org.thoughtcrime.securesms.components.mention.MentionAnnotation; import org.thoughtcrime.securesms.components.reminder.ExpiredBuildReminder; +import org.thoughtcrime.securesms.components.reminder.GroupsV1MigrationInitiationReminder; import org.thoughtcrime.securesms.components.reminder.GroupsV1MigrationSuggestionsReminder; import org.thoughtcrime.securesms.components.reminder.PendingGroupJoinRequestsReminder; import org.thoughtcrime.securesms.components.reminder.Reminder; @@ -167,6 +168,7 @@ import org.thoughtcrime.securesms.groups.ui.GroupErrors; import org.thoughtcrime.securesms.groups.ui.LeaveGroupDialog; import org.thoughtcrime.securesms.groups.ui.invitesandrequests.ManagePendingAndRequestingMembersActivity; import org.thoughtcrime.securesms.groups.ui.managegroup.ManageGroupActivity; +import org.thoughtcrime.securesms.groups.ui.migration.GroupsV1MigrationInitiationBottomSheetDialogFragment; import org.thoughtcrime.securesms.groups.ui.migration.GroupsV1MigrationSuggestionsDialog; import org.thoughtcrime.securesms.insights.InsightsLauncher; import org.thoughtcrime.securesms.invites.InviteReminderModel; @@ -455,7 +457,7 @@ public class ConversationActivity extends PassphraseRequiredActivity initializeMentionsViewModel(); initializeEnabledCheck(); initializePendingRequestsBanner(); - initializeGroupV1MigrationSuggestionsBanner(); + initializeGroupV1MigrationsBanners(); initializeSecurity(recipient.get().isRegistered(), isDefaultSms).addListener(new AssertedSuccessListener() { @Override public void onSuccess(Boolean result) { @@ -1550,11 +1552,14 @@ public class ConversationActivity extends PassphraseRequiredActivity .observe(this, actionablePendingGroupRequests -> updateReminders()); } - private void initializeGroupV1MigrationSuggestionsBanner() { + private void initializeGroupV1MigrationsBanners() { groupViewModel.getGroupV1MigrationSuggestions() .observe(this, s -> updateReminders()); + groupViewModel.getShowGroupsV1MigrationBanner() + .observe(this, b -> updateReminders()); } + private ListenableFuture initializeDraftFromDatabase() { SettableFuture future = new SettableFuture<>(); @@ -1711,6 +1716,7 @@ public class ConversationActivity extends PassphraseRequiredActivity Optional inviteReminder = inviteReminderModel.getReminder(); Integer actionableRequestingMembers = groupViewModel.getActionableRequestingMembers().getValue(); List gv1MigrationSuggestions = groupViewModel.getGroupV1MigrationSuggestions().getValue(); + Boolean gv1MigrationBanner = groupViewModel.getShowGroupsV1MigrationBanner().getValue(); if (UnauthorizedReminder.isEligible(this)) { reminderView.get().showReminder(new UnauthorizedReminder(this)); @@ -1735,6 +1741,15 @@ public class ConversationActivity extends PassphraseRequiredActivity startActivity(ManagePendingAndRequestingMembersActivity.newIntent(this, getRecipient().getGroupId().get().requireV2())); } }); + } else if (gv1MigrationBanner == Boolean.TRUE && recipient.get().isPushV1Group()) { + reminderView.get().showReminder(new GroupsV1MigrationInitiationReminder(this)); + reminderView.get().setOnActionClickListener(actionId -> { + if (actionId == R.id.reminder_action_gv1_initiation_update_group) { + GroupsV1MigrationInitiationBottomSheetDialogFragment.showForInitiation(getSupportFragmentManager(), recipient.getId()); + } else if (actionId == R.id.reminder_action_gv1_initiation_not_now) { + groupViewModel.onMigrationInitiationReminderBannerDismissed(recipient.getId()); + } + }); } else if (gv1MigrationSuggestions != null && gv1MigrationSuggestions.size() > 0 && recipient.get().isPushV2Group()) { reminderView.get().showReminder(new GroupsV1MigrationSuggestionsReminder(this, gv1MigrationSuggestions)); reminderView.get().setOnActionClickListener(actionId -> { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java index 72e3973efb..a15bf58b1d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java @@ -91,7 +91,7 @@ import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.database.model.MmsMessageRecord; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.groups.GroupId; -import org.thoughtcrime.securesms.groups.ui.migration.GroupsV1MigrationBottomSheetDialogFragment; +import org.thoughtcrime.securesms.groups.ui.migration.GroupsV1MigrationInfoBottomSheetDialogFragment; import org.thoughtcrime.securesms.jobs.DirectoryRefreshJob; import org.thoughtcrime.securesms.jobs.MultiDeviceViewOnceOpenJob; import org.thoughtcrime.securesms.keyvalue.SignalStore; @@ -1427,7 +1427,7 @@ public class ConversationFragment extends LoggingFragment { @Override public void onGroupMigrationLearnMoreClicked(@NonNull List pendingRecipients) { - GroupsV1MigrationBottomSheetDialogFragment.showForLearnMore(requireFragmentManager(), pendingRecipients); + GroupsV1MigrationInfoBottomSheetDialogFragment.showForLearnMore(requireFragmentManager(), pendingRecipients); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationGroupViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationGroupViewModel.java index af0aa343d5..7ed8b34d34 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationGroupViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationGroupViewModel.java @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.conversation; import android.app.Application; +import android.content.Context; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -16,13 +17,13 @@ import com.annimon.stream.Stream; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.GroupDatabase; import org.thoughtcrime.securesms.database.GroupDatabase.GroupRecord; -import org.thoughtcrime.securesms.database.RecipientDatabase; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.groups.GroupChangeBusyException; import org.thoughtcrime.securesms.groups.GroupChangeFailedException; import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.groups.GroupManager; import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason; +import org.thoughtcrime.securesms.groups.GroupsV1MigrationUtil; import org.thoughtcrime.securesms.profiles.spoofing.ReviewRecipient; import org.thoughtcrime.securesms.profiles.spoofing.ReviewUtil; import org.thoughtcrime.securesms.recipients.Recipient; @@ -37,15 +38,19 @@ import java.io.IOException; import java.util.Collections; import java.util.List; import java.util.Set; +import java.util.concurrent.TimeUnit; final class ConversationGroupViewModel extends ViewModel { + private static final long GV1_MIGRATION_REMINDER_INTERVAL = TimeUnit.DAYS.toMillis(1); + private final MutableLiveData liveRecipient; private final LiveData groupActiveState; private final LiveData selfMembershipLevel; private final LiveData actionableRequestingMembers; private final LiveData reviewState; private final LiveData> gv1MigrationSuggestions; + private final LiveData gv1MigrationReminder; private ConversationGroupViewModel() { this.liveRecipient = new MutableLiveData<>(); @@ -65,6 +70,7 @@ final class ConversationGroupViewModel extends ViewModel { this.selfMembershipLevel = Transformations.distinctUntilChanged(Transformations.map(groupRecord, ConversationGroupViewModel::mapToSelfMembershipLevel)); this.actionableRequestingMembers = Transformations.distinctUntilChanged(Transformations.map(groupRecord, ConversationGroupViewModel::mapToActionableRequestingMemberCount)); this.gv1MigrationSuggestions = Transformations.distinctUntilChanged(LiveDataUtil.mapAsync(groupRecord, ConversationGroupViewModel::mapToGroupV1MigrationSuggestions)); + this.gv1MigrationReminder = Transformations.distinctUntilChanged(LiveDataUtil.mapAsync(groupRecord, ConversationGroupViewModel::mapToGroupV1MigrationReminder)); this.reviewState = LiveDataUtil.combineLatest(groupRecord, duplicates, (record, dups) -> dups.isEmpty() @@ -86,6 +92,13 @@ final class ConversationGroupViewModel extends ViewModel { }); } + void onMigrationInitiationReminderBannerDismissed(@NonNull RecipientId recipientId) { + SignalExecutors.BOUNDED.execute(() -> { + DatabaseFactory.getRecipientDatabase(ApplicationDependencies.getApplication()).markGroupsV1MigrationReminderSeen(recipientId, System.currentTimeMillis()); + liveRecipient.postValue(liveRecipient.getValue()); + }); + } + /** * The number of pending group join requests that can be actioned by this client. */ @@ -109,6 +122,10 @@ final class ConversationGroupViewModel extends ViewModel { return gv1MigrationSuggestions; } + @NonNull LiveData getShowGroupsV1MigrationBanner() { + return gv1MigrationReminder; + } + private static @Nullable GroupRecord getGroupRecordForRecipient(@Nullable Recipient recipient) { if (recipient != null && recipient.isGroup()) { Application context = ApplicationDependencies.getApplication(); @@ -156,15 +173,30 @@ final class ConversationGroupViewModel extends ViewModel { Set difference = SetUtil.difference(record.getFormerV1Members(), record.getMembers()); return Stream.of(Recipient.resolvedList(difference)) - .filter(r -> r.hasUuid() && - r.getGroupsV1MigrationCapability() == Recipient.Capability.SUPPORTED && - r.getGroupsV2Capability() == Recipient.Capability.SUPPORTED && - r.getProfileKey() != null && - r.getRegistered() == RecipientDatabase.RegisteredState.REGISTERED) + .filter(GroupsV1MigrationUtil::isAutoMigratable) .map(Recipient::getId) .toList(); } + @WorkerThread + private static boolean mapToGroupV1MigrationReminder(@Nullable GroupRecord record) { + if (record == null || !record.isV1Group() || !record.isActive() || !FeatureFlags.groupsV1ManualMigration()) { + return false; + } + + boolean canAutoMigrate = Stream.of(Recipient.resolvedList(record.getMembers())) + .allMatch(GroupsV1MigrationUtil::isAutoMigratable); + + if (canAutoMigrate) { + return false; + } + + Context context = ApplicationDependencies.getApplication(); + long lastReminderTime = DatabaseFactory.getRecipientDatabase(context).getGroupsV1MigrationReminderLastSeen(record.getRecipientId()); + + return System.currentTimeMillis() - lastReminderTime > GV1_MIGRATION_REMINDER_INTERVAL; + } + public static void onCancelJoinRequest(@NonNull Recipient recipient, @NonNull AsynchronousCallback.WorkerThread callback) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java index 638bb53644..fdd94860fd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java @@ -207,7 +207,7 @@ public class MmsSmsDatabase extends Database { public Cursor getConversationSnippet(long threadId) { String order = MmsSmsColumns.NORMALIZED_DATE_RECEIVED + " DESC"; - String selection = MmsSmsColumns.THREAD_ID + " = " + threadId + " AND (" + SmsDatabase.TYPE + " IS NULL OR " + SmsDatabase.TYPE + " != " + SmsDatabase.Types.PROFILE_CHANGE_TYPE + ")"; + String selection = MmsSmsColumns.THREAD_ID + " = " + threadId + " AND (" + SmsDatabase.TYPE + " IS NULL OR " + SmsDatabase.TYPE + " NOT IN (" + SmsDatabase.Types.PROFILE_CHANGE_TYPE + ", " + SmsDatabase.Types.GV1_MIGRATION_TYPE + "))"; return queryTables(PROJECTION, selection, order, "1"); } 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 a50c2735f0..74d5c95ba8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java @@ -86,47 +86,48 @@ public class RecipientDatabase extends Database { private static final String TAG = RecipientDatabase.class.getSimpleName(); - static final String TABLE_NAME = "recipient"; - public static final String ID = "_id"; - private static final String UUID = "uuid"; - private static final String USERNAME = "username"; - public static final String PHONE = "phone"; - public static final String EMAIL = "email"; - static final String GROUP_ID = "group_id"; - private static final String GROUP_TYPE = "group_type"; - private static final String BLOCKED = "blocked"; - private static final String MESSAGE_RINGTONE = "message_ringtone"; - private static final String MESSAGE_VIBRATE = "message_vibrate"; - private static final String CALL_RINGTONE = "call_ringtone"; - private static final String CALL_VIBRATE = "call_vibrate"; - private static final String NOTIFICATION_CHANNEL = "notification_channel"; - private static final String MUTE_UNTIL = "mute_until"; - private static final String COLOR = "color"; - private static final String SEEN_INVITE_REMINDER = "seen_invite_reminder"; - private static final String DEFAULT_SUBSCRIPTION_ID = "default_subscription_id"; - private static final String MESSAGE_EXPIRATION_TIME = "message_expiration_time"; - public static final String REGISTERED = "registered"; - public static final String SYSTEM_DISPLAY_NAME = "system_display_name"; - private static final String SYSTEM_PHOTO_URI = "system_photo_uri"; - public static final String SYSTEM_PHONE_TYPE = "system_phone_type"; - public static final String SYSTEM_PHONE_LABEL = "system_phone_label"; - private static final String SYSTEM_CONTACT_URI = "system_contact_uri"; - private static final String SYSTEM_INFO_PENDING = "system_info_pending"; - private static final String PROFILE_KEY = "profile_key"; - private static final String PROFILE_KEY_CREDENTIAL = "profile_key_credential"; - private static final String SIGNAL_PROFILE_AVATAR = "signal_profile_avatar"; - private static final String PROFILE_SHARING = "profile_sharing"; - private static final String LAST_PROFILE_FETCH = "last_profile_fetch"; - private static final String UNIDENTIFIED_ACCESS_MODE = "unidentified_access_mode"; - private static final String FORCE_SMS_SELECTION = "force_sms_selection"; - private static final String CAPABILITIES = "capabilities"; - private static final String STORAGE_SERVICE_ID = "storage_service_key"; - private static final String DIRTY = "dirty"; - private static final String PROFILE_GIVEN_NAME = "signal_profile_name"; - private static final String PROFILE_FAMILY_NAME = "profile_family_name"; - private static final String PROFILE_JOINED_NAME = "profile_joined_name"; - private static final String MENTION_SETTING = "mention_setting"; - private static final String STORAGE_PROTO = "storage_proto"; + static final String TABLE_NAME = "recipient"; + public static final String ID = "_id"; + private static final String UUID = "uuid"; + private static final String USERNAME = "username"; + public static final String PHONE = "phone"; + public static final String EMAIL = "email"; + static final String GROUP_ID = "group_id"; + private static final String GROUP_TYPE = "group_type"; + private static final String BLOCKED = "blocked"; + private static final String MESSAGE_RINGTONE = "message_ringtone"; + private static final String MESSAGE_VIBRATE = "message_vibrate"; + private static final String CALL_RINGTONE = "call_ringtone"; + private static final String CALL_VIBRATE = "call_vibrate"; + private static final String NOTIFICATION_CHANNEL = "notification_channel"; + private static final String MUTE_UNTIL = "mute_until"; + private static final String COLOR = "color"; + private static final String SEEN_INVITE_REMINDER = "seen_invite_reminder"; + private static final String DEFAULT_SUBSCRIPTION_ID = "default_subscription_id"; + private static final String MESSAGE_EXPIRATION_TIME = "message_expiration_time"; + public static final String REGISTERED = "registered"; + public static final String SYSTEM_DISPLAY_NAME = "system_display_name"; + private static final String SYSTEM_PHOTO_URI = "system_photo_uri"; + public static final String SYSTEM_PHONE_TYPE = "system_phone_type"; + public static final String SYSTEM_PHONE_LABEL = "system_phone_label"; + private static final String SYSTEM_CONTACT_URI = "system_contact_uri"; + private static final String SYSTEM_INFO_PENDING = "system_info_pending"; + private static final String PROFILE_KEY = "profile_key"; + private static final String PROFILE_KEY_CREDENTIAL = "profile_key_credential"; + private static final String SIGNAL_PROFILE_AVATAR = "signal_profile_avatar"; + private static final String PROFILE_SHARING = "profile_sharing"; + private static final String LAST_PROFILE_FETCH = "last_profile_fetch"; + private static final String UNIDENTIFIED_ACCESS_MODE = "unidentified_access_mode"; + private static final String FORCE_SMS_SELECTION = "force_sms_selection"; + private static final String CAPABILITIES = "capabilities"; + private static final String STORAGE_SERVICE_ID = "storage_service_key"; + private static final String DIRTY = "dirty"; + private static final String PROFILE_GIVEN_NAME = "signal_profile_name"; + private static final String PROFILE_FAMILY_NAME = "profile_family_name"; + private static final String PROFILE_JOINED_NAME = "profile_joined_name"; + private static final String MENTION_SETTING = "mention_setting"; + private static final String STORAGE_PROTO = "storage_proto"; + private static final String LAST_GV1_MIGRATE_REMINDER = "last_gv1_migrate_reminder"; public static final String SEARCH_PROFILE_NAME = "search_signal_profile"; private static final String SORT_NAME = "sort_name"; @@ -305,46 +306,47 @@ public class RecipientDatabase extends Database { } public static final String CREATE_TABLE = - "CREATE TABLE " + TABLE_NAME + " (" + ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " + - UUID + " TEXT UNIQUE DEFAULT NULL, " + - USERNAME + " TEXT UNIQUE DEFAULT NULL, " + - PHONE + " TEXT UNIQUE DEFAULT NULL, " + - EMAIL + " TEXT UNIQUE DEFAULT NULL, " + - GROUP_ID + " TEXT UNIQUE DEFAULT NULL, " + - GROUP_TYPE + " INTEGER DEFAULT " + GroupType.NONE.getId() + ", " + - BLOCKED + " INTEGER DEFAULT 0," + - MESSAGE_RINGTONE + " TEXT DEFAULT NULL, " + - MESSAGE_VIBRATE + " INTEGER DEFAULT " + VibrateState.DEFAULT.getId() + ", " + - CALL_RINGTONE + " TEXT DEFAULT NULL, " + - CALL_VIBRATE + " INTEGER DEFAULT " + VibrateState.DEFAULT.getId() + ", " + - NOTIFICATION_CHANNEL + " TEXT DEFAULT NULL, " + - MUTE_UNTIL + " INTEGER DEFAULT 0, " + - COLOR + " TEXT DEFAULT NULL, " + - SEEN_INVITE_REMINDER + " INTEGER DEFAULT " + InsightsBannerTier.NO_TIER.getId() + ", " + - DEFAULT_SUBSCRIPTION_ID + " INTEGER DEFAULT -1, " + - MESSAGE_EXPIRATION_TIME + " INTEGER DEFAULT 0, " + - REGISTERED + " INTEGER DEFAULT " + RegisteredState.UNKNOWN.getId() + ", " + - SYSTEM_DISPLAY_NAME + " TEXT DEFAULT NULL, " + - SYSTEM_PHOTO_URI + " TEXT DEFAULT NULL, " + - SYSTEM_PHONE_LABEL + " TEXT DEFAULT NULL, " + - SYSTEM_PHONE_TYPE + " INTEGER DEFAULT -1, " + - SYSTEM_CONTACT_URI + " TEXT DEFAULT NULL, " + - SYSTEM_INFO_PENDING + " INTEGER DEFAULT 0, " + - PROFILE_KEY + " TEXT DEFAULT NULL, " + - PROFILE_KEY_CREDENTIAL + " TEXT DEFAULT NULL, " + - PROFILE_GIVEN_NAME + " TEXT DEFAULT NULL, " + - PROFILE_FAMILY_NAME + " TEXT DEFAULT NULL, " + - PROFILE_JOINED_NAME + " TEXT DEFAULT NULL, " + - SIGNAL_PROFILE_AVATAR + " TEXT DEFAULT NULL, " + - PROFILE_SHARING + " INTEGER DEFAULT 0, " + - LAST_PROFILE_FETCH + " INTEGER DEFAULT 0, " + - UNIDENTIFIED_ACCESS_MODE + " INTEGER DEFAULT 0, " + - FORCE_SMS_SELECTION + " INTEGER DEFAULT 0, " + - STORAGE_SERVICE_ID + " TEXT UNIQUE DEFAULT NULL, " + - DIRTY + " INTEGER DEFAULT " + DirtyState.CLEAN.getId() + ", " + - MENTION_SETTING + " INTEGER DEFAULT " + MentionSetting.ALWAYS_NOTIFY.getId() + ", " + - STORAGE_PROTO + " TEXT DEFAULT NULL, " + - CAPABILITIES + " INTEGER DEFAULT 0);"; + "CREATE TABLE " + TABLE_NAME + " (" + ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " + + UUID + " TEXT UNIQUE DEFAULT NULL, " + + USERNAME + " TEXT UNIQUE DEFAULT NULL, " + + PHONE + " TEXT UNIQUE DEFAULT NULL, " + + EMAIL + " TEXT UNIQUE DEFAULT NULL, " + + GROUP_ID + " TEXT UNIQUE DEFAULT NULL, " + + GROUP_TYPE + " INTEGER DEFAULT " + GroupType.NONE.getId() + ", " + + BLOCKED + " INTEGER DEFAULT 0," + + MESSAGE_RINGTONE + " TEXT DEFAULT NULL, " + + MESSAGE_VIBRATE + " INTEGER DEFAULT " + VibrateState.DEFAULT.getId() + ", " + + CALL_RINGTONE + " TEXT DEFAULT NULL, " + + CALL_VIBRATE + " INTEGER DEFAULT " + VibrateState.DEFAULT.getId() + ", " + + NOTIFICATION_CHANNEL + " TEXT DEFAULT NULL, " + + MUTE_UNTIL + " INTEGER DEFAULT 0, " + + COLOR + " TEXT DEFAULT NULL, " + + SEEN_INVITE_REMINDER + " INTEGER DEFAULT " + InsightsBannerTier.NO_TIER.getId() + ", " + + DEFAULT_SUBSCRIPTION_ID + " INTEGER DEFAULT -1, " + + MESSAGE_EXPIRATION_TIME + " INTEGER DEFAULT 0, " + + REGISTERED + " INTEGER DEFAULT " + RegisteredState.UNKNOWN.getId() + ", " + + SYSTEM_DISPLAY_NAME + " TEXT DEFAULT NULL, " + + SYSTEM_PHOTO_URI + " TEXT DEFAULT NULL, " + + SYSTEM_PHONE_LABEL + " TEXT DEFAULT NULL, " + + SYSTEM_PHONE_TYPE + " INTEGER DEFAULT -1, " + + SYSTEM_CONTACT_URI + " TEXT DEFAULT NULL, " + + SYSTEM_INFO_PENDING + " INTEGER DEFAULT 0, " + + PROFILE_KEY + " TEXT DEFAULT NULL, " + + PROFILE_KEY_CREDENTIAL + " TEXT DEFAULT NULL, " + + PROFILE_GIVEN_NAME + " TEXT DEFAULT NULL, " + + PROFILE_FAMILY_NAME + " TEXT DEFAULT NULL, " + + PROFILE_JOINED_NAME + " TEXT DEFAULT NULL, " + + SIGNAL_PROFILE_AVATAR + " TEXT DEFAULT NULL, " + + PROFILE_SHARING + " INTEGER DEFAULT 0, " + + LAST_PROFILE_FETCH + " INTEGER DEFAULT 0, " + + UNIDENTIFIED_ACCESS_MODE + " INTEGER DEFAULT 0, " + + FORCE_SMS_SELECTION + " INTEGER DEFAULT 0, " + + STORAGE_SERVICE_ID + " TEXT UNIQUE DEFAULT NULL, " + + DIRTY + " INTEGER DEFAULT " + DirtyState.CLEAN.getId() + ", " + + MENTION_SETTING + " INTEGER DEFAULT " + MentionSetting.ALWAYS_NOTIFY.getId() + ", " + + STORAGE_PROTO + " TEXT DEFAULT NULL, " + + CAPABILITIES + " INTEGER DEFAULT 0, " + + LAST_GV1_MIGRATE_REMINDER + " INTEGER DEFAULT 0);"; private static final String INSIGHTS_INVITEE_LIST = "SELECT " + TABLE_NAME + "." + ID + " FROM " + TABLE_NAME + @@ -1479,6 +1481,26 @@ public class RecipientDatabase extends Database { } } + public void markGroupsV1MigrationReminderSeen(@NonNull RecipientId id, long time) { + ContentValues values = new ContentValues(1); + values.put(LAST_GV1_MIGRATE_REMINDER, time); + if (update(id, values)) { + Recipient.live(id).refresh(); + } + } + + public long getGroupsV1MigrationReminderLastSeen(@NonNull RecipientId id) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + + try (Cursor cursor = db.query(TABLE_NAME, new String[] { LAST_GV1_MIGRATE_REMINDER }, ID_WHERE, SqlUtil.buildArgs(id), null, null, null)) { + if (cursor.moveToFirst()) { + return CursorUtil.requireLong(cursor, LAST_GV1_MIGRATE_REMINDER); + } + } + + return 0; + } + public void setCapabilities(@NonNull RecipientId id, @NonNull SignalServiceProfile.Capabilities capabilities) { long value = 0; 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 6bb50588d2..dcd7794216 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java @@ -222,7 +222,7 @@ public class ThreadDatabase extends Database { } public void updateSnippet(long threadId, String snippet, @Nullable Uri attachment, long date, long type, boolean unarchive) { - if (MmsSmsColumns.Types.isProfileChange(type)) { + if (isSilentType(type)) { return; } @@ -1370,6 +1370,11 @@ public class ThreadDatabase extends Database { return query; } + private boolean isSilentType(long type) { + return MmsSmsColumns.Types.isProfileChange(type) || + MmsSmsColumns.Types.isGroupV1MigrationEvent(type); + } + public Reader readerFor(Cursor cursor) { return new Reader(cursor); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java index 7715131c54..1b891f57bf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java @@ -159,8 +159,9 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { private static final int CAPABILITIES_REFACTOR = 79; private static final int GV1_MIGRATION = 80; private static final int NOTIFIED_TIMESTAMP = 81; + private static final int GV1_MIGRATION_LAST_SEEN = 82; - private static final int DATABASE_VERSION = 81; + private static final int DATABASE_VERSION = 82; private static final String DATABASE_NAME = "signal.db"; private final Context context; @@ -1165,6 +1166,10 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { db.execSQL("ALTER TABLE mms ADD COLUMN notified_timestamp INTEGER DEFAULT 0"); } + if (oldVersion < GV1_MIGRATION_LAST_SEEN) { + db.execSQL("ALTER TABLE recipient ADD COLUMN last_gv1_migrate_reminder INTEGER DEFAULT 0"); + } + db.setTransactionSuccessful(); } finally { db.endTransaction(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupId.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupId.java index e8003a29f9..de0d20740f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupId.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupId.java @@ -175,9 +175,8 @@ public abstract class GroupId { return encodedId.hashCode(); } - @NonNull @Override - public String toString() { + public @NonNull String toString() { return encodedId; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupsV1MigrationUtil.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupsV1MigrationUtil.java new file mode 100644 index 0000000000..5a0d3c322d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupsV1MigrationUtil.java @@ -0,0 +1,244 @@ +package org.thoughtcrime.securesms.groups; + +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.GroupDatabase; +import org.thoughtcrime.securesms.database.RecipientDatabase; +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.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil; + +import java.io.Closeable; +import java.io.IOException; +import java.util.List; + +import static org.thoughtcrime.securesms.groups.v2.processing.GroupsV2StateProcessor.LATEST; + +public final class GroupsV1MigrationUtil { + + private static final String TAG = Log.tag(GroupsV1MigrationUtil.class); + + private GroupsV1MigrationUtil() {} + + public static void migrate(@NonNull Context context, @NonNull RecipientId recipientId, boolean forced) + throws IOException, RetryLaterException, GroupChangeBusyException, InvalidMigrationStateException + { + Recipient groupRecipient = Recipient.resolved(recipientId); + Long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(recipientId); + GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context); + + if (threadId == null) { + Log.w(TAG, "No thread found!"); + throw new InvalidMigrationStateException(); + } + + if (!groupRecipient.isPushV1Group()) { + Log.w(TAG, "Not a V1 group!"); + throw new InvalidMigrationStateException(); + } + + if (groupRecipient.getParticipants().size() > FeatureFlags.groupLimits().getHardLimit()) { + Log.w(TAG, "Too many members! Size: " + groupRecipient.getParticipants().size()); + throw new InvalidMigrationStateException(); + } + + GroupId.V1 gv1Id = groupRecipient.requireGroupId().requireV1(); + GroupId.V2 gv2Id = gv1Id.deriveV2MigrationGroupId(); + GroupMasterKey gv2MasterKey = gv1Id.deriveV2MigrationMasterKey(); + boolean newlyCreated = false; + + if (groupDatabase.groupExists(gv2Id)) { + Log.w(TAG, "We already have a V2 group for this V1 group! Must have been added before we were migration-capable."); + throw new InvalidMigrationStateException(); + } + + if (!groupRecipient.isActiveGroup()) { + Log.w(TAG, "Group is inactive! Can't migrate."); + throw new InvalidMigrationStateException(); + } + + switch (GroupManager.v2GroupStatus(context, gv2MasterKey)) { + case DOES_NOT_EXIST: + Log.i(TAG, "Group does not exist on the service."); + + if (!groupRecipient.isProfileSharing()) { + Log.w(TAG, "Profile sharing is disabled! Can't migrate."); + throw new InvalidMigrationStateException(); + } + + if (!forced && SignalStore.internalValues().disableGv1AutoMigrateInitiation()) { + Log.w(TAG, "Auto migration initiation has been disabled! Skipping."); + throw new InvalidMigrationStateException(); + } + + if (!forced && !FeatureFlags.groupsV1AutoMigration()) { + Log.w(TAG, "Auto migration is not enabled! Skipping."); + throw new InvalidMigrationStateException(); + } + + if (forced && !FeatureFlags.groupsV1ManualMigration()) { + Log.w(TAG, "Manual migration is not enabled! Skipping."); + throw new InvalidMigrationStateException(); + } + + 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()) { + Log.w(TAG, "Not allowed to invite or leave registered users behind in an auto-migration! Skipping."); + throw new InvalidMigrationStateException(); + } + + Log.i(TAG, "Attempting to create group."); + + try { + GroupManager.migrateGroupToServer(context, gv1Id, possibleMembers); + newlyCreated = true; + Log.i(TAG, "Successfully created!"); + } catch (GroupChangeFailedException e) { + Log.w(TAG, "Failed to migrate group. Retrying.", e); + throw new RetryLaterException(); + } catch (MembershipNotSuitableForV2Exception e) { + Log.w(TAG, "Failed to migrate job due to the membership not yet being suitable for GV2. Aborting.", e); + return; + } catch (GroupAlreadyExistsException e) { + Log.w(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: + Log.w(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: + Log.w(TAG, "The migrated group already exists, and we're in it. Continuing on."); + break; + default: throw new AssertionError(); + } + + Log.i(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 + { + try (Closeable ignored = GroupsV2ProcessingLock.acquireGroupProcessingLock()) { + Recipient recipient = Recipient.externalGroupExact(context, gv1Id); + long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(recipient); + + performLocalMigration(context, gv1Id, threadId, recipient); + } catch (GroupChangeBusyException e) { + throw new IOException(e); + } + } + + private static @Nullable DecryptedGroup performLocalMigration(@NonNull Context context, + @NonNull GroupId.V1 gv1Id, + long threadId, + @NonNull Recipient groupRecipient) + throws IOException, GroupChangeBusyException + { + try (Closeable ignored = GroupsV2ProcessingLock.acquireGroupProcessingLock()){ + 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).insertGroupV1MigrationEvents(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) { + 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); + } + + DatabaseFactory.getGroupDatabase(context).setActive(gv1Id, false); + DatabaseFactory.getGroupDatabase(context).remove(gv1Id, Recipient.self().getId()); + } + + /** + * 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(); + } + + /** + * True if the user meets all the requirements to be auto-migrated, otherwise false. + */ + public static boolean isAutoMigratable(@NonNull Recipient recipient) { + return recipient.hasUuid() && + recipient.getGroupsV2Capability() == Recipient.Capability.SUPPORTED && + recipient.getGroupsV1MigrationCapability() == Recipient.Capability.SUPPORTED && + recipient.getRegistered() == RecipientDatabase.RegisteredState.REGISTERED && + recipient.getProfileKey() != null; + } + + public static final class InvalidMigrationStateException extends Exception { + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupFragment.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupFragment.java index 686925a24f..6bbc2f36e3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupFragment.java @@ -45,6 +45,7 @@ import org.thoughtcrime.securesms.groups.ui.invitesandrequests.ManagePendingAndR import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupInviteSentDialog; import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupRightsDialog; import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupsLearnMoreBottomSheetDialogFragment; +import org.thoughtcrime.securesms.groups.ui.migration.GroupsV1MigrationInitiationBottomSheetDialogFragment; import org.thoughtcrime.securesms.groups.ui.pendingmemberinvites.PendingMemberInvitesActivity; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.mediaoverview.MediaOverviewActivity; @@ -384,6 +385,17 @@ public class ManageGroupFragment extends LoggingFragment { groupInfoText.setLearnMoreVisible(true); groupInfoText.setVisibility(View.VISIBLE); break; + case LEGACY_GROUP_UPGRADE: + groupInfoText.setText(R.string.ManageGroupActivity_legacy_group_upgrade); + groupInfoText.setOnLinkClickListener(v -> GroupsV1MigrationInitiationBottomSheetDialogFragment.showForInitiation(requireFragmentManager(), Recipient.externalPossiblyMigratedGroup(requireContext(), groupId).getId())); + groupInfoText.setLearnMoreVisible(true, R.string.ManageGroupActivity_upgrade_this_group); + groupInfoText.setVisibility(View.VISIBLE); + break; + case LEGACY_GROUP_TOO_LARGE: + groupInfoText.setText(context.getString(R.string.ManageGroupActivity_legacy_group_too_large, FeatureFlags.groupLimits().getHardLimit() - 1)); + groupInfoText.setLearnMoreVisible(false); + groupInfoText.setVisibility(View.VISIBLE); + break; case MMS_WARNING: groupInfoText.setText(R.string.ManageGroupActivity_this_is_an_insecure_mms_group); groupInfoText.setOnLinkClickListener(v -> startActivity(new Intent(requireContext(), InviteActivity.class))); 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 ce7c25d17e..0d8c168c67 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 @@ -43,6 +43,7 @@ import org.thoughtcrime.securesms.recipients.RecipientUtil; import org.thoughtcrime.securesms.util.AsynchronousCallback; import org.thoughtcrime.securesms.util.DefaultValueLiveData; import org.thoughtcrime.securesms.util.ExpirationUtil; +import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.Util; import org.thoughtcrime.securesms.util.livedata.LiveDataUtil; import org.thoughtcrime.securesms.util.views.SimpleProgressDialog; @@ -122,9 +123,15 @@ public class ManageGroupViewModel extends ViewModel { this.mentionSetting = Transformations.distinctUntilChanged(Transformations.map(this.groupRecipient, recipient -> MentionUtil.getMentionSettingDisplayValue(context, recipient.getMentionSetting()))); this.groupLinkOn = Transformations.map(liveGroup.getGroupLink(), GroupLinkUrlAndStatus::isEnabled); - this.groupInfoMessage = Transformations.map(this.showLegacyIndicator, - showLegacyInfo -> { - if (showLegacyInfo) { + this.groupInfoMessage = Transformations.map(this.groupRecipient, + recipient -> { + boolean showLegacyInfo = recipient.requireGroupId().isV1(); + + if (showLegacyInfo && FeatureFlags.groupsV1ManualMigration() && recipient.getParticipants().size() > FeatureFlags.groupLimits().getHardLimit()) { + return GroupInfoMessage.LEGACY_GROUP_TOO_LARGE; + } else if (showLegacyInfo && FeatureFlags.groupsV1ManualMigration()) { + return GroupInfoMessage.LEGACY_GROUP_UPGRADE; + } else if (showLegacyInfo) { return GroupInfoMessage.LEGACY_GROUP_LEARN_MORE; } else if (groupId.isMms()) { return GroupInfoMessage.MMS_WARNING; @@ -393,6 +400,8 @@ public class ManageGroupViewModel extends ViewModel { enum GroupInfoMessage { NONE, LEGACY_GROUP_LEARN_MORE, + LEGACY_GROUP_UPGRADE, + LEGACY_GROUP_TOO_LARGE, MMS_WARNING } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/migration/GroupsV1MigrationBottomSheetDialogFragment.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/migration/GroupsV1MigrationInfoBottomSheetDialogFragment.java similarity index 60% rename from app/src/main/java/org/thoughtcrime/securesms/groups/ui/migration/GroupsV1MigrationBottomSheetDialogFragment.java rename to app/src/main/java/org/thoughtcrime/securesms/groups/ui/migration/GroupsV1MigrationInfoBottomSheetDialogFragment.java index 225de366ce..e82baed10f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/migration/GroupsV1MigrationBottomSheetDialogFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/migration/GroupsV1MigrationInfoBottomSheetDialogFragment.java @@ -24,19 +24,24 @@ import org.thoughtcrime.securesms.util.ThemeUtil; import java.util.ArrayList; import java.util.List; -public final class GroupsV1MigrationBottomSheetDialogFragment extends BottomSheetDialogFragment { +/** + * Shows more info about a GV1->GV2 migration event. Looks similar to + * {@link GroupsV1MigrationInitiationBottomSheetDialogFragment}, but only displays static data. + */ +public final class GroupsV1MigrationInfoBottomSheetDialogFragment extends BottomSheetDialogFragment { private static final String KEY_PENDING = "pending"; - private GroupsV1MigrationViewModel viewModel; - private GroupMemberListView pendingList; - private TextView pendingTitle; + private GroupsV1MigrationInfoViewModel viewModel; + private GroupMemberListView pendingList; + private TextView pendingTitle; + private View pendingContainer; public static void showForLearnMore(@NonNull FragmentManager manager, @NonNull List pendingRecipients) { Bundle args = new Bundle(); args.putParcelableArrayList(KEY_PENDING, new ArrayList<>(pendingRecipients)); - GroupsV1MigrationBottomSheetDialogFragment fragment = new GroupsV1MigrationBottomSheetDialogFragment(); + GroupsV1MigrationInfoBottomSheetDialogFragment fragment = new GroupsV1MigrationInfoBottomSheetDialogFragment(); fragment.setArguments(args); fragment.show(manager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG); @@ -58,13 +63,17 @@ public final class GroupsV1MigrationBottomSheetDialogFragment extends BottomShee @Override public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { - this.pendingTitle = view.findViewById(R.id.gv1_learn_more_pending_title); - this.pendingList = view.findViewById(R.id.gv1_learn_more_pending_list); + this.pendingContainer = view.findViewById(R.id.gv1_learn_more_pending_container); + this.pendingTitle = view.findViewById(R.id.gv1_learn_more_pending_title); + this.pendingList = view.findViewById(R.id.gv1_learn_more_pending_list); - List pending = getArguments().containsKey(KEY_PENDING) ? getArguments().getParcelableArrayList(KEY_PENDING) : null; + //noinspection ConstantConditions + List pending = getArguments().getParcelableArrayList(KEY_PENDING); - this.viewModel = ViewModelProviders.of(this, new GroupsV1MigrationViewModel.Factory(pending)).get(GroupsV1MigrationViewModel.class); + this.viewModel = ViewModelProviders.of(this, new GroupsV1MigrationInfoViewModel.Factory(pending)).get(GroupsV1MigrationInfoViewModel.class); viewModel.getPendingMembers().observe(getViewLifecycleOwner(), this::onPendingMembersChanged); + + view.findViewById(R.id.gv1_learn_more_ok_button).setOnClickListener(v -> dismiss()); } @Override @@ -73,7 +82,12 @@ public final class GroupsV1MigrationBottomSheetDialogFragment extends BottomShee } private void onPendingMembersChanged(@NonNull List pendingMembers) { - pendingTitle.setText(getResources().getQuantityText(R.plurals.GroupsV1MigrationLearnMore_these_members_will_need_to_accept_an_invite, pendingMembers.size())); - pendingList.setDisplayOnlyMembers(pendingMembers); + if (pendingMembers.size() > 0) { + pendingContainer.setVisibility(View.VISIBLE); + pendingTitle.setText(getResources().getQuantityText(R.plurals.GroupsV1MigrationLearnMore_these_members_will_need_to_accept_an_invite, pendingMembers.size())); + pendingList.setDisplayOnlyMembers(pendingMembers); + } else { + pendingContainer.setVisibility(View.GONE); + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/migration/GroupsV1MigrationViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/migration/GroupsV1MigrationInfoViewModel.java similarity index 83% rename from app/src/main/java/org/thoughtcrime/securesms/groups/ui/migration/GroupsV1MigrationViewModel.java rename to app/src/main/java/org/thoughtcrime/securesms/groups/ui/migration/GroupsV1MigrationInfoViewModel.java index 7f2d42afaa..38e9686b18 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/migration/GroupsV1MigrationViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/migration/GroupsV1MigrationInfoViewModel.java @@ -12,11 +12,11 @@ import org.thoughtcrime.securesms.util.concurrent.SignalExecutors; import java.util.List; -class GroupsV1MigrationViewModel extends ViewModel { +class GroupsV1MigrationInfoViewModel extends ViewModel { private final MutableLiveData> pendingMembers; - private GroupsV1MigrationViewModel(@NonNull List pendingMembers) { + private GroupsV1MigrationInfoViewModel(@NonNull List pendingMembers) { this.pendingMembers = new MutableLiveData<>(); SignalExecutors.BOUNDED.execute(() -> { @@ -38,7 +38,7 @@ class GroupsV1MigrationViewModel extends ViewModel { @Override public @NonNull T create(@NonNull Class modelClass) { - return modelClass.cast(new GroupsV1MigrationViewModel(pendingMembers)); + return modelClass.cast(new GroupsV1MigrationInfoViewModel(pendingMembers)); } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/migration/GroupsV1MigrationInitiationBottomSheetDialogFragment.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/migration/GroupsV1MigrationInitiationBottomSheetDialogFragment.java new file mode 100644 index 0000000000..1afe479bbb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/migration/GroupsV1MigrationInitiationBottomSheetDialogFragment.java @@ -0,0 +1,133 @@ +package org.thoughtcrime.securesms.groups.ui.migration; + +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.DialogFragment; +import androidx.fragment.app.FragmentManager; +import androidx.lifecycle.ViewModelProviders; + +import com.google.android.material.bottomsheet.BottomSheetDialogFragment; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.groups.ui.GroupMemberListView; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.BottomSheetUtil; +import org.thoughtcrime.securesms.util.ThemeUtil; +import org.thoughtcrime.securesms.util.views.SimpleProgressDialog; + +/** + * A bottom sheet that allows a user to initiation a manual GV1->GV2 migration. Will show the user + * the members that will be invited/left behind. + */ +public final class GroupsV1MigrationInitiationBottomSheetDialogFragment extends BottomSheetDialogFragment { + + private static final String KEY_GROUP_RECIPIENT_ID = "group_recipient_id"; + + private GroupsV1MigrationInitiationViewModel viewModel; + private GroupMemberListView inviteList; + private TextView inviteTitle; + private View inviteContainer; + private GroupMemberListView ineligibleList; + private TextView ineligibleTitle; + private View ineligibleContainer; + + public static void showForInitiation(@NonNull FragmentManager manager, @NonNull RecipientId groupRecipientId) { + Bundle args = new Bundle(); + args.putParcelable(KEY_GROUP_RECIPIENT_ID, groupRecipientId); + + GroupsV1MigrationInitiationBottomSheetDialogFragment fragment = new GroupsV1MigrationInitiationBottomSheetDialogFragment(); + fragment.setArguments(args); + + fragment.show(manager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG); + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + setStyle(DialogFragment.STYLE_NORMAL, + ThemeUtil.isDarkTheme(requireContext()) ? R.style.Theme_Signal_RoundedBottomSheet + : R.style.Theme_Signal_RoundedBottomSheet_Light); + + super.onCreate(savedInstanceState); + } + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.groupsv1_migration_bottom_sheet, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + this.inviteContainer = view.findViewById(R.id.gv1_migrate_invite_container); + this.inviteTitle = view.findViewById(R.id.gv1_migrate_invite_title); + this.inviteList = view.findViewById(R.id.gv1_migrate_invite_list); + this.ineligibleContainer = view.findViewById(R.id.gv1_migrate_ineligible_container); + this.ineligibleTitle = view.findViewById(R.id.gv1_migrate_ineligible_title); + this.ineligibleList = view.findViewById(R.id.gv1_migrate_ineligible_list); + + inviteList.setNestedScrollingEnabled(false); + ineligibleList.setNestedScrollingEnabled(false); + + //noinspection ConstantConditions + RecipientId groupRecipientId = getArguments().getParcelable(KEY_GROUP_RECIPIENT_ID); + + //noinspection ConstantConditions + viewModel = ViewModelProviders.of(this, new GroupsV1MigrationInitiationViewModel.Factory(groupRecipientId)).get(GroupsV1MigrationInitiationViewModel.class); + viewModel.getMigrationState().observe(getViewLifecycleOwner(), this::onMigrationStateChanged); + + view.findViewById(R.id.gv1_migrate_cancel_button).setOnClickListener(v -> dismiss()); + view.findViewById(R.id.gv1_migrate_upgrade_button).setOnClickListener(v -> onUpgradeClicked()); + } + + @Override + public void show(@NonNull FragmentManager manager, @Nullable String tag) { + BottomSheetUtil.show(manager, tag, this); + } + + private void onMigrationStateChanged(@NonNull MigrationState migrationState) { + if (migrationState.getNeedsInvite().size() > 0) { + inviteContainer.setVisibility(View.VISIBLE); + inviteTitle.setText(getResources().getQuantityText(R.plurals.GroupsV1MigrationInitiation_these_members_will_need_to_accept_an_invite, migrationState.getNeedsInvite().size())); + inviteList.setDisplayOnlyMembers(migrationState.getNeedsInvite()); + } else { + inviteContainer.setVisibility(View.GONE); + } + + if (migrationState.getIneligible().size() > 0) { + ineligibleContainer.setVisibility(View.VISIBLE); + ineligibleTitle.setText(getResources().getQuantityText(R.plurals.GroupsV1MigrationInitiation_these_members_are_not_capable_of_joining_new_groups, migrationState.getIneligible().size())); + ineligibleList.setDisplayOnlyMembers(migrationState.getIneligible()); + } else { + ineligibleContainer.setVisibility(View.GONE); + } + } + + private void onUpgradeClicked() { + AlertDialog dialog = SimpleProgressDialog.show(requireContext()); + viewModel.onUpgradeClicked().observe(getViewLifecycleOwner(), result -> { + switch (result) { + case SUCCESS: + dismiss(); + break; + case FAILURE_GENERAL: + Toast.makeText(requireContext(), R.string.GroupsV1MigrationInitiation_failed_to_upgrade, Toast.LENGTH_SHORT).show(); + dismiss(); + break; + case FAILURE_NETWORK: + Toast.makeText(requireContext(), R.string.GroupsV1MigrationInitiation_encountered_a_network_error, Toast.LENGTH_SHORT).show(); + dismiss(); + break; + default: + throw new IllegalStateException(); + } + dialog.dismiss(); + }); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/migration/GroupsV1MigrationInitiationViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/migration/GroupsV1MigrationInitiationViewModel.java new file mode 100644 index 0000000000..6896b090b1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/migration/GroupsV1MigrationInitiationViewModel.java @@ -0,0 +1,51 @@ +package org.thoughtcrime.securesms.groups.ui.migration; + +import androidx.annotation.NonNull; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.recipients.RecipientId; + +class GroupsV1MigrationInitiationViewModel extends ViewModel { + + private final RecipientId groupRecipientId; + private final MutableLiveData migrationState; + private final GroupsV1MigrationRepository repository; + + private GroupsV1MigrationInitiationViewModel(@NonNull RecipientId groupRecipientId) { + this.groupRecipientId = groupRecipientId; + this.migrationState = new MutableLiveData<>(); + this.repository = new GroupsV1MigrationRepository(); + + repository.getMigrationState(groupRecipientId, migrationState::postValue); + } + + @NonNull LiveData getMigrationState() { + return migrationState; + } + + @NonNull LiveData onUpgradeClicked() { + MutableLiveData migrationResult = new MutableLiveData<>(); + + repository.upgradeGroup(groupRecipientId, migrationResult::postValue); + + return migrationResult; + } + + static class Factory extends ViewModelProvider.NewInstanceFactory { + + private final RecipientId groupRecipientId; + + Factory(@NonNull RecipientId groupRecipientId) { + this.groupRecipientId = groupRecipientId; + } + + @Override + public @NonNull T create(@NonNull Class modelClass) { + return modelClass.cast(new GroupsV1MigrationInitiationViewModel(groupRecipientId)); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/migration/GroupsV1MigrationRepository.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/migration/GroupsV1MigrationRepository.java new file mode 100644 index 0000000000..f97be92c33 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/migration/GroupsV1MigrationRepository.java @@ -0,0 +1,108 @@ +package org.thoughtcrime.securesms.groups.ui.migration; + +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; +import androidx.core.util.Consumer; + +import com.annimon.stream.Collectors; +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.database.RecipientDatabase; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.groups.GroupChangeBusyException; +import org.thoughtcrime.securesms.groups.GroupsV1MigrationUtil; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; +import org.thoughtcrime.securesms.jobs.RetrieveProfileJob; +import org.thoughtcrime.securesms.logging.Log; +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.concurrent.SignalExecutors; + +import java.io.IOException; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +final class GroupsV1MigrationRepository { + + private static final String TAG = Log.tag(GroupsV1MigrationRepository.class); + + void getMigrationState(@NonNull RecipientId groupRecipientId, @NonNull Consumer callback) { + SignalExecutors.BOUNDED.execute(() -> callback.accept(getMigrationState(groupRecipientId))); + } + + void upgradeGroup(@NonNull RecipientId recipientId, @NonNull Consumer callback) { + SignalExecutors.UNBOUNDED.execute(() -> { + if (!NetworkConstraint.isMet(ApplicationDependencies.getApplication())) { + Log.w(TAG, "No network!"); + callback.accept(MigrationResult.FAILURE_NETWORK); + return; + } + + if (!Recipient.resolved(recipientId).isPushV1Group()) { + Log.w(TAG, "Not a V1 group!"); + callback.accept(MigrationResult.FAILURE_GENERAL); + return; + } + + try { + GroupsV1MigrationUtil.migrate(ApplicationDependencies.getApplication(), recipientId, true); + callback.accept(MigrationResult.SUCCESS); + } catch (IOException | RetryLaterException | GroupChangeBusyException e) { + callback.accept(MigrationResult.FAILURE_NETWORK); + } catch (GroupsV1MigrationUtil.InvalidMigrationStateException e) { + callback.accept(MigrationResult.FAILURE_GENERAL); + } + }); + } + + @WorkerThread + private MigrationState getMigrationState(@NonNull RecipientId groupRecipientId) { + Recipient group = Recipient.resolved(groupRecipientId); + + if (!group.isPushV1Group()) { + return new MigrationState(Collections.emptyList(), Collections.emptyList()); + } + + Set needsRefresh = Stream.of(group.getParticipants()) + .filter(r -> r.getGroupsV2Capability() != Recipient.Capability.SUPPORTED || + r.getGroupsV1MigrationCapability() != Recipient.Capability.SUPPORTED) + .map(Recipient::getId) + .collect(Collectors.toSet()); + + List jobs = RetrieveProfileJob.forRecipients(needsRefresh); + + for (Job job : jobs) { + if (!ApplicationDependencies.getJobManager().runSynchronously(job, TimeUnit.SECONDS.toMillis(3)).isPresent()) { + Log.w(TAG, "Failed to refresh capabilities in time!"); + } + } + + try { + RecipientUtil.ensureUuidsAreAvailable(ApplicationDependencies.getApplication(), group.getParticipants()); + } catch (IOException e) { + Log.w(TAG, "Failed to refresh UUIDs!", e); + } + + group = group.fresh(); + + List ineligible = Stream.of(group.getParticipants()) + .filter(r -> !r.hasUuid() || + r.getGroupsV2Capability() != Recipient.Capability.SUPPORTED || + r.getGroupsV1MigrationCapability() != Recipient.Capability.SUPPORTED || + r.getRegistered() != RecipientDatabase.RegisteredState.REGISTERED) + .toList(); + + List invites = Stream.of(group.getParticipants()) + .filterNot(ineligible::contains) + .filterNot(Recipient::isSelf) + .filter(r -> r.getProfileKey() == null) + .toList(); + + return new MigrationState(invites, ineligible); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/migration/MigrationResult.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/migration/MigrationResult.java new file mode 100644 index 0000000000..47efa2d7ab --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/migration/MigrationResult.java @@ -0,0 +1,5 @@ +package org.thoughtcrime.securesms.groups.ui.migration; + +enum MigrationResult { + SUCCESS, FAILURE_GENERAL, FAILURE_NETWORK +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/migration/MigrationState.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/migration/MigrationState.java new file mode 100644 index 0000000000..f9ffee857e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/migration/MigrationState.java @@ -0,0 +1,30 @@ +package org.thoughtcrime.securesms.groups.ui.migration; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.recipients.Recipient; + +import java.util.List; + +/** + * Represents the migration state of a group. Namely, which users will be invited or left behind. + */ +final class MigrationState { + private final List needsInvite; + private final List ineligible; + + MigrationState(@NonNull List needsInvite, + @NonNull List ineligible) + { + this.needsInvite = needsInvite; + this.ineligible = ineligible; + } + + public @NonNull List getNeedsInvite() { + return needsInvite; + } + + public @NonNull List getIneligible() { + return ineligible; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/GroupV1MigrationJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/GroupV1MigrationJob.java index b7d52df966..3a8c3760ca 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/GroupV1MigrationJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/GroupV1MigrationJob.java @@ -1,44 +1,28 @@ 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.GroupDatabase; 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.groups.GroupsV1MigrationUtil; 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; @@ -48,56 +32,40 @@ 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"; + public static final long MANUAL_TIMEOUT = TimeUnit.SECONDS.toMillis(20); - private static final int ROUTINE_LIMIT = 50; + private static final String KEY_RECIPIENT_ID = "recipient_id"; + + private static final int ROUTINE_LIMIT = 50; private static final long REFRESH_INTERVAL = TimeUnit.HOURS.toMillis(3); - private static final Object MIGRATION_LOCK = new Object(); - 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 GroupV1MigrationJob(@NonNull RecipientId recipientId) { + this(new Parameters.Builder() + .setQueue(recipientId.toQueueKey()) + .setMaxAttempts(Parameters.UNLIMITED) + .setLifespan(TimeUnit.DAYS.toMillis(7)) + .addConstraint(NetworkConstraint.KEY) + .build(), + recipientId); } - 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) { + private GroupV1MigrationJob(@NonNull Parameters parameters, @NonNull RecipientId recipientId) { 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)); + ApplicationDependencies.getJobManager().add(new GroupV1MigrationJob(recipientId)); } }); } @@ -132,7 +100,7 @@ public class GroupV1MigrationJob extends BaseJob { } for (ThreadRecord thread : threads) { - jobManager.add(new GroupV1MigrationJob(thread.getRecipient().getId(), false)); + jobManager.add(new GroupV1MigrationJob(thread.getRecipient().getId())); needsRefresh.addAll(Stream.of(thread.getRecipient().getParticipants()) .filter(r -> r.getGroupsV2Capability() != Recipient.Capability.SUPPORTED || @@ -151,7 +119,6 @@ public class GroupV1MigrationJob extends BaseJob { @Override public @NonNull Data serialize() { return new Data.Builder().putString(KEY_RECIPIENT_ID, recipientId.serialize()) - .putBoolean(KEY_FORCED, forced) .build(); } @@ -161,187 +128,14 @@ public class GroupV1MigrationJob extends BaseJob { } @Override - protected void onRun() throws IOException, RetryLaterException { - Recipient groupRecipient = Recipient.resolved(recipientId); - Long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(recipientId); - GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context); - - 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; - - if (groupDatabase.groupExists(gv2Id)) { - warn(TAG, "We already have a V2 group for this V1 group! Must have been added before we were migration-capable."); - return; - } - - 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 { - synchronized (MIGRATION_LOCK) { - 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 { - synchronized (MIGRATION_LOCK) { - 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).insertGroupV1MigrationEvents(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); + protected void onRun() throws IOException, GroupChangeBusyException, RetryLaterException { 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); + GroupsV1MigrationUtil.migrate(context, recipientId, false); + } catch (GroupsV1MigrationUtil.InvalidMigrationStateException e) { + Log.w(TAG, "Invalid migration state. Skipping."); } } - /** - * 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 || @@ -357,9 +151,7 @@ public class GroupV1MigrationJob extends BaseJob { 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)); + return new GroupV1MigrationJob(parameters, RecipientId.from(data.getString(KEY_RECIPIENT_ID))); } } } 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 4657ef65d1..d1ea212206 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushProcessMessageJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushProcessMessageJob.java @@ -50,6 +50,7 @@ import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.groups.GroupManager; import org.thoughtcrime.securesms.groups.GroupNotAMemberException; import org.thoughtcrime.securesms.groups.GroupV1MessageProcessor; +import org.thoughtcrime.securesms.groups.GroupsV1MigrationUtil; import org.thoughtcrime.securesms.jobmanager.Data; import org.thoughtcrime.securesms.jobmanager.Job; import org.thoughtcrime.securesms.jobmanager.JobManager; @@ -357,7 +358,7 @@ public final class PushProcessMessageJob extends BaseJob { Optional possibleGv1 = groupDatabase.getGroupV1ByExpectedV2(groupIdV2); if (possibleGv1.isPresent()) { - GroupV1MigrationJob.performLocalMigration(context, possibleGv1.get().getId().requireV1()); + GroupsV1MigrationUtil.performLocalMigration(context, possibleGv1.get().getId().requireV1()); } if (!updateGv2GroupFromServerOrP2PChange(content, message.getGroupContext().get().getGroupV2().get())) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageSyncJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageSyncJob.java index 8c6f65731c..a2759886d6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageSyncJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageSyncJob.java @@ -6,13 +6,13 @@ import androidx.annotation.NonNull; import com.annimon.stream.Stream; -import org.signal.zkgroup.groups.GroupMasterKey; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.RecipientDatabase; import org.thoughtcrime.securesms.database.RecipientDatabase.RecipientSettings; import org.thoughtcrime.securesms.database.StorageKeyDatabase; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.groups.GroupsV1MigrationUtil; import org.thoughtcrime.securesms.jobmanager.Data; import org.thoughtcrime.securesms.jobmanager.Job; import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; @@ -31,7 +31,6 @@ import org.thoughtcrime.securesms.storage.StorageSyncModels; import org.thoughtcrime.securesms.storage.StorageSyncValidations; import org.thoughtcrime.securesms.tracing.Trace; 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.Util; @@ -56,7 +55,6 @@ import java.util.Iterator; import java.util.List; import java.util.Locale; import java.util.Map; -import java.util.Set; import java.util.concurrent.TimeUnit; /** @@ -293,7 +291,7 @@ public class StorageSyncJob extends BaseJob { if (idMap.containsKey(id)) { Log.i(TAG, "Discovered a new GV2 ID that is actually a migrated V1 group! Migrating now."); - GroupV1MigrationJob.performLocalMigration(context, idMap.get(id)); + GroupsV1MigrationUtil.performLocalMigration(context, idMap.get(id)); recordIterator.remove(); } } 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 c54c94addb..8718d34d7b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java @@ -64,6 +64,7 @@ public final class FeatureFlags { 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"; + private static final String GV1_FORCED_MIGRATE_VERSION = "android.groupsv2.forcedMigrateVersion"; private static final String GROUP_CALLING_VERSION = "android.groupsv2.callingVersion"; /** @@ -195,7 +196,7 @@ public final class FeatureFlags { */ public static SelectionLimits groupLimits() { return new SelectionLimits(getInteger(GROUPS_V2_RECOMMENDED_LIMIT, 151), - getInteger(GROUPS_V2_HARD_LIMIT, 1001)); + getInteger(GROUPS_V2_HARD_LIMIT, 1001)); } /** @@ -276,10 +277,16 @@ public final class FeatureFlags { return groupsV1AutoMigration() && getVersionFlag(GV1_MANUAL_MIGRATE_VERSION) == VersionFlag.ON; } + /** Whether or not group calling is enabled. */ public static boolean groupCalling() { return getVersionFlag(GROUP_CALLING_VERSION) == VersionFlag.ON; } + /** Whether or not forced migration from GV1->GV2 is enabled. */ + public static boolean groupsV1ForcedMigration() { + return groupsV1AutoMigration() && getVersionFlag(GV1_FORCED_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/res/layout/groupsv1_migration_bottom_sheet.xml b/app/src/main/res/layout/groupsv1_migration_bottom_sheet.xml new file mode 100644 index 0000000000..1e48fa8b70 --- /dev/null +++ b/app/src/main/res/layout/groupsv1_migration_bottom_sheet.xml @@ -0,0 +1,182 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +