Allow pending member invite cancelation.

This commit is contained in:
Alan Evans 2020-04-08 12:56:57 -03:00 committed by Greyson Parrelli
parent 1d63970a25
commit 68d29d9a0f
17 changed files with 538 additions and 87 deletions

View File

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

View File

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

View File

@ -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<Boolean> busy = new DefaultValueLiveData<>(false);
@Nullable private Runnable onClick;
private GroupMemberEntry() {
}
@ -20,6 +26,14 @@ public abstract class GroupMemberEntry {
return onClick;
}
public LiveData<Boolean> getBusy() {
return busy;
}
public void setBusy(boolean busy) {
this.busy.postValue(busy);
}
public final static class FullMember extends GroupMemberEntry {
private final Recipient member;
@ -35,29 +49,39 @@ public abstract class GroupMemberEntry {
public final static class PendingMember extends GroupMemberEntry {
private final Recipient invitee;
private final byte[] inviteeCipherText;
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<UuidCiphertext> ciphertexts;
private final boolean cancellable;
public UnknownPendingMemberCount(@NonNull Recipient inviter, int inviteCount) {
public UnknownPendingMemberCount(@NonNull Recipient inviter,
@NonNull Collection<UuidCiphertext> 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<UuidCiphertext> getCiphertexts() {
return ciphertexts;
}
public boolean isCancellable() {
return cancellable;
}
}
}

View File

@ -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<GroupMemberListAdapter.ViewHolder> {
final class GroupMemberListAdapter extends LifecycleRecyclerAdapter<GroupMemberListAdapter.ViewHolder> {
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<GroupMemberListA
private final ArrayList<GroupMemberEntry> data = new ArrayList<>();
@Nullable private AdminActionsListener adminActionsListener;
void updateData(@NonNull Collection<? extends GroupMemberEntry> recipients) {
data.clear();
data.addAll(recipients);
@ -36,20 +41,24 @@ final class GroupMemberListAdapter extends RecyclerView.Adapter<GroupMemberListA
case FULL_MEMBER:
return new FullMemberViewHolder(LayoutInflater.from(parent.getContext())
.inflate(R.layout.group_recipient_list_item,
parent, false));
parent, false), adminActionsListener);
case OWN_INVITE_PENDING:
return new OwnInvitePendingMemberViewHolder(LayoutInflater.from(parent.getContext())
.inflate(R.layout.group_recipient_list_item,
parent, false));
parent, false), adminActionsListener);
case OTHER_INVITE_PENDING_COUNT:
return new UnknownPendingMemberCountViewHolder(LayoutInflater.from(parent.getContext())
.inflate(R.layout.group_recipient_list_item,
parent, false));
parent, false), adminActionsListener);
default:
throw new AssertionError();
}
}
void setAdminActionsListener(@Nullable AdminActionsListener adminActionsListener) {
this.adminActionsListener = adminActionsListener;
}
@Override
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
holder.bind(data.get(position));
@ -75,20 +84,26 @@ final class GroupMemberListAdapter extends RecyclerView.Adapter<GroupMemberListA
return data.size();
}
static abstract class ViewHolder extends RecyclerView.ViewHolder {
static abstract class ViewHolder extends LifecycleViewHolder {
final Context context;
private final AvatarImageView avatar;
private final TextView recipient;
final PopupMenuView popupMenu;
final View popupMenuContainer;
final ProgressBar busyProgress;
@Nullable final AdminActionsListener adminActionsListener;
ViewHolder(@NonNull View itemView) {
ViewHolder(@NonNull View itemView, @Nullable AdminActionsListener adminActionsListener) {
super(itemView);
context = itemView.getContext();
avatar = itemView.findViewById(R.id.recipient_avatar);
recipient = itemView.findViewById(R.id.recipient_name);
popupMenu = itemView.findViewById(R.id.popupMenu);
this.context = itemView.getContext();
this.avatar = itemView.findViewById(R.id.recipient_avatar);
this.recipient = itemView.findViewById(R.id.recipient_name);
this.popupMenu = itemView.findViewById(R.id.popupMenu);
this.popupMenuContainer = itemView.findViewById(R.id.popupMenuProgressContainer);
this.busyProgress = itemView.findViewById(R.id.menuBusyProgress);
this.adminActionsListener = adminActionsListener;
}
void bindRecipient(@NonNull Recipient recipient) {
@ -103,18 +118,36 @@ final class GroupMemberListAdapter extends RecyclerView.Adapter<GroupMemberListA
}
void bind(@NonNull GroupMemberEntry memberEntry) {
busyProgress.setVisibility(View.GONE);
hideMenu();
Runnable onClick = memberEntry.getOnClick();
View.OnClickListener onClickListener = v -> { 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<GroupMemberListA
final static class OwnInvitePendingMemberViewHolder extends ViewHolder {
OwnInvitePendingMemberViewHolder(@NonNull View itemView) {
super(itemView);
OwnInvitePendingMemberViewHolder(@NonNull View itemView, @Nullable AdminActionsListener adminActionsListener) {
super(itemView, adminActionsListener);
}
@Override
@ -140,27 +173,59 @@ final class GroupMemberListAdapter extends RecyclerView.Adapter<GroupMemberListA
GroupMemberEntry.PendingMember pendingMember = (GroupMemberEntry.PendingMember) memberEntry;
bindRecipient(pendingMember.getInvitee());
if (pendingMember.isCancellable() && adminActionsListener != null) {
popupMenu.setMenu(R.menu.own_invite_pending_menu,
item -> {
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();
}
}
}
}

View File

@ -48,6 +48,10 @@ public final class GroupMemberListView extends RecyclerView {
}
}
public void setAdminActionsListener(@Nullable AdminActionsListener adminActionsListener) {
membersAdapter.setAdminActionsListener(adminActionsListener);
}
public void setMembers(@NonNull Collection<? extends GroupMemberEntry> recipients) {
membersAdapter.updateData(recipients);
}

View File

@ -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();
}
@ -51,9 +64,26 @@ public final class PopupMenuView extends View {
public void setMenu(@MenuRes int menu, @NonNull ItemClick 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 {
boolean onItemClick(@IdRes int menuItemId);
}

View File

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

View File

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

View File

@ -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 GroupId groupId;
private final PendingMemberRepository pendingMemberRepository;
private final MutableLiveData<List<GroupMemberEntry.PendingMember>> whoYouInvited = new MutableLiveData<>();
private final MutableLiveData<List<GroupMemberEntry.UnknownPendingMemberCount>> whoOthersInvited = new MutableLiveData<>();
private final DefaultValueLiveData<List<GroupMemberEntry.PendingMember>> whoYouInvited = new DefaultValueLiveData<>(Collections.emptyList());
private final DefaultValueLiveData<List<GroupMemberEntry.UnknownPendingMemberCount>> whoOthersInvited = new DefaultValueLiveData<>(Collections.emptyList());
PendingMemberInvitesViewModel(@NonNull Context context,
@NonNull GroupId.V2 groupId,
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<List<GroupMemberEntry.PendingMember>> 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<GroupMemberEntry.PendingMember> newList = new ArrayList<>(whoYouInvited.getValue());
Iterator<GroupMemberEntry.PendingMember> 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<GroupMemberEntry.UnknownPendingMemberCount> newList = new ArrayList<>(whoOthersInvited.getValue());
Iterator<GroupMemberEntry.UnknownPendingMemberCount> 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 extends ViewModel> T create(@NonNull Class<T> modelClass) {
//noinspection unchecked
return (T) new PendingMemberInvitesViewModel(context, groupId, new PendingMemberRepository(context.getApplicationContext()));
return (T) new PendingMemberInvitesViewModel(context, new PendingMemberRepository(context.getApplicationContext(), groupId));
}
}
}

View File

@ -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 static final String TAG = Log.tag(PendingMemberRepository.class);
private final Context context;
private final GroupId.V2 groupId;
private final Executor executor;
PendingMemberRepository(@NonNull Context context) {
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<InviteeResult> onInviteesLoaded) {
public void getInvitees(@NonNull Consumer<InviteeResult> onInviteesLoaded) {
executor.execute(() -> {
GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context);
GroupDatabase.V2GroupProperties v2GroupProperties = groupDatabase.getGroup(groupId).get().requireV2GroupProperties();
@ -41,6 +58,7 @@ final class PendingMemberRepository {
List<SinglePendingMemberInvitedByYou> byMe = new ArrayList<>(pendingMembersList.size());
List<MultiplePendingMembersInvitedByAnother> 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) {
try {
Recipient invitee = GroupProtoUtil.pendingMemberToRecipient(context, pendingMember);
byte[] uuidCipherText = pendingMember.getUuidCipherText().toByteArray();
UuidCiphertext uuidCipherText = new UuidCiphertext(pendingMember.getUuidCipherText().toByteArray());
byMe.add(new SinglePendingMemberInvitedByYou(invitee, uuidCipherText));
} catch (InvalidInputException e) {
Log.w(TAG, e);
}
}
} else {
Recipient inviter = GroupProtoUtil.uuidByteStringToRecipient(context, inviterUuid);
ArrayList<UuidCiphertext> uuidCipherTexts = new ArrayList<>(invitedMembers.size());
ArrayList<byte[]> 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<UuidCiphertext> 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<SinglePendingMemberInvitedByYou> byMe;
private final List<MultiplePendingMembersInvitedByAnother> byOthers;
private final boolean canCancelOthersInvites;
private InviteeResult(List<SinglePendingMemberInvitedByYou> byMe,
List<MultiplePendingMembersInvitedByAnother> byOthers)
List<MultiplePendingMembersInvitedByAnother> byOthers,
boolean canCancelOthersInvites)
{
this.byMe = byMe;
this.byOthers = byOthers;
this.canCancelOthersInvites = canCancelOthersInvites;
}
public List<SinglePendingMemberInvitedByYou> getByMe() {
@ -91,13 +131,17 @@ final class PendingMemberRepository {
public List<MultiplePendingMembersInvitedByAnother> getByOthers() {
return byOthers;
}
public boolean isCanCancelOthersInvites() {
return canCancelOthersInvites;
}
}
public final static class SinglePendingMemberInvitedByYou {
private final Recipient invitee;
private final byte[] inviteeCipherText;
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<byte[]> uuidCipherTexts;
private final Collection<UuidCiphertext> uuidCipherTexts;
private MultiplePendingMembersInvitedByAnother(@NonNull Recipient inviter, @NonNull ArrayList<byte[]> uuidCipherTexts) {
private MultiplePendingMembersInvitedByAnother(@NonNull Recipient inviter, @NonNull Collection<UuidCiphertext> uuidCipherTexts) {
this.inviter = inviter;
this.uuidCipherTexts = uuidCipherTexts;
}
@ -124,7 +168,7 @@ final class PendingMemberRepository {
return inviter;
}
public ArrayList<byte[]> getUuidCipherTexts() {
public Collection<UuidCiphertext> getUuidCipherTexts() {
return uuidCipherTexts;
}
}

View File

@ -0,0 +1,19 @@
package org.thoughtcrime.securesms.util;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
public abstract class LifecycleRecyclerAdapter<VH extends LifecycleViewHolder> extends RecyclerView.Adapter<VH> {
@Override
public void onViewAttachedToWindow(@NonNull VH holder) {
super.onViewAttachedToWindow(holder);
holder.onAttachedToWindow();
}
@Override
public void onViewDetachedFromWindow(@NonNull VH holder) {
super.onViewDetachedFromWindow(holder);
holder.onDetachedFromWindow();
}
}

View File

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

View File

@ -25,12 +25,23 @@
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" />
<FrameLayout
android:id="@+id/popupMenuProgressContainer"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginEnd="16dp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:visibility="visible">
<org.thoughtcrime.securesms.groups.ui.PopupMenuView
android:id="@+id/popupMenu"
android:layout_width="24dp"
@ -42,4 +53,14 @@
app:layout_constraintTop_toTopOf="parent"
tools:visibility="visible" />
<ProgressBar
android:id="@+id/menuBusyProgress"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:indeterminate="true"
android:visibility="gone"
tools:visibility="visible" />
</FrameLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/cancel_invites"
android:title="@string/PendingMembersActivity_cancel_invites" />
</menu>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/cancel_invite"
android:title="@string/PendingMembersActivity_cancel_invite" />
</menu>

View File

@ -456,11 +456,29 @@
<string name="PendingMembersActivity_no_pending_invites_by_other_group_members">No pending invites by other group members.</string>
<string name="PendingMembersActivity_missing_detail_explanation">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.</string>
<string name="PendingMembersActivity_cancel_invite">Cancel invite</string>
<string name="PendingMembersActivity_cancel_invites">Cancel invites</string>
<plurals name="PendingMembersActivity_cancel_invites">
<item quantity="one">Cancel invite</item>
<item quantity="other">Cancel %1$d invites</item>
</plurals>
<plurals name="PendingMembersActivity_error_canceling_invite">
<item quantity="one">Error canceling invite</item>
<item quantity="other">Error canceling invites</item>
</plurals>
<plurals name="GroupMemberList_invited">
<item quantity="one">%1$s invited 1 person</item>
<item quantity="other">%1$s invited %2$d people</item>
</plurals>
<!-- GV2 Invite cancellation confirmation -->
<string name="GroupManagement_cancel_own_single_invite">Do you want to cancel the invite you sent to %1$s?</string>
<plurals name="GroupManagement_cancel_others_invites">
<item quantity="one">Do you want to cancel the invite sent by %1$s?</item>
<item quantity="other">Do you want to cancel %2$d invites sent by %1$s?</item>
</plurals>
<!-- CropImageActivity -->
<string name="CropImageActivity_group_avatar">Group avatar</string>
<string name="CropImageActivity_profile_avatar">Avatar</string>

View File

@ -192,12 +192,13 @@ public final class GroupsV2Operations {
.setPresentation(ByteString.copyFrom(presentation.serialize())));
}
public GroupChange.Actions.Builder createRemoveInvitationChange(final Set<byte[]> uuidCipherTextsFromInvitesToRemove) {
public GroupChange.Actions.Builder createRemoveInvitationChange(final Set<UuidCiphertext> 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;