diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b2ebd28f2f..f4b586b994 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -257,6 +257,9 @@ android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize" android:theme="@style/TextSecure.LightNoActionBar" /> + + 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) - .setCancelable(true) - .setView(R.layout.dialog_group_members) - .setPositiveButton(android.R.string.ok, null) - .show(); + AlertDialog dialog = new AlertDialog.Builder(fragmentActivity) + .setTitle(R.string.ConversationActivity_group_members) + .setIconAttribute(R.attr.group_members_dialog_icon) + .setCancelable(true) + .setView(R.layout.dialog_group_members) + .setPositiveButton(android.R.string.ok, null) + .show(); - GroupMemberListView memberListView = dialog.findViewById(R.id.list_members); + GroupMemberListView memberListView = dialog.findViewById(R.id.list_members); - ArrayList pendingMembers = new ArrayList<>(members.size()); - for (Recipient member : members) { - GroupMemberEntry.FullMember entry = new GroupMemberEntry.FullMember(member); + LiveGroup liveGroup = new LiveGroup(groupRecipient.requireGroupId()); - entry.setOnClick(() -> { - dialog.dismiss(); - contactClick(member); - }); + //noinspection ConstantConditions + liveGroup.getFullMembers().observe(fragmentActivity, memberListView::setMembers); - if (member.isLocalNumber()) { - pendingMembers.add(0, entry); - } else { - pendingMembers.add(entry); - } - } + dialog.setOnDismissListener(d -> liveGroup.removeObservers(fragmentActivity)); - //noinspection ConstantConditions - memberListView.setMembers(pendingMembers); - } - ); + memberListView.setRecipientClickListener(recipient -> { + dialog.dismiss(); + contactClick(recipient); + }); } private void contactClick(@NonNull Recipient recipient) { 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 df48c9df4c..9c37dd5449 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java @@ -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() { - @Override - protected Void doInBackground(Void... params) { - 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) { - invalidateOptionsMenu(); - if (fragment != null) fragment.setLastSeen(0); - } - }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); - }); + 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; + }, + (errorString) -> { + if (errorString != null) { + Toast.makeText(ConversationActivity.this, errorString, Toast.LENGTH_SHORT).show(); + } else { + invalidateOptionsMenu(); + if (fragment != null) fragment.setLastSeen(0); + } + }) + ); } 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() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java index 010461399b..64283c929a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java @@ -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 { diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupAccessControl.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupAccessControl.java new file mode 100644 index 0000000000..803d7d653f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupAccessControl.java @@ -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; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupChangeFailedException.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupChangeFailedException.java new file mode 100644 index 0000000000..9ad71e1b46 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupChangeFailedException.java @@ -0,0 +1,8 @@ +package org.thoughtcrime.securesms.groups; + +public final class GroupChangeFailedException extends Exception { + + GroupChangeFailedException(Throwable throwable) { + super(throwable); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupInsufficientRightsException.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupInsufficientRightsException.java new file mode 100644 index 0000000000..6e3a76bfb7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupInsufficientRightsException.java @@ -0,0 +1,8 @@ +package org.thoughtcrime.securesms.groups; + +public final class GroupInsufficientRightsException extends Exception { + + GroupInsufficientRightsException(Throwable throwable) { + super(throwable); + } +} 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 e970fd34a0..81a63f9cf7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java @@ -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; diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/LiveGroup.java b/app/src/main/java/org/thoughtcrime/securesms/groups/LiveGroup.java new file mode 100644 index 0000000000..0faa03803b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/LiveGroup.java @@ -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 { + + private static final Comparator LOCAL_FIRST = (m1, m2) -> Boolean.compare(m2.getMember().isLocalNumber(), m1.getMember().isLocalNumber()); + private static final Comparator ADMIN_FIRST = (m1, m2) -> Boolean.compare(m2.isAdmin(), m1.isAdmin()); + private static final Comparator MEMBER_ORDER = ComparatorCompat.chain(LOCAL_FIRST) + .thenComparing(ADMIN_FIRST); + + private final GroupDatabase groupDatabase; + private final LiveData 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 group = groupDatabase.getGroup(groupRecipient.getId()); + if (group.isPresent()) { + postValue(group.get()); + } + } + ); + } + + public LiveData getTitle() { + return Transformations.map(this, GroupDatabase.GroupRecord::getTitle); + } + + public LiveData isSelfAdmin() { + return Transformations.map(this, g -> g.isAdmin(Recipient.self())); + } + + public LiveData getRecipientIsAdmin(@NonNull RecipientId recipientId) { + return LiveDataUtil.mapAsync(this, g -> g.isAdmin(Recipient.resolved(recipientId))); + } + + public LiveData getPendingMemberCount() { + return Transformations.map(this, g -> g.isV2Group() ? g.requireV2GroupProperties().getDecryptedGroup().getPendingMembersCount() : 0); + } + + public LiveData getMembershipAdditionAccessControl() { + return Transformations.map(this, GroupDatabase.GroupRecord::getMembershipAdditionAccessControl); + } + + public LiveData getAttributesAccessControl() { + return Transformations.map(this, GroupDatabase.GroupRecord::getAttributesAccessControl); + } + + public LiveData> 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 getExpireMessages() { + return Transformations.map(recipient, Recipient::getExpireMessages); + } + + public LiveData 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 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); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/V1GroupManager.java b/app/src/main/java/org/thoughtcrime/securesms/groups/V1GroupManager.java index c3e5a25aca..50dd0c42f7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/V1GroupManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/V1GroupManager.java @@ -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); + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupMemberEntry.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupMemberEntry.java index 290b157874..0081da68fe 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupMemberEntry.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/GroupMemberEntry.java @@ -12,20 +12,11 @@ import java.util.Collection; public abstract class GroupMemberEntry { - private final DefaultValueLiveData busy = new DefaultValueLiveData<>(false); - @Nullable private Runnable onClick; + private final DefaultValueLiveData busy = new DefaultValueLiveData<>(false); private GroupMemberEntry() { } - public void setOnClick(@NonNull Runnable onClick) { - this.onClick = onClick; - } - - public @Nullable Runnable getOnClick() { - return onClick; - } - public LiveData 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) { - this.member = 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); + } } } 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 ef7cda3b64..3f4fbe567e 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 @@ -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 { @@ -27,12 +28,19 @@ final class GroupMemberListAdapter extends LifecycleRecyclerAdapter data = new ArrayList<>(); - @Nullable private AdminActionsListener adminActionsListener; + @Nullable private AdminActionsListener adminActionsListener; + @Nullable private RecipientClickListener recipientClickListener; - void updateData(@NonNull Collection recipients) { - data.clear(); - data.addAll(recipients); - notifyDataSetChanged(); + void updateData(@NonNull List 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 { + 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 oldData; + private final List newData; + + DiffCallback(List oldData, List 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); + } + } } 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 6b403f3c12..cd2f62c2c3 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 @@ -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 recipients) { + public void setRecipientClickListener(@Nullable RecipientClickListener listener) { + membersAdapter.setRecipientClickListener(listener); + } + + public void setMembers(@NonNull List recipients) { membersAdapter.updateData(recipients); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/RecipientClickListener.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/RecipientClickListener.java new file mode 100644 index 0000000000..d990d501c4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/RecipientClickListener.java @@ -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); +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupActivity.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupActivity.java new file mode 100644 index 0000000000..a859b81940 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupActivity.java @@ -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; + } +} 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 new file mode 100644 index 0000000000..8f17562bd9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupFragment.java @@ -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(); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupRepository.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupRepository.java new file mode 100644 index 0000000000..e371a45f9a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupRepository.java @@ -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 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 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); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupViewModel.java new file mode 100644 index 0000000000..2fa2509de0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupViewModel.java @@ -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 title; + private final LiveData isAdmin; + private final LiveData canEditGroupAttributes; + private final LiveData> members; + private final LiveData pendingMemberCount; + private final LiveData disappearingMessageTimer; + private final LiveData memberCountSummary; + private final LiveData editMembershipRights; + private final LiveData editGroupAttributesRights; + private final MutableLiveData 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> getMembers() { + return members; + } + + LiveData getPendingMemberCount() { + return pendingMemberCount; + } + + LiveData getMemberCountSummary() { + return memberCountSummary; + } + + LiveData getGroupViewState() { + return groupViewState; + } + + LiveData getTitle() { + return title; + } + + LiveData getMembershipRights() { + return editMembershipRights; + } + + LiveData getEditGroupAttributesRights() { + return editGroupAttributesRights; + } + + LiveData getIsAdmin() { + return isAdmin; + } + + LiveData getCanEditGroupAttributes() { + return canEditGroupAttributes; + } + + LiveData 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 create(@NonNull Class modelClass) { + //noinspection unchecked + return (T) new ManageGroupViewModel(context, new ManageGroupRepository(context.getApplicationContext(), groupId)); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/dialogs/GroupRightsDialog.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/dialogs/GroupRightsDialog.java new file mode 100644 index 0000000000..270b8c6c6b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/dialogs/GroupRightsDialog.java @@ -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; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientDialogRepository.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientDialogRepository.java index e6d689b4ae..e156f9b448 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientDialogRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientDialogRepository.java @@ -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,44 +15,29 @@ 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; + @NonNull private final Context context; + @NonNull private final RecipientId recipientId; + @Nullable private final GroupId groupId; RecipientDialogRepository(@NonNull Context context, @NonNull RecipientId recipientId, @Nullable GroupId groupId) { - this.context = context; - this.groupDatabase = DatabaseFactory.getGroupDatabase(context); - this.recipientId = recipientId; - this.groupId = groupId; + this.context = 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); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientDialogViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientDialogViewModel.java index ffc96b3f2c..700143ec3c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientDialogViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientDialogViewModel.java @@ -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,24 +38,23 @@ final class RecipientDialogViewModel extends ViewModel { this.recipientDialogRepository = recipientDialogRepository; this.identity = new MutableLiveData<>(); - MutableLiveData localIsAdmin = new DefaultValueLiveData<>(false); - MutableLiveData 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()); + + LiveData localIsAdmin = source.isSelfAdmin(); + LiveData recipientIsAdmin = source.getRecipientIsAdmin(recipientDialogRepository.getRecipientId()); + + adminActionStatus = LiveDataUtil.combineLatest(localIsAdmin, recipientIsAdmin, + (localAdmin, recipientAdmin) -> + new AdminActionStatus(localAdmin, + localAdmin && !recipientAdmin, + localAdmin && recipientAdmin)); + } else { + adminActionStatus = new MutableLiveData<>(new AdminActionStatus(false, false, false)); } - adminActionStatus = Transformations.map(new LiveDataPair<>(localIsAdmin, recipientIsAdmin, false, false), - pair -> { - boolean localAdmin = pair.first(); - boolean recipientAdmin = pair.second(); - - return new AdminActionStatus(localAdmin, - localAdmin && !recipientAdmin, - localAdmin && recipientAdmin); - }); - recipient = Recipient.live(recipientDialogRepository.getRecipientId()).getLiveData(); recipientDialogRepository.getIdentity(identity::setValue); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/livedata/LiveDataUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/livedata/LiveDataUtil.java index ba838bc6b2..63742de1e9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/livedata/LiveDataUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/livedata/LiveDataUtil.java @@ -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}. + *

