Prevent last admin from leaving without selecting new admin.

master
Cody Henthorne 2020-07-16 15:52:24 -04:00 committed by Greyson Parrelli
parent b10fc6a0b0
commit ae2b6e4d7a
18 changed files with 608 additions and 88 deletions

View File

@ -504,6 +504,9 @@
<activity android:name=".groups.ui.creategroup.details.AddGroupDetailsActivity"
android:theme="@style/Theme.Signal.DayNight.NoActionBar" />
<activity android:name=".groups.ui.chooseadmin.ChooseNewAdminActivity"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize" />
<service android:enabled="true" android:name="org.thoughtcrime.securesms.service.WebRtcCallService"/>
<service android:enabled="true" android:name=".service.ApplicationMigrationService"/>
<service android:enabled="true" android:exported="false" android:name=".service.KeyCachingService"/>

View File

@ -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() {

View File

@ -139,6 +139,16 @@ public final class GroupManager {
}
}
@WorkerThread
public static void addMemberAdminsAndLeaveGroup(@NonNull Context context, @NonNull GroupId.V2 groupId, @NonNull Collection<RecipientId> 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

View File

@ -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<RecipientId> newAdmins)
throws GroupChangeFailedException, GroupNotAMemberException, GroupInsufficientRightsException, IOException
{
Recipient self = Recipient.self();
List<UUID> 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

View File

@ -37,9 +37,10 @@ public final class LiveGroup {
.thenComparing(HAS_DISPLAY_NAME)
.thenComparing(ALPHABETICAL);
private final GroupDatabase groupDatabase;
private final LiveData<Recipient> recipient;
private final LiveData<GroupDatabase.GroupRecord> groupRecord;
private final GroupDatabase groupDatabase;
private final LiveData<Recipient> recipient;
private final LiveData<GroupDatabase.GroupRecord> groupRecord;
private final LiveData<List<GroupMemberEntry.FullMember>> 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<List<GroupMemberEntry.FullMember>> getNonAdminFullMembers() {
return Transformations.map(fullMembers,
members -> Stream.of(members)
.filterNot(GroupMemberEntry.FullMember::isAdmin)
.toList());
}
public LiveData<List<GroupMemberEntry.FullMember>> 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<Integer> getExpireMessages() {

View File

@ -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<GroupMemberListAdapter.ViewHolder> {
@ -29,11 +31,20 @@ final class GroupMemberListAdapter extends LifecycleRecyclerAdapter<GroupMemberL
private static final int OTHER_INVITE_PENDING_COUNT = 2;
private static final int NEW_GROUP_CANDIDATE = 3;
private final ArrayList<GroupMemberEntry> data = new ArrayList<>();
private final List<GroupMemberEntry> data = new ArrayList<>();
private final Set<GroupMemberEntry> 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<? extends GroupMemberEntry> recipients) {
if (data.isEmpty()) {
@ -43,6 +54,21 @@ final class GroupMemberListAdapter extends LifecycleRecyclerAdapter<GroupMemberL
DiffUtil.DiffResult diffResult = DiffUtil.calculateDiff(new DiffCallback(data, recipients));
data.clear();
data.addAll(recipients);
if (!selection.isEmpty()) {
Set<GroupMemberEntry> 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<GroupMemberL
.inflate(R.layout.group_recipient_list_item, parent, false),
recipientClickListener,
recipientLongClickListener,
adminActionsListener);
adminActionsListener,
selectionChangeListener);
case OWN_INVITE_PENDING:
return new OwnInvitePendingMemberViewHolder(LayoutInflater.from(parent.getContext())
.inflate(R.layout.group_recipient_list_item, parent, false),
recipientClickListener,
recipientLongClickListener,
adminActionsListener);
adminActionsListener,
selectionChangeListener);
case OTHER_INVITE_PENDING_COUNT:
return new UnknownPendingMemberCountViewHolder(LayoutInflater.from(parent.getContext())
.inflate(R.layout.group_recipient_list_item, parent, false),
adminActionsListener);
adminActionsListener,
selectionChangeListener);
case NEW_GROUP_CANDIDATE:
return new NewGroupInviteeViewHolder(LayoutInflater.from(parent.getContext())
.inflate(R.layout.group_new_candidate_recipient_list_item, parent, false),
recipientClickListener,
recipientLongClickListener);
recipientLongClickListener,
selectionChangeListener);
default:
throw new AssertionError();
}
@ -88,9 +118,14 @@ final class GroupMemberListAdapter extends LifecycleRecyclerAdapter<GroupMemberL
this.recipientLongClickListener = recipientLongClickListener;
}
void setRecipientSelectionChangeListener(@Nullable RecipientSelectionChangeListener recipientSelectionChangeListener) {
this.recipientSelectionChangeListener = recipientSelectionChangeListener;
}
@Override
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
holder.bind(data.get(position));
GroupMemberEntry entry = data.get(position);
holder.bind(entry, selection.contains(entry));
}
@Override
@ -120,10 +155,12 @@ final class GroupMemberListAdapter extends LifecycleRecyclerAdapter<GroupMemberL
final Context context;
final AvatarImageView avatar;
final TextView recipient;
final CheckBox selected;
final PopupMenuView popupMenu;
final View popupMenuContainer;
final ProgressBar busyProgress;
final View admin;
final SelectionChangeListener selectionChangeListener;
@Nullable final RecipientClickListener recipientClickListener;
@Nullable final AdminActionsListener adminActionsListener;
@Nullable final RecipientLongClickListener recipientLongClickListener;
@ -131,13 +168,15 @@ final class GroupMemberListAdapter extends LifecycleRecyclerAdapter<GroupMemberL
ViewHolder(@NonNull View itemView,
@Nullable RecipientClickListener recipientClickListener,
@Nullable RecipientLongClickListener recipientLongClickListener,
@Nullable AdminActionsListener adminActionsListener)
@Nullable AdminActionsListener adminActionsListener,
@NonNull SelectionChangeListener selectionChangeListener)
{
super(itemView);
this.context = itemView.getContext();
this.avatar = itemView.findViewById(R.id.recipient_avatar);
this.recipient = itemView.findViewById(R.id.recipient_name);
this.selected = itemView.findViewById(R.id.recipient_selected);
this.popupMenu = itemView.findViewById(R.id.popupMenu);
this.popupMenuContainer = itemView.findViewById(R.id.popupMenuProgressContainer);
this.busyProgress = itemView.findViewById(R.id.menuBusyProgress);
@ -145,6 +184,7 @@ final class GroupMemberListAdapter extends LifecycleRecyclerAdapter<GroupMemberL
this.recipientClickListener = recipientClickListener;
this.recipientLongClickListener = recipientLongClickListener;
this.adminActionsListener = adminActionsListener;
this.selectionChangeListener = selectionChangeListener;
}
void bindRecipient(@NonNull Recipient recipient) {
@ -166,20 +206,22 @@ final class GroupMemberListAdapter extends LifecycleRecyclerAdapter<GroupMemberL
this.itemView.setEnabled(true);
this.itemView.setOnClickListener(v -> {
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<GroupMemberL
busyProgress.setVisibility(busy ? View.VISIBLE : View.GONE);
popupMenu.setVisibility(busy ? View.GONE : View.VISIBLE);
});
selected.setChecked(isSelected);
}
void hideMenu() {
@ -208,14 +252,15 @@ final class GroupMemberListAdapter extends LifecycleRecyclerAdapter<GroupMemberL
FullMemberViewHolder(@NonNull View itemView,
@Nullable RecipientClickListener recipientClickListener,
@Nullable RecipientLongClickListener recipientLongClickListener,
@Nullable AdminActionsListener adminActionsListener)
@Nullable AdminActionsListener adminActionsListener,
@NonNull SelectionChangeListener selectionChangeListener)
{
super(itemView, recipientClickListener, recipientLongClickListener, adminActionsListener);
super(itemView, recipientClickListener, recipientLongClickListener, adminActionsListener, selectionChangeListener);
}
@Override
void bind(@NonNull GroupMemberEntry memberEntry) {
super.bind(memberEntry);
void bind(@NonNull GroupMemberEntry memberEntry, boolean isSelected) {
super.bind(memberEntry, isSelected);
GroupMemberEntry.FullMember fullMember = (GroupMemberEntry.FullMember) memberEntry;
@ -231,16 +276,17 @@ final class GroupMemberListAdapter extends LifecycleRecyclerAdapter<GroupMemberL
NewGroupInviteeViewHolder(@NonNull View itemView,
@Nullable RecipientClickListener recipientClickListener,
@Nullable RecipientLongClickListener recipientLongClickListener)
@Nullable RecipientLongClickListener recipientLongClickListener,
@NonNull SelectionChangeListener selectionChangeListener)
{
super(itemView, recipientClickListener, recipientLongClickListener, null);
super(itemView, recipientClickListener, recipientLongClickListener, null, selectionChangeListener);
smsContact = itemView.findViewById(R.id.sms_contact);
smsWarning = itemView.findViewById(R.id.sms_warning);
}
@Override
void bind(@NonNull GroupMemberEntry memberEntry) {
void bind(@NonNull GroupMemberEntry memberEntry, boolean isSelected) {
GroupMemberEntry.NewGroupCandidate newGroupCandidate = (GroupMemberEntry.NewGroupCandidate) memberEntry;
bindRecipient(newGroupCandidate.getMember());
@ -256,16 +302,17 @@ final class GroupMemberListAdapter extends LifecycleRecyclerAdapter<GroupMemberL
final static class OwnInvitePendingMemberViewHolder extends ViewHolder {
OwnInvitePendingMemberViewHolder(@NonNull View itemView,
@Nullable RecipientClickListener recipientClickListener,
@Nullable RecipientLongClickListener recipientLongClickListener,
@Nullable AdminActionsListener adminActionsListener)
@Nullable RecipientClickListener recipientClickListener,
@Nullable RecipientLongClickListener recipientLongClickListener,
@Nullable AdminActionsListener adminActionsListener,
@NonNull SelectionChangeListener selectionChangeListener)
{
super(itemView, recipientClickListener, recipientLongClickListener, adminActionsListener);
super(itemView, recipientClickListener, recipientLongClickListener, adminActionsListener, selectionChangeListener);
}
@Override
void bind(@NonNull GroupMemberEntry memberEntry) {
super.bind(memberEntry);
void bind(@NonNull GroupMemberEntry memberEntry, boolean isSelected) {
super.bind(memberEntry, isSelected);
GroupMemberEntry.PendingMember pendingMember = (GroupMemberEntry.PendingMember) memberEntry;
@ -288,13 +335,16 @@ final class GroupMemberListAdapter extends LifecycleRecyclerAdapter<GroupMemberL
final static class UnknownPendingMemberCountViewHolder extends ViewHolder {
UnknownPendingMemberCountViewHolder(@NonNull View itemView, @Nullable AdminActionsListener adminActionsListener) {
super(itemView, null, null, adminActionsListener);
UnknownPendingMemberCountViewHolder(@NonNull View itemView,
@Nullable AdminActionsListener adminActionsListener,
@NonNull SelectionChangeListener selectionChangeListener)
{
super(itemView, null, null, adminActionsListener, selectionChangeListener);
}
@Override
void bind(@NonNull GroupMemberEntry memberEntry) {
super.bind(memberEntry);
void bind(@NonNull GroupMemberEntry memberEntry, boolean isSelected) {
super.bind(memberEntry, isSelected);
GroupMemberEntry.UnknownPendingMemberCount pendingMembers = (GroupMemberEntry.UnknownPendingMemberCount) memberEntry;
Recipient inviter = pendingMembers.getInviter();
@ -327,6 +377,23 @@ final class GroupMemberListAdapter extends LifecycleRecyclerAdapter<GroupMemberL
}
}
private final class SelectionChangeListener {
void onSelectionChange(int position, boolean isChecked) {
if (selectable) {
if (isChecked) {
selection.add(data.get(position));
} else {
selection.remove(data.get(position));
}
notifyItemChanged(position);
if (recipientSelectionChangeListener != null) {
recipientSelectionChangeListener.onSelectionChanged(selection);
}
}
}
}
private final static class DiffCallback extends DiffUtil.Callback {
private final List<? extends GroupMemberEntry> oldData;
private final List<? extends GroupMemberEntry> newData;

View File

@ -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<? extends GroupMemberEntry> recipients) {
membersAdapter.updateData(recipients);
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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<Set<GroupMemberEntry.FullMember>> 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<List<GroupMemberEntry.FullMember>> getNonAdminFullMembers() {
return liveGroup.getNonAdminFullMembers();
}
@NonNull LiveData<Set<GroupMemberEntry.FullMember>> getSelection() {
return selection;
}
void setSelection(@NonNull Set<GroupMemberEntry.FullMember> selection) {
this.selection.setValue(selection);
}
void updateAdminsAndLeave(@NonNull Consumer<UpdateResult> consumer) {
//noinspection ConstantConditions
List<RecipientId> 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 extends ViewModel> T create(@NonNull Class<T> modelClass) {
//noinspection ConstantConditions
return modelClass.cast(new ChooseNewAdminViewModel(groupId, new ChooseNewAdminRepository(ApplicationDependencies.getApplication())));
}
}
}

View File

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

View File

@ -0,0 +1,57 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/toolbar_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?android:attr/windowBackground"
app:elevation="0dp">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?actionBarSize"
android:background="@android:color/transparent"
app:title="@string/ChooseNewAdminActivity_choose_new_admin"
app:titleTextColor="?title_text_color_primary" />
</com.google.android.material.appbar.AppBarLayout>
<FrameLayout android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_gravity="center"
android:layout_weight="1"
android:orientation="vertical">
<org.thoughtcrime.securesms.groups.ui.GroupMemberListView
android:id="@+id/choose_new_admin_group_list"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:selectable="true"
tools:listitem="@layout/group_recipient_list_item" />
<com.dd.CircularProgressButton
android:id="@+id/choose_new_admin_done"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
android:textColor="@color/white"
app:cpb_colorIndicator="@color/white"
app:cpb_colorProgress="?colorAccent"
app:cpb_cornerRadius="28dp"
app:cpb_selectorIdle="?attr/circular_progress_button_state"
app:cpb_textIdle="@string/ChooseNewAdminActivity_done"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
</FrameLayout>
</LinearLayout>

View File

@ -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"/>
<androidx.appcompat.widget.AppCompatCheckBox
android:id="@+id/recipient_selected"
android:background="?attr/contact_selection_checkbox_background"
android:button="@null"
android:layout_width="22dp"
android:layout_height="22dp"
android:focusable="false"
android:clickable="false"
app:layout_constraintBottom_toBottomOf="@+id/recipient_avatar"
app:layout_constraintEnd_toEndOf="@+id/recipient_avatar"
tools:checked="true"/>
<TextView
android:id="@+id/recipient_name"

View File

@ -529,6 +529,7 @@
<declare-styleable name="GroupMemberListView">
<attr name="maxHeight" />
<attr name="selectable" format="boolean" />
</declare-styleable>
<declare-styleable name="WebRtcAudioOutputToggleButtonState">

View File

@ -227,6 +227,9 @@
<string name="ConversationActivity_this_device_does_not_appear_to_support_dial_actions">This device does not appear to support dial actions.</string>
<string name="ConversationActivity_leave_group">Leave group?</string>
<string name="ConversationActivity_are_you_sure_you_want_to_leave_this_group">Are you sure you want to leave this group?</string>
<string name="ConversationActivity_choose_new_admin">Choose new admin</string>
<string name="ConversationActivity_before_you_leave_you_must_choose_at_least_one_new_admin_for_this_group">Before you leave, you must choose at least one new admin for this group.</string>
<string name="ConversationActivity_choose_admin">Choose admin</string>
<string name="ConversationActivity_transport_insecure_sms">Insecure SMS</string>
<string name="ConversationActivity_transport_insecure_mms">Insecure MMS</string>
<string name="ConversationActivity_transport_signal">Signal</string>
@ -442,6 +445,11 @@
<string name="AddToGroupActivity_add_to_group">Add to group</string>
<string name="AddToGroupActivity_add_to_groups">Add to groups</string>
<!-- ChooseNewAdminActivity -->
<string name="ChooseNewAdminActivity_choose_new_admin">Choose new admin</string>
<string name="ChooseNewAdminActivity_done">Done</string>
<string name="ChooseNewAdminActivity_you_left">You left \"%1$s.\"</string>
<!-- GroupShareProfileView -->
<string name="GroupShareProfileView_share_your_profile_name_and_photo_with_this_group">Share your profile name and photo with this group?</string>
<string name="GroupShareProfileView_do_you_want_to_make_your_profile_name_and_photo_visible_to_all_current_and_future_members_of_this_group">Do you want to make your profile name and photo visible to all current and future members of this group?</string>

View File

@ -207,6 +207,18 @@ public final class GroupsV2Operations {
return actions;
}
public GroupChange.Actions.Builder createLeaveAndPromoteMembersToAdmin(UUID self, List<UUID> 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()