Add support for manual initiation of GV1->GV2 migrations.

This commit is contained in:
Greyson Parrelli
2020-11-12 09:52:21 -05:00
committed by GitHub
parent 4eaa6ebb47
commit 7e347f5cce
27 changed files with 1161 additions and 424 deletions

View File

@@ -175,9 +175,8 @@ public abstract class GroupId {
return encodedId.hashCode();
}
@NonNull
@Override
public String toString() {
public @NonNull String toString() {
return encodedId;
}

View File

@@ -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<Recipient> registeredMembers = RecipientUtil.getEligibleForSending(groupRecipient.getParticipants());
List<Recipient> 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<RecipientId> 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<Recipient> getMigratableAutoMigrationMembers(@NonNull List<Recipient> 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<Recipient> getMigratableManualMigrationMembers(@NonNull List<Recipient> 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 {
}
}

View File

@@ -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)));

View File

@@ -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
}

View File

@@ -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<RecipientId> 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<RecipientId> pending = getArguments().containsKey(KEY_PENDING) ? getArguments().getParcelableArrayList(KEY_PENDING) : null;
//noinspection ConstantConditions
List<RecipientId> 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<Recipient> 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);
}
}
}

View File

@@ -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<List<Recipient>> pendingMembers;
private GroupsV1MigrationViewModel(@NonNull List<RecipientId> pendingMembers) {
private GroupsV1MigrationInfoViewModel(@NonNull List<RecipientId> pendingMembers) {
this.pendingMembers = new MutableLiveData<>();
SignalExecutors.BOUNDED.execute(() -> {
@@ -38,7 +38,7 @@ class GroupsV1MigrationViewModel extends ViewModel {
@Override
public @NonNull<T extends ViewModel> T create(@NonNull Class<T> modelClass) {
return modelClass.cast(new GroupsV1MigrationViewModel(pendingMembers));
return modelClass.cast(new GroupsV1MigrationInfoViewModel(pendingMembers));
}
}
}

View File

@@ -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();
});
}
}

View File

@@ -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> 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<MigrationState> getMigrationState() {
return migrationState;
}
@NonNull LiveData<MigrationResult> onUpgradeClicked() {
MutableLiveData <MigrationResult> 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 extends ViewModel> T create(@NonNull Class<T> modelClass) {
return modelClass.cast(new GroupsV1MigrationInitiationViewModel(groupRecipientId));
}
}
}

View File

@@ -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<MigrationState> callback) {
SignalExecutors.BOUNDED.execute(() -> callback.accept(getMigrationState(groupRecipientId)));
}
void upgradeGroup(@NonNull RecipientId recipientId, @NonNull Consumer<MigrationResult> 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<RecipientId> needsRefresh = Stream.of(group.getParticipants())
.filter(r -> r.getGroupsV2Capability() != Recipient.Capability.SUPPORTED ||
r.getGroupsV1MigrationCapability() != Recipient.Capability.SUPPORTED)
.map(Recipient::getId)
.collect(Collectors.toSet());
List<Job> 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<Recipient> 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<Recipient> invites = Stream.of(group.getParticipants())
.filterNot(ineligible::contains)
.filterNot(Recipient::isSelf)
.filter(r -> r.getProfileKey() == null)
.toList();
return new MigrationState(invites, ineligible);
}
}

View File

@@ -0,0 +1,5 @@
package org.thoughtcrime.securesms.groups.ui.migration;
enum MigrationResult {
SUCCESS, FAILURE_GENERAL, FAILURE_NETWORK
}

View File

@@ -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<Recipient> needsInvite;
private final List<Recipient> ineligible;
MigrationState(@NonNull List<Recipient> needsInvite,
@NonNull List<Recipient> ineligible)
{
this.needsInvite = needsInvite;
this.ineligible = ineligible;
}
public @NonNull List<Recipient> getNeedsInvite() {
return needsInvite;
}
public @NonNull List<Recipient> getIneligible() {
return ineligible;
}
}