+ * The background function order is run serially, albeit possibly across multiple threads. + *

+ * The background function may not run for all {@param source} updates. Later updates taking priority. + */ + public static LiveData mapAsync(@NonNull LiveData source, @NonNull Function backgroundFunction) { + return mapAsync(SignalExecutors.BOUNDED, source, backgroundFunction); + } + + /** + * Runs the {@param backgroundFunction} on the supplied {@param executor}. + *

+ * Regardless of the executor supplied, the background function is run serially. + *

+ * The background function may not run for all {@param source} updates. Later updates taking priority. + */ + public static LiveData mapAsync(@NonNull Executor executor, @NonNull LiveData source, @NonNull Function backgroundFunction) { + MediatorLiveData 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. + *

+ * 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); + } + } + } } diff --git a/app/src/main/res/color/ultramarine_text_button.xml b/app/src/main/res/color/ultramarine_text_button.xml new file mode 100644 index 0000000000..7de1224175 --- /dev/null +++ b/app/src/main/res/color/ultramarine_text_button.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/group_manage_activity.xml b/app/src/main/res/layout/group_manage_activity.xml new file mode 100644 index 0000000000..9bc5e51f35 --- /dev/null +++ b/app/src/main/res/layout/group_manage_activity.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/app/src/main/res/layout/group_manage_fragment.xml b/app/src/main/res/layout/group_manage_fragment.xml new file mode 100644 index 0000000000..753b5c60a6 --- /dev/null +++ b/app/src/main/res/layout/group_manage_fragment.xml @@ -0,0 +1,248 @@ + + + + + + + + + + + + + + + + + + + + + + + +