From 68d29d9a0fdbb520295efd2cd7dab84a89f56c12 Mon Sep 17 00:00:00 2001 From: Alan Evans Date: Wed, 8 Apr 2020 12:56:57 -0300 Subject: [PATCH] Allow pending member invite cancelation. --- .../securesms/groups/GroupManager.java | 13 ++ .../groups/ui/AdminActionsListener.java | 10 ++ .../securesms/groups/ui/GroupMemberEntry.java | 52 ++++++-- .../groups/ui/GroupMemberListAdapter.java | 119 ++++++++++++++---- .../groups/ui/GroupMemberListView.java | 4 + .../securesms/groups/ui/PopupMenuView.java | 38 +++++- .../InviteCancelConfirmationDialog.java | 48 +++++++ .../PendingMemberInvitesFragment.java | 28 +++++ .../PendingMemberInvitesViewModel.java | 103 ++++++++++++--- .../PendingMemberRepository.java | 88 +++++++++---- .../util/LifecycleRecyclerAdapter.java | 19 +++ .../securesms/util/LifecycleViewHolder.java | 33 +++++ .../res/layout/group_recipient_list_item.xml | 29 ++++- .../res/menu/others_invite_pending_menu.xml | 8 ++ .../main/res/menu/own_invite_pending_menu.xml | 8 ++ app/src/main/res/values/strings.xml | 18 +++ .../api/groupsv2/GroupsV2Operations.java | 7 +- 17 files changed, 538 insertions(+), 87 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/ui/AdminActionsListener.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/ui/pendingmemberinvites/InviteCancelConfirmationDialog.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/util/LifecycleRecyclerAdapter.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/util/LifecycleViewHolder.java create mode 100644 app/src/main/res/menu/others_invite_pending_menu.xml create mode 100644 app/src/main/res/menu/own_invite_pending_menu.xml diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java index 54e0358a5b..e970fd34a0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java @@ -7,10 +7,14 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.WorkerThread; +import org.signal.zkgroup.VerificationFailedException; +import org.signal.zkgroup.groups.UuidCiphertext; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; +import org.whispersystems.signalservice.api.groupsv2.InvalidGroupStateException; import org.whispersystems.signalservice.api.util.InvalidNumberException; +import java.io.IOException; import java.util.Collection; import java.util.HashSet; import java.util.Set; @@ -54,6 +58,15 @@ public final class GroupManager { return V1GroupManager.leaveGroup(context, groupId.requireV1()); } + @WorkerThread + public static void cancelInvites(@NonNull Context context, + @NonNull GroupId.V2 groupId, + @NonNull Collection uuidCipherTexts) + throws InvalidGroupStateException, VerificationFailedException, IOException + { + throw new AssertionError("NYI"); // TODO: GV2 allow invite cancellation + } + public static class GroupActionResult { private final Recipient groupRecipient; private final long threadId; diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/AdminActionsListener.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/AdminActionsListener.java new file mode 100644 index 0000000000..8d540e9ca3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/AdminActionsListener.java @@ -0,0 +1,10 @@ +package org.thoughtcrime.securesms.groups.ui; + +import androidx.annotation.NonNull; + +public interface AdminActionsListener { + + void onCancelInvite(@NonNull GroupMemberEntry.PendingMember pendingMember); + + void onCancelAllInvites(@NonNull GroupMemberEntry.UnknownPendingMemberCount pendingMembers); +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupMemberEntry.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupMemberEntry.java index fd0ec948dd..290b157874 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupMemberEntry.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupMemberEntry.java @@ -2,12 +2,18 @@ package org.thoughtcrime.securesms.groups.ui; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.lifecycle.LiveData; +import org.signal.zkgroup.groups.UuidCiphertext; import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.DefaultValueLiveData; + +import java.util.Collection; public abstract class GroupMemberEntry { - private @Nullable Runnable onClick; + private final DefaultValueLiveData busy = new DefaultValueLiveData<>(false); + @Nullable private Runnable onClick; private GroupMemberEntry() { } @@ -20,6 +26,14 @@ public abstract class GroupMemberEntry { return onClick; } + public LiveData getBusy() { + return busy; + } + + public void setBusy(boolean busy) { + this.busy.postValue(busy); + } + public final static class FullMember extends GroupMemberEntry { private final Recipient member; @@ -34,30 +48,40 @@ public abstract class GroupMemberEntry { } public final static class PendingMember extends GroupMemberEntry { - private final Recipient invitee; - private final byte[] inviteeCipherText; + private final Recipient invitee; + private final UuidCiphertext inviteeCipherText; + private final boolean cancellable; - public PendingMember(@NonNull Recipient invitee, @NonNull byte[] inviteeCipherText) { + public PendingMember(@NonNull Recipient invitee, @NonNull UuidCiphertext inviteeCipherText, boolean cancellable) { this.invitee = invitee; this.inviteeCipherText = inviteeCipherText; + this.cancellable = cancellable; } public Recipient getInvitee() { return invitee; } - public byte[] getInviteeCipherText() { + public UuidCiphertext getInviteeCipherText() { return inviteeCipherText; } + + public boolean isCancellable() { + return cancellable; + } } public final static class UnknownPendingMemberCount extends GroupMemberEntry { - private Recipient inviter; - private int inviteCount; + private final Recipient inviter; + private final Collection ciphertexts; + private final boolean cancellable; - public UnknownPendingMemberCount(@NonNull Recipient inviter, int inviteCount) { + public UnknownPendingMemberCount(@NonNull Recipient inviter, + @NonNull Collection ciphertexts, + boolean cancellable) { this.inviter = inviter; - this.inviteCount = inviteCount; + this.ciphertexts = ciphertexts; + this.cancellable = cancellable; } public Recipient getInviter() { @@ -65,7 +89,15 @@ public abstract class GroupMemberEntry { } public int getInviteCount() { - return inviteCount; + return ciphertexts.size(); + } + + public Collection getCiphertexts() { + return ciphertexts; + } + + public boolean isCancellable() { + return cancellable; } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupMemberListAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupMemberListAdapter.java index 52ec5c3704..9124c1c2a0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupMemberListAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupMemberListAdapter.java @@ -4,19 +4,22 @@ import android.content.Context; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; +import android.widget.ProgressBar; import android.widget.TextView; import androidx.annotation.NonNull; -import androidx.recyclerview.widget.RecyclerView; +import androidx.annotation.Nullable; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.components.AvatarImageView; import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.LifecycleRecyclerAdapter; +import org.thoughtcrime.securesms.util.LifecycleViewHolder; import java.util.ArrayList; import java.util.Collection; -final class GroupMemberListAdapter extends RecyclerView.Adapter { +final class GroupMemberListAdapter extends LifecycleRecyclerAdapter { private static final int FULL_MEMBER = 0; private static final int OWN_INVITE_PENDING = 1; @@ -24,6 +27,8 @@ final class GroupMemberListAdapter extends RecyclerView.Adapter data = new ArrayList<>(); + @Nullable private AdminActionsListener adminActionsListener; + void updateData(@NonNull Collection recipients) { data.clear(); data.addAll(recipients); @@ -36,20 +41,24 @@ final class GroupMemberListAdapter extends RecyclerView.Adapter { if (onClick != null) onClick.run(); }; - this.avatar.setOnClickListener(onClickListener); - this.recipient.setOnClickListener(onClickListener); + avatar.setOnClickListener(onClickListener); + recipient.setOnClickListener(onClickListener); + + memberEntry.getBusy().observe(this, busy -> { + busyProgress.setVisibility(busy ? View.VISIBLE : View.GONE); + popupMenu.setVisibility(busy ? View.GONE : View.VISIBLE); + }); + } + + void hideMenu() { + popupMenuContainer.setVisibility(View.GONE); + popupMenu.setVisibility(View.GONE); + } + + void showMenu() { + popupMenuContainer.setVisibility(View.VISIBLE); + popupMenu.setVisibility(View.VISIBLE); } } final static class FullMemberViewHolder extends ViewHolder { - FullMemberViewHolder(@NonNull View itemView) { - super(itemView); + FullMemberViewHolder(@NonNull View itemView, @Nullable AdminActionsListener adminActionsListener) { + super(itemView, adminActionsListener); } @Override @@ -129,8 +162,8 @@ final class GroupMemberListAdapter extends RecyclerView.Adapter { + if (item == R.id.cancel_invite) { + adminActionsListener.onCancelInvite(pendingMember); + return true; + } + return false; + }); + showMenu(); + } } } final static class UnknownPendingMemberCountViewHolder extends ViewHolder { - UnknownPendingMemberCountViewHolder(@NonNull View itemView) { - super(itemView); + UnknownPendingMemberCountViewHolder(@NonNull View itemView, @Nullable AdminActionsListener adminActionsListener) { + super(itemView, adminActionsListener); } @Override void bind(@NonNull GroupMemberEntry memberEntry) { super.bind(memberEntry); - GroupMemberEntry.UnknownPendingMemberCount pendingMemberCount = (GroupMemberEntry.UnknownPendingMemberCount) memberEntry; + GroupMemberEntry.UnknownPendingMemberCount pendingMembers = (GroupMemberEntry.UnknownPendingMemberCount) memberEntry; - Recipient inviter = pendingMemberCount.getInviter(); + Recipient inviter = pendingMembers.getInviter(); String displayName = inviter.getDisplayName(itemView.getContext()); String displayText = context.getResources().getQuantityString(R.plurals.GroupMemberList_invited, - pendingMemberCount.getInviteCount(), - displayName, pendingMemberCount.getInviteCount()); + pendingMembers.getInviteCount(), + displayName, pendingMembers.getInviteCount()); bindImageAndText(inviter, displayText); + + if (pendingMembers.isCancellable() && adminActionsListener != null) { + popupMenu.setMenu(R.menu.others_invite_pending_menu, + item -> { + if (item.getItemId() == R.id.cancel_invites) { + item.setTitle(context.getResources().getQuantityString(R.plurals.PendingMembersActivity_cancel_invites, pendingMembers.getInviteCount(), + pendingMembers.getInviteCount())); + return true; + } + return true; + }, + item -> { + if (item == R.id.cancel_invites) { + adminActionsListener.onCancelAllInvites(pendingMembers); + return true; + } + return false; + }); + showMenu(); + } } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupMemberListView.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupMemberListView.java index 710f7bf1b0..6b403f3c12 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupMemberListView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupMemberListView.java @@ -48,6 +48,10 @@ public final class GroupMemberListView extends RecyclerView { } } + public void setAdminActionsListener(@Nullable AdminActionsListener adminActionsListener) { + membersAdapter.setAdminActionsListener(adminActionsListener); + } + public void setMembers(@NonNull Collection recipients) { membersAdapter.updateData(recipients); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/PopupMenuView.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/PopupMenuView.java index ba695307bb..4014fed4f4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/PopupMenuView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/PopupMenuView.java @@ -2,7 +2,9 @@ package org.thoughtcrime.securesms.groups.ui; import android.content.Context; import android.util.AttributeSet; +import android.view.Menu; import android.view.MenuInflater; +import android.view.MenuItem; import android.view.View; import androidx.annotation.IdRes; @@ -15,8 +17,9 @@ import org.thoughtcrime.securesms.R; public final class PopupMenuView extends View { - private @MenuRes int menu; - private @Nullable ItemClick callback; + @MenuRes private int menu; + @Nullable private PrepareOptionsMenuItem prepareOptionsMenuItemCallback; + @Nullable private ItemClick callback; public PopupMenuView(Context context) { super(context); @@ -43,6 +46,16 @@ public final class PopupMenuView extends View { inflater.inflate(menu, popup.getMenu()); + if (prepareOptionsMenuItemCallback != null) { + Menu menu = popup.getMenu(); + for (int i = menu.size() - 1; i >= 0; i--) { + MenuItem item = menu.getItem(i); + if (!prepareOptionsMenuItemCallback.onPrepareOptionsMenuItem(item)) { + menu.removeItem(item.getItemId()); + } + } + } + popup.setOnMenuItemClickListener(item -> callback.onItemClick(item.getItemId())); popup.show(); } @@ -50,8 +63,25 @@ public final class PopupMenuView extends View { } public void setMenu(@MenuRes int menu, @NonNull ItemClick callback) { - this.menu = menu; - this.callback = callback; + this.menu = menu; + this.prepareOptionsMenuItemCallback = null; + this.callback = callback; + } + + public void setMenu(@MenuRes int menu, @NonNull PrepareOptionsMenuItem prepareOptionsMenuItem, @NonNull ItemClick callback) { + this.menu = menu; + this.prepareOptionsMenuItemCallback = prepareOptionsMenuItem; + this.callback = callback; + } + + public interface PrepareOptionsMenuItem { + + /** + * Chance to change the {@link MenuItem} after inflation. + * + * @return true to keep the {@link MenuItem}. false to remove the {@link MenuItem}. + */ + boolean onPrepareOptionsMenuItem(@NonNull MenuItem menuItem); } public interface ItemClick { diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/pendingmemberinvites/InviteCancelConfirmationDialog.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/pendingmemberinvites/InviteCancelConfirmationDialog.java new file mode 100644 index 0000000000..9d01af0681 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/pendingmemberinvites/InviteCancelConfirmationDialog.java @@ -0,0 +1,48 @@ +package org.thoughtcrime.securesms.groups.ui.pendingmemberinvites; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.recipients.Recipient; + +final class InviteCancelConfirmationDialog { + + private InviteCancelConfirmationDialog() { + } + + /** + * Confirms that you want to cancel an invite that you sent. + */ + static AlertDialog showOwnInviteCancelConfirmationDialog(@NonNull Context context, + @NonNull Recipient invitee, + @NonNull Runnable onCancel) + { + return new AlertDialog.Builder(context) + .setMessage(context.getString(R.string.GroupManagement_cancel_own_single_invite, + invitee.getDisplayName(context))) + .setPositiveButton(R.string.yes, (dialog, which) -> onCancel.run()) + .setNegativeButton(R.string.no, null) + .show(); + } + + /** + * Confirms that you want to cancel a number of invites that another member sent. + */ + static AlertDialog showOthersInviteCancelConfirmationDialog(@NonNull Context context, + @NonNull Recipient inviter, + int numberOfInvitations, + @NonNull Runnable onCancel) + { + return new AlertDialog.Builder(context) + .setMessage(context.getResources().getQuantityString(R.plurals.GroupManagement_cancel_others_invites, + numberOfInvitations, + inviter.getDisplayName(context), + numberOfInvitations)) + .setPositiveButton(R.string.yes, (dialog, which) -> onCancel.run()) + .setNegativeButton(R.string.no, null) + .show(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/pendingmemberinvites/PendingMemberInvitesFragment.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/pendingmemberinvites/PendingMemberInvitesFragment.java index 273c084bfb..f9837cf186 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/pendingmemberinvites/PendingMemberInvitesFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/pendingmemberinvites/PendingMemberInvitesFragment.java @@ -12,6 +12,8 @@ import androidx.lifecycle.ViewModelProviders; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.groups.ui.AdminActionsListener; +import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry; import org.thoughtcrime.securesms.groups.ui.GroupMemberListView; import java.util.Objects; @@ -45,6 +47,32 @@ public class PendingMemberInvitesFragment extends Fragment { youInvitedEmptyState = view.findViewById(R.id.no_pending_from_you); othersInvitedEmptyState = view.findViewById(R.id.no_pending_from_others); + youInvited.setAdminActionsListener(new AdminActionsListener() { + + @Override + public void onCancelInvite(@NonNull GroupMemberEntry.PendingMember pendingMember) { + viewModel.cancelInviteFor(pendingMember); + } + + @Override + public void onCancelAllInvites(@NonNull GroupMemberEntry.UnknownPendingMemberCount pendingMembers) { + throw new AssertionError(); + } + }); + + othersInvited.setAdminActionsListener(new AdminActionsListener() { + + @Override + public void onCancelInvite(@NonNull GroupMemberEntry.PendingMember pendingMember) { + throw new AssertionError(); + } + + @Override + public void onCancelAllInvites(@NonNull GroupMemberEntry.UnknownPendingMemberCount pendingMembers) { + viewModel.cancelInvitesFor(pendingMembers); + } + }); + return view; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/pendingmemberinvites/PendingMemberInvitesViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/pendingmemberinvites/PendingMemberInvitesViewModel.java index 1790544e52..6a8109c4bf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/pendingmemberinvites/PendingMemberInvitesViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/pendingmemberinvites/PendingMemberInvitesViewModel.java @@ -1,39 +1,39 @@ package org.thoughtcrime.securesms.groups.ui.pendingmemberinvites; import android.content.Context; +import android.widget.Toast; import androidx.annotation.NonNull; import androidx.lifecycle.LiveData; -import androidx.lifecycle.MutableLiveData; import androidx.lifecycle.ViewModel; import androidx.lifecycle.ViewModelProvider; +import org.signal.zkgroup.groups.UuidCiphertext; +import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry; -import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.util.DefaultValueLiveData; +import org.thoughtcrime.securesms.util.concurrent.SimpleTask; import java.util.ArrayList; +import java.util.Collections; +import java.util.Iterator; import java.util.List; public class PendingMemberInvitesViewModel extends ViewModel { - private static final String TAG = Log.tag(PendingMemberInvitesViewModel.class); + private final Context context; + private final PendingMemberRepository pendingMemberRepository; + private final DefaultValueLiveData> whoYouInvited = new DefaultValueLiveData<>(Collections.emptyList()); + private final DefaultValueLiveData> whoOthersInvited = new DefaultValueLiveData<>(Collections.emptyList()); - private final Context context; - private final GroupId groupId; - private final PendingMemberRepository pendingMemberRepository; - private final MutableLiveData> whoYouInvited = new MutableLiveData<>(); - private final MutableLiveData> whoOthersInvited = new MutableLiveData<>(); - - PendingMemberInvitesViewModel(@NonNull Context context, - @NonNull GroupId.V2 groupId, - @NonNull PendingMemberRepository pendingMemberRepository) + private PendingMemberInvitesViewModel(@NonNull Context context, + @NonNull PendingMemberRepository pendingMemberRepository) { this.context = context; - this.groupId = groupId; this.pendingMemberRepository = pendingMemberRepository; - pendingMemberRepository.getInvitees(groupId, this::setMembers); + pendingMemberRepository.getInvitees(this::setMembers); } public LiveData> getWhoYouInvited() { @@ -55,17 +55,86 @@ public class PendingMemberInvitesViewModel extends ViewModel { for (PendingMemberRepository.SinglePendingMemberInvitedByYou pendingMember : inviteeResult.getByMe()) { byMe.add(new GroupMemberEntry.PendingMember(pendingMember.getInvitee(), - pendingMember.getInviteeCipherText())); + pendingMember.getInviteeCipherText(), + true)); } for (PendingMemberRepository.MultiplePendingMembersInvitedByAnother pendingMembers : inviteeResult.getByOthers()) { byOthers.add(new GroupMemberEntry.UnknownPendingMemberCount(pendingMembers.getInviter(), - pendingMembers.getUuidCipherTexts().size())); + pendingMembers.getUuidCipherTexts(), + inviteeResult.isCanCancelOthersInvites())); } setInvitees(byMe, byOthers); } + void cancelInviteFor(@NonNull GroupMemberEntry.PendingMember pendingMember) { + UuidCiphertext inviteeCipherText = pendingMember.getInviteeCipherText(); + + InviteCancelConfirmationDialog.showOwnInviteCancelConfirmationDialog(context, pendingMember.getInvitee(), () -> + SimpleTask.run( + () -> { + pendingMember.setBusy(true); + try { + return pendingMemberRepository.cancelInvites(Collections.singleton(inviteeCipherText)); + } finally { + pendingMember.setBusy(false); + } + }, + result -> { + if (result) { + ArrayList newList = new ArrayList<>(whoYouInvited.getValue()); + Iterator iterator = newList.iterator(); + + while (iterator.hasNext()) { + if (iterator.next().getInviteeCipherText().equals(inviteeCipherText)) { + iterator.remove(); + } + } + + whoYouInvited.setValue(newList); + } else { + toastErrorCanceling(1); + } + } + )); + } + + void cancelInvitesFor(@NonNull GroupMemberEntry.UnknownPendingMemberCount pendingMembers) { + InviteCancelConfirmationDialog.showOthersInviteCancelConfirmationDialog(context, pendingMembers.getInviter(), pendingMembers.getInviteCount(), + () -> SimpleTask.run( + () -> { + pendingMembers.setBusy(true); + try { + return pendingMemberRepository.cancelInvites(pendingMembers.getCiphertexts()); + } finally { + pendingMembers.setBusy(false); + } + }, + result -> { + if (result) { + ArrayList newList = new ArrayList<>(whoOthersInvited.getValue()); + Iterator iterator = newList.iterator(); + + while (iterator.hasNext()) { + if (iterator.next().getInviter().equals(pendingMembers.getInviter())) { + iterator.remove(); + } + } + + whoOthersInvited.setValue(newList); + } else { + toastErrorCanceling(pendingMembers.getInviteCount()); + } + } + )); + } + + private void toastErrorCanceling(int quantity) { + Toast.makeText(context, context.getResources().getQuantityText(R.plurals.PendingMembersActivity_error_canceling_invite, quantity), Toast.LENGTH_SHORT) + .show(); + } + public static class Factory implements ViewModelProvider.Factory { private final Context context; @@ -79,7 +148,7 @@ public class PendingMemberInvitesViewModel extends ViewModel { @Override public @NonNull T create(@NonNull Class modelClass) { //noinspection unchecked - return (T) new PendingMemberInvitesViewModel(context, groupId, new PendingMemberRepository(context.getApplicationContext())); + return (T) new PendingMemberInvitesViewModel(context, new PendingMemberRepository(context.getApplicationContext(), groupId)); } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/pendingmemberinvites/PendingMemberRepository.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/pendingmemberinvites/PendingMemberRepository.java index 741ab89240..458a7d2c7d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/pendingmemberinvites/PendingMemberRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/pendingmemberinvites/PendingMemberRepository.java @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.groups.ui.pendingmemberinvites; import android.content.Context; import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; import androidx.core.util.Consumer; import com.annimon.stream.Stream; @@ -10,29 +11,45 @@ import com.google.protobuf.ByteString; import org.signal.storageservice.protos.groups.local.DecryptedGroup; import org.signal.storageservice.protos.groups.local.DecryptedPendingMember; +import org.signal.zkgroup.InvalidInputException; +import org.signal.zkgroup.VerificationFailedException; +import org.signal.zkgroup.groups.UuidCiphertext; import org.signal.zkgroup.util.UUIDUtil; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.GroupDatabase; import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.groups.GroupManager; import org.thoughtcrime.securesms.groups.GroupProtoUtil; +import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.util.concurrent.SignalExecutors; +import org.whispersystems.signalservice.api.groupsv2.InvalidGroupStateException; +import java.io.IOException; import java.util.ArrayList; +import java.util.Collection; import java.util.List; +import java.util.UUID; import java.util.concurrent.Executor; +/** + * Repository for modifying the pending members on a single group. + */ final class PendingMemberRepository { - private final Context context; - private final Executor executor; + private static final String TAG = Log.tag(PendingMemberRepository.class); - PendingMemberRepository(@NonNull Context context) { + private final Context context; + private final GroupId.V2 groupId; + private final Executor executor; + + PendingMemberRepository(@NonNull Context context, @NonNull GroupId.V2 groupId) { this.context = context.getApplicationContext(); this.executor = SignalExecutors.BOUNDED; + this.groupId = groupId; } - public void getInvitees(GroupId.V2 groupId, @NonNull Consumer onInviteesLoaded) { + public void getInvitees(@NonNull Consumer onInviteesLoaded) { executor.execute(() -> { GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context); GroupDatabase.V2GroupProperties v2GroupProperties = groupDatabase.getGroup(groupId).get().requireV2GroupProperties(); @@ -41,6 +58,7 @@ final class PendingMemberRepository { List byMe = new ArrayList<>(pendingMembersList.size()); List byOthers = new ArrayList<>(pendingMembersList.size()); ByteString self = ByteString.copyFrom(UUIDUtil.serialize(Recipient.self().getUuid().get())); + boolean selfIsAdmin = v2GroupProperties.isAdmin(Recipient.self()); Stream.of(pendingMembersList) .groupBy(DecryptedPendingMember::getAddedByUuid) @@ -51,17 +69,25 @@ final class PendingMemberRepository { if (self.equals(inviterUuid)) { for (DecryptedPendingMember pendingMember : invitedMembers) { - Recipient invitee = GroupProtoUtil.pendingMemberToRecipient(context, pendingMember); - byte[] uuidCipherText = pendingMember.getUuidCipherText().toByteArray(); + try { + Recipient invitee = GroupProtoUtil.pendingMemberToRecipient(context, pendingMember); + UuidCiphertext uuidCipherText = new UuidCiphertext(pendingMember.getUuidCipherText().toByteArray()); - byMe.add(new SinglePendingMemberInvitedByYou(invitee, uuidCipherText)); + byMe.add(new SinglePendingMemberInvitedByYou(invitee, uuidCipherText)); + } catch (InvalidInputException e) { + Log.w(TAG, e); + } } } else { - Recipient inviter = GroupProtoUtil.uuidByteStringToRecipient(context, inviterUuid); + Recipient inviter = GroupProtoUtil.uuidByteStringToRecipient(context, inviterUuid); + ArrayList uuidCipherTexts = new ArrayList<>(invitedMembers.size()); - ArrayList uuidCipherTexts = new ArrayList<>(invitedMembers.size()); for (DecryptedPendingMember pendingMember : invitedMembers) { - uuidCipherTexts.add(pendingMember.getUuidCipherText().toByteArray()); + try { + uuidCipherTexts.add(new UuidCiphertext(pendingMember.getUuidCipherText().toByteArray())); + } catch (InvalidInputException e) { + Log.w(TAG, e); + } } byOthers.add(new MultiplePendingMembersInvitedByAnother(inviter, uuidCipherTexts)); @@ -69,19 +95,33 @@ final class PendingMemberRepository { } ); - onInviteesLoaded.accept(new InviteeResult(byMe, byOthers)); + onInviteesLoaded.accept(new InviteeResult(byMe, byOthers, selfIsAdmin)); }); } + @WorkerThread + boolean cancelInvites(@NonNull Collection uuidCipherTexts) { + try { + GroupManager.cancelInvites(context, groupId, uuidCipherTexts); + return true; + } catch (InvalidGroupStateException | VerificationFailedException | IOException e) { + Log.w(TAG, e); + return false; + } + } + public static final class InviteeResult { private final List byMe; private final List byOthers; + private final boolean canCancelOthersInvites; private InviteeResult(List byMe, - List byOthers) + List byOthers, + boolean canCancelOthersInvites) { - this.byMe = byMe; - this.byOthers = byOthers; + this.byMe = byMe; + this.byOthers = byOthers; + this.canCancelOthersInvites = canCancelOthersInvites; } public List getByMe() { @@ -91,13 +131,17 @@ final class PendingMemberRepository { public List getByOthers() { return byOthers; } + + public boolean isCanCancelOthersInvites() { + return canCancelOthersInvites; + } } public final static class SinglePendingMemberInvitedByYou { - private final Recipient invitee; - private final byte[] inviteeCipherText; + private final Recipient invitee; + private final UuidCiphertext inviteeCipherText; - private SinglePendingMemberInvitedByYou(@NonNull Recipient invitee, @NonNull byte[] inviteeCipherText) { + private SinglePendingMemberInvitedByYou(@NonNull Recipient invitee, @NonNull UuidCiphertext inviteeCipherText) { this.invitee = invitee; this.inviteeCipherText = inviteeCipherText; } @@ -106,16 +150,16 @@ final class PendingMemberRepository { return invitee; } - public byte[] getInviteeCipherText() { + public UuidCiphertext getInviteeCipherText() { return inviteeCipherText; } } public final static class MultiplePendingMembersInvitedByAnother { - private final Recipient inviter; - private final ArrayList uuidCipherTexts; + private final Recipient inviter; + private final Collection uuidCipherTexts; - private MultiplePendingMembersInvitedByAnother(@NonNull Recipient inviter, @NonNull ArrayList uuidCipherTexts) { + private MultiplePendingMembersInvitedByAnother(@NonNull Recipient inviter, @NonNull Collection uuidCipherTexts) { this.inviter = inviter; this.uuidCipherTexts = uuidCipherTexts; } @@ -124,7 +168,7 @@ final class PendingMemberRepository { return inviter; } - public ArrayList getUuidCipherTexts() { + public Collection getUuidCipherTexts() { return uuidCipherTexts; } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/LifecycleRecyclerAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/util/LifecycleRecyclerAdapter.java new file mode 100644 index 0000000000..04150f96d3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/LifecycleRecyclerAdapter.java @@ -0,0 +1,19 @@ +package org.thoughtcrime.securesms.util; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +public abstract class LifecycleRecyclerAdapter extends RecyclerView.Adapter { + + @Override + public void onViewAttachedToWindow(@NonNull VH holder) { + super.onViewAttachedToWindow(holder); + holder.onAttachedToWindow(); + } + + @Override + public void onViewDetachedFromWindow(@NonNull VH holder) { + super.onViewDetachedFromWindow(holder); + holder.onDetachedFromWindow(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/LifecycleViewHolder.java b/app/src/main/java/org/thoughtcrime/securesms/util/LifecycleViewHolder.java new file mode 100644 index 0000000000..aa59015aaa --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/LifecycleViewHolder.java @@ -0,0 +1,33 @@ +package org.thoughtcrime.securesms.util; + +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.lifecycle.Lifecycle; +import androidx.lifecycle.LifecycleOwner; +import androidx.lifecycle.LifecycleRegistry; +import androidx.recyclerview.widget.RecyclerView; + +public abstract class LifecycleViewHolder extends RecyclerView.ViewHolder implements LifecycleOwner { + + private final LifecycleRegistry lifecycleRegistry; + + public LifecycleViewHolder(@NonNull View itemView) { + super(itemView); + + lifecycleRegistry = new LifecycleRegistry(this); + } + + void onAttachedToWindow() { + lifecycleRegistry.setCurrentState(Lifecycle.State.RESUMED); + } + + void onDetachedFromWindow() { + lifecycleRegistry.setCurrentState(Lifecycle.State.DESTROYED); + } + + @Override + public @NonNull Lifecycle getLifecycle() { + return lifecycleRegistry; + } +} diff --git a/app/src/main/res/layout/group_recipient_list_item.xml b/app/src/main/res/layout/group_recipient_list_item.xml index d65ee6e808..279ab64aa0 100644 --- a/app/src/main/res/layout/group_recipient_list_item.xml +++ b/app/src/main/res/layout/group_recipient_list_item.xml @@ -25,14 +25,14 @@ android:textColor="?title_text_color_primary" android:textSize="14sp" app:layout_constraintBottom_toBottomOf="@+id/recipient_avatar" - app:layout_constraintEnd_toStartOf="@+id/popupMenu" + app:layout_constraintEnd_toStartOf="@+id/popupMenuProgressContainer" app:layout_constraintHorizontal_bias="0" app:layout_constraintStart_toEndOf="@+id/recipient_avatar" app:layout_constraintTop_toTopOf="@+id/recipient_avatar" tools:text="@tools:sample/full_names" /> - + tools:visibility="visible"> + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/others_invite_pending_menu.xml b/app/src/main/res/menu/others_invite_pending_menu.xml new file mode 100644 index 0000000000..2c7edfbe08 --- /dev/null +++ b/app/src/main/res/menu/others_invite_pending_menu.xml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/own_invite_pending_menu.xml b/app/src/main/res/menu/own_invite_pending_menu.xml new file mode 100644 index 0000000000..4ebfcf4d39 --- /dev/null +++ b/app/src/main/res/menu/own_invite_pending_menu.xml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b48eeaea77..7c0c7d7afd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -456,11 +456,29 @@ No pending invites by other group members. Details of people invited by other group members are not shown. If invitees choose to join, their information will be shared with the group at that time. They will not see any messages in the group until they join. + Cancel invite + Cancel invites + + Cancel invite + Cancel %1$d invites + + + Error canceling invite + Error canceling invites + + %1$s invited 1 person %1$s invited %2$d people + + Do you want to cancel the invite you sent to %1$s? + + Do you want to cancel the invite sent by %1$s? + Do you want to cancel %2$d invites sent by %1$s? + + Group avatar Avatar diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Operations.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Operations.java index 6f3734b2d1..5d72397906 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Operations.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Operations.java @@ -192,12 +192,13 @@ public final class GroupsV2Operations { .setPresentation(ByteString.copyFrom(presentation.serialize()))); } - public GroupChange.Actions.Builder createRemoveInvitationChange(final Set uuidCipherTextsFromInvitesToRemove) { + public GroupChange.Actions.Builder createRemoveInvitationChange(final Set uuidCipherTextsFromInvitesToRemove) { GroupChange.Actions.Builder builder = GroupChange.Actions .newBuilder(); - for (byte[] uuidCipherText: uuidCipherTextsFromInvitesToRemove) { - builder.addDeletePendingMembers(GroupChange.Actions.DeletePendingMemberAction.newBuilder().setDeletedUserId(ByteString.copyFrom(uuidCipherText))); + for (UuidCiphertext uuidCipherText: uuidCipherTextsFromInvitesToRemove) { + builder.addDeletePendingMembers(GroupChange.Actions.DeletePendingMemberAction.newBuilder() + .setDeletedUserId(ByteString.copyFrom(uuidCipherText.serialize()))); } return builder;