From ae2b6e4d7ae7bbf7ba63422b3abd8dbedbf87fa6 Mon Sep 17 00:00:00 2001 From: Cody Henthorne Date: Thu, 16 Jul 2020 15:52:24 -0400 Subject: [PATCH] Prevent last admin from leaving without selecting new admin. --- app/src/main/AndroidManifest.xml | 3 + .../conversation/ConversationActivity.java | 5 +- .../securesms/groups/GroupManager.java | 10 ++ .../securesms/groups/GroupManagerV2.java | 12 ++ .../securesms/groups/LiveGroup.java | 33 +++-- .../groups/ui/GroupMemberListAdapter.java | 131 +++++++++++++----- .../groups/ui/GroupMemberListView.java | 17 ++- .../securesms/groups/ui/LeaveGroupDialog.java | 115 +++++++++++---- .../ui/RecipientSelectionChangeListener.java | 9 ++ .../chooseadmin/ChooseNewAdminActivity.java | 124 +++++++++++++++++ .../chooseadmin/ChooseNewAdminRepository.java | 62 +++++++++ .../chooseadmin/ChooseNewAdminViewModel.java | 76 ++++++++++ .../ui/managegroup/ManageGroupFragment.java | 6 +- .../res/layout/choose_new_admin_activity.xml | 57 ++++++++ .../res/layout/group_recipient_list_item.xml | 15 +- app/src/main/res/values/attrs.xml | 1 + app/src/main/res/values/strings.xml | 8 ++ .../api/groupsv2/GroupsV2Operations.java | 12 ++ 18 files changed, 608 insertions(+), 88 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/ui/RecipientSelectionChangeListener.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/ui/chooseadmin/ChooseNewAdminActivity.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/ui/chooseadmin/ChooseNewAdminRepository.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/ui/chooseadmin/ChooseNewAdminViewModel.java create mode 100644 app/src/main/res/layout/choose_new_admin_activity.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f80910055..ae3b61002 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -504,6 +504,9 @@ + + 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 a620460bc..53427cb01 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java @@ -1203,10 +1203,7 @@ public class ConversationActivity extends PassphraseRequiredActivity return; } - LeaveGroupDialog.handleLeavePushGroup(ConversationActivity.this, - getLifecycle(), - getRecipient().requireGroupId().requirePush(), - null); + LeaveGroupDialog.handleLeavePushGroup(this, getRecipient().requireGroupId().requirePush(), this::finish); } private void handleManageGroup() { 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 f4e2fae73..cde0b8022 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java @@ -139,6 +139,16 @@ public final class GroupManager { } } + @WorkerThread + public static void addMemberAdminsAndLeaveGroup(@NonNull Context context, @NonNull GroupId.V2 groupId, @NonNull Collection newAdmins) + throws GroupChangeBusyException, GroupChangeFailedException, IOException, GroupInsufficientRightsException, GroupNotAMemberException + { + try (GroupManagerV2.GroupEditor edit = new GroupManagerV2(context).edit(groupId.requireV2())) { + edit.addMemberAdminsAndLeaveGroup(newAdmins); + Log.i(TAG, "Left group " + groupId); + } + } + @WorkerThread public static void ejectFromGroup(@NonNull Context context, @NonNull GroupId.V2 groupId, @NonNull Recipient recipient) throws GroupChangeBusyException, GroupChangeFailedException, GroupInsufficientRightsException, GroupNotAMemberException, IOException diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java index ee4ac6585..2d89aae4d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java @@ -6,6 +6,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.WorkerThread; +import com.annimon.stream.Stream; import com.google.protobuf.InvalidProtocolBufferException; import org.signal.storageservice.protos.groups.AccessControl; @@ -296,6 +297,17 @@ final class GroupManagerV2 { return commitChangeWithConflictResolution(groupOperations.createRemoveMembersChange(Collections.singleton(recipient.getUuid().get()))); } + @WorkerThread + @NonNull GroupManager.GroupActionResult addMemberAdminsAndLeaveGroup(Collection newAdmins) + throws GroupChangeFailedException, GroupNotAMemberException, GroupInsufficientRightsException, IOException + { + Recipient self = Recipient.self(); + List newAdminRecipients = Stream.of(newAdmins).map(id -> Recipient.resolved(id).getUuid().get()).toList(); + + return commitChangeWithConflictResolution(groupOperations.createLeaveAndPromoteMembersToAdmin(self.getUuid().get(), + newAdminRecipients)); + } + @WorkerThread @Nullable GroupManager.GroupActionResult updateSelfProfileKeyInGroup() throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/LiveGroup.java b/app/src/main/java/org/thoughtcrime/securesms/groups/LiveGroup.java index f24ecaca0..9617e6ed4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/LiveGroup.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/LiveGroup.java @@ -37,9 +37,10 @@ public final class LiveGroup { .thenComparing(HAS_DISPLAY_NAME) .thenComparing(ALPHABETICAL); - private final GroupDatabase groupDatabase; - private final LiveData recipient; - private final LiveData groupRecord; + private final GroupDatabase groupDatabase; + private final LiveData recipient; + private final LiveData groupRecord; + private final LiveData> fullMembers; public LiveGroup(@NonNull GroupId groupId) { Context context = ApplicationDependencies.getApplication(); @@ -47,7 +48,15 @@ public final class LiveGroup { this.groupDatabase = DatabaseFactory.getGroupDatabase(context); this.recipient = Transformations.switchMap(liveRecipient, LiveRecipient::getLiveData); - this.groupRecord = LiveDataUtil.filterNotNull(LiveDataUtil.mapAsync(recipient, groupRecipient-> groupDatabase.getGroup(groupRecipient.getId()).orNull())); + this.groupRecord = LiveDataUtil.filterNotNull(LiveDataUtil.mapAsync(recipient, groupRecipient -> groupDatabase.getGroup(groupRecipient.getId()).orNull())); + this.fullMembers = LiveDataUtil.mapAsync(groupRecord, + g -> Stream.of(g.getMembers()) + .map(m -> { + Recipient recipient = Recipient.resolved(m); + return new GroupMemberEntry.FullMember(recipient, g.isAdmin(recipient)); + }) + .sorted(MEMBER_ORDER) + .toList()); SignalExecutors.BOUNDED.execute(() -> liveRecipient.postValue(Recipient.externalGroup(context, groupId).live())); } @@ -90,15 +99,15 @@ public final class LiveGroup { return Transformations.map(groupRecord, GroupDatabase.GroupRecord::getAttributesAccessControl); } + public LiveData> getNonAdminFullMembers() { + return Transformations.map(fullMembers, + members -> Stream.of(members) + .filterNot(GroupMemberEntry.FullMember::isAdmin) + .toList()); + } + public LiveData> getFullMembers() { - return LiveDataUtil.mapAsync(groupRecord, - g -> Stream.of(g.getMembers()) - .map(m -> { - Recipient recipient = Recipient.resolved(m); - return new GroupMemberEntry.FullMember(recipient, g.isAdmin(recipient)); - }) - .sorted(MEMBER_ORDER) - .toList()); + return fullMembers; } public LiveData getExpireMessages() { 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 94b31a577..a94c7f072 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,6 +4,7 @@ import android.content.Context; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; +import android.widget.CheckBox; import android.widget.ProgressBar; import android.widget.TextView; @@ -17,10 +18,11 @@ 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 org.thoughtcrime.securesms.util.ThemeUtil; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; +import java.util.Set; final class GroupMemberListAdapter extends LifecycleRecyclerAdapter { @@ -29,11 +31,20 @@ final class GroupMemberListAdapter extends LifecycleRecyclerAdapter data = new ArrayList<>(); + private final List data = new ArrayList<>(); + private final Set selection = new HashSet<>(); + private final SelectionChangeListener selectionChangeListener = new SelectionChangeListener(); - @Nullable private AdminActionsListener adminActionsListener; - @Nullable private RecipientClickListener recipientClickListener; - @Nullable private RecipientLongClickListener recipientLongClickListener; + private final boolean selectable; + + @Nullable private AdminActionsListener adminActionsListener; + @Nullable private RecipientClickListener recipientClickListener; + @Nullable private RecipientLongClickListener recipientLongClickListener; + @Nullable private RecipientSelectionChangeListener recipientSelectionChangeListener; + + GroupMemberListAdapter(boolean selectable) { + this.selectable = selectable; + } void updateData(@NonNull List recipients) { if (data.isEmpty()) { @@ -43,6 +54,21 @@ final class GroupMemberListAdapter extends LifecycleRecyclerAdapter newSelection = new HashSet<>(); + for (GroupMemberEntry entry : recipients) { + if (selection.contains(entry)) { + newSelection.add(entry); + } + } + selection.clear(); + selection.addAll(newSelection); + if (recipientSelectionChangeListener != null) { + recipientSelectionChangeListener.onSelectionChanged(selection); + } + } + diffResult.dispatchUpdatesTo(this); } } @@ -55,22 +81,26 @@ final class GroupMemberListAdapter extends LifecycleRecyclerAdapter { - if (recipientClickListener != null && getAdapterPosition() != RecyclerView.NO_POSITION) { - recipientClickListener.onClick(recipient); + if (getAdapterPosition() != RecyclerView.NO_POSITION) { + if (recipientClickListener != null) { + recipientClickListener.onClick(recipient); + } + selectionChangeListener.onSelectionChange(getAdapterPosition(), !selected.isChecked()); } }); this.itemView.setOnLongClickListener(v -> { if (recipientLongClickListener != null && getAdapterPosition() != RecyclerView.NO_POSITION) { return recipientLongClickListener.onLongClick(recipient); } - return false; }); } - void bind(@NonNull GroupMemberEntry memberEntry) { + void bind(@NonNull GroupMemberEntry memberEntry, boolean isSelected) { busyProgress.setVisibility(View.GONE); admin.setVisibility(View.GONE); hideMenu(); @@ -190,6 +232,8 @@ final class GroupMemberListAdapter extends LifecycleRecyclerAdapter oldData; private final List newData; 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 270afc8eb..0a00108f7 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 @@ -15,8 +15,8 @@ import java.util.List; public final class GroupMemberListView extends RecyclerView { - private final GroupMemberListAdapter membersAdapter = new GroupMemberListAdapter(); - private int maxHeight; + private GroupMemberListAdapter membersAdapter; + private int maxHeight; public GroupMemberListView(@NonNull Context context) { super(context); @@ -38,17 +38,20 @@ public final class GroupMemberListView extends RecyclerView { setHasFixedSize(true); } - setLayoutManager(new LinearLayoutManager(context)); - setAdapter(membersAdapter); - + boolean selectable = false; if (attrs != null) { TypedArray typedArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.GroupMemberListView, 0, 0); try { maxHeight = typedArray.getDimensionPixelSize(R.styleable.GroupMemberListView_maxHeight, 0); + selectable = typedArray.getBoolean(R.styleable.GroupMemberListView_selectable, false); } finally { typedArray.recycle(); } } + + membersAdapter = new GroupMemberListAdapter(selectable); + setLayoutManager(new LinearLayoutManager(context)); + setAdapter(membersAdapter); } public void setAdminActionsListener(@Nullable AdminActionsListener adminActionsListener) { @@ -63,6 +66,10 @@ public final class GroupMemberListView extends RecyclerView { membersAdapter.setRecipientLongClickListener(listener); } + public void setRecipientSelectionChangeListener(@Nullable RecipientSelectionChangeListener listener) { + membersAdapter.setRecipientSelectionChangeListener(listener); + } + public void setMembers(@NonNull List recipients) { membersAdapter.updateData(recipients); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/LeaveGroupDialog.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/LeaveGroupDialog.java index e37e60339..50eb607a9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/LeaveGroupDialog.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/LeaveGroupDialog.java @@ -1,60 +1,115 @@ package org.thoughtcrime.securesms.groups.ui; -import android.content.Context; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; -import androidx.lifecycle.Lifecycle; +import androidx.fragment.app.FragmentActivity; + +import com.annimon.stream.Stream; import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.GroupDatabase; import org.thoughtcrime.securesms.groups.GroupChangeBusyException; import org.thoughtcrime.securesms.groups.GroupChangeFailedException; import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.groups.GroupManager; +import org.thoughtcrime.securesms.groups.ui.chooseadmin.ChooseNewAdminActivity; import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.util.concurrent.SimpleTask; import java.io.IOException; +import java.util.List; public final class LeaveGroupDialog { private static final String TAG = Log.tag(LeaveGroupDialog.class); - private LeaveGroupDialog() { + @NonNull private final FragmentActivity activity; + @NonNull private final GroupId.Push groupId; + @Nullable private final Runnable onSuccess; + + public static void handleLeavePushGroup(@NonNull FragmentActivity activity, + @NonNull GroupId.Push groupId, + @Nullable Runnable onSuccess) { + new LeaveGroupDialog(activity, groupId, onSuccess).show(); } - public static void handleLeavePushGroup(@NonNull Context context, - @NonNull Lifecycle lifecycle, - @NonNull GroupId.Push groupId, - @Nullable Runnable onSuccess) - { - new AlertDialog.Builder(context) - .setTitle(context.getString(R.string.ConversationActivity_leave_group)) + private LeaveGroupDialog(@NonNull FragmentActivity activity, + @NonNull GroupId.Push groupId, + @Nullable Runnable onSuccess) { + this.activity = activity; + this.groupId = groupId; + this.onSuccess = onSuccess; + } + + public void show() { + if (!groupId.isV2()) { + showLeaveDialog(); + return; + } + + SimpleTask.run(activity.getLifecycle(), () -> { + GroupDatabase.V2GroupProperties groupProperties = DatabaseFactory.getGroupDatabase(activity) + .getGroup(groupId) + .transform(GroupDatabase.GroupRecord::requireV2GroupProperties) + .orNull(); + + if (groupProperties != null && groupProperties.isAdmin(Recipient.self())) { + List otherMemberRecipients = groupProperties.getMemberRecipients(GroupDatabase.MemberSet.FULL_MEMBERS_EXCLUDING_SELF); + long otherAdminsCount = Stream.of(otherMemberRecipients).filter(groupProperties::isAdmin).count(); + + return otherAdminsCount == 0 && !otherMemberRecipients.isEmpty(); + } + + return false; + }, mustSelectNewAdmin -> { + if (mustSelectNewAdmin) { + showSelectNewAdminDialog(); + } else { + showLeaveDialog(); + } + }); + } + + private void showSelectNewAdminDialog() { + new AlertDialog.Builder(activity) + .setTitle(R.string.ConversationActivity_choose_new_admin) + .setMessage(R.string.ConversationActivity_before_you_leave_you_must_choose_at_least_one_new_admin_for_this_group) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(R.string.ConversationActivity_choose_admin, (d,w) -> activity.startActivity(ChooseNewAdminActivity.createIntent(activity, groupId.requireV2()))) + .show(); + } + + private void showLeaveDialog() { + new AlertDialog.Builder(activity) + .setTitle(R.string.ConversationActivity_leave_group) .setIconAttribute(R.attr.dialog_info_icon) .setCancelable(true) - .setMessage(context.getString(R.string.ConversationActivity_are_you_sure_you_want_to_leave_this_group)) - .setPositiveButton(R.string.yes, (dialog, which) -> - SimpleTask.run( - lifecycle, - () -> { - try { - GroupManager.leaveGroup(context, groupId); - return true; - } catch (GroupChangeFailedException | GroupChangeBusyException | IOException e) { - Log.w(TAG, e); - return false; - } - }, - (success) -> { - if (success) { - if (onSuccess != null) onSuccess.run(); - } else { - Toast.makeText(context, R.string.ConversationActivity_error_leaving_group, Toast.LENGTH_LONG).show(); - } - })) + .setMessage(R.string.ConversationActivity_are_you_sure_you_want_to_leave_this_group) + .setPositiveButton(R.string.yes, (dialog, which) -> SimpleTask.run(activity.getLifecycle(), this::leaveGroup, this::handleLeaveGroupResult)) .setNegativeButton(R.string.no, null) .show(); } + + private boolean leaveGroup() { + try { + GroupManager.leaveGroup(activity, groupId); + return true; + } catch (GroupChangeFailedException | GroupChangeBusyException | IOException e) { + Log.w(TAG, e); + return false; + } + } + + private void handleLeaveGroupResult(boolean success) { + if (success) { + if (onSuccess != null) onSuccess.run(); + } else { + Toast.makeText(activity, R.string.ConversationActivity_error_leaving_group, Toast.LENGTH_LONG).show(); + } + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/RecipientSelectionChangeListener.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/RecipientSelectionChangeListener.java new file mode 100644 index 000000000..300059ac3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/RecipientSelectionChangeListener.java @@ -0,0 +1,9 @@ +package org.thoughtcrime.securesms.groups.ui; + +import androidx.annotation.NonNull; + +import java.util.Set; + +public interface RecipientSelectionChangeListener { + void onSelectionChanged(@NonNull Set selection); +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/chooseadmin/ChooseNewAdminActivity.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/chooseadmin/ChooseNewAdminActivity.java new file mode 100644 index 000000000..1746c5198 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/chooseadmin/ChooseNewAdminActivity.java @@ -0,0 +1,124 @@ +package org.thoughtcrime.securesms.groups.ui.chooseadmin; + +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.view.MenuItem; +import android.view.View; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.appcompat.widget.Toolbar; +import androidx.lifecycle.ViewModelProviders; + +import com.annimon.stream.Collectors; +import com.annimon.stream.Stream; +import com.dd.CircularProgressButton; + +import org.thoughtcrime.securesms.MainActivity; +import org.thoughtcrime.securesms.PassphraseRequiredActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.groups.BadGroupIdException; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.groups.ui.GroupErrors; +import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry; +import org.thoughtcrime.securesms.groups.ui.GroupMemberListView; +import org.thoughtcrime.securesms.groups.ui.chooseadmin.ChooseNewAdminRepository.UpdateResult; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme; +import org.thoughtcrime.securesms.util.DynamicTheme; + +import java.util.Objects; + +public final class ChooseNewAdminActivity extends PassphraseRequiredActivity { + + private static final String EXTRA_GROUP_ID = "group_id"; + + private ChooseNewAdminViewModel viewModel; + private GroupMemberListView groupList; + private CircularProgressButton done; + private GroupId.V2 groupId; + + private final DynamicTheme dynamicTheme = new DynamicNoActionBarTheme(); + + public static Intent createIntent(@NonNull Context context, @NonNull GroupId.V2 groupId) { + Intent intent = new Intent(context, ChooseNewAdminActivity.class); + intent.putExtra(EXTRA_GROUP_ID, groupId.toString()); + return intent; + } + + @Override + protected void onPreCreate() { + dynamicTheme.onCreate(this); + } + + @Override + protected void onCreate(Bundle savedInstanceState, boolean ready) { + super.onCreate(savedInstanceState, ready); + setContentView(R.layout.choose_new_admin_activity); + + Toolbar toolbar = findViewById(R.id.toolbar); + setSupportActionBar(toolbar); + //noinspection ConstantConditions + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + + try { + groupId = GroupId.parse(Objects.requireNonNull(getIntent().getStringExtra(EXTRA_GROUP_ID))).requireV2(); + } catch (BadGroupIdException e) { + throw new AssertionError(e); + } + + groupList = findViewById(R.id.choose_new_admin_group_list); + done = findViewById(R.id.choose_new_admin_done); + done.setIndeterminateProgressMode(true); + + initializeViewModel(); + + groupList.setRecipientSelectionChangeListener(selection -> viewModel.setSelection(Stream.of(selection) + .select(GroupMemberEntry.FullMember.class) + .collect(Collectors.toSet()))); + + done.setOnClickListener(v -> { + done.setClickable(false); + done.setProgress(50); + viewModel.updateAdminsAndLeave(this::handleUpdateAndLeaveResult); + }); + } + + @Override + protected void onResume() { + super.onResume(); + dynamicTheme.onResume(this); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == android.R.id.home) { + finish(); + return true; + } + + return super.onOptionsItemSelected(item); + } + + private void initializeViewModel() { + viewModel = ViewModelProviders.of(this, new ChooseNewAdminViewModel.Factory(groupId)).get(ChooseNewAdminViewModel.class); + + viewModel.getNonAdminFullMembers().observe(this, groupList::setMembers); + viewModel.getSelection().observe(this, selection -> done.setVisibility(selection.isEmpty() ? View.GONE : View.VISIBLE)); + } + + private void handleUpdateAndLeaveResult(@NonNull UpdateResult updateResult) { + if (updateResult.isSuccess()) { + String title = Recipient.externalGroup(this, groupId).getDisplayName(this); + Toast.makeText(this, getString(R.string.ChooseNewAdminActivity_you_left, title), Toast.LENGTH_LONG).show(); + startActivity(new Intent(this, MainActivity.class)); + finish(); + } else { + done.setClickable(true); + done.setProgress(0); + //noinspection ConstantConditions + Toast.makeText(this, GroupErrors.getUserDisplayMessage(updateResult.getFailureReason()), Toast.LENGTH_LONG).show(); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/chooseadmin/ChooseNewAdminRepository.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/chooseadmin/ChooseNewAdminRepository.java new file mode 100644 index 000000000..3ac88b92b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/chooseadmin/ChooseNewAdminRepository.java @@ -0,0 +1,62 @@ +package org.thoughtcrime.securesms.groups.ui.chooseadmin; + +import android.app.Application; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; +import androidx.core.util.Consumer; + +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.ui.GroupChangeFailureReason; +import org.thoughtcrime.securesms.recipients.RecipientId; + +import java.io.IOException; +import java.util.List; + +public final class ChooseNewAdminRepository { + private Application context; + + ChooseNewAdminRepository(@NonNull Application context) { + this.context = context; + } + + @WorkerThread + @NonNull UpdateResult updateAdminsAndLeave(@NonNull GroupId.V2 groupId, @NonNull List newAdminIds) { + try { + GroupManager.addMemberAdminsAndLeaveGroup(context, groupId, newAdminIds); + return new UpdateResult(); + } catch (GroupInsufficientRightsException e) { + return new UpdateResult(GroupChangeFailureReason.NO_RIGHTS); + } catch (GroupNotAMemberException e) { + return new UpdateResult(GroupChangeFailureReason.NOT_A_MEMBER); + } catch (GroupChangeFailedException | GroupChangeBusyException | IOException e) { + return new UpdateResult(GroupChangeFailureReason.OTHER); + } + } + + static final class UpdateResult { + final @Nullable GroupChangeFailureReason failureReason; + + UpdateResult() { + this(null); + } + + UpdateResult(@Nullable GroupChangeFailureReason failureReason) { + this.failureReason = failureReason; + } + + boolean isSuccess() { + return failureReason == null; + } + + @Nullable GroupChangeFailureReason getFailureReason() { + return failureReason; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/chooseadmin/ChooseNewAdminViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/chooseadmin/ChooseNewAdminViewModel.java new file mode 100644 index 000000000..e365c2065 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/chooseadmin/ChooseNewAdminViewModel.java @@ -0,0 +1,76 @@ +package org.thoughtcrime.securesms.groups.ui.chooseadmin; + +import androidx.annotation.NonNull; +import androidx.annotation.StringRes; +import androidx.core.util.Consumer; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.Transformations; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.groups.LiveGroup; +import org.thoughtcrime.securesms.groups.ui.GroupErrors; +import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry; +import org.thoughtcrime.securesms.groups.ui.chooseadmin.ChooseNewAdminRepository.UpdateResult; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.concurrent.SimpleTask; +import org.thoughtcrime.securesms.util.livedata.LiveDataUtil; + +import java.util.Collections; +import java.util.List; +import java.util.Set; + +final class ChooseNewAdminViewModel extends ViewModel { + + private final GroupId.V2 groupId; + private final ChooseNewAdminRepository repository; + private final LiveGroup liveGroup; + private final MutableLiveData> selection; + + public ChooseNewAdminViewModel(@NonNull GroupId.V2 groupId, @NonNull ChooseNewAdminRepository repository) { + this.groupId = groupId; + this.repository = repository; + + liveGroup = new LiveGroup(groupId); + selection = new MutableLiveData<>(Collections.emptySet()); + } + + @NonNull LiveData> getNonAdminFullMembers() { + return liveGroup.getNonAdminFullMembers(); + } + + @NonNull LiveData> getSelection() { + return selection; + } + + void setSelection(@NonNull Set selection) { + this.selection.setValue(selection); + } + + void updateAdminsAndLeave(@NonNull Consumer consumer) { + //noinspection ConstantConditions + List recipientIds = Stream.of(selection.getValue()).map(entry -> entry.getMember().getId()).toList(); + SimpleTask.run(() -> repository.updateAdminsAndLeave(groupId, recipientIds), consumer::accept); + } + + static final class Factory implements ViewModelProvider.Factory { + + private final GroupId.V2 groupId; + + Factory(@NonNull GroupId.V2 groupId) { + this.groupId = groupId; + } + + @Override + public @NonNull T create(@NonNull Class modelClass) { + //noinspection ConstantConditions + return modelClass.cast(new ChooseNewAdminViewModel(groupId, new ChooseNewAdminRepository(ApplicationDependencies.getApplication()))); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupFragment.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupFragment.java index 0f476f7f4..3a7a5d046 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupFragment.java @@ -24,6 +24,7 @@ import com.google.android.material.snackbar.Snackbar; import org.thoughtcrime.securesms.AvatarPreviewActivity; import org.thoughtcrime.securesms.LoggingFragment; +import org.thoughtcrime.securesms.MainActivity; import org.thoughtcrime.securesms.MediaPreviewActivity; import org.thoughtcrime.securesms.MuteDialog; import org.thoughtcrime.securesms.PushContactSelectionActivity; @@ -234,10 +235,7 @@ public class ManageGroupFragment extends LoggingFragment { }); leaveGroup.setVisibility(groupId.isPush() ? View.VISIBLE : View.GONE); - leaveGroup.setOnClickListener(v -> LeaveGroupDialog.handleLeavePushGroup(context, - getLifecycle(), - groupId.requirePush(), - null)); + leaveGroup.setOnClickListener(v -> LeaveGroupDialog.handleLeavePushGroup(requireActivity(), groupId.requirePush(), () -> startActivity(new Intent(requireActivity(), MainActivity.class)))); viewModel.getDisappearingMessageTimer().observe(getViewLifecycleOwner(), string -> disappearingMessages.setText(string)); diff --git a/app/src/main/res/layout/choose_new_admin_activity.xml b/app/src/main/res/layout/choose_new_admin_activity.xml new file mode 100644 index 000000000..1eb449b96 --- /dev/null +++ b/app/src/main/res/layout/choose_new_admin_activity.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + 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 e8a2d067f..b768e3c6e 100644 --- a/app/src/main/res/layout/group_recipient_list_item.xml +++ b/app/src/main/res/layout/group_recipient_list_item.xml @@ -15,7 +15,20 @@ android:layout_marginStart="16dp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent" /> + app:layout_constraintTop_toTopOf="parent" + tools:src="@tools:sample/avatars"/> + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9feb16761..d11925632 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -227,6 +227,9 @@ This device does not appear to support dial actions. Leave group? Are you sure you want to leave this group? + Choose new admin + Before you leave, you must choose at least one new admin for this group. + Choose admin Insecure SMS Insecure MMS Signal @@ -442,6 +445,11 @@ Add to group Add to groups + + Choose new admin + Done + You left \"%1$s.\" + Share your profile name and photo with this group? Do you want to make your profile name and photo visible to all current and future members of this group? 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 cd27ec007..0e7ce59e3 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 @@ -207,6 +207,18 @@ public final class GroupsV2Operations { return actions; } + public GroupChange.Actions.Builder createLeaveAndPromoteMembersToAdmin(UUID self, List membersToMakeAdmin) { + GroupChange.Actions.Builder actions = createRemoveMembersChange(Collections.singleton(self)); + + for (UUID member : membersToMakeAdmin) { + actions.addModifyMemberRoles(GroupChange.Actions.ModifyMemberRoleAction.newBuilder() + .setUserId(encryptUuid(member)) + .setRole(Member.Role.ADMINISTRATOR)); + } + + return actions; + } + public GroupChange.Actions.Builder createModifyGroupTimerChange(int timerDurationSeconds) { return GroupChange.Actions .newBuilder()