New group management screen.

This commit is contained in:
Alan Evans 2020-04-27 16:27:31 -03:00 committed by Greyson Parrelli
parent e0502c24e1
commit 723639d928
30 changed files with 1621 additions and 175 deletions

View file

@ -257,6 +257,9 @@
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:theme="@style/TextSecure.LightNoActionBar" />
<activity android:name=".groups.ui.managegroup.ManageGroupActivity"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
<activity android:name=".DatabaseMigrationActivity"
android:theme="@style/NoAnimation.Theme.AppCompat.Light.DarkActionBar"
android:launchMode="singleTask"

View file

@ -1,44 +1,27 @@
package org.thoughtcrime.securesms;
import android.content.Intent;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
import androidx.fragment.app.FragmentActivity;
import androidx.lifecycle.Lifecycle;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.GroupDatabase;
import org.thoughtcrime.securesms.groups.GroupId;
import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry;
import org.thoughtcrime.securesms.groups.LiveGroup;
import org.thoughtcrime.securesms.groups.ui.GroupMemberListView;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientExporter;
import org.thoughtcrime.securesms.recipients.ui.bottomsheet.RecipientBottomSheetDialogFragment;
import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
import java.util.ArrayList;
public final class GroupMembersDialog {
private final FragmentActivity fragmentActivity;
private final Recipient groupRecipient;
private final Lifecycle lifecycle;
public GroupMembersDialog(@NonNull FragmentActivity activity,
@NonNull Recipient groupRecipient,
@NonNull Lifecycle lifecycle)
@NonNull Recipient groupRecipient)
{
this.fragmentActivity = activity;
this.groupRecipient = groupRecipient;
this.lifecycle = lifecycle;
}
public void display() {
SimpleTask.run(
lifecycle,
() -> DatabaseFactory.getGroupDatabase(fragmentActivity).getGroupMembers(groupRecipient.requireGroupId(), GroupDatabase.MemberSet.FULL_MEMBERS_INCLUDING_SELF),
members -> {
AlertDialog dialog = new AlertDialog.Builder(fragmentActivity)
.setTitle(R.string.ConversationActivity_group_members)
.setIconAttribute(R.attr.group_members_dialog_icon)
@ -49,26 +32,17 @@ public final class GroupMembersDialog {
GroupMemberListView memberListView = dialog.findViewById(R.id.list_members);
ArrayList<GroupMemberEntry.FullMember> pendingMembers = new ArrayList<>(members.size());
for (Recipient member : members) {
GroupMemberEntry.FullMember entry = new GroupMemberEntry.FullMember(member);
entry.setOnClick(() -> {
dialog.dismiss();
contactClick(member);
});
if (member.isLocalNumber()) {
pendingMembers.add(0, entry);
} else {
pendingMembers.add(entry);
}
}
LiveGroup liveGroup = new LiveGroup(groupRecipient.requireGroupId());
//noinspection ConstantConditions
memberListView.setMembers(pendingMembers);
}
);
liveGroup.getFullMembers().observe(fragmentActivity, memberListView::setMembers);
dialog.setOnDismissListener(d -> liveGroup.removeObservers(fragmentActivity));
memberListView.setRecipientClickListener(recipient -> {
dialog.dismiss();
contactClick(recipient);
});
}
private void contactClick(@NonNull Recipient recipient) {

View file

@ -148,7 +148,11 @@ import org.thoughtcrime.securesms.database.model.StickerRecord;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.events.ReminderUpdateEvent;
import org.thoughtcrime.securesms.giph.ui.GiphyActivity;
import org.thoughtcrime.securesms.groups.GroupChangeFailedException;
import org.thoughtcrime.securesms.groups.GroupInsufficientRightsException;
import org.thoughtcrime.securesms.groups.GroupManager;
import org.thoughtcrime.securesms.groups.ui.LeaveGroupDialog;
import org.thoughtcrime.securesms.groups.ui.managegroup.ManageGroupActivity;
import org.thoughtcrime.securesms.groups.ui.pendingmemberinvites.PendingMemberInvitesActivity;
import org.thoughtcrime.securesms.insights.InsightsLauncher;
import org.thoughtcrime.securesms.invites.InviteReminderModel;
@ -846,6 +850,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
case R.id.menu_distribution_broadcast: handleDistributionBroadcastEnabled(item); return true;
case R.id.menu_distribution_conversation: handleDistributionConversationEnabled(item); return true;
case R.id.menu_edit_group: handleEditPushGroup(); return true;
case R.id.menu_manage_group: handleManagePushGroup(); return true;
case R.id.menu_pending_members: handlePendingMembers(); return true;
case R.id.menu_leave: handleLeavePushGroup(); return true;
case R.id.menu_invite: handleInviteLink(); return true;
@ -924,29 +929,43 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
//////// Event Handlers
private void handleSelectMessageExpiration() {
if (isPushGroupConversation() && !isActiveGroup()) {
boolean activeGroup = isActiveGroup();
if (isPushGroupConversation() && !activeGroup) {
return;
}
//noinspection CodeBlock2Expr
ExpirationDialog.show(this, recipient.get().getExpireMessages(), expirationTime -> {
new AsyncTask<Void, Void, Void>() {
@Override
protected Void doInBackground(Void... params) {
ExpirationDialog.show(this, recipient.get().getExpireMessages(),
expirationTime ->
SimpleTask.run(
getLifecycle(),
() -> {
if (activeGroup) {
try {
GroupManager.updateGroupTimer(ConversationActivity.this, getRecipient().requireGroupId().requirePush(), expirationTime);
} catch (GroupInsufficientRightsException e) {
Log.w(TAG, e);
return ConversationActivity.this.getString(R.string.ManageGroupActivity_you_dont_have_the_rights_to_do_this);
} catch (GroupChangeFailedException e) {
Log.w(TAG, e);
return ConversationActivity.this.getString(R.string.ManageGroupActivity_failed_to_update_the_group);
}
} else {
DatabaseFactory.getRecipientDatabase(ConversationActivity.this).setExpireMessages(recipient.getId(), expirationTime);
OutgoingExpirationUpdateMessage outgoingMessage = new OutgoingExpirationUpdateMessage(getRecipient(), System.currentTimeMillis(), expirationTime * 1000L);
MessageSender.send(ConversationActivity.this, outgoingMessage, threadId, false, null);
return null;
}
@Override
protected void onPostExecute(Void result) {
return null;
},
(errorString) -> {
if (errorString != null) {
Toast.makeText(ConversationActivity.this, errorString, Toast.LENGTH_SHORT).show();
} else {
invalidateOptionsMenu();
if (fragment != null) fragment.setLastSeen(0);
}
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
});
})
);
}
private void handleMuteNotifications() {
@ -1129,6 +1148,10 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
startActivityForResult(intent, GROUP_EDIT);
}
private void handleManagePushGroup() {
startActivityForResult(ManageGroupActivity.newIntent(ConversationActivity.this, recipient.get().requireGroupId()), GROUP_EDIT);
}
private void handlePendingMembers() {
startActivity(PendingMemberInvitesActivity.newIntent(ConversationActivity.this, recipient.get().requireGroupId().requireV2()));
}
@ -1182,7 +1205,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
}
private void handleDisplayGroupRecipients() {
new GroupMembersDialog(this, getRecipient(), getLifecycle()).display();
new GroupMembersDialog(this, getRecipient()).display();
}
private void handleAddToContacts() {

View file

@ -15,12 +15,14 @@ import com.google.protobuf.InvalidProtocolBufferException;
import net.sqlcipher.database.SQLiteDatabase;
import org.signal.storageservice.protos.groups.AccessControl;
import org.signal.storageservice.protos.groups.Member;
import org.signal.storageservice.protos.groups.local.DecryptedGroup;
import org.signal.storageservice.protos.groups.local.DecryptedMember;
import org.signal.zkgroup.InvalidInputException;
import org.signal.zkgroup.groups.GroupMasterKey;
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
import org.thoughtcrime.securesms.groups.GroupAccessControl;
import org.thoughtcrime.securesms.groups.GroupId;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.recipients.Recipient;
@ -651,6 +653,34 @@ public final class GroupDatabase extends Database {
public boolean isAdmin(@NonNull Recipient recipient) {
return isV2Group() && requireV2GroupProperties().isAdmin(recipient);
}
/**
* Who is allowed to add to the membership of this group.
*/
public GroupAccessControl getMembershipAdditionAccessControl() {
if (isV2Group()) {
if (requireV2GroupProperties().getDecryptedGroup().getAccessControl().getMembers() == AccessControl.AccessRequired.MEMBER) {
return GroupAccessControl.ALL_MEMBERS;
}
return GroupAccessControl.ONLY_ADMINS;
} else {
return GroupAccessControl.ALL_MEMBERS;
}
}
/**
* Who is allowed to modify the attributes of this group, name/avatar/timer etc.
*/
public GroupAccessControl getAttributesAccessControl() {
if (isV2Group()) {
if (requireV2GroupProperties().getDecryptedGroup().getAccessControl().getAttributes() == AccessControl.AccessRequired.MEMBER) {
return GroupAccessControl.ALL_MEMBERS;
}
return GroupAccessControl.ONLY_ADMINS;
} else {
return GroupAccessControl.ALL_MEMBERS;
}
}
}
public static class V2GroupProperties {

View file

@ -0,0 +1,20 @@
package org.thoughtcrime.securesms.groups;
import androidx.annotation.StringRes;
import org.thoughtcrime.securesms.R;
public enum GroupAccessControl {
ALL_MEMBERS(R.string.GroupManagement_access_level_all_members),
ONLY_ADMINS(R.string.GroupManagement_access_level_only_admins);
private final @StringRes int string;
GroupAccessControl(@StringRes int string) {
this.string = string;
}
public @StringRes int getString() {
return string;
}
}

View file

@ -0,0 +1,8 @@
package org.thoughtcrime.securesms.groups;
public final class GroupChangeFailedException extends Exception {
GroupChangeFailedException(Throwable throwable) {
super(throwable);
}
}

View file

@ -0,0 +1,8 @@
package org.thoughtcrime.securesms.groups;
public final class GroupInsufficientRightsException extends Exception {
GroupInsufficientRightsException(Throwable throwable) {
super(throwable);
}
}

View file

@ -12,6 +12,7 @@ import org.signal.zkgroup.groups.UuidCiphertext;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.whispersystems.signalservice.api.groupsv2.InvalidGroupStateException;
import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedException;
import org.whispersystems.signalservice.api.util.InvalidNumberException;
import java.io.IOException;
@ -58,6 +59,17 @@ public final class GroupManager {
return V1GroupManager.leaveGroup(context, groupId.requireV1());
}
@WorkerThread
public static void updateGroupTimer(@NonNull Context context, @NonNull GroupId.Push groupId, int expirationTime)
throws GroupChangeFailedException, GroupInsufficientRightsException
{
if (groupId.isV2()) {
throw new GroupChangeFailedException(new AssertionError("NYI")); // TODO: GV2 allow timer change
} else {
V1GroupManager.updateGroupTimer(context, groupId.requireV1(), expirationTime);
}
}
@WorkerThread
public static void cancelInvites(@NonNull Context context,
@NonNull GroupId.V2 groupId,
@ -67,6 +79,24 @@ public final class GroupManager {
throw new AssertionError("NYI"); // TODO: GV2 allow invite cancellation
}
@WorkerThread
public static void applyMembershipAdditionRightsChange(@NonNull Context context,
@NonNull GroupId.V2 groupId,
@NonNull GroupAccessControl newRights)
throws GroupChangeFailedException, GroupInsufficientRightsException
{
throw new GroupChangeFailedException(new AssertionError("NYI")); // TODO: GV2 allow membership addition rights change
}
@WorkerThread
public static void applyAttributesRightsChange(@NonNull Context context,
@NonNull GroupId.V2 groupId,
@NonNull GroupAccessControl newRights)
throws GroupChangeFailedException, GroupInsufficientRightsException
{
throw new GroupChangeFailedException(new AssertionError("NYI")); // TODO: GV2 allow attributes rights change
}
public static class GroupActionResult {
private final Recipient groupRecipient;
private final long threadId;

View file

@ -0,0 +1,125 @@
package org.thoughtcrime.securesms.groups;
import android.content.Context;
import android.content.res.Resources;
import androidx.annotation.NonNull;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MediatorLiveData;
import androidx.lifecycle.Transformations;
import com.annimon.stream.ComparatorCompat;
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.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
import org.whispersystems.libsignal.util.guava.Optional;
import java.util.Comparator;
import java.util.List;
public final class LiveGroup extends MediatorLiveData<GroupDatabase.GroupRecord> {
private static final Comparator<GroupMemberEntry.FullMember> LOCAL_FIRST = (m1, m2) -> Boolean.compare(m2.getMember().isLocalNumber(), m1.getMember().isLocalNumber());
private static final Comparator<GroupMemberEntry.FullMember> ADMIN_FIRST = (m1, m2) -> Boolean.compare(m2.isAdmin(), m1.isAdmin());
private static final Comparator<? super GroupMemberEntry.FullMember> MEMBER_ORDER = ComparatorCompat.chain(LOCAL_FIRST)
.thenComparing(ADMIN_FIRST);
private final GroupDatabase groupDatabase;
private final LiveData<Recipient> recipient;
public LiveGroup(@NonNull GroupId groupId) {
Context context = ApplicationDependencies.getApplication();
this.groupDatabase = DatabaseFactory.getGroupDatabase(context);
recipient = Recipient.externalGroup(context, groupId).live().getLiveData();
addSource(recipient, this::refresh);
}
private void refresh(@NonNull Recipient groupRecipient) {
SignalExecutors.BOUNDED.execute(() -> {
Optional<GroupDatabase.GroupRecord> group = groupDatabase.getGroup(groupRecipient.getId());
if (group.isPresent()) {
postValue(group.get());
}
}
);
}
public LiveData<String> getTitle() {
return Transformations.map(this, GroupDatabase.GroupRecord::getTitle);
}
public LiveData<Boolean> isSelfAdmin() {
return Transformations.map(this, g -> g.isAdmin(Recipient.self()));
}
public LiveData<Boolean> getRecipientIsAdmin(@NonNull RecipientId recipientId) {
return LiveDataUtil.mapAsync(this, g -> g.isAdmin(Recipient.resolved(recipientId)));
}
public LiveData<Integer> getPendingMemberCount() {
return Transformations.map(this, g -> g.isV2Group() ? g.requireV2GroupProperties().getDecryptedGroup().getPendingMembersCount() : 0);
}
public LiveData<GroupAccessControl> getMembershipAdditionAccessControl() {
return Transformations.map(this, GroupDatabase.GroupRecord::getMembershipAdditionAccessControl);
}
public LiveData<GroupAccessControl> getAttributesAccessControl() {
return Transformations.map(this, GroupDatabase.GroupRecord::getAttributesAccessControl);
}
public LiveData<List<GroupMemberEntry.FullMember>> getFullMembers() {
return LiveDataUtil.mapAsync(this,
g -> Stream.of(g.getMembers())
.map(m -> {
Recipient recipient = Recipient.resolved(m);
return new GroupMemberEntry.FullMember(recipient, g.isAdmin(recipient));
})
.sorted(MEMBER_ORDER)
.toList());
}
public LiveData<Integer> getExpireMessages() {
return Transformations.map(recipient, Recipient::getExpireMessages);
}
public LiveData<Boolean> selfCanEditGroupAttributes() {
return LiveDataUtil.combineLatest(isSelfAdmin(),
getAttributesAccessControl(),
(admin, rights) -> {
switch (rights) {
case ALL_MEMBERS:
return true;
case ONLY_ADMINS:
return admin;
default:
throw new AssertionError();
}
}
);
}
public LiveData<String> getMembershipCountDescription(@NonNull Resources resources) {
return LiveDataUtil.combineLatest(getFullMembers(),
getPendingMemberCount(),
(fullMembers, invitedCount) -> getMembershipDescription(resources, invitedCount, fullMembers.size()));
}
private static String getMembershipDescription(@NonNull Resources resources, int invitedCount, int fullMemberCount) {
return invitedCount > 0 ? resources.getQuantityString(R.plurals.MessageRequestProfileView_members_and_invited, fullMemberCount,
fullMemberCount, invitedCount)
: resources.getQuantityString(R.plurals.MessageRequestProfileView_members, fullMemberCount,
fullMemberCount);
}
}

View file

@ -15,12 +15,14 @@ import org.thoughtcrime.securesms.attachments.UriAttachment;
import org.thoughtcrime.securesms.database.AttachmentDatabase;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.GroupDatabase;
import org.thoughtcrime.securesms.database.RecipientDatabase;
import org.thoughtcrime.securesms.database.ThreadDatabase;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.groups.GroupManager.GroupActionResult;
import org.thoughtcrime.securesms.jobs.LeaveGroupJob;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.mms.MmsException;
import org.thoughtcrime.securesms.mms.OutgoingExpirationUpdateMessage;
import org.thoughtcrime.securesms.mms.OutgoingGroupMediaMessage;
import org.thoughtcrime.securesms.profiles.AvatarHelper;
import org.thoughtcrime.securesms.providers.BlobProvider;
@ -175,4 +177,16 @@ final class V1GroupManager {
return false;
}
}
@WorkerThread
static void updateGroupTimer(@NonNull Context context, @NonNull GroupId.V1 groupId, int expirationTime) {
RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context);
ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(context);
Recipient recipient = Recipient.externalGroup(context, groupId);
long threadId = threadDatabase.getThreadIdFor(recipient);
recipientDatabase.setExpireMessages(recipient.getId(), expirationTime);
OutgoingExpirationUpdateMessage outgoingMessage = new OutgoingExpirationUpdateMessage(recipient, System.currentTimeMillis(), expirationTime * 1000L);
MessageSender.send(context, outgoingMessage, threadId, false, null);
}
}

View file

@ -13,19 +13,10 @@ import java.util.Collection;
public abstract class GroupMemberEntry {
private final DefaultValueLiveData<Boolean> busy = new DefaultValueLiveData<>(false);
@Nullable private Runnable onClick;
private GroupMemberEntry() {
}
public void setOnClick(@NonNull Runnable onClick) {
this.onClick = onClick;
}
public @Nullable Runnable getOnClick() {
return onClick;
}
public LiveData<Boolean> getBusy() {
return busy;
}
@ -34,17 +25,52 @@ public abstract class GroupMemberEntry {
this.busy.postValue(busy);
}
@Override
public abstract boolean equals(@Nullable Object obj);
@Override
public abstract int hashCode();
abstract boolean sameId(GroupMemberEntry newItem);
public final static class FullMember extends GroupMemberEntry {
private final Recipient member;
private final boolean isAdmin;
public FullMember(@NonNull Recipient member) {
public FullMember(@NonNull Recipient member, boolean isAdmin) {
this.member = member;
this.isAdmin = isAdmin;
}
public Recipient getMember() {
return member;
}
public boolean isAdmin() {
return isAdmin;
}
@Override
boolean sameId(GroupMemberEntry newItem) {
if (getClass() != newItem.getClass()) return false;
return member.getId().equals(((GroupMemberEntry.FullMember) newItem).member.getId());
}
@Override
public boolean equals(@Nullable Object obj) {
if (!(obj instanceof FullMember)) return false;
FullMember other = (FullMember) obj;
return other.member.equals(member) &&
other.isAdmin == isAdmin;
}
@Override
public int hashCode() {
return member.hashCode() * 31 + (isAdmin ? 1 : 0);
}
}
public final static class PendingMember extends GroupMemberEntry {
@ -69,6 +95,32 @@ public abstract class GroupMemberEntry {
public boolean isCancellable() {
return cancellable;
}
@Override
boolean sameId(GroupMemberEntry newItem) {
if (getClass() != newItem.getClass()) return false;
return invitee.getId().equals(((GroupMemberEntry.PendingMember) newItem).invitee.getId());
}
@Override
public boolean equals(@Nullable Object obj) {
if (!(obj instanceof PendingMember)) return false;
PendingMember other = (PendingMember) obj;
return other.invitee.equals(invitee) &&
other.inviteeCipherText.equals(inviteeCipherText) &&
other.cancellable == cancellable;
}
@Override
public int hashCode() {
int hash = invitee.hashCode();
hash *= 31;
hash += inviteeCipherText.hashCode();
hash *= 31;
return hash + (cancellable ? 1 : 0);
}
}
public final static class UnknownPendingMemberCount extends GroupMemberEntry {
@ -99,5 +151,31 @@ public abstract class GroupMemberEntry {
public boolean isCancellable() {
return cancellable;
}
@Override
boolean sameId(GroupMemberEntry newItem) {
if (getClass() != newItem.getClass()) return false;
return inviter.getId().equals(((GroupMemberEntry.UnknownPendingMemberCount) newItem).inviter.getId());
}
@Override
public boolean equals(@Nullable Object obj) {
if (!(obj instanceof UnknownPendingMemberCount)) return false;
UnknownPendingMemberCount other = (UnknownPendingMemberCount) obj;
return other.inviter.equals(inviter) &&
other.ciphertexts.equals(ciphertexts) &&
other.cancellable == cancellable;
}
@Override
public int hashCode() {
int hash = inviter.hashCode();
hash *= 31;
hash += ciphertexts.hashCode();
hash *= 31;
return hash + (cancellable ? 1 : 0);
}
}
}

View file

@ -9,6 +9,7 @@ import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.DiffUtil;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.AvatarImageView;
@ -17,7 +18,7 @@ import org.thoughtcrime.securesms.util.LifecycleRecyclerAdapter;
import org.thoughtcrime.securesms.util.LifecycleViewHolder;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
final class GroupMemberListAdapter extends LifecycleRecyclerAdapter<GroupMemberListAdapter.ViewHolder> {
@ -28,11 +29,18 @@ final class GroupMemberListAdapter extends LifecycleRecyclerAdapter<GroupMemberL
private final ArrayList<GroupMemberEntry> data = new ArrayList<>();
@Nullable private AdminActionsListener adminActionsListener;
@Nullable private RecipientClickListener recipientClickListener;
void updateData(@NonNull Collection<? extends GroupMemberEntry> recipients) {
data.clear();
void updateData(@NonNull List<? extends GroupMemberEntry> recipients) {
if (data.isEmpty()) {
data.addAll(recipients);
notifyDataSetChanged();
} else {
DiffUtil.DiffResult diffResult = DiffUtil.calculateDiff(new DiffCallback(data, recipients));
data.clear();
data.addAll(recipients);
diffResult.dispatchUpdatesTo(this);
}
}
@Override
@ -41,11 +49,11 @@ final class GroupMemberListAdapter extends LifecycleRecyclerAdapter<GroupMemberL
case FULL_MEMBER:
return new FullMemberViewHolder(LayoutInflater.from(parent.getContext())
.inflate(R.layout.group_recipient_list_item,
parent, false), adminActionsListener);
parent, false), recipientClickListener, adminActionsListener);
case OWN_INVITE_PENDING:
return new OwnInvitePendingMemberViewHolder(LayoutInflater.from(parent.getContext())
.inflate(R.layout.group_recipient_list_item,
parent, false), adminActionsListener);
parent, false), recipientClickListener, adminActionsListener);
case OTHER_INVITE_PENDING_COUNT:
return new UnknownPendingMemberCountViewHolder(LayoutInflater.from(parent.getContext())
.inflate(R.layout.group_recipient_list_item,
@ -59,6 +67,10 @@ final class GroupMemberListAdapter extends LifecycleRecyclerAdapter<GroupMemberL
this.adminActionsListener = adminActionsListener;
}
void setRecipientClickListener(@Nullable RecipientClickListener recipientClickListener) {
this.recipientClickListener = recipientClickListener;
}
@Override
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
holder.bind(data.get(position));
@ -87,14 +99,19 @@ final class GroupMemberListAdapter extends LifecycleRecyclerAdapter<GroupMemberL
static abstract class ViewHolder extends LifecycleViewHolder {
final Context context;
private final AvatarImageView avatar;
private final TextView recipient;
final AvatarImageView avatar;
final TextView recipient;
final PopupMenuView popupMenu;
final View popupMenuContainer;
final ProgressBar busyProgress;
final View admin;
@Nullable final RecipientClickListener recipientClickListener;
@Nullable final AdminActionsListener adminActionsListener;
ViewHolder(@NonNull View itemView, @Nullable AdminActionsListener adminActionsListener) {
ViewHolder(@NonNull View itemView,
@Nullable RecipientClickListener recipientClickListener,
@Nullable AdminActionsListener adminActionsListener)
{
super(itemView);
this.context = itemView.getContext();
@ -103,6 +120,8 @@ final class GroupMemberListAdapter extends LifecycleRecyclerAdapter<GroupMemberL
this.popupMenu = itemView.findViewById(R.id.popupMenu);
this.popupMenuContainer = itemView.findViewById(R.id.popupMenuProgressContainer);
this.busyProgress = itemView.findViewById(R.id.menuBusyProgress);
this.admin = itemView.findViewById(R.id.admin);
this.recipientClickListener = recipientClickListener;
this.adminActionsListener = adminActionsListener;
}
@ -117,15 +136,23 @@ final class GroupMemberListAdapter extends LifecycleRecyclerAdapter<GroupMemberL
this.avatar.setRecipient(recipient);
}
void bindRecipientClick(@NonNull Recipient recipient) {
View.OnClickListener onClickListener = v -> {
if (recipientClickListener != null) {
recipientClickListener.onClick(recipient);
}
};
this.avatar.setOnClickListener(onClickListener);
this.recipient.setOnClickListener(onClickListener);
}
void bind(@NonNull GroupMemberEntry memberEntry) {
busyProgress.setVisibility(View.GONE);
admin.setVisibility(View.GONE);
hideMenu();
Runnable onClick = memberEntry.getOnClick();
View.OnClickListener onClickListener = v -> { if (onClick != null) onClick.run(); };
avatar.setOnClickListener(onClickListener);
recipient.setOnClickListener(onClickListener);
avatar.setOnClickListener(null);
recipient.setOnClickListener(null);
memberEntry.getBusy().observe(this, busy -> {
busyProgress.setVisibility(busy ? View.VISIBLE : View.GONE);
@ -146,8 +173,11 @@ final class GroupMemberListAdapter extends LifecycleRecyclerAdapter<GroupMemberL
final static class FullMemberViewHolder extends ViewHolder {
FullMemberViewHolder(@NonNull View itemView, @Nullable AdminActionsListener adminActionsListener) {
super(itemView, adminActionsListener);
FullMemberViewHolder(@NonNull View itemView,
@Nullable RecipientClickListener recipientClickListener,
@Nullable AdminActionsListener adminActionsListener)
{
super(itemView, recipientClickListener, adminActionsListener);
}
@Override
@ -157,13 +187,18 @@ final class GroupMemberListAdapter extends LifecycleRecyclerAdapter<GroupMemberL
GroupMemberEntry.FullMember fullMember = (GroupMemberEntry.FullMember) memberEntry;
bindRecipient(fullMember.getMember());
bindRecipientClick(fullMember.getMember());
admin.setVisibility(fullMember.isAdmin() ? View.VISIBLE : View.INVISIBLE);
}
}
final static class OwnInvitePendingMemberViewHolder extends ViewHolder {
OwnInvitePendingMemberViewHolder(@NonNull View itemView, @Nullable AdminActionsListener adminActionsListener) {
super(itemView, adminActionsListener);
OwnInvitePendingMemberViewHolder(@NonNull View itemView,
@Nullable RecipientClickListener recipientClickListener,
@Nullable AdminActionsListener adminActionsListener)
{
super(itemView, recipientClickListener, adminActionsListener);
}
@Override
@ -173,6 +208,7 @@ final class GroupMemberListAdapter extends LifecycleRecyclerAdapter<GroupMemberL
GroupMemberEntry.PendingMember pendingMember = (GroupMemberEntry.PendingMember) memberEntry;
bindRecipient(pendingMember.getInvitee());
bindRecipientClick(pendingMember.getInvitee());
if (pendingMember.isCancellable() && adminActionsListener != null) {
popupMenu.setMenu(R.menu.own_invite_pending_menu,
@ -191,7 +227,7 @@ final class GroupMemberListAdapter extends LifecycleRecyclerAdapter<GroupMemberL
final static class UnknownPendingMemberCountViewHolder extends ViewHolder {
UnknownPendingMemberCountViewHolder(@NonNull View itemView, @Nullable AdminActionsListener adminActionsListener) {
super(itemView, adminActionsListener);
super(itemView, null, adminActionsListener);
}
@Override
@ -228,4 +264,40 @@ final class GroupMemberListAdapter extends LifecycleRecyclerAdapter<GroupMemberL
}
}
}
private final static class DiffCallback extends DiffUtil.Callback {
private final List<? extends GroupMemberEntry> oldData;
private final List<? extends GroupMemberEntry> newData;
DiffCallback(List<? extends GroupMemberEntry> oldData, List<? extends GroupMemberEntry> newData) {
this.oldData = oldData;
this.newData = newData;
}
@Override
public int getOldListSize() {
return oldData.size();
}
@Override
public int getNewListSize() {
return newData.size();
}
@Override
public boolean areItemsTheSame(int oldItemPosition, int newItemPosition) {
GroupMemberEntry oldItem = oldData.get(oldItemPosition);
GroupMemberEntry newItem = newData.get(newItemPosition);
return oldItem.sameId(newItem);
}
@Override
public boolean areContentsTheSame(int oldItemPosition, int newItemPosition) {
GroupMemberEntry oldItem = oldData.get(oldItemPosition);
GroupMemberEntry newItem = newData.get(newItemPosition);
return oldItem.equals(newItem);
}
}
}

View file

@ -11,7 +11,7 @@ import androidx.recyclerview.widget.RecyclerView;
import org.thoughtcrime.securesms.R;
import java.util.Collection;
import java.util.List;
public final class GroupMemberListView extends RecyclerView {
@ -52,7 +52,11 @@ public final class GroupMemberListView extends RecyclerView {
membersAdapter.setAdminActionsListener(adminActionsListener);
}
public void setMembers(@NonNull Collection<? extends GroupMemberEntry> recipients) {
public void setRecipientClickListener(@Nullable RecipientClickListener listener) {
membersAdapter.setRecipientClickListener(listener);
}
public void setMembers(@NonNull List<? extends GroupMemberEntry> recipients) {
membersAdapter.updateData(recipients);
}

View file

@ -0,0 +1,9 @@
package org.thoughtcrime.securesms.groups.ui;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.recipients.Recipient;
public interface RecipientClickListener {
void onClick(@NonNull Recipient recipient);
}

View file

@ -0,0 +1,59 @@
package org.thoughtcrime.securesms.groups.ui.managegroup;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import androidx.annotation.NonNull;
import androidx.appcompat.widget.Toolbar;
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.groups.GroupId;
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
import org.thoughtcrime.securesms.util.DynamicTheme;
public class ManageGroupActivity extends PassphraseRequiredActionBarActivity {
private static final String GROUP_ID = "GROUP_ID";
private final DynamicTheme dynamicTheme = new DynamicNoActionBarTheme();
public static Intent newIntent(@NonNull Context context, @NonNull GroupId groupId) {
Intent intent = new Intent(context, ManageGroupActivity.class);
intent.putExtra(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.group_manage_activity);
if (savedInstanceState == null) {
getSupportFragmentManager().beginTransaction()
.replace(R.id.container, ManageGroupFragment.newInstance(getIntent().getStringExtra(GROUP_ID)))
.commitNow();
}
Toolbar toolbar = findViewById(R.id.toolbar);
setSupportActionBar(toolbar);
//noinspection ConstantConditions
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
}
@Override
public void onResume() {
super.onResume();
dynamicTheme.onResume(this);
}
@Override
public boolean onSupportNavigateUp() {
onBackPressed();
return true;
}
}

View file

@ -0,0 +1,207 @@
package org.thoughtcrime.securesms.groups.ui.managegroup;
import android.content.Context;
import android.content.Intent;
import android.database.Cursor;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.view.ViewCompat;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentActivity;
import androidx.lifecycle.ViewModelProviders;
import org.thoughtcrime.securesms.MediaPreviewActivity;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.AvatarImageView;
import org.thoughtcrime.securesms.components.ThreadPhotoRailView;
import org.thoughtcrime.securesms.groups.GroupId;
import org.thoughtcrime.securesms.groups.ui.GroupMemberListView;
import org.thoughtcrime.securesms.groups.ui.LeaveGroupDialog;
import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupRightsDialog;
import org.thoughtcrime.securesms.groups.ui.pendingmemberinvites.PendingMemberInvitesActivity;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.mediaoverview.MediaOverviewActivity;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.recipients.ui.bottomsheet.RecipientBottomSheetDialogFragment;
import java.util.Objects;
public class ManageGroupFragment extends Fragment {
private static final String GROUP_ID = "GROUP_ID";
private static final String TAG = Log.tag(ManageGroupFragment.class);
private static final int RETURN_FROM_MEDIA = 33114;
private ManageGroupViewModel viewModel;
private GroupMemberListView groupMemberList;
private View listPending;
private TextView groupTitle;
private TextView memberCount;
private AvatarImageView avatar;
private ThreadPhotoRailView threadPhotoRailView;
private View groupMediaCard;
private View accessControlCard;
private View pendingMembersCard;
private ManageGroupViewModel.CursorFactory cursorFactory;
private View photoRailLabel;
private Button editGroupAccessValue;
private Button editGroupMembershipValue;
private Button disappearingMessages;
private Button blockGroup;
private Button leaveGroup;
static ManageGroupFragment newInstance(@NonNull String groupId) {
ManageGroupFragment fragment = new ManageGroupFragment();
Bundle args = new Bundle();
args.putString(GROUP_ID, groupId);
fragment.setArguments(args);
return fragment;
}
@Override
public @Nullable View onCreateView(@NonNull LayoutInflater inflater,
@Nullable ViewGroup container,
@Nullable Bundle savedInstanceState)
{
View view = inflater.inflate(R.layout.group_manage_fragment, container, false);
avatar = view.findViewById(R.id.group_avatar);
groupTitle = view.findViewById(R.id.group_title);
memberCount = view.findViewById(R.id.member_count);
groupMemberList = view.findViewById(R.id.group_members);
listPending = view.findViewById(R.id.listPending);
threadPhotoRailView = view.findViewById(R.id.recent_photos);
groupMediaCard = view.findViewById(R.id.group_media_card);
accessControlCard = view.findViewById(R.id.group_access_control_card);
pendingMembersCard = view.findViewById(R.id.group_pending_card);
photoRailLabel = view.findViewById(R.id.rail_label);
editGroupAccessValue = view.findViewById(R.id.edit_group_access_value);
editGroupMembershipValue = view.findViewById(R.id.edit_group_membership_value);
disappearingMessages = view.findViewById(R.id.disappearing_messages);
blockGroup = view.findViewById(R.id.blockGroup);
leaveGroup = view.findViewById(R.id.leaveGroup);
return view;
}
@Override
public void onActivityCreated(@Nullable Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
Context context = requireContext();
GroupId.Push groupId = GroupId.parseOrThrow(Objects.requireNonNull(requireArguments().getString(GROUP_ID))).requirePush();
ManageGroupViewModel.Factory factory = new ManageGroupViewModel.Factory(context, groupId);
viewModel = ViewModelProviders.of(requireActivity(), factory).get(ManageGroupViewModel.class);
viewModel.getMembers().observe(getViewLifecycleOwner(), members -> groupMemberList.setMembers(members));
viewModel.getPendingMemberCount().observe(getViewLifecycleOwner(),
members -> {
if (members > 0) {
listPending.setEnabled(true);
listPending.setOnClickListener(v -> {
FragmentActivity activity = requireActivity();
activity.startActivity(PendingMemberInvitesActivity.newIntent(activity, groupId.requireV2()));
});
} else {
listPending.setEnabled(false);
listPending.setOnClickListener(null);
}
});
viewModel.getTitle().observe(getViewLifecycleOwner(), groupTitle::setText);
viewModel.getMemberCountSummary().observe(getViewLifecycleOwner(), memberCount::setText);
viewModel.getGroupViewState().observe(getViewLifecycleOwner(), vs -> {
if (vs == null) return;
photoRailLabel.setOnClickListener(v -> startActivity(MediaOverviewActivity.forThread(context, vs.getThreadId())));
avatar.setRecipient(vs.getGroupRecipient());
setMediaCursorFactory(vs.getMediaCursorFactory());
threadPhotoRailView.setListener(mediaRecord ->
startActivityForResult(MediaPreviewActivity.intentFromMediaRecord(context,
mediaRecord,
ViewCompat.getLayoutDirection(threadPhotoRailView) == ViewCompat.LAYOUT_DIRECTION_LTR),
RETURN_FROM_MEDIA));
accessControlCard.setVisibility(vs.getGroupRecipient().requireGroupId().isV2() ? View.VISIBLE : View.GONE);
pendingMembersCard.setVisibility(vs.getGroupRecipient().requireGroupId().isV2() ? View.VISIBLE : View.GONE);
});
leaveGroup.setVisibility(groupId.isPush() ? View.VISIBLE : View.GONE);
leaveGroup.setOnClickListener(v -> LeaveGroupDialog.handleLeavePushGroup(context,
getLifecycle(),
groupId.requirePush(),
null));
viewModel.getDisappearingMessageTimer().observe(getViewLifecycleOwner(), string -> disappearingMessages.setText(string));
disappearingMessages.setOnClickListener(v -> viewModel.handleExpirationSelection());
blockGroup.setOnClickListener(v -> viewModel.blockAndLeave(requireActivity()));
viewModel.getMembershipRights().observe(getViewLifecycleOwner(), r -> {
if (r != null) {
editGroupMembershipValue.setText(r.getString());
editGroupMembershipValue.setOnClickListener(v -> new GroupRightsDialog(context, GroupRightsDialog.Type.MEMBERSHIP, r, (from, to) -> viewModel.applyMembershipRightsChange(to)).show());
}
}
);
viewModel.getEditGroupAttributesRights().observe(getViewLifecycleOwner(), r -> {
if (r != null) {
editGroupAccessValue.setText(r.getString());
editGroupAccessValue.setOnClickListener(v -> new GroupRightsDialog(context, GroupRightsDialog.Type.ATTRIBUTES, r, (from, to) -> viewModel.applyAttributesRightsChange(to)).show());
}
}
);
viewModel.getIsAdmin().observe(getViewLifecycleOwner(), admin -> {
editGroupMembershipValue.setEnabled(admin);
editGroupAccessValue.setEnabled(admin);
});
viewModel.getCanEditGroupAttributes().observe(getViewLifecycleOwner(), canEdit -> disappearingMessages.setEnabled(canEdit));
groupMemberList.setRecipientClickListener(recipient -> RecipientBottomSheetDialogFragment.create(recipient.getId(), groupId).show(requireFragmentManager(), "BOTTOM"));
}
private void setMediaCursorFactory(@Nullable ManageGroupViewModel.CursorFactory cursorFactory) {
if (this.cursorFactory != cursorFactory) {
this.cursorFactory = cursorFactory;
applyMediaCursorFactory();
}
}
private void applyMediaCursorFactory() {
Context context = getContext();
if (context == null) return;
if (this.cursorFactory != null) {
Cursor cursor = this.cursorFactory.create();
threadPhotoRailView.setCursor(GlideApp.with(context), cursor);
groupMediaCard.setVisibility(cursor.getCount() > 0 ? View.VISIBLE : View.GONE);
} else {
threadPhotoRailView.setCursor(GlideApp.with(context), null);
groupMediaCard.setVisibility(View.GONE);
}
}
@Override
public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode == RETURN_FROM_MEDIA) {
applyMediaCursorFactory();
}
}
}

View file

@ -0,0 +1,143 @@
package org.thoughtcrime.securesms.groups.ui.managegroup;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.annotation.StringRes;
import androidx.annotation.WorkerThread;
import androidx.core.util.Consumer;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.ThreadDatabase;
import org.thoughtcrime.securesms.groups.GroupAccessControl;
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.logging.Log;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
import java.util.concurrent.ExecutorService;
final class ManageGroupRepository {
private static final String TAG = Log.tag(ManageGroupRepository.class);
private final Context context;
private final GroupId groupId;
private final ExecutorService executor;
ManageGroupRepository(@NonNull Context context, @NonNull GroupId groupId) {
this.context = context;
this.executor = SignalExecutors.BOUNDED;
this.groupId = groupId;
}
public GroupId getGroupId() {
return groupId;
}
void getGroupState(@NonNull Consumer<GroupStateResult> onGroupStateLoaded) {
executor.execute(() -> onGroupStateLoaded.accept(getGroupState()));
}
@WorkerThread
private GroupStateResult getGroupState() {
ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(context);
Recipient groupRecipient = Recipient.externalGroup(context, groupId);
long threadId = threadDatabase.getThreadIdFor(groupRecipient);
return new GroupStateResult(threadId, groupRecipient);
}
void setExpiration(int newExpirationTime, @NonNull Error error) {
SignalExecutors.BOUNDED.execute(() -> {
try {
GroupManager.updateGroupTimer(context, groupId.requirePush(), newExpirationTime);
} catch (GroupInsufficientRightsException e) {
Log.w(TAG, e);
error.onError(FailureReason.NO_RIGHTS);
} catch (GroupChangeFailedException e) {
Log.w(TAG, e);
error.onError(FailureReason.OTHER);
}
});
}
void applyMembershipRightsChange(@NonNull GroupAccessControl newRights, @NonNull Error error) {
SignalExecutors.BOUNDED.execute(() -> {
try {
GroupManager.applyMembershipAdditionRightsChange(context, groupId.requireV2(), newRights);
} catch (GroupInsufficientRightsException e) {
Log.w(TAG, e);
error.onError(FailureReason.NO_RIGHTS);
} catch (GroupChangeFailedException e) {
Log.w(TAG, e);
error.onError(FailureReason.OTHER);
}
});
}
void applyAttributesRightsChange(@NonNull GroupAccessControl newRights, @NonNull Error error) {
SignalExecutors.BOUNDED.execute(() -> {
try {
GroupManager.applyAttributesRightsChange(context, groupId.requireV2(), newRights);
} catch (GroupInsufficientRightsException e) {
Log.w(TAG, e);
error.onError(FailureReason.NO_RIGHTS);
} catch (GroupChangeFailedException e) {
Log.w(TAG, e);
error.onError(FailureReason.OTHER);
}
});
}
public void getRecipient(@NonNull Consumer<Recipient> recipientCallback) {
SimpleTask.run(SignalExecutors.BOUNDED,
() -> Recipient.externalGroup(context, groupId),
recipientCallback::accept);
}
static final class GroupStateResult {
private final long threadId;
private final Recipient recipient;
private GroupStateResult(long threadId,
Recipient recipient)
{
this.threadId = threadId;
this.recipient = recipient;
}
long getThreadId() {
return threadId;
}
Recipient getRecipient() {
return recipient;
}
}
public enum FailureReason {
NO_RIGHTS(R.string.ManageGroupActivity_you_dont_have_the_rights_to_do_this),
OTHER(R.string.ManageGroupActivity_failed_to_update_the_group);
private final @StringRes int toastMessage;
FailureReason(@StringRes int toastMessage) {
this.toastMessage = toastMessage;
}
public @StringRes int getToastMessage() {
return toastMessage;
}
}
public interface Error {
void onError(@NonNull FailureReason failureReason);
}
}

View file

@ -0,0 +1,185 @@
package org.thoughtcrime.securesms.groups.ui.managegroup;
import android.content.Context;
import android.database.Cursor;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.WorkerThread;
import androidx.fragment.app.FragmentActivity;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.Transformations;
import androidx.lifecycle.ViewModel;
import androidx.lifecycle.ViewModelProvider;
import org.thoughtcrime.securesms.BlockUnblockDialog;
import org.thoughtcrime.securesms.ExpirationDialog;
import org.thoughtcrime.securesms.database.MediaDatabase;
import org.thoughtcrime.securesms.database.loaders.MediaLoader;
import org.thoughtcrime.securesms.database.loaders.ThreadMediaLoader;
import org.thoughtcrime.securesms.groups.GroupAccessControl;
import org.thoughtcrime.securesms.groups.GroupId;
import org.thoughtcrime.securesms.groups.LiveGroup;
import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientUtil;
import org.thoughtcrime.securesms.util.ExpirationUtil;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
import java.util.List;
public class ManageGroupViewModel extends ViewModel {
private final Context context;
private final ManageGroupRepository manageGroupRepository;
private final LiveData<String> title;
private final LiveData<Boolean> isAdmin;
private final LiveData<Boolean> canEditGroupAttributes;
private final LiveData<List<GroupMemberEntry.FullMember>> members;
private final LiveData<Integer> pendingMemberCount;
private final LiveData<String> disappearingMessageTimer;
private final LiveData<String> memberCountSummary;
private final LiveData<GroupAccessControl> editMembershipRights;
private final LiveData<GroupAccessControl> editGroupAttributesRights;
private final MutableLiveData<GroupViewState> groupViewState = new MutableLiveData<>(null);
private ManageGroupViewModel(@NonNull Context context, @NonNull ManageGroupRepository manageGroupRepository) {
this.context = context;
this.manageGroupRepository = manageGroupRepository;
manageGroupRepository.getGroupState(this::groupStateLoaded);
LiveGroup liveGroup = new LiveGroup(manageGroupRepository.getGroupId());
this.title = liveGroup.getTitle();
this.isAdmin = liveGroup.isSelfAdmin();
this.members = liveGroup.getFullMembers();
this.pendingMemberCount = liveGroup.getPendingMemberCount();
this.memberCountSummary = liveGroup.getMembershipCountDescription(context.getResources());
this.editMembershipRights = liveGroup.getMembershipAdditionAccessControl();
this.editGroupAttributesRights = liveGroup.getAttributesAccessControl();
this.disappearingMessageTimer = Transformations.map(liveGroup.getExpireMessages(), expiration -> ExpirationUtil.getExpirationDisplayValue(context, expiration));
this.canEditGroupAttributes = liveGroup.selfCanEditGroupAttributes();
}
@WorkerThread
private void groupStateLoaded(@NonNull ManageGroupRepository.GroupStateResult groupStateResult) {
groupViewState.postValue(new GroupViewState(groupStateResult.getThreadId(),
groupStateResult.getRecipient(),
() -> new ThreadMediaLoader(context, groupStateResult.getThreadId(), MediaLoader.MediaType.GALLERY, MediaDatabase.Sorting.Newest).getCursor()));
}
LiveData<List<GroupMemberEntry.FullMember>> getMembers() {
return members;
}
LiveData<Integer> getPendingMemberCount() {
return pendingMemberCount;
}
LiveData<String> getMemberCountSummary() {
return memberCountSummary;
}
LiveData<GroupViewState> getGroupViewState() {
return groupViewState;
}
LiveData<String> getTitle() {
return title;
}
LiveData<GroupAccessControl> getMembershipRights() {
return editMembershipRights;
}
LiveData<GroupAccessControl> getEditGroupAttributesRights() {
return editGroupAttributesRights;
}
LiveData<Boolean> getIsAdmin() {
return isAdmin;
}
LiveData<Boolean> getCanEditGroupAttributes() {
return canEditGroupAttributes;
}
LiveData<String> getDisappearingMessageTimer() {
return disappearingMessageTimer;
}
void handleExpirationSelection() {
manageGroupRepository.getRecipient(groupRecipient ->
ExpirationDialog.show(context,
groupRecipient.getExpireMessages(),
expirationTime -> manageGroupRepository.setExpiration(expirationTime, this::showErrorToast)));
}
void applyMembershipRightsChange(@NonNull GroupAccessControl newRights) {
manageGroupRepository.applyMembershipRightsChange(newRights, this::showErrorToast);
}
void applyAttributesRightsChange(@NonNull GroupAccessControl newRights) {
manageGroupRepository.applyAttributesRightsChange(newRights, this::showErrorToast);
}
void blockAndLeave(@NonNull FragmentActivity activity) {
manageGroupRepository.getRecipient(recipient -> BlockUnblockDialog.showBlockFor(activity, activity.getLifecycle(), recipient,
() -> RecipientUtil.block(context, recipient)));
}
@WorkerThread
private void showErrorToast(@NonNull ManageGroupRepository.FailureReason e) {
Util.runOnMain(() -> Toast.makeText(context, e.getToastMessage(), Toast.LENGTH_SHORT).show());
}
static final class GroupViewState {
private final long threadId;
@NonNull private final Recipient groupRecipient;
@NonNull private final CursorFactory mediaCursorFactory;
private GroupViewState(long threadId,
@NonNull Recipient groupRecipient,
@NonNull CursorFactory mediaCursorFactory)
{
this.threadId = threadId;
this.groupRecipient = groupRecipient;
this.mediaCursorFactory = mediaCursorFactory;
}
long getThreadId() {
return threadId;
}
@NonNull Recipient getGroupRecipient() {
return groupRecipient;
}
@NonNull CursorFactory getMediaCursorFactory() {
return mediaCursorFactory;
}
}
interface CursorFactory {
Cursor create();
}
public static class Factory implements ViewModelProvider.Factory {
private final Context context;
private final GroupId.Push groupId;
public Factory(@NonNull Context context, @NonNull GroupId.Push groupId) {
this.context = context;
this.groupId = groupId;
}
@Override
public @NonNull <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
//noinspection unchecked
return (T) new ManageGroupViewModel(context, new ManageGroupRepository(context.getApplicationContext(), groupId));
}
}
}

View file

@ -0,0 +1,63 @@
package org.thoughtcrime.securesms.groups.ui.managegroup.dialogs;
import android.content.Context;
import androidx.annotation.ArrayRes;
import androidx.annotation.NonNull;
import androidx.annotation.StringRes;
import androidx.appcompat.app.AlertDialog;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.groups.GroupAccessControl;
public final class GroupRightsDialog {
private final AlertDialog.Builder builder;
@NonNull private GroupAccessControl rights;
public GroupRightsDialog(@NonNull Context context,
@NonNull Type type,
@NonNull GroupAccessControl currentRights,
@NonNull GroupRightsDialog.OnChange onChange)
{
rights = currentRights;
builder = new AlertDialog.Builder(context)
.setTitle(type.message)
.setSingleChoiceItems(type.choices, currentRights.ordinal(), (dialog, which) -> rights = GroupAccessControl.values()[which])
.setNegativeButton(android.R.string.cancel, (dialog, which) -> {
})
.setPositiveButton(android.R.string.ok, (dialog, which) -> {
GroupAccessControl newGroupAccessControl = rights;
if (newGroupAccessControl != currentRights) {
onChange.changed(currentRights, newGroupAccessControl);
}
});
}
public void show() {
builder.show();
}
public interface OnChange {
void changed(@NonNull GroupAccessControl from, @NonNull GroupAccessControl to);
}
public enum Type {
MEMBERSHIP(R.string.GroupManagement_choose_who_can_add_or_invite_new_members,
R.array.GroupManagement_edit_group_membership_choices),
ATTRIBUTES(R.string.GroupManagement_choose_who_can_change_the_group_name_and_photo,
R.array.GroupManagement_edit_group_info_choices);
@StringRes private final int message;
@ArrayRes private final int choices;
Type(@StringRes int message, @ArrayRes int choices) {
this.message = message;
this.choices = choices;
}
}
}

View file

@ -6,7 +6,6 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.GroupDatabase;
import org.thoughtcrime.securesms.database.IdentityDatabase;
import org.thoughtcrime.securesms.groups.GroupId;
import org.thoughtcrime.securesms.recipients.Recipient;
@ -16,7 +15,6 @@ import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
final class RecipientDialogRepository {
@NonNull private final GroupDatabase groupDatabase;
@NonNull private final Context context;
@NonNull private final RecipientId recipientId;
@Nullable private final GroupId groupId;
@ -26,34 +24,20 @@ final class RecipientDialogRepository {
@Nullable GroupId groupId)
{
this.context = context;
this.groupDatabase = DatabaseFactory.getGroupDatabase(context);
this.recipientId = recipientId;
this.groupId = groupId;
}
@NonNull RecipientId getRecipientId() {
@NonNull
RecipientId getRecipientId() {
return recipientId;
}
@Nullable GroupId getGroupId() {
@Nullable
GroupId getGroupId() {
return groupId;
}
void isAdminOfGroup(@NonNull RecipientId recipientId, @NonNull AdminCallback callback) {
SimpleTask.run(SignalExecutors.BOUNDED,
() -> {
if (groupId != null) {
Recipient recipient = Recipient.resolved(recipientId);
return groupDatabase.getGroup(groupId)
.transform(g -> g.isAdmin(recipient))
.or(false);
} else {
return false;
}
},
callback::isAdmin);
}
void getIdentity(@NonNull IdentityCallback callback) {
SimpleTask.run(SignalExecutors.BOUNDED,
() -> DatabaseFactory.getIdentityDatabase(context)
@ -62,16 +46,12 @@ final class RecipientDialogRepository {
callback::remoteIdentity);
}
public void getRecipient(@NonNull RecipientCallback recipientCallback) {
void getRecipient(@NonNull RecipientCallback recipientCallback) {
SimpleTask.run(SignalExecutors.BOUNDED,
() -> Recipient.resolved(recipientId),
recipientCallback::onRecipient);
}
interface AdminCallback {
void isAdmin(boolean admin);
}
interface IdentityCallback {
void remoteIdentity(@Nullable IdentityDatabase.IdentityRecord identityRecord);
}

View file

@ -8,7 +8,6 @@ import androidx.annotation.Nullable;
import androidx.fragment.app.FragmentActivity;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.Transformations;
import androidx.lifecycle.ViewModel;
import androidx.lifecycle.ViewModelProvider;
@ -17,12 +16,12 @@ import org.thoughtcrime.securesms.RecipientPreferenceActivity;
import org.thoughtcrime.securesms.VerifyIdentityActivity;
import org.thoughtcrime.securesms.database.IdentityDatabase;
import org.thoughtcrime.securesms.groups.GroupId;
import org.thoughtcrime.securesms.groups.LiveGroup;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.recipients.RecipientUtil;
import org.thoughtcrime.securesms.util.CommunicationActions;
import org.thoughtcrime.securesms.util.DefaultValueLiveData;
import org.thoughtcrime.securesms.util.livedata.LiveDataPair;
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
final class RecipientDialogViewModel extends ViewModel {
@ -39,23 +38,22 @@ final class RecipientDialogViewModel extends ViewModel {
this.recipientDialogRepository = recipientDialogRepository;
this.identity = new MutableLiveData<>();
MutableLiveData<Boolean> localIsAdmin = new DefaultValueLiveData<>(false);
MutableLiveData<Boolean> recipientIsAdmin = new DefaultValueLiveData<>(false);
boolean recipientIsSelf = recipientDialogRepository.getRecipientId().equals(Recipient.self().getId());
if (recipientDialogRepository.getGroupId() != null && recipientDialogRepository.getGroupId().isV2()) {
recipientDialogRepository.isAdminOfGroup(Recipient.self().getId(), localIsAdmin::setValue);
recipientDialogRepository.isAdminOfGroup(recipientDialogRepository.getRecipientId(), recipientIsAdmin::setValue);
}
if (recipientDialogRepository.getGroupId() != null && recipientDialogRepository.getGroupId().isV2() && !recipientIsSelf) {
LiveGroup source = new LiveGroup(recipientDialogRepository.getGroupId());
adminActionStatus = Transformations.map(new LiveDataPair<>(localIsAdmin, recipientIsAdmin, false, false),
pair -> {
boolean localAdmin = pair.first();
boolean recipientAdmin = pair.second();
LiveData<Boolean> localIsAdmin = source.isSelfAdmin();
LiveData<Boolean> recipientIsAdmin = source.getRecipientIsAdmin(recipientDialogRepository.getRecipientId());
return new AdminActionStatus(localAdmin,
adminActionStatus = LiveDataUtil.combineLatest(localIsAdmin, recipientIsAdmin,
(localAdmin, recipientAdmin) ->
new AdminActionStatus(localAdmin,
localAdmin && !recipientAdmin,
localAdmin && recipientAdmin);
});
localAdmin && recipientAdmin));
} else {
adminActionStatus = new MutableLiveData<>(new AdminActionStatus(false, false, false));
}
recipient = Recipient.live(recipientDialogRepository.getRecipientId()).getLiveData();

View file

@ -4,11 +4,43 @@ import androidx.annotation.NonNull;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MediatorLiveData;
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
import org.whispersystems.libsignal.util.guava.Function;
import java.util.concurrent.Executor;
public final class LiveDataUtil {
private LiveDataUtil() {
}
/**
* Runs the {@param backgroundFunction} on {@link SignalExecutors#BOUNDED}.
* <p>
* The background function order is run serially, albeit possibly across multiple threads.
* <p>
* The background function may not run for all {@param source} updates. Later updates taking priority.
*/
public static <A, B> LiveData<B> mapAsync(@NonNull LiveData<A> source, @NonNull Function<A, B> backgroundFunction) {
return mapAsync(SignalExecutors.BOUNDED, source, backgroundFunction);
}
/**
* Runs the {@param backgroundFunction} on the supplied {@param executor}.
* <p>
* Regardless of the executor supplied, the background function is run serially.
* <p>
* The background function may not run for all {@param source} updates. Later updates taking priority.
*/
public static <A, B> LiveData<B> mapAsync(@NonNull Executor executor, @NonNull LiveData<A> source, @NonNull Function<A, B> backgroundFunction) {
MediatorLiveData<B> outputLiveData = new MediatorLiveData<>();
Executor liveDataExecutor = new SerialLiveDataExecutor(executor);
outputLiveData.addSource(source, currentValue -> liveDataExecutor.execute(() -> outputLiveData.postValue(backgroundFunction.apply(currentValue))));
return outputLiveData;
}
/**
* Once there is non-null data on both input {@link LiveData}, the {@link Combine} function is run
* and produces a live data of the combined data.
@ -64,4 +96,42 @@ public final class LiveDataUtil {
}
}
}
/**
* Executor decorator that runs serially but enqueues just the latest task, dropping any pending task.
* <p>
* Based on SerialExecutor https://docs.oracle.com/javase/7/docs/api/java/util/concurrent/Executor.html
* but modified to represent a queue of size one which is replaced by the latest call to {@link #execute(Runnable)}.
*/
private static final class SerialLiveDataExecutor implements Executor {
private final Executor executor;
private Runnable next;
private Runnable active;
SerialLiveDataExecutor(@NonNull Executor executor) {
this.executor = executor;
}
public synchronized void execute(@NonNull Runnable command) {
next = () -> {
try {
command.run();
} finally {
scheduleNext();
}
};
if (active == null) {
scheduleNext();
}
}
private synchronized void scheduleNext() {
active = next;
next = null;
if (active != null) {
executor.execute(active);
}
}
}
}

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="@color/core_ultramarine" android:state_enabled="true" />
<item android:color="?attr/title_text_color_secondary" />
</selector>

View file

@ -0,0 +1,24 @@
<?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"
tools:context=".groups.ui.managegroup.ManageGroupActivity">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:theme="@style/ThemeOverlay.AppCompat.ActionBar"
app:popupTheme="@style/ThemeOverlay.AppCompat.Light"
app:title=" "
app:titleTextColor="?attr/title_text_color_primary" />
<FrameLayout
android:id="@+id/container"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</LinearLayout>

View file

@ -0,0 +1,248 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView 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:background="?attr/pending_member_background"
android:fillViewport="true"
tools:context=".groups.ui.managegroup.ManageGroupFragment">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<androidx.cardview.widget.CardView
android:id="@+id/group_title_card"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardBackgroundColor="?android:attr/windowBackground"
app:layout_constraintTop_toTopOf="parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingBottom="16dp">
<org.thoughtcrime.securesms.components.AvatarImageView
android:id="@+id/group_avatar"
android:layout_width="96dp"
android:layout_height="96dp"
android:layout_gravity="center_horizontal" />
<TextView
android:id="@+id/group_title"
style="@style/TextAppearance.Signal.Title2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginTop="14dp"
tools:text="Parkdale Run Crew" />
<TextView
android:id="@+id/member_count"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:textSize="14sp"
tools:text="12 members (4 invited)" />
</LinearLayout>
</androidx.cardview.widget.CardView>
<androidx.cardview.widget.CardView
android:id="@+id/group_disappearing_messages_card"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
app:cardBackgroundColor="?android:attr/windowBackground"
app:layout_constraintTop_toBottomOf="@id/group_title_card">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<Button
style="@style/Widget.Signal.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:text="@string/ManageGroupActivity_disappearing_messages" />
<Button
android:id="@+id/disappearing_messages"
style="@style/Widget.Signal.Button.TextButton.Ultramarine"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAlignment="textEnd"
tools:text="Off" />
</LinearLayout>
</androidx.cardview.widget.CardView>
<androidx.cardview.widget.CardView
android:id="@+id/group_media_card"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:visibility="gone"
app:cardBackgroundColor="?android:attr/windowBackground"
app:layout_constraintTop_toBottomOf="@id/group_disappearing_messages_card"
tools:visibility="visible">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<Button
android:id="@+id/rail_label"
style="@style/Widget.Signal.Button.TextButton.Ultramarine"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:focusableInTouchMode="true"
android:padding="16dp"
android:text="@string/recipient_preference_activity__shared_media" />
<org.thoughtcrime.securesms.components.ThreadPhotoRailView
android:id="@+id/recent_photos"
android:layout_width="match_parent"
android:layout_height="90dp"
android:layout_marginBottom="16dp" />
</LinearLayout>
</androidx.cardview.widget.CardView>
<androidx.cardview.widget.CardView
android:id="@+id/group_access_control_card"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:visibility="gone"
app:cardBackgroundColor="?android:attr/windowBackground"
app:layout_constraintTop_toBottomOf="@id/group_media_card"
tools:visibility="visible">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<Button
android:id="@+id/edit_group_membership_title"
style="@style/Widget.Signal.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/ManageGroupActivity_who_can_edit_group_membership" />
<Button
android:id="@+id/edit_group_membership_value"
style="@style/Widget.Signal.Button.TextButton.Ultramarine"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAlignment="textEnd"
tools:text="Only admin" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<Button
android:id="@+id/edit_group_access_title"
style="@style/Widget.Signal.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/ManageGroupActivity_who_can_edit_group_info" />
<Button
android:id="@+id/edit_group_access_value"
style="@style/Widget.Signal.Button.TextButton.Ultramarine"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAlignment="textEnd"
tools:text="All members" />
</LinearLayout>
</LinearLayout>
</androidx.cardview.widget.CardView>
<androidx.cardview.widget.CardView
android:id="@+id/group_membership_card"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
app:cardBackgroundColor="?android:attr/windowBackground"
app:layout_constraintTop_toBottomOf="@id/group_access_control_card">
<org.thoughtcrime.securesms.groups.ui.GroupMemberListView
android:id="@+id/group_members"
android:layout_width="match_parent"
android:layout_height="0dp"
app:maxHeight="280dp"
tools:listitem="@layout/group_recipient_list_item" />
</androidx.cardview.widget.CardView>
<androidx.cardview.widget.CardView
android:id="@+id/group_pending_card"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
app:cardBackgroundColor="?android:attr/windowBackground"
app:layout_constraintTop_toBottomOf="@id/group_membership_card">
<Button
android:id="@+id/listPending"
style="@style/Widget.Signal.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/ManageGroupActivity_pending_group_invites" />
</androidx.cardview.widget.CardView>
<androidx.cardview.widget.CardView
android:id="@+id/group_block_and_leave_card"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
app:cardBackgroundColor="?android:attr/windowBackground"
app:layout_constraintTop_toBottomOf="@id/group_pending_card">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<Button
android:id="@+id/blockGroup"
style="@style/Widget.Signal.Button.TextButton.Red"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/ManageGroupActivity_block_group" />
<Button
android:id="@+id/leaveGroup"
style="@style/Widget.Signal.Button.TextButton.Red"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/ManageGroupActivity_leave_group" />
</LinearLayout>
</androidx.cardview.widget.CardView>
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.core.widget.NestedScrollView>

View file

@ -22,15 +22,31 @@
android:layout_marginEnd="8dp"
android:gravity="start|center_vertical"
android:textAlignment="viewStart"
android:textColor="?title_text_color_primary"
android:textSize="14sp"
android:textAppearance="@style/TextAppearance.Signal.Body2"
app:layout_constraintBottom_toBottomOf="@+id/recipient_avatar"
app:layout_constraintEnd_toStartOf="@+id/popupMenuProgressContainer"
app:layout_constraintEnd_toStartOf="@+id/admin"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintStart_toEndOf="@+id/recipient_avatar"
app:layout_constraintTop_toTopOf="@+id/recipient_avatar"
tools:text="@tools:sample/full_names" />
<TextView
android:id="@+id/admin"
android:layout_width="wrap_content"
android:layout_height="0dp"
android:layout_marginEnd="16dp"
android:gravity="start|center_vertical"
android:text="@string/GroupRecipientListItem_admin"
android:textColor="?attr/title_text_color_secondary"
android:textAppearance="@style/TextAppearance.Signal.Body2"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@+id/recipient_name"
app:layout_constraintEnd_toStartOf="@+id/popupMenuProgressContainer"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintStart_toEndOf="@+id/recipient_name"
app:layout_constraintTop_toTopOf="@+id/recipient_name"
tools:visibility="visible" />
<FrameLayout
android:id="@+id/popupMenuProgressContainer"
android:layout_width="24dp"

View file

@ -17,6 +17,17 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ProgressBar
android:id="@+id/admin_action_busy"
android:layout_width="0dp"
android:layout_height="0dp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@+id/recipient_avatar"
app:layout_constraintEnd_toEndOf="@+id/recipient_avatar"
app:layout_constraintStart_toStartOf="@+id/recipient_avatar"
app:layout_constraintTop_toTopOf="@+id/recipient_avatar"
tools:visibility="visible" />
<TextView
android:id="@+id/full_name"
style="@style/TextAppearance.Signal.Body1.Bold"
@ -53,7 +64,7 @@
<Button
android:id="@+id/message_button"
style="@style/Signal.Button.TextButton.Drawable"
style="@style/Widget.Signal.Button.TextButton.Drawable"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
@ -63,7 +74,7 @@
<Button
android:id="@+id/secure_call_button"
style="@style/Signal.Button.TextButton.Drawable"
style="@style/Widget.Signal.Button.TextButton.Drawable"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
@ -72,7 +83,7 @@
<Button
android:id="@+id/block_button"
style="@style/Signal.Button.TextButton.Drawable"
style="@style/Widget.Signal.Button.TextButton.Drawable"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
@ -83,7 +94,7 @@
<Button
android:id="@+id/unblock_button"
style="@style/Signal.Button.TextButton.Drawable"
style="@style/Widget.Signal.Button.TextButton.Drawable"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
@ -94,7 +105,7 @@
<Button
android:id="@+id/view_safety_number_button"
style="@style/Signal.Button.TextButton.Drawable"
style="@style/Widget.Signal.Button.TextButton.Drawable"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
@ -103,7 +114,7 @@
<Button
android:id="@+id/make_group_admin_button"
style="@style/Signal.Button.TextButton.Drawable"
style="@style/Widget.Signal.Button.TextButton.Drawable"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
@ -114,7 +125,7 @@
<Button
android:id="@+id/remove_group_admin_button"
style="@style/Signal.Button.TextButton.Drawable"
style="@style/Widget.Signal.Button.TextButton.Drawable"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
@ -122,10 +133,9 @@
android:visibility="gone"
app:drawableStartCompat="?attr/recipient_make_admin_icon"
tools:visibility="visible" />
<Button
android:id="@+id/remove_from_group_button"
style="@style/Signal.Button.TextButton.Drawable"
style="@style/Widget.Signal.Button.TextButton.Drawable"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"

View file

@ -1,10 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto">
<!-- TODO GV2 Remove old one when GV2 can replace all functions -->
<item android:id="@+id/menu_edit_group"
android:title="@string/conversation__menu_edit_group"
app:showAsAction="collapseActionView" />
<item android:id="@+id/menu_manage_group"
android:title="@string/conversation__menu_manage_group"
app:showAsAction="collapseActionView" />
<item android:id="@+id/menu_leave"
android:title="@string/conversation__menu_leave_group"
app:showAsAction="collapseActionView"/>

View file

@ -429,6 +429,7 @@
<!-- GroupCreateActivity -->
<string name="GroupCreateActivity_actionbar_title">New group</string>
<string name="GroupCreateActivity_actionbar_edit_title">Edit group</string>
<string name="GroupCreateActivity_actionbar_manage_title">Manage group</string>
<string name="GroupCreateActivity_group_name_hint">Group name</string>
<string name="GroupCreateActivity_actionbar_mms_title">New MMS group</string>
<string name="GroupCreateActivity_contacts_dont_support_push">You have selected a contact that doesn\'t support Signal groups, so this group will be MMS.</string>
@ -457,6 +458,14 @@
<string name="GroupManagement_access_level_all_members">All members</string>
<string name="GroupManagement_access_level_only_admins">Only admins</string>
<string name="GroupManagement_access_level_unknown" translatable="false">Unknown</string>
<array name="GroupManagement_edit_group_membership_choices">
<item>@string/GroupManagement_access_level_all_members</item>
<item>@string/GroupManagement_access_level_only_admins</item>
</array>
<array name="GroupManagement_edit_group_info_choices">
<item>@string/GroupManagement_access_level_all_members</item>
<item>@string/GroupManagement_access_level_only_admins</item>
</array>
<!-- PendingMembersActivity -->
<string name="PendingMemberInvitesActivity_pending_group_invites">Pending group invites</string>
@ -477,6 +486,20 @@
<item quantity="other">Error canceling invites</item>
</plurals>
<!-- ManageGroupActivity -->
<string name="ManageGroupActivity_disappearing_messages">Disappearing messages</string>
<string name="ManageGroupActivity_pending_group_invites">Pending group invites</string>
<string name="ManageGroupActivity_who_can_edit_group_membership">Who can edit group membership</string>
<string name="ManageGroupActivity_who_can_edit_group_info">Who can edit group info</string>
<string name="ManageGroupActivity_block_group">Block group</string>
<string name="ManageGroupActivity_leave_group">Leave group</string>
<string name="ManageGroupActivity_you_dont_have_the_rights_to_do_this">You don\'t have the rights to do this</string>
<string name="ManageGroupActivity_failed_to_update_the_group">Failed to update the group</string>
<string name="GroupManagement_choose_who_can_add_or_invite_new_members">Choose who can add or invite new members</string>
<string name="GroupManagement_choose_who_can_change_the_group_name_and_photo">Choose who can change the group name and photo</string>
<plurals name="GroupMemberList_invited">
<item quantity="one">%1$s invited 1 person</item>
<item quantity="other">%1$s invited %2$d people</item>
@ -1836,6 +1859,7 @@
<!-- conversation -->
<string name="conversation__menu_add_attachment">Add attachment</string>
<string name="conversation__menu_edit_group">Edit group</string>
<string name="conversation__menu_manage_group">Manage group</string>
<string name="conversation__menu_leave_group">Leave group</string>
<string name="conversation__menu_view_all_media">All media</string>
<string name="conversation__menu_conversation_settings">Conversation settings</string>
@ -2167,6 +2191,9 @@
<string name="RecipientBottomSheet_make_group_admin">Make group admin</string>
<string name="RecipientBottomSheet_remove_as_admin">Remove as admin</string>
<string name="RecipientBottomSheet_remove_from_group">Remove from group</string>
<string name="GroupRecipientListItem_admin">Admin</string>
<!-- EOF -->
</resources>

View file

@ -415,15 +415,23 @@
</attr>
</declare-styleable>
<style name="Signal.Button.TextButton" parent="Widget.AppCompat.Button.Borderless">
<style name="Widget.Signal.Button.TextButton" parent="Widget.AppCompat.Button.Borderless">
<item name="android:textAppearance">@style/TextAppearance.Signal.Body1</item>
</style>
<style name="Signal.Button.TextButton.Drawable">
<style name="Widget.Signal.Button.TextButton.Drawable">
<item name="android:textAlignment">viewStart</item>
<item name="android:drawablePadding">16dp</item>
</style>
<style name="Widget.Signal.Button.TextButton.Red" >
<item name="android:textColor">@color/core_red</item>
</style>
<style name="Widget.Signal.Button.TextButton.Ultramarine" >
<item name="android:textColor">@color/ultramarine_text_button</item>
</style>
<style name="Widget.Signal.Button.CalleeDialog" parent="Widget.AppCompat.Button">
<item name="android:textColor">@color/core_ultramarine</item>
<item name="android:background">@drawable/callee_dialog_button_background</item>