From d307db8a95d0248c9f88e38a49ac54ce2b7c3934 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Mon, 9 Nov 2020 08:30:58 -0500 Subject: [PATCH] Add the ability to add suggested members after a GV1 migration. --- .../GroupsV1MigrationSuggestionsReminder.java | 26 +++++ .../conversation/ConversationActivity.java | 44 ++++--- .../ConversationGroupViewModel.java | 38 ++++++ .../securesms/database/GroupDatabase.java | 3 + .../GroupsV1MigrationSuggestionsDialog.java | 109 ++++++++++++++++++ .../util/concurrent/SignalExecutors.java | 4 +- app/src/main/res/values/ids.xml | 3 + app/src/main/res/values/strings.xml | 33 ++++++ 8 files changed, 244 insertions(+), 16 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/reminder/GroupsV1MigrationSuggestionsReminder.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/ui/migration/GroupsV1MigrationSuggestionsDialog.java diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/reminder/GroupsV1MigrationSuggestionsReminder.java b/app/src/main/java/org/thoughtcrime/securesms/components/reminder/GroupsV1MigrationSuggestionsReminder.java new file mode 100644 index 0000000000..d3c7f4dc5d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/reminder/GroupsV1MigrationSuggestionsReminder.java @@ -0,0 +1,26 @@ +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 add anyone that might have been missed in GV1->GV2 migration. + */ +public class GroupsV1MigrationSuggestionsReminder extends Reminder { + public GroupsV1MigrationSuggestionsReminder(@NonNull Context context, @NonNull List suggestions) { + super(null, context.getResources().getQuantityString(R.plurals.GroupsV1MigrationSuggestionsReminder_members_couldnt_be_added_to_the_new_group, suggestions.size(), suggestions.size())); + addAction(new Action(context.getResources().getQuantityString(R.plurals.GroupsV1MigrationSuggestionsReminder_add_members, suggestions.size()), R.id.reminder_action_gv1_suggestion_add_members)); + addAction(new Action(context.getResources().getString(R.string.GroupsV1MigrationSuggestionsReminder_not_now), R.id.reminder_action_gv1_suggestion_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 df459b2782..85dc840b4e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java @@ -69,7 +69,6 @@ import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.AlertDialog; import androidx.appcompat.widget.SearchView; import androidx.appcompat.widget.Toolbar; -import androidx.core.content.ContextCompat; import androidx.core.content.pm.ShortcutInfoCompat; import androidx.core.content.pm.ShortcutManagerCompat; import androidx.core.graphics.drawable.DrawableCompat; @@ -117,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.GroupsV1MigrationSuggestionsReminder; import org.thoughtcrime.securesms.components.reminder.PendingGroupJoinRequestsReminder; import org.thoughtcrime.securesms.components.reminder.Reminder; import org.thoughtcrime.securesms.components.reminder.ReminderView; @@ -167,6 +167,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.GroupsV1MigrationSuggestionsDialog; import org.thoughtcrime.securesms.insights.InsightsLauncher; import org.thoughtcrime.securesms.invites.InviteReminderModel; import org.thoughtcrime.securesms.invites.InviteReminderRepository; @@ -212,7 +213,6 @@ import org.thoughtcrime.securesms.permissions.Permissions; import org.thoughtcrime.securesms.profiles.GroupShareProfileView; import org.thoughtcrime.securesms.profiles.spoofing.ReviewBannerView; import org.thoughtcrime.securesms.profiles.spoofing.ReviewCardDialogFragment; -import org.thoughtcrime.securesms.profiles.spoofing.ReviewUtil; import org.thoughtcrime.securesms.providers.BlobProvider; import org.thoughtcrime.securesms.reactions.ReactionsBottomSheetDialogFragment; import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiBottomSheetDialogFragment; @@ -457,6 +457,7 @@ public class ConversationActivity extends PassphraseRequiredActivity initializeMentionsViewModel(); initializeEnabledCheck(); initializePendingRequestsBanner(); + initializeGroupV1MigrationSuggestionsBanner(); initializeSecurity(recipient.get().isRegistered(), isDefaultSms).addListener(new AssertedSuccessListener() { @Override public void onSuccess(Boolean result) { @@ -1547,6 +1548,11 @@ public class ConversationActivity extends PassphraseRequiredActivity .observe(this, actionablePendingGroupRequests -> updateReminders()); } + private void initializeGroupV1MigrationSuggestionsBanner() { + groupViewModel.getGroupV1MigrationSuggestions() + .observe(this, s -> updateReminders()); + } + private ListenableFuture initializeDraftFromDatabase() { SettableFuture future = new SettableFuture<>(); @@ -1702,6 +1708,7 @@ public class ConversationActivity extends PassphraseRequiredActivity protected void updateReminders() { Optional inviteReminder = inviteReminderModel.getReminder(); Integer actionableRequestingMembers = groupViewModel.getActionableRequestingMembers().getValue(); + List gv1MigrationSuggestions = groupViewModel.getGroupV1MigrationSuggestions().getValue(); if (UnauthorizedReminder.isEligible(this)) { reminderView.get().showReminder(new UnauthorizedReminder(this)); @@ -1726,25 +1733,32 @@ public class ConversationActivity extends PassphraseRequiredActivity startActivity(ManagePendingAndRequestingMembersActivity.newIntent(this, getRecipient().getGroupId().get().requireV2())); } }); + } else if (gv1MigrationSuggestions != null && gv1MigrationSuggestions.size() > 0 && recipient.get().isPushV2Group()) { + reminderView.get().showReminder(new GroupsV1MigrationSuggestionsReminder(this, gv1MigrationSuggestions)); + reminderView.get().setOnActionClickListener(actionId -> { + if (actionId == R.id.reminder_action_gv1_suggestion_add_members) { + GroupsV1MigrationSuggestionsDialog.show(this, recipient.get().requireGroupId().requireV2(), gv1MigrationSuggestions); + } else if (actionId == R.id.reminder_action_gv1_suggestion_not_now) { + groupViewModel.onSuggestedMembersBannerDismissed(recipient.get().requireGroupId()); + } + }); + reminderView.get().setOnDismissListener(() -> { + }); } else if (reminderView.resolved()) { reminderView.get().hide(); } } private void handleReminderAction(@IdRes int reminderActionId) { - switch (reminderActionId) { - case R.id.reminder_action_invite: - handleInviteLink(); - reminderView.get().requestDismiss(); - break; - case R.id.reminder_action_view_insights: - InsightsLauncher.showInsightsDashboard(getSupportFragmentManager()); - break; - case R.id.reminder_action_update_now: - PlayStoreUtil.openPlayStoreOrOurApkDownloadPage(this); - break; - default: - throw new IllegalArgumentException("Unknown ID: " + reminderActionId); + if (reminderActionId == R.id.reminder_action_invite) { + handleInviteLink(); + reminderView.get().requestDismiss(); + } else if (reminderActionId == R.id.reminder_action_view_insights) { + InsightsLauncher.showInsightsDashboard(getSupportFragmentManager()); + } else if (reminderActionId == R.id.reminder_action_update_now) { + PlayStoreUtil.openPlayStoreOrOurApkDownloadPage(this); + } else { + throw new IllegalArgumentException("Unknown ID: " + reminderActionId); } } 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 0b34efa54d..af0aa343d5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationGroupViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationGroupViewModel.java @@ -4,6 +4,7 @@ import android.app.Application; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; import androidx.lifecycle.LiveData; import androidx.lifecycle.MutableLiveData; import androidx.lifecycle.Transformations; @@ -15,6 +16,7 @@ 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; @@ -24,14 +26,17 @@ import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason; import org.thoughtcrime.securesms.profiles.spoofing.ReviewRecipient; import org.thoughtcrime.securesms.profiles.spoofing.ReviewUtil; import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.util.AsynchronousCallback; import org.thoughtcrime.securesms.util.FeatureFlags; +import org.thoughtcrime.securesms.util.SetUtil; import org.thoughtcrime.securesms.util.concurrent.SignalExecutors; import org.thoughtcrime.securesms.util.livedata.LiveDataUtil; import java.io.IOException; import java.util.Collections; import java.util.List; +import java.util.Set; final class ConversationGroupViewModel extends ViewModel { @@ -40,6 +45,7 @@ final class ConversationGroupViewModel extends ViewModel { private final LiveData selfMembershipLevel; private final LiveData actionableRequestingMembers; private final LiveData reviewState; + private final LiveData> gv1MigrationSuggestions; private ConversationGroupViewModel() { this.liveRecipient = new MutableLiveData<>(); @@ -58,6 +64,7 @@ final class ConversationGroupViewModel extends ViewModel { this.groupActiveState = Transformations.distinctUntilChanged(Transformations.map(groupRecord, ConversationGroupViewModel::mapToGroupActiveState)); 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.reviewState = LiveDataUtil.combineLatest(groupRecord, duplicates, (record, dups) -> dups.isEmpty() @@ -70,6 +77,15 @@ final class ConversationGroupViewModel extends ViewModel { liveRecipient.setValue(recipient); } + void onSuggestedMembersBannerDismissed(@NonNull GroupId groupId) { + SignalExecutors.BOUNDED.execute(() -> { + if (groupId.isV2()) { + DatabaseFactory.getGroupDatabase(ApplicationDependencies.getApplication()).clearFormerV1Members(groupId.requireV2()); + liveRecipient.postValue(liveRecipient.getValue()); + } + }); + } + /** * The number of pending group join requests that can be actioned by this client. */ @@ -89,6 +105,10 @@ final class ConversationGroupViewModel extends ViewModel { return reviewState; } + @NonNull LiveData> getGroupV1MigrationSuggestions() { + return gv1MigrationSuggestions; + } + private static @Nullable GroupRecord getGroupRecordForRecipient(@Nullable Recipient recipient) { if (recipient != null && recipient.isGroup()) { Application context = ApplicationDependencies.getApplication(); @@ -127,6 +147,24 @@ final class ConversationGroupViewModel extends ViewModel { return record.memberLevel(Recipient.self()); } + @WorkerThread + private static List mapToGroupV1MigrationSuggestions(@Nullable GroupRecord record) { + if (record == null) { + return Collections.emptyList(); + } + + 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) + .map(Recipient::getId) + .toList(); + } + public static void onCancelJoinRequest(@NonNull Recipient recipient, @NonNull AsynchronousCallback.WorkerThread callback) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java index acd795a498..35da2372fb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java @@ -37,6 +37,7 @@ import org.whispersystems.signalservice.api.util.UuidUtil; import java.io.Closeable; import java.security.SecureRandom; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.HashSet; @@ -162,6 +163,8 @@ public final class GroupDatabase extends Database { values.putNull(FORMER_V1_MEMBERS); databaseHelper.getWritableDatabase().update(TABLE_NAME, values, GROUP_ID + " = ?", SqlUtil.buildArgs(id)); + + Recipient.live(Recipient.externalGroupExact(context, id).getId()).refresh(); } Optional getGroup(Cursor cursor) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/migration/GroupsV1MigrationSuggestionsDialog.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/migration/GroupsV1MigrationSuggestionsDialog.java new file mode 100644 index 0000000000..2c7c5c0aed --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/migration/GroupsV1MigrationSuggestionsDialog.java @@ -0,0 +1,109 @@ +package org.thoughtcrime.securesms.groups.ui.migration; + +import android.content.DialogInterface; +import android.content.DialogInterface.OnDismissListener; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.FragmentActivity; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.groups.GroupChangeBusyException; +import org.thoughtcrime.securesms.groups.GroupChangeFailedException; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.groups.GroupInsufficientRightsException; +import org.thoughtcrime.securesms.groups.GroupManager; +import org.thoughtcrime.securesms.groups.GroupNotAMemberException; +import org.thoughtcrime.securesms.groups.MembershipNotSuitableForV2Exception; +import org.thoughtcrime.securesms.groups.ui.GroupMemberListView; +import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.concurrent.SignalExecutors; +import org.thoughtcrime.securesms.util.concurrent.SimpleTask; +import org.thoughtcrime.securesms.util.views.SimpleProgressDialog; + +import java.io.IOException; +import java.util.List; + +/** + * Shows a list of members that got lost when migrating from a V1->V2 group, giving you the chance + * to add them back. + */ +public final class GroupsV1MigrationSuggestionsDialog { + + private static final String TAG = Log.tag(GroupsV1MigrationSuggestionsDialog.class); + + private final FragmentActivity fragmentActivity; + private final GroupId.V2 groupId; + private final List suggestions; + + public static void show(@NonNull FragmentActivity activity, + @NonNull GroupId.V2 groupId, + @NonNull List suggestions) + { + new GroupsV1MigrationSuggestionsDialog(activity, groupId, suggestions).display(); + } + + private GroupsV1MigrationSuggestionsDialog(@NonNull FragmentActivity activity, + @NonNull GroupId.V2 groupId, + @NonNull List suggestions) + { + this.fragmentActivity = activity; + this.groupId = groupId; + this.suggestions = suggestions; + } + + private void display() { + AlertDialog dialog = new AlertDialog.Builder(fragmentActivity) + .setTitle(fragmentActivity.getResources().getQuantityString(R.plurals.GroupsV1MigrationSuggestionsDialog_add_members_question, suggestions.size())) + .setMessage(fragmentActivity.getResources().getQuantityString(R.plurals.GroupsV1MigrationSuggestionsDialog_these_members_couldnt_be_automatically_added, suggestions.size())) + .setView(R.layout.dialog_group_members) + .setPositiveButton(fragmentActivity.getResources().getQuantityString(R.plurals.GroupsV1MigrationSuggestionsDialog_add_members, suggestions.size()), (d, i) -> onAddClicked(d)) + .setNegativeButton(android.R.string.cancel, (d, i) -> d.dismiss()) + .show(); + + GroupMemberListView memberListView = dialog.findViewById(R.id.list_members); + + SimpleTask.run(() -> Recipient.resolvedList(suggestions), + memberListView::setDisplayOnlyMembers); + } + + private void onAddClicked(@NonNull DialogInterface rootDialog) { + SimpleProgressDialog.DismissibleDialog progressDialog = SimpleProgressDialog.showDelayed(fragmentActivity, 300, 0); + SimpleTask.run(SignalExecutors.UNBOUNDED, () -> { + try { + GroupManager.addMembers(fragmentActivity, groupId.requirePush(), suggestions); + Log.i(TAG, "Successfully added members! Clearing former members."); + DatabaseFactory.getGroupDatabase(fragmentActivity).clearFormerV1Members(groupId); + return Result.SUCCESS; + } catch (IOException | GroupChangeBusyException e) { + Log.w(TAG, "Temporary failure.", e); + return Result.NETWORK_ERROR; + } catch (GroupNotAMemberException | GroupInsufficientRightsException | MembershipNotSuitableForV2Exception | GroupChangeFailedException e) { + Log.w(TAG, "Permanent failure! Clearing former members.", e); + DatabaseFactory.getGroupDatabase(fragmentActivity).clearFormerV1Members(groupId); + return Result.IMPOSSIBLE; + } + }, result -> { + progressDialog.dismiss(); + rootDialog.dismiss(); + + switch (result) { + case NETWORK_ERROR: + Toast.makeText(fragmentActivity, fragmentActivity.getResources().getQuantityText(R.plurals.GroupsV1MigrationSuggestionsDialog_failed_to_add_members_try_again_later, suggestions.size()), Toast.LENGTH_SHORT).show(); + break; + case IMPOSSIBLE: + Toast.makeText(fragmentActivity, fragmentActivity.getResources().getQuantityText(R.plurals.GroupsV1MigrationSuggestionsDialog_cannot_add_members, suggestions.size()), Toast.LENGTH_SHORT).show(); + break; + } + }); + } + + private enum Result { + SUCCESS, NETWORK_ERROR, IMPOSSIBLE + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/concurrent/SignalExecutors.java b/app/src/main/java/org/thoughtcrime/securesms/util/concurrent/SignalExecutors.java index a72741f7ae..173fdec150 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/concurrent/SignalExecutors.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/concurrent/SignalExecutors.java @@ -18,12 +18,14 @@ import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; -public class SignalExecutors { +public final class SignalExecutors { public static final ExecutorService UNBOUNDED = Executors.newCachedThreadPool(new NumberedThreadFactory("signal-unbounded")); public static final ExecutorService BOUNDED = Executors.newFixedThreadPool(getIdealThreadCount(), new NumberedThreadFactory("signal-bounded")); public static final ExecutorService SERIAL = Executors.newSingleThreadExecutor(new NumberedThreadFactory("signal-serial")); + private SignalExecutors() {} + public static ExecutorService newCachedSingleThreadExecutor(final String name) { ThreadPoolExecutor executor = new ThreadPoolExecutor(1, 1, 15, TimeUnit.SECONDS, new LinkedBlockingQueue<>(), r -> new Thread(r, name)); executor.allowCoreThreadTimeOut(true); diff --git a/app/src/main/res/values/ids.xml b/app/src/main/res/values/ids.xml index 61c80ab5a1..d0572cbf69 100644 --- a/app/src/main/res/values/ids.xml +++ b/app/src/main/res/values/ids.xml @@ -8,4 +8,7 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 41da63261b..02513b0141 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -556,6 +556,39 @@ These members are not capable of joining New Groups, and have been removed from the group: + + + %1$d member couldn\'t be re-added to the New Group. Do you want to add them now? + %1$d members couldn\'t be re-added to the New Group. Do you want to add them now? + + + Add member + Add members + + Not now + + + + Add member? + Add members? + + + This member couldn\'t be automatically added to the New Group when it was upgraded: + These members couldn\'t be automatically added to the New Group when it was upgraded: + + + Add member + Add members + + + Failed to add member. Try again later. + Failed to add members. Try again later. + + + Cannot add member. + Cannot add members. + + Leave group? You will no longer be able to send or receive messages in this group.