diff --git a/app/build.gradle b/app/build.gradle index 571f454542..280a97816c 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -330,7 +330,7 @@ dependencies { force = true } implementation 'androidx.recyclerview:recyclerview:1.1.0' - implementation 'com.google.android.material:material:1.2.1' + implementation 'com.google.android.material:material:1.3.0' implementation 'androidx.legacy:legacy-support-v13:1.0.0' implementation 'androidx.cardview:cardview:1.0.0' implementation 'androidx.preference:preference:1.0.0' @@ -527,6 +527,10 @@ task signProductionWebsiteRelease { } def getLastCommitTimestamp() { + if (!(new File('.git').exists())) { + return System.currentTimeMillis().toString() + } + new ByteArrayOutputStream().withStream { os -> def result = exec { executable = 'git' @@ -539,6 +543,10 @@ def getLastCommitTimestamp() { } def getGitHash() { + if (!(new File('.git').exists())) { + return "abcd1234" + } + def stdout = new ByteArrayOutputStream() exec { commandLine 'git', 'rev-parse', '--short', 'HEAD' diff --git a/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java b/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java index cee974ab35..6165098106 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java @@ -8,10 +8,8 @@ import androidx.annotation.Nullable; import androidx.lifecycle.LifecycleOwner; import androidx.lifecycle.Observer; -import org.thoughtcrime.securesms.components.MaskView; import org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackState; import org.thoughtcrime.securesms.contactshare.Contact; -import org.thoughtcrime.securesms.conversation.ConversationItem; import org.thoughtcrime.securesms.conversation.ConversationMessage; import org.thoughtcrime.securesms.database.model.InMemoryMessageRecord; import org.thoughtcrime.securesms.database.model.MessageRecord; @@ -78,6 +76,7 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable { void onEnableCallNotificationsClicked(); void onPlayInlineContent(ConversationMessage conversationMessage); void onInMemoryMessageClicked(@NonNull InMemoryMessageRecord messageRecord); + void onViewGroupDescriptionChange(@Nullable GroupId groupId, @NonNull String description, boolean isMessageRequestAccepted); /** @return true if handled, false if you want to let the normal url handling continue */ boolean onUrlClicked(@NonNull String url); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationBannerView.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationBannerView.java index a6bc1fdf29..6f6ff36efd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationBannerView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationBannerView.java @@ -5,6 +5,7 @@ import android.content.res.ColorStateList; import android.graphics.PorterDuff; import android.graphics.drawable.LayerDrawable; import android.text.TextUtils; +import android.text.method.LinkMovementMethod; import android.util.AttributeSet; import android.view.View; import android.widget.TextView; @@ -87,6 +88,7 @@ public class ConversationBannerView extends ConstraintLayout { public void setDescription(@Nullable CharSequence description) { contactDescription.setText(description); + contactDescription.setVisibility(TextUtils.isEmpty(description) ? GONE : VISIBLE); } public void showBackgroundBubble(boolean enabled) { @@ -109,6 +111,10 @@ public class ConversationBannerView extends ConstraintLayout { contactDescription.setVisibility(View.GONE); } + public void setLinkifyDescription(boolean enable) { + contactDescription.setMovementMethod(enable ? LinkMovementMethod.getInstance() : null); + } + private static final class FallbackPhotoProvider extends Recipient.FallbackPhotoProvider { @Override public @NonNull FallbackContactPhoto getPhotoForRecipientWithoutName() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java index fd86ff96e5..2f44bd47ef 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java @@ -107,7 +107,9 @@ import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ProjectionRecycler; import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange; import org.thoughtcrime.securesms.groups.ui.invitesandrequests.invite.GroupLinkInviteFriendsBottomSheetDialogFragment; +import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupDescriptionDialog; import org.thoughtcrime.securesms.groups.ui.migration.GroupsV1MigrationInfoBottomSheetDialogFragment; +import org.thoughtcrime.securesms.groups.v2.GroupDescriptionUtil; import org.thoughtcrime.securesms.jobs.DirectoryRefreshJob; import org.thoughtcrime.securesms.jobs.MultiDeviceViewOnceOpenJob; import org.thoughtcrime.securesms.keyvalue.SignalStore; @@ -492,7 +494,7 @@ public class ConversationFragment extends LoggingFragment { }); } - private static void presentMessageRequestProfileView(@NonNull Context context, @NonNull MessageRequestViewModel.RecipientInfo recipientInfo, @Nullable ConversationBannerView conversationBanner) { + private void presentMessageRequestProfileView(@NonNull Context context, @NonNull MessageRequestViewModel.RecipientInfo recipientInfo, @Nullable ConversationBannerView conversationBanner) { if (conversationBanner == null) { return; } @@ -536,7 +538,20 @@ public class ConversationFragment extends LoggingFragment { } if (groups.isEmpty() || isSelf) { - conversationBanner.hideDescription(); + if (TextUtils.isEmpty(recipientInfo.getGroupDescription())) { + conversationBanner.setLinkifyDescription(false); + conversationBanner.hideDescription(); + } else { + conversationBanner.setLinkifyDescription(true); + boolean linkifyWebLinks = recipientInfo.getMessageRequestState() == MessageRequestState.NONE; + conversationBanner.setDescription(GroupDescriptionUtil.style(context, + recipientInfo.getGroupDescription(), + linkifyWebLinks, + () -> GroupDescriptionDialog.show(getChildFragmentManager(), + recipient.getDisplayName(context), + recipientInfo.getGroupDescription(), + linkifyWebLinks))); + } } else { final String description; @@ -1630,6 +1645,13 @@ public class ConversationFragment extends LoggingFragment { .show(); } } + + @Override + public void onViewGroupDescriptionChange(@Nullable GroupId groupId, @NonNull String description, boolean isMessageRequestAccepted) { + if (groupId != null) { + GroupDescriptionDialog.show(getChildFragmentManager(), groupId, description, isMessageRequestAccepted); + } + } } public void refreshList() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateItem.java index 03f4f02223..5694eae97b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationUpdateItem.java @@ -67,6 +67,7 @@ public final class ConversationUpdateItem extends FrameLayout private Recipient conversationRecipient; private Optional nextMessageRecord; private MessageRecord messageRecord; + private boolean isMessageRequestAccepted; private LiveData displayBody; private EventListener eventListener; @@ -112,7 +113,7 @@ public final class ConversationUpdateItem extends FrameLayout { this.batchSelected = batchSelected; - bind(lifecycleOwner, conversationMessage, previousMessageRecord, nextMessageRecord, conversationRecipient, hasWallpaper); + bind(lifecycleOwner, conversationMessage, previousMessageRecord, nextMessageRecord, conversationRecipient, hasWallpaper, isMessageRequestAccepted); } @Override @@ -130,12 +131,14 @@ public final class ConversationUpdateItem extends FrameLayout @NonNull Optional previousMessageRecord, @NonNull Optional nextMessageRecord, @NonNull Recipient conversationRecipient, - boolean hasWallpaper) + boolean hasWallpaper, + boolean isMessageRequestAccepted) { - this.conversationMessage = conversationMessage; - this.messageRecord = conversationMessage.getMessageRecord(); - this.nextMessageRecord = nextMessageRecord; - this.conversationRecipient = conversationRecipient; + this.conversationMessage = conversationMessage; + this.messageRecord = conversationMessage.getMessageRecord(); + this.nextMessageRecord = nextMessageRecord; + this.conversationRecipient = conversationRecipient; + this.isMessageRequestAccepted = isMessageRequestAccepted; senderObserver.observe(lifecycleOwner, messageRecord.getIndividualRecipient()); @@ -164,7 +167,7 @@ public final class ConversationUpdateItem extends FrameLayout observeDisplayBody(lifecycleOwner, spannableMessage); - present(conversationMessage, nextMessageRecord, conversationRecipient); + present(conversationMessage, nextMessageRecord, conversationRecipient, isMessageRequestAccepted); presentBackground(shouldCollapse(messageRecord, previousMessageRecord), shouldCollapse(messageRecord, nextMessageRecord), @@ -265,7 +268,8 @@ public final class ConversationUpdateItem extends FrameLayout private void present(@NonNull ConversationMessage conversationMessage, @NonNull Optional nextMessageRecord, - @NonNull Recipient conversationRecipient) + @NonNull Recipient conversationRecipient, + boolean isMessageRequestAccepted) { if (batchSelected.contains(conversationMessage)) setSelected(true); else setSelected(false); @@ -350,6 +354,14 @@ public final class ConversationUpdateItem extends FrameLayout eventListener.onInMemoryMessageClicked(inMemoryMessageRecord); } }); + } else if (conversationMessage.getMessageRecord().isGroupV2DescriptionUpdate()) { + actionButton.setVisibility(VISIBLE); + actionButton.setText(R.string.ConversationUpdateItem_view); + actionButton.setOnClickListener(v -> { + if (eventListener != null) { + eventListener.onViewGroupDescriptionChange(conversationRecipient.getGroupId().orNull(), conversationMessage.getMessageRecord().getGroupV2DescriptionUpdate(), isMessageRequestAccepted); + } + }); } else { actionButton.setVisibility(GONE); actionButton.setOnClickListener(null); @@ -439,7 +451,7 @@ public final class ConversationUpdateItem extends FrameLayout public void onChanged(Recipient recipient) { if (recipient.getId() == conversationRecipient.getId() && (conversationRecipient == null || !conversationRecipient.hasSameContent(recipient))) { conversationRecipient = recipient; - present(conversationMessage, nextMessageRecord, conversationRecipient); + present(conversationMessage, nextMessageRecord, conversationRecipient, isMessageRequestAccepted); } } } 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 bb05d37bad..6b0b993d7e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/GroupDatabase.java @@ -920,6 +920,14 @@ public final class GroupDatabase extends Database { return title; } + public @NonNull String getDescription() { + if (v2GroupProperties == null) { + return ""; + } else { + return v2GroupProperties.getDecryptedGroup().getDescription(); + } + } + public @NonNull List getMembers() { return members; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageProducer.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageProducer.java index 79d43778e1..9abfa601c5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageProducer.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageProducer.java @@ -98,6 +98,7 @@ final class GroupsV2UpdateMessageProducer { describeUnknownEditorRevokedInvitations(change, updates); describeUnknownEditorPromotePending(change, updates); describeUnknownEditorNewTitle(change, updates); + describeUnknownEditorNewDescription(change, updates); describeUnknownEditorNewAvatar(change, updates); describeUnknownEditorNewTimer(change, updates); describeUnknownEditorNewAttributeAccess(change, updates); @@ -121,6 +122,7 @@ final class GroupsV2UpdateMessageProducer { describeRevokedInvitations(change, updates); describePromotePending(change, updates); describeNewTitle(change, updates); + describeNewDescription(change, updates); describeNewAvatar(change, updates); describeNewTimer(change, updates); describeNewAttributeAccess(change, updates); @@ -431,12 +433,30 @@ final class GroupsV2UpdateMessageProducer { } } + private void describeNewDescription(@NonNull DecryptedGroupChange change, @NonNull List updates) { + boolean editorIsYou = change.getEditor().equals(selfUuidBytes); + + if (change.hasNewDescription()) { + if (editorIsYou) { + updates.add(updateDescription(context.getString(R.string.MessageRecord_you_changed_the_group_description), R.drawable.ic_update_group_name_16)); + } else { + updates.add(updateDescription(change.getEditor(), editor -> context.getString(R.string.MessageRecord_s_changed_the_group_description, editor), R.drawable.ic_update_group_name_16)); + } + } + } + private void describeUnknownEditorNewTitle(@NonNull DecryptedGroupChange change, @NonNull List updates) { if (change.hasNewTitle()) { updates.add(updateDescription(context.getString(R.string.MessageRecord_the_group_name_has_changed_to_s, StringUtil.isolateBidi(change.getNewTitle().getValue())), R.drawable.ic_update_group_name_16)); } } + private void describeUnknownEditorNewDescription(@NonNull DecryptedGroupChange change, @NonNull List updates) { + if (change.hasNewDescription()) { + updates.add(updateDescription(context.getString(R.string.MessageRecord_the_group_description_has_changed), R.drawable.ic_update_group_name_16)); + } + } + private void describeNewAvatar(@NonNull DecryptedGroupChange change, @NonNull List updates) { boolean editorIsYou = change.getEditor().equals(selfUuidBytes); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java index cb31010135..26b9c31fce 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java @@ -360,6 +360,22 @@ public abstract class MessageRecord extends DisplayRecord { return UpdateDescription.mentioning(joinedMembers, stringFactory, R.drawable.ic_video_16); } + public boolean isGroupV2DescriptionUpdate() { + DecryptedGroupV2Context decryptedGroupV2Context = getDecryptedGroupV2Context(); + if (decryptedGroupV2Context != null) { + return decryptedGroupV2Context.hasChange() && getDecryptedGroupV2Context().getChange().hasNewDescription(); + } + return false; + } + + public @NonNull String getGroupV2DescriptionUpdate() { + DecryptedGroupV2Context decryptedGroupV2Context = getDecryptedGroupV2Context(); + if (decryptedGroupV2Context != null) { + return decryptedGroupV2Context.getChange().hasNewDescription() ? decryptedGroupV2Context.getChange().getNewDescription().getValue() : ""; + } + return ""; + } + /** * Describes a UUID by it's corresponding recipient's {@link Recipient#getDisplayName(Context)}. */ 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 7282fffd9d..6c5ea4c074 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManager.java @@ -62,17 +62,22 @@ public final class GroupManager { } @WorkerThread - public static GroupActionResult updateGroupDetails(@NonNull Context context, - @NonNull GroupId groupId, - @Nullable byte[] avatar, - boolean avatarChanged, - @NonNull String name, - boolean nameChanged) - throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException, GroupChangeBusyException + public static GroupActionResult updateGroupDetails(@NonNull Context context, + @NonNull GroupId groupId, + @Nullable byte[] avatar, + boolean avatarChanged, + @NonNull String name, + boolean nameChanged, + @NonNull String description, + boolean descriptionChanged) + throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException, GroupChangeBusyException { if (groupId.isV2()) { try (GroupManagerV2.GroupEditor edit = new GroupManagerV2(context).edit(groupId.requireV2())) { - return edit.updateGroupTitleAndAvatar(nameChanged ? name : null, avatar, avatarChanged); + return edit.updateGroupTitleDescriptionAndAvatar(nameChanged ? name : null, + descriptionChanged ? description : null, + avatar, + avatarChanged); } } else if (groupId.isV1()) { List members = DatabaseFactory.getGroupDatabase(context) diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java index 8e9cc9006e..c4e2fa5066 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java @@ -345,13 +345,17 @@ final class GroupManagerV2 { } @WorkerThread - @NonNull GroupManager.GroupActionResult updateGroupTitleAndAvatar(@Nullable String title, @Nullable byte[] avatarBytes, boolean avatarChanged) + @NonNull GroupManager.GroupActionResult updateGroupTitleDescriptionAndAvatar(@Nullable String title, @Nullable String description, @Nullable byte[] avatarBytes, boolean avatarChanged) throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException { try { GroupChange.Actions.Builder change = title != null ? groupOperations.createModifyGroupTitle(title) : GroupChange.Actions.newBuilder(); + if (description != null) { + change.setModifyDescription(groupOperations.createModifyGroupDescription(description)); + } + if (avatarChanged) { String cdnKey = avatarBytes != null ? groupsV2Api.uploadAvatar(avatarBytes, groupSecretParams, authorization.getAuthorizationForToday(selfUuid, groupSecretParams)) : ""; diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/LiveGroup.java b/app/src/main/java/org/thoughtcrime/securesms/groups/LiveGroup.java index 12134be5e1..48543d3826 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/LiveGroup.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/LiveGroup.java @@ -125,6 +125,10 @@ public final class LiveGroup { }); } + public LiveData getDescription() { + return Transformations.map(groupRecord, GroupDatabase.GroupRecord::getDescription); + } + public LiveData getGroupRecipient() { return recipient; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ParcelableGroupId.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ParcelableGroupId.java new file mode 100644 index 0000000000..15c93cf3c4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ParcelableGroupId.java @@ -0,0 +1,53 @@ +package org.thoughtcrime.securesms.groups; + +import android.os.Parcel; +import android.os.Parcelable; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +public final class ParcelableGroupId implements Parcelable { + + private final GroupId groupId; + + public static Parcelable from(@Nullable GroupId groupId) { + return new ParcelableGroupId(groupId); + } + + public static @Nullable GroupId get(@Nullable ParcelableGroupId parcelableGroupId) { + if (parcelableGroupId == null) { + return null; + } + return parcelableGroupId.groupId; + } + + ParcelableGroupId(@Nullable GroupId groupId) { + this.groupId = groupId; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + if (groupId != null) { + dest.writeString(groupId.toString()); + } else { + dest.writeString(null); + } + } + + @Override + public int describeContents() { + return 0; + } + + public static final Creator CREATOR = new Creator() { + @Override + public ParcelableGroupId createFromParcel(Parcel in) { + return new ParcelableGroupId(GroupId.parseNullableOrThrow(in.readString())); + } + + @Override + public ParcelableGroupId[] newArray(int size) { + return new ParcelableGroupId[size]; + } + }; +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/GroupDetails.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/GroupDetails.java index 37db0fa56a..85dd1af442 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/GroupDetails.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/GroupDetails.java @@ -21,6 +21,10 @@ public final class GroupDetails { return joinInfo.getTitle(); } + public @NonNull String getGroupDescription() { + return joinInfo.getDescription(); + } + public @Nullable byte[] getAvatarBytes() { return avatarBytes; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/GroupJoinBottomSheetDialogFragment.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/GroupJoinBottomSheetDialogFragment.java index 98dbf39543..e23ded8be9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/GroupJoinBottomSheetDialogFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/invitesandrequests/joining/GroupJoinBottomSheetDialogFragment.java @@ -2,6 +2,8 @@ package org.thoughtcrime.securesms.groups.ui.invitesandrequests.joining; import android.content.Intent; import android.os.Bundle; +import android.text.TextUtils; +import android.text.method.LinkMovementMethod; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -26,6 +28,8 @@ import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto; import org.thoughtcrime.securesms.contacts.avatars.ResourceContactPhoto; import org.thoughtcrime.securesms.conversation.ConversationIntents; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupDescriptionDialog; +import org.thoughtcrime.securesms.groups.v2.GroupDescriptionUtil; import org.thoughtcrime.securesms.groups.v2.GroupInviteLinkUrl; import org.thoughtcrime.securesms.jobs.RetrieveProfileJob; import org.thoughtcrime.securesms.recipients.Recipient; @@ -42,6 +46,7 @@ public final class GroupJoinBottomSheetDialogFragment extends BottomSheetDialogF private AvatarImageView avatar; private TextView groupName; private TextView groupDetails; + private TextView groupDescription; private TextView groupJoinExplain; private Button groupJoinButton; private Button groupCancelButton; @@ -76,6 +81,7 @@ public final class GroupJoinBottomSheetDialogFragment extends BottomSheetDialogF busy = view.findViewById(R.id.group_join_busy); avatar = view.findViewById(R.id.group_join_recipient_avatar); groupName = view.findViewById(R.id.group_join_group_name); + groupDescription = view.findViewById(R.id.group_join_group_description); groupDetails = view.findViewById(R.id.group_join_group_details); groupJoinExplain = view.findViewById(R.id.group_join_explain); @@ -98,6 +104,10 @@ public final class GroupJoinBottomSheetDialogFragment extends BottomSheetDialogF groupName.setText(details.getGroupName()); groupDetails.setText(requireContext().getResources().getQuantityString(R.plurals.GroupJoinBottomSheetDialogFragment_group_dot_d_members, details.getGroupMembershipCount(), details.getGroupMembershipCount())); + if (!TextUtils.isEmpty(details.getGroupDescription())) { + updateGroupDescription(details.getGroupName(), details.getGroupDescription()); + } + switch (getGroupJoinStatus()) { case UPDATE_LINKED_DEVICE_TO_JOIN: groupJoinExplain.setText(R.string.GroupJoinUpdateRequiredBottomSheetDialogFragment_update_linked_device_message); @@ -145,6 +155,15 @@ public final class GroupJoinBottomSheetDialogFragment extends BottomSheetDialogF ); } + private void updateGroupDescription(@NonNull String name, @NonNull String description) { + groupDescription.setVisibility(View.VISIBLE); + groupDescription.setMovementMethod(LinkMovementMethod.getInstance()); + groupDescription.setText(GroupDescriptionUtil.style(requireContext(), + description, + true, + () -> GroupDescriptionDialog.show(getChildFragmentManager(), name, description, true))); + } + private static ExtendedGroupJoinStatus getGroupJoinStatus() { if (Recipient.self().getGroupsV2Capability() != Recipient.Capability.SUPPORTED) { return ExtendedGroupJoinStatus.UPDATE_LINKED_DEVICE_TO_JOIN; diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupFragment.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupFragment.java index 95afd34ea2..238908fad9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupFragment.java @@ -5,6 +5,8 @@ import android.content.Intent; import android.database.Cursor; import android.graphics.Color; import android.os.Bundle; +import android.text.TextUtils; +import android.text.method.LinkMovementMethod; import android.view.LayoutInflater; import android.view.MenuItem; import android.view.View; @@ -17,7 +19,6 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.widget.SwitchCompat; import androidx.appcompat.widget.Toolbar; -import androidx.core.view.ViewCompat; import androidx.fragment.app.FragmentActivity; import androidx.lifecycle.ViewModelProviders; @@ -35,6 +36,7 @@ import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.color.MaterialColor; import org.thoughtcrime.securesms.components.AvatarImageView; import org.thoughtcrime.securesms.components.ThreadPhotoRailView; +import org.thoughtcrime.securesms.components.emoji.EmojiTextView; import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto; import org.thoughtcrime.securesms.contacts.avatars.FallbackPhoto80dp; import org.thoughtcrime.securesms.groups.GroupId; @@ -43,10 +45,12 @@ import org.thoughtcrime.securesms.groups.ui.GroupErrors; import org.thoughtcrime.securesms.groups.ui.GroupMemberListView; import org.thoughtcrime.securesms.groups.ui.LeaveGroupDialog; import org.thoughtcrime.securesms.groups.ui.invitesandrequests.ManagePendingAndRequestingMembersActivity; +import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupDescriptionDialog; import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupInviteSentDialog; import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupRightsDialog; import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupsLearnMoreBottomSheetDialogFragment; import org.thoughtcrime.securesms.groups.ui.migration.GroupsV1MigrationInitiationBottomSheetDialogFragment; +import org.thoughtcrime.securesms.groups.v2.GroupDescriptionUtil; import org.thoughtcrime.securesms.mediaoverview.MediaOverviewActivity; import org.thoughtcrime.securesms.mms.GlideApp; import org.thoughtcrime.securesms.notifications.NotificationChannels; @@ -84,6 +88,7 @@ public class ManageGroupFragment extends LoggingFragment { private TextView pendingAndRequestingCount; private Toolbar toolbar; private TextView groupName; + private EmojiTextView groupDescription; private LearnMoreTextView groupInfoText; private TextView memberCountUnderAvatar; private TextView memberCountAboveList; @@ -145,6 +150,7 @@ public class ManageGroupFragment extends LoggingFragment { avatar = view.findViewById(R.id.group_avatar); toolbar = view.findViewById(R.id.toolbar); groupName = view.findViewById(R.id.name); + groupDescription = view.findViewById(R.id.manage_group_description); groupInfoText = view.findViewById(R.id.manage_group_info_text); memberCountUnderAvatar = view.findViewById(R.id.member_count); memberCountAboveList = view.findViewById(R.id.member_count_2); @@ -233,6 +239,7 @@ public class ManageGroupFragment extends LoggingFragment { }); viewModel.getTitle().observe(getViewLifecycleOwner(), groupName::setText); + viewModel.getDescription().observe(getViewLifecycleOwner(), this::updateGroupDescription); viewModel.getMemberCountSummary().observe(getViewLifecycleOwner(), memberCountUnderAvatar::setText); viewModel.getFullMemberCountSummary().observe(getViewLifecycleOwner(), memberCountAboveList::setText); viewModel.getGroupRecipient().observe(getViewLifecycleOwner(), groupRecipient -> { @@ -432,6 +439,31 @@ public class ManageGroupFragment extends LoggingFragment { } } + private void updateGroupDescription(@NonNull ManageGroupViewModel.Description description) { + if (!TextUtils.isEmpty(description.getDescription()) || (FeatureFlags.groupsV2Description() && description.canEditDescription())) { + groupDescription.setVisibility(View.VISIBLE); + groupDescription.setMovementMethod(LinkMovementMethod.getInstance()); + memberCountUnderAvatar.setVisibility(View.GONE); + } else { + groupDescription.setVisibility(View.GONE); + groupDescription.setMovementMethod(null); + memberCountUnderAvatar.setVisibility(View.VISIBLE); + } + + if (TextUtils.isEmpty(description.getDescription())) { + if (FeatureFlags.groupsV2Description() && description.canEditDescription()) { + groupDescription.setText(R.string.ManageGroupActivity_add_group_description); + groupDescription.setOnClickListener(v -> startActivity(EditProfileActivity.getIntentForGroupProfile(requireActivity(), getGroupId()))); + } + } else { + groupDescription.setOnClickListener(null); + groupDescription.setText(GroupDescriptionUtil.style(requireContext(), + description.getDescription(), + description.shouldLinkifyWebLinks(), + () -> GroupDescriptionDialog.show(getChildFragmentManager(), getGroupId(), null, description.shouldLinkifyWebLinks()))); + } + } + @Override public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { super.onActivityResult(requestCode, resultCode, data); 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 index 1d8bebbd0e..d5182bb02e 100644 --- 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 @@ -46,6 +46,7 @@ import org.thoughtcrime.securesms.util.DefaultValueLiveData; import org.thoughtcrime.securesms.util.ExpirationUtil; import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.livedata.LiveDataUtil; +import org.thoughtcrime.securesms.util.livedata.Store; import org.thoughtcrime.securesms.util.views.SimpleProgressDialog; import java.util.ArrayList; @@ -59,6 +60,8 @@ public class ManageGroupViewModel extends ViewModel { private final Context context; private final ManageGroupRepository manageGroupRepository; private final LiveData title; + private final Store descriptionStore; + private final LiveData description; private final LiveData isAdmin; private final LiveData canEditGroupAttributes; private final LiveData canAddMembers; @@ -71,11 +74,11 @@ public class ManageGroupViewModel extends ViewModel { private final LiveData editMembershipRights; private final LiveData editGroupAttributesRights; private final LiveData groupRecipient; - private final MutableLiveData groupViewState = new MutableLiveData<>(null); + private final MutableLiveData groupViewState = new MutableLiveData<>(null); private final LiveData muteState; private final LiveData hasCustomNotifications; private final LiveData canCollapseMemberList; - private final DefaultValueLiveData memberListCollapseState = new DefaultValueLiveData<>(CollapseState.COLLAPSED); + private final DefaultValueLiveData memberListCollapseState = new DefaultValueLiveData<>(CollapseState.COLLAPSED); private final LiveData canLeaveGroup; private final LiveData canBlockGroup; private final LiveData canUnblockGroup; @@ -140,6 +143,15 @@ public class ManageGroupViewModel extends ViewModel { return GroupInfoMessage.NONE; } }); + + this.descriptionStore = new Store<>(Description.NONE); + this.description = groupId.isV2() ? this.descriptionStore.getStateLiveData() : LiveDataUtil.empty(); + + if (groupId.isV2()) { + this.descriptionStore.update(liveGroup.getDescription(), (description, state) -> new Description(description, state.shouldLinkifyWebLinks, state.canEditDescription)); + this.descriptionStore.update(LiveDataUtil.mapAsync(groupRecipient, r -> RecipientUtil.isMessageRequestAccepted(context, r)), (linkify, state) -> new Description(state.description, linkify, state.canEditDescription)); + this.descriptionStore.update(this.canEditGroupAttributes, (canEdit, state) -> new Description(state.description, state.shouldLinkifyWebLinks, canEdit)); + } } @WorkerThread @@ -181,6 +193,10 @@ public class ManageGroupViewModel extends ViewModel { return title; } + LiveData getDescription() { + return description; + } + LiveData getMuteState() { return muteState; } @@ -419,6 +435,32 @@ public class ManageGroupViewModel extends ViewModel { Cursor create(); } + public static class Description { + private static final Description NONE = new Description("", false, false); + + private final String description; + private final boolean shouldLinkifyWebLinks; + private final boolean canEditDescription; + + public Description(String description, boolean shouldLinkifyWebLinks, boolean canEditDescription) { + this.description = description; + this.shouldLinkifyWebLinks = shouldLinkifyWebLinks; + this.canEditDescription = canEditDescription; + } + + public @NonNull String getDescription() { + return description; + } + + public boolean shouldLinkifyWebLinks() { + return shouldLinkifyWebLinks; + } + + public boolean canEditDescription() { + return canEditDescription; + } + } + public static class Factory implements ViewModelProvider.Factory { private final Context context; private final GroupId groupId; diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/dialogs/GroupDescriptionDialog.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/dialogs/GroupDescriptionDialog.java new file mode 100644 index 0000000000..a72fc16f86 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/dialogs/GroupDescriptionDialog.java @@ -0,0 +1,90 @@ +package org.thoughtcrime.securesms.groups.ui.managegroup.dialogs; + +import android.app.Dialog; +import android.os.Bundle; +import android.text.TextUtils; +import android.text.method.LinkMovementMethod; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.DialogFragment; +import androidx.fragment.app.FragmentManager; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.groups.LiveGroup; +import org.thoughtcrime.securesms.groups.ParcelableGroupId; +import org.thoughtcrime.securesms.groups.v2.GroupDescriptionUtil; + +/** + * Dialog to show a full group description. Information regarding the description can be provided + * as arguments, or a {@link GroupId} can be provided and the dialog will load it. If both are provided, + * the title/description from the arguments takes precedence. + */ +public final class GroupDescriptionDialog extends DialogFragment { + + private static final String ARGUMENT_GROUP_ID = "group_id"; + private static final String ARGUMENT_TITLE = "title"; + private static final String ARGUMENT_DESCRIPTION = "description"; + private static final String ARGUMENT_LINKIFY = "linkify"; + private static final String DIALOG_TAG = "GroupDescriptionDialog"; + + private TextView descriptionText; + + public static void show(@NonNull FragmentManager fragmentManager, @NonNull String title, @Nullable String description, boolean linkify) { + show(fragmentManager, null, title, description, linkify); + } + + public static void show(@NonNull FragmentManager fragmentManager, @NonNull GroupId groupId, @Nullable String description, boolean linkify) { + show(fragmentManager, groupId, null, description, linkify); + } + + private static void show(@NonNull FragmentManager fragmentManager, @Nullable GroupId groupId, @Nullable String title, @Nullable String description, boolean linkify) { + Bundle arguments = new Bundle(); + arguments.putParcelable(ARGUMENT_GROUP_ID, ParcelableGroupId.from(groupId)); + arguments.putString(ARGUMENT_TITLE, title); + arguments.putString(ARGUMENT_DESCRIPTION, description); + arguments.putBoolean(ARGUMENT_LINKIFY, linkify); + + GroupDescriptionDialog dialogFragment = new GroupDescriptionDialog(); + dialogFragment.setArguments(arguments); + + dialogFragment.show(fragmentManager, DIALOG_TAG); + } + + @Override + public @NonNull Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { + View dialogView = LayoutInflater.from(getContext()).inflate(R.layout.group_description_dialog, null, false); + String argumentTitle = requireArguments().getString(ARGUMENT_TITLE, null); + String argumentDescription = requireArguments().getString(ARGUMENT_DESCRIPTION, null); + GroupId argumentGroupId = ParcelableGroupId.get(requireArguments().getParcelable(ARGUMENT_GROUP_ID)); + boolean linkify = requireArguments().getBoolean(ARGUMENT_LINKIFY, false); + LiveGroup liveGroup = argumentGroupId != null ? new LiveGroup(argumentGroupId) : null; + + descriptionText = dialogView.findViewById(R.id.group_description_dialog_text); + descriptionText.setMovementMethod(LinkMovementMethod.getInstance()); + + MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(requireContext(), R.style.Signal_ThemeOverlay_Dialog_Rounded); + Dialog dialog = builder.setTitle(TextUtils.isEmpty(argumentTitle) ? getString(R.string.GroupDescriptionDialog__group_description) : argumentTitle) + .setView(dialogView) + .setPositiveButton(android.R.string.ok, null) + .create(); + + if (argumentDescription != null) { + descriptionText.setText(GroupDescriptionUtil.style(requireContext(), argumentDescription, linkify, null)); + } else if (liveGroup != null) { + liveGroup.getDescription().observe(this, d -> descriptionText.setText(GroupDescriptionUtil.style(requireContext(), d, linkify, null))); + } + + if (TextUtils.isEmpty(argumentTitle) && liveGroup != null) { + liveGroup.getTitle().observe(this, dialog::setTitle); + } + + return dialog; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/GroupDescriptionUtil.java b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/GroupDescriptionUtil.java new file mode 100644 index 0000000000..74a1820033 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/GroupDescriptionUtil.java @@ -0,0 +1,69 @@ +package org.thoughtcrime.securesms.groups.v2; + +import android.content.Context; +import android.graphics.Typeface; +import android.text.Spannable; +import android.text.SpannableString; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.TextPaint; +import android.text.style.ClickableSpan; +import android.text.style.URLSpan; +import android.text.util.Linkify; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.linkpreview.LinkPreviewUtil; + +public final class GroupDescriptionUtil { + + public static final int MAX_DESCRIPTION_LENGTH = 80; + + /** + * Style a group description. + * + * @param description full description + * @param linkify flag indicating if web urls should be linkified + * @param moreClick Callback for when truncating and need to show more via another means. Required to enable truncating. + * @return styled group description + */ + public static @NonNull Spannable style(@NonNull Context context, @NonNull String description, boolean linkify, @Nullable Runnable moreClick) { + SpannableString descriptionSpannable = new SpannableString(description); + + if (linkify) { + int linkPattern = Linkify.WEB_URLS | Linkify.EMAIL_ADDRESSES | Linkify.PHONE_NUMBERS; + boolean hasLinks = Linkify.addLinks(descriptionSpannable, linkPattern); + + if (hasLinks) { + Stream.of(descriptionSpannable.getSpans(0, descriptionSpannable.length(), URLSpan.class)) + .filterNot(url -> LinkPreviewUtil.isLegalUrl(url.getURL())) + .forEach(descriptionSpannable::removeSpan); + } + } + + if (moreClick != null && descriptionSpannable.length() > MAX_DESCRIPTION_LENGTH) { + ClickableSpan style = new ClickableSpan() { + @Override + public void onClick(@NonNull View widget) { + moreClick.run(); + } + + @Override + public void updateDrawState(@NonNull TextPaint ds) { + ds.setTypeface(Typeface.DEFAULT_BOLD); + } + }; + + SpannableStringBuilder builder = new SpannableStringBuilder(descriptionSpannable.subSequence(0, MAX_DESCRIPTION_LENGTH)).append(context.getString(R.string.ManageGroupActivity_more)); + builder.setSpan(style, MAX_DESCRIPTION_LENGTH + 1, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + return builder; + } + + return descriptionSpannable; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/GroupInfo.java b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/GroupInfo.java new file mode 100644 index 0000000000..68d9fddaa8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/GroupInfo.java @@ -0,0 +1,29 @@ +package org.thoughtcrime.securesms.messagerequests; + +import androidx.annotation.NonNull; + +final class GroupInfo { + static final GroupInfo ZERO = new GroupInfo(0, 0, ""); + + private final int fullMemberCount; + private final int pendingMemberCount; + private final String description; + + GroupInfo(int fullMemberCount, int pendingMemberCount, @NonNull String description) { + this.fullMemberCount = fullMemberCount; + this.pendingMemberCount = pendingMemberCount; + this.description = description; + } + + int getFullMemberCount() { + return fullMemberCount; + } + + int getPendingMemberCount() { + return pendingMemberCount; + } + + public @NonNull String getDescription() { + return description; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/GroupMemberCount.java b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/GroupMemberCount.java deleted file mode 100644 index 028638ec16..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/GroupMemberCount.java +++ /dev/null @@ -1,21 +0,0 @@ -package org.thoughtcrime.securesms.messagerequests; - -final class GroupMemberCount { - static final GroupMemberCount ZERO = new GroupMemberCount(0, 0); - - private final int fullMemberCount; - private final int pendingMemberCount; - - GroupMemberCount(int fullMemberCount, int pendingMemberCount) { - this.fullMemberCount = fullMemberCount; - this.pendingMemberCount = pendingMemberCount; - } - - int getFullMemberCount() { - return fullMemberCount; - } - - int getPendingMemberCount() { - return pendingMemberCount; - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestRepository.java b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestRepository.java index b387df612d..83a02ad110 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestRepository.java @@ -56,18 +56,18 @@ final class MessageRequestRepository { }); } - void getMemberCount(@NonNull RecipientId recipientId, @NonNull Consumer onMemberCountLoaded) { + void getGroupInfo(@NonNull RecipientId recipientId, @NonNull Consumer onGroupInfoLoaded) { executor.execute(() -> { GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context); Optional groupRecord = groupDatabase.getGroup(recipientId); - onMemberCountLoaded.accept(groupRecord.transform(record -> { + onGroupInfoLoaded.accept(groupRecord.transform(record -> { if (record.isV2Group()) { DecryptedGroup decryptedGroup = record.requireV2GroupProperties().getDecryptedGroup(); - return new GroupMemberCount(decryptedGroup.getMembersCount(), decryptedGroup.getPendingMembersCount()); + return new GroupInfo(decryptedGroup.getMembersCount(), decryptedGroup.getPendingMembersCount(), decryptedGroup.getDescription()); } else { - return new GroupMemberCount(record.getMembers().size(), 0); + return new GroupInfo(record.getMembers().size(), 0, ""); } - }).or(GroupMemberCount.ZERO)); + }).or(GroupInfo.ZERO)); }); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestViewModel.java index 33bb096e0f..3c98d03ec6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/MessageRequestViewModel.java @@ -22,6 +22,7 @@ import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.util.SingleLiveEvent; import org.thoughtcrime.securesms.util.livedata.LiveDataTriple; import org.thoughtcrime.securesms.util.livedata.LiveDataUtil; +import org.thoughtcrime.securesms.util.livedata.Store; import java.util.Collections; import java.util.List; @@ -33,10 +34,9 @@ public class MessageRequestViewModel extends ViewModel { private final MutableLiveData recipient = new MutableLiveData<>(); private final LiveData messageData; private final MutableLiveData> groups = new MutableLiveData<>(Collections.emptyList()); - private final MutableLiveData memberCount = new MutableLiveData<>(GroupMemberCount.ZERO); + private final MutableLiveData groupInfo = new MutableLiveData<>(GroupInfo.ZERO); private final LiveData requestReviewDisplayState; - private final LiveData recipientInfo = Transformations.map(new LiveDataTriple<>(recipient, memberCount, groups), - triple -> new RecipientInfo(triple.first(), triple.second(), triple.third())); + private final Store recipientInfoStore = new Store<>(new RecipientInfo(null, null, null, null)); private final MessageRequestRepository repository; @@ -44,7 +44,7 @@ public class MessageRequestViewModel extends ViewModel { private long threadId; private final RecipientForeverObserver recipientObserver = recipient -> { - loadMemberCount(); + loadGroupInfo(); this.recipient.setValue(recipient); }; @@ -52,6 +52,11 @@ public class MessageRequestViewModel extends ViewModel { this.repository = repository; this.messageData = LiveDataUtil.mapAsync(recipient, this::createMessageDataForRecipient); this.requestReviewDisplayState = LiveDataUtil.mapAsync(messageData, MessageRequestViewModel::transformHolderToReviewDisplayState); + + recipientInfoStore.update(this.recipient, (recipient, state) -> new RecipientInfo(recipient, state.groupInfo, state.sharedGroups, state.messageRequestState)); + recipientInfoStore.update(this.groupInfo, (groupInfo, state) -> new RecipientInfo(state.recipient, groupInfo, state.sharedGroups, state.messageRequestState)); + recipientInfoStore.update(this.groups, (sharedGroups, state) -> new RecipientInfo(state.recipient, state.groupInfo, sharedGroups, state.messageRequestState)); + recipientInfoStore.update(this.messageData, (messageData, state) -> new RecipientInfo(state.recipient, state.groupInfo, state.sharedGroups, messageData.messageState)); } public void setConversationInfo(@NonNull RecipientId recipientId, long threadId) { @@ -64,7 +69,7 @@ public class MessageRequestViewModel extends ViewModel { loadRecipient(); loadGroups(); - loadMemberCount(); + loadGroupInfo(); } @Override @@ -87,7 +92,7 @@ public class MessageRequestViewModel extends ViewModel { } public LiveData getRecipientInfo() { - return recipientInfo; + return recipientInfoStore.getStateLiveData(); } public LiveData getMessageRequestStatus() { @@ -161,8 +166,8 @@ public class MessageRequestViewModel extends ViewModel { repository.getGroups(liveRecipient.getId(), this.groups::postValue); } - private void loadMemberCount() { - repository.getMemberCount(liveRecipient.getId(), memberCount::postValue); + private void loadGroupInfo() { + repository.getGroupInfo(liveRecipient.getId(), groupInfo::postValue); } private static RequestReviewDisplayState transformHolderToReviewDisplayState(@NonNull MessageData holder) { @@ -181,14 +186,16 @@ public class MessageRequestViewModel extends ViewModel { } public static class RecipientInfo { - @Nullable private final Recipient recipient; - @NonNull private final GroupMemberCount groupMemberCount; - @NonNull private final List sharedGroups; + @Nullable private final Recipient recipient; + @NonNull private final GroupInfo groupInfo; + @NonNull private final List sharedGroups; + @Nullable private final MessageRequestState messageRequestState; - private RecipientInfo(@Nullable Recipient recipient, @Nullable GroupMemberCount groupMemberCount, @Nullable List sharedGroups) { - this.recipient = recipient; - this.groupMemberCount = groupMemberCount == null ? GroupMemberCount.ZERO : groupMemberCount; - this.sharedGroups = sharedGroups == null ? Collections.emptyList() : sharedGroups; + private RecipientInfo(@Nullable Recipient recipient, @Nullable GroupInfo groupInfo, @Nullable List sharedGroups, @Nullable MessageRequestState messageRequestState) { + this.recipient = recipient; + this.groupInfo = groupInfo == null ? GroupInfo.ZERO : groupInfo; + this.sharedGroups = sharedGroups == null ? Collections.emptyList() : sharedGroups; + this.messageRequestState = messageRequestState; } @Nullable @@ -197,17 +204,26 @@ public class MessageRequestViewModel extends ViewModel { } public int getGroupMemberCount() { - return groupMemberCount.getFullMemberCount(); + return groupInfo.getFullMemberCount(); } public int getGroupPendingMemberCount() { - return groupMemberCount.getPendingMemberCount(); + return groupInfo.getPendingMemberCount(); + } + + public @NonNull String getGroupDescription() { + return groupInfo.getDescription(); } @NonNull public List getSharedGroups() { return sharedGroups; } + + @Nullable + public MessageRequestState getMessageRequestState() { + return messageRequestState; + } } public enum Status { diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditGroupProfileRepository.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditGroupProfileRepository.java index 3951d39978..2da2d50368 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditGroupProfileRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditGroupProfileRepository.java @@ -78,17 +78,34 @@ class EditGroupProfileRepository implements EditProfileRepository { }, nameConsumer::accept); } + @Override + public void getCurrentDescription(@NonNull Consumer descriptionConsumer) { + SimpleTask.run(() -> { + RecipientId recipientId = getRecipientId(); + + return DatabaseFactory.getGroupDatabase(context) + .getGroup(recipientId) + .transform(groupRecord -> { + String description = groupRecord.getDescription(); + return description == null ? "" : description; + }) + .or(""); + }, descriptionConsumer::accept); + } + @Override public void uploadProfile(@NonNull ProfileName profileName, @NonNull String displayName, boolean displayNameChanged, + @NonNull String description, + boolean descriptionChanged, @Nullable byte[] avatar, boolean avatarChanged, @NonNull Consumer uploadResultConsumer) { SimpleTask.run(() -> { try { - GroupManager.updateGroupDetails(context, groupId, avatar, avatarChanged, displayName, displayNameChanged); + GroupManager.updateGroupDetails(context, groupId, avatar, avatarChanged, displayName, displayNameChanged, description, descriptionChanged); return UploadResult.SUCCESS; } catch (GroupChangeException | IOException e) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileFragment.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileFragment.java index 4c37b31141..b544bad14e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileFragment.java @@ -5,7 +5,7 @@ import android.content.Context; import android.content.Intent; import android.os.Build; import android.os.Bundle; -import android.text.Editable; +import android.text.InputType; import android.view.LayoutInflater; import android.view.View; import android.view.ViewAnimationUtils; @@ -35,22 +35,20 @@ import org.thoughtcrime.securesms.mediasend.AvatarSelectionActivity; import org.thoughtcrime.securesms.mediasend.AvatarSelectionBottomSheetDialogFragment; import org.thoughtcrime.securesms.mediasend.Media; import org.thoughtcrime.securesms.mms.GlideApp; -import org.thoughtcrime.securesms.profiles.ProfileName; import org.thoughtcrime.securesms.profiles.manage.EditProfileNameFragment; import org.thoughtcrime.securesms.providers.BlobProvider; import org.thoughtcrime.securesms.registration.RegistrationUtil; import org.thoughtcrime.securesms.util.CommunicationActions; import org.thoughtcrime.securesms.util.FeatureFlags; -import org.thoughtcrime.securesms.util.StringUtil; import org.thoughtcrime.securesms.util.concurrent.SimpleTask; import org.thoughtcrime.securesms.util.text.AfterTextChanged; import org.thoughtcrime.securesms.util.views.LearnMoreTextView; -import org.whispersystems.libsignal.util.guava.Optional; import java.io.IOException; import java.io.InputStream; import static android.app.Activity.RESULT_OK; +import static org.thoughtcrime.securesms.groups.v2.GroupDescriptionUtil.MAX_DESCRIPTION_LENGTH; import static org.thoughtcrime.securesms.profiles.edit.EditProfileActivity.EXCLUDE_SYSTEM; import static org.thoughtcrime.securesms.profiles.edit.EditProfileActivity.GROUP_ID; import static org.thoughtcrime.securesms.profiles.edit.EditProfileActivity.NEXT_BUTTON_TEXT; @@ -61,6 +59,8 @@ public class EditProfileFragment extends LoggingFragment { private static final String TAG = Log.tag(EditProfileFragment.class); private static final short REQUEST_CODE_SELECT_AVATAR = 31726; + private static final int MAX_DESCRIPTION_GLYPHS = 480; + private static final int MAX_DESCRIPTION_BYTES = 8192; private Toolbar toolbar; private View title; @@ -97,8 +97,8 @@ public class EditProfileFragment extends LoggingFragment { public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { GroupId groupId = GroupId.parseNullableOrThrow(requireArguments().getString(GROUP_ID, null)); - initializeResources(view, groupId); initializeViewModel(requireArguments().getBoolean(EXCLUDE_SYSTEM, false), groupId, savedInstanceState != null); + initializeResources(view, groupId); initializeProfileAvatar(); initializeProfileName(); } @@ -183,9 +183,25 @@ public class EditProfileFragment extends LoggingFragment { givenName.requestFocus(); toolbar.setTitle(R.string.EditProfileFragment__edit_group_name_and_photo); preview.setVisibility(View.GONE); - familyName.setVisibility(View.GONE); - familyName.setEnabled(false); - view.findViewById(R.id.description_text).setVisibility(View.GONE); + + if (FeatureFlags.groupsV2Description()) { + EditTextUtil.addGraphemeClusterLimitFilter(familyName, MAX_DESCRIPTION_GLYPHS); + familyName.addTextChangedListener(new AfterTextChanged(s -> { + EditProfileNameFragment.trimFieldToMaxByteLength(s, MAX_DESCRIPTION_BYTES); + viewModel.setFamilyName(s.toString()); + })); + familyName.setHint(R.string.EditProfileFragment__group_description); + familyName.setSingleLine(false); + familyName.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_MULTI_LINE | InputType.TYPE_TEXT_FLAG_CAP_SENTENCES); + + LearnMoreTextView descriptionText = view.findViewById(R.id.description_text); + descriptionText.setLearnMoreVisible(false); + descriptionText.setText(R.string.CreateProfileActivity_group_descriptions_will_be_visible_to_members_of_this_group_and_people_who_have_been_invited); + } else { + familyName.setVisibility(View.GONE); + familyName.setEnabled(false); + view.findViewById(R.id.description_text).setVisibility(View.GONE); + } view.findViewById(R.id.avatar_placeholder).setImageResource(R.drawable.ic_group_outline_40); } else { EditTextUtil.addGraphemeClusterLimitFilter(givenName, EditProfileNameFragment.NAME_MAX_GLYPHS); diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileRepository.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileRepository.java index eb8743aa80..230ea5ae15 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileRepository.java @@ -17,9 +17,13 @@ interface EditProfileRepository { void getCurrentName(@NonNull Consumer nameConsumer); + void getCurrentDescription(@NonNull Consumer descriptionConsumer); + void uploadProfile(@NonNull ProfileName profileName, @NonNull String displayName, boolean displayNameChanged, + @NonNull String description, + boolean descriptionChanged, @Nullable byte[] avatar, boolean avatarChanged, @NonNull Consumer uploadResultConsumer); diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileViewModel.java index 53978025b0..3da8722036 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileViewModel.java @@ -13,24 +13,24 @@ import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.profiles.ProfileName; import org.thoughtcrime.securesms.util.StringUtil; import org.thoughtcrime.securesms.util.livedata.LiveDataUtil; -import org.whispersystems.libsignal.util.guava.Optional; import java.util.Arrays; import java.util.Objects; class EditProfileViewModel extends ViewModel { - private final MutableLiveData givenName = new MutableLiveData<>(); - private final MutableLiveData familyName = new MutableLiveData<>(); - private final LiveData trimmedGivenName = Transformations.map(givenName, StringUtil::trimToVisualBounds); - private final LiveData trimmedFamilyName = Transformations.map(familyName, StringUtil::trimToVisualBounds); - private final LiveData internalProfileName = LiveDataUtil.combineLatest(trimmedGivenName, trimmedFamilyName, ProfileName::fromParts); - private final MutableLiveData internalAvatar = new MutableLiveData<>(); - private final MutableLiveData originalAvatar = new MutableLiveData<>(); - private final MutableLiveData originalDisplayName = new MutableLiveData<>(); - private final LiveData isFormValid; - private final EditProfileRepository repository; - private final GroupId groupId; + private final MutableLiveData givenName = new MutableLiveData<>(); + private final MutableLiveData familyName = new MutableLiveData<>(); + private final LiveData trimmedGivenName = Transformations.map(givenName, StringUtil::trimToVisualBounds); + private final LiveData trimmedFamilyName = Transformations.map(familyName, StringUtil::trimToVisualBounds); + private final LiveData internalProfileName = LiveDataUtil.combineLatest(trimmedGivenName, trimmedFamilyName, ProfileName::fromParts); + private final MutableLiveData internalAvatar = new MutableLiveData<>(); + private final MutableLiveData originalAvatar = new MutableLiveData<>(); + private final MutableLiveData originalDisplayName = new MutableLiveData<>(); + private final LiveData isFormValid; + private final EditProfileRepository repository; + private final GroupId groupId; + private String originalDescription; private EditProfileViewModel(@NonNull EditProfileRepository repository, boolean hasInstanceState, @Nullable GroupId groupId) { this.repository = repository; @@ -42,6 +42,10 @@ class EditProfileViewModel extends ViewModel { if (groupId != null) { repository.getCurrentDisplayName(originalDisplayName::setValue); repository.getCurrentName(givenName::setValue); + repository.getCurrentDescription(d -> { + originalDescription = d; + familyName.setValue(d); + }); } else { repository.getCurrentProfileName(name -> { givenName.setValue(name.getGivenName()); @@ -103,6 +107,7 @@ class EditProfileViewModel extends ViewModel { public void submitProfile(Consumer uploadResultConsumer) { ProfileName profileName = isGroup() ? ProfileName.EMPTY : internalProfileName.getValue(); String displayName = isGroup() ? givenName.getValue() : ""; + String description = isGroup() ? familyName.getValue() : ""; if (profileName == null || displayName == null) { return; @@ -111,10 +116,13 @@ class EditProfileViewModel extends ViewModel { byte[] oldAvatar = originalAvatar.getValue(); byte[] newAvatar = internalAvatar.getValue(); String oldDisplayName = isGroup() ? originalDisplayName.getValue() : null; + String oldDescription = isGroup() ? originalDescription : null; repository.uploadProfile(profileName, displayName, !Objects.equals(StringUtil.stripBidiProtection(oldDisplayName), displayName), + description, + !Objects.equals(StringUtil.stripBidiProtection(oldDescription), description), newAvatar, !Arrays.equals(oldAvatar, newAvatar), uploadResultConsumer); diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditSelfProfileRepository.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditSelfProfileRepository.java index 45f2f60a86..f7bff8a3c8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditSelfProfileRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditSelfProfileRepository.java @@ -107,10 +107,16 @@ public class EditSelfProfileRepository implements EditProfileRepository { nameConsumer.accept(""); } + @Override public void getCurrentDescription(@NonNull Consumer descriptionConsumer) { + descriptionConsumer.accept(""); + } + @Override public void uploadProfile(@NonNull ProfileName profileName, @NonNull String displayName, boolean displayNameChanged, + @NonNull String description, + boolean descriptionChanged, @Nullable byte[] avatar, boolean avatarChanged, @NonNull Consumer uploadResultConsumer) diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/EditProfileNameFragment.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/EditProfileNameFragment.java index 4f1c0125de..e6c65c8429 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/EditProfileNameFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/EditProfileNameFragment.java @@ -104,7 +104,11 @@ public class EditProfileNameFragment extends Fragment { } public static void trimFieldToMaxByteLength(Editable s) { - int trimmedLength = StringUtil.trimToFit(s.toString(), ProfileName.MAX_PART_LENGTH).length(); + trimFieldToMaxByteLength(s, ProfileName.MAX_PART_LENGTH); + } + + public static void trimFieldToMaxByteLength(Editable s, int length) { + int trimmedLength = StringUtil.trimToFit(s.toString(), length).length(); if (s.length() > trimmedLength) { s.delete(trimmedLength, s.length()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java index 2513281682..970831be99 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java @@ -80,6 +80,7 @@ public final class FeatureFlags { private static final String NOTIFICATION_REWRITE = "android.notificationRewrite"; private static final String MP4_GIF_SEND_SUPPORT = "android.mp4GifSendSupport"; private static final String MEDIA_QUALITY_LEVELS = "android.mediaQuality.levels"; + private static final String GROUPS_V2_DESCRIPTION_VERSION = "android.groupsv2.descriptionVersion"; /** * We will only store remote values for flags in this set. If you want a flag to be controllable @@ -113,7 +114,8 @@ public final class FeatureFlags { MESSAGE_PROCESSOR_DELAY, NOTIFICATION_REWRITE, MP4_GIF_SEND_SUPPORT, - MEDIA_QUALITY_LEVELS + MEDIA_QUALITY_LEVELS, + GROUPS_V2_DESCRIPTION_VERSION ); @VisibleForTesting @@ -159,7 +161,8 @@ public final class FeatureFlags { GV1_FORCED_MIGRATE, NOTIFICATION_REWRITE, MP4_GIF_SEND_SUPPORT, - MEDIA_QUALITY_LEVELS + MEDIA_QUALITY_LEVELS, + GROUPS_V2_DESCRIPTION_VERSION ); /** @@ -359,6 +362,10 @@ public final class FeatureFlags { return getString(MEDIA_QUALITY_LEVELS, ""); } + public static boolean groupsV2Description() { + return getVersionFlag(GROUPS_V2_DESCRIPTION_VERSION) == VersionFlag.ON; + } + /** Only for rendering debug info. */ public static synchronized @NonNull Map getMemoryValues() { return new TreeMap<>(REMOTE_VALUES); 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 b34d0f4ff6..199c81714d 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 @@ -126,6 +126,10 @@ public final class LiveDataUtil { return new MutableLiveData<>(item); } + public static LiveData empty() { + return new MutableLiveData<>(); + } + /** * Emits {@param whileWaiting} until {@param main} starts emitting. */ diff --git a/app/src/main/res/layout/group_description_dialog.xml b/app/src/main/res/layout/group_description_dialog.xml new file mode 100644 index 0000000000..ef96678a57 --- /dev/null +++ b/app/src/main/res/layout/group_description_dialog.xml @@ -0,0 +1,15 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/group_join_bottom_sheet.xml b/app/src/main/res/layout/group_join_bottom_sheet.xml index 7ddd668a59..8fe730385a 100644 --- a/app/src/main/res/layout/group_join_bottom_sheet.xml +++ b/app/src/main/res/layout/group_join_bottom_sheet.xml @@ -28,7 +28,7 @@ app:layout_constraintTop_toTopOf="@+id/group_join_recipient_avatar" tools:visibility="visible" /> - + +