diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index f80910055d..ae3b610029 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 a620460bc1..53427cb012 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 f4e2fae732..cde0b80224 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 ee4ac65851..2d89aae4de 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 f24ecaca0d..9617e6ed4e 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 94b31a5775..a94c7f072d 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 extends GroupMemberEntry> 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 extends GroupMemberEntry> 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 270afc8eb3..0a00108f77 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 extends GroupMemberEntry> 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 e37e603398..50eb607a92 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 0000000000..300059ac30
--- /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 0000000000..1746c51984
--- /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 0000000000..3ac88b92bb
--- /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 0000000000..e365c20657
--- /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 0f476f7f4c..3a7a5d0467 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 0000000000..1eb449b966
--- /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 e8a2d067f3..b768e3c6e0 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 9feb16761e..d11925632e 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 cd27ec0073..0e7ce59e34 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()