From 5d14166a27a82d55feea40b91706aecd53196fa2 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Mon, 23 Jan 2023 13:11:28 -0400 Subject: [PATCH] Add support for arbitrary rows in contact search. --- .../contacts/paged/ArbitraryRepository.kt | 20 + .../contacts/paged/ContactSearchAdapter.kt | 454 +++++++++++++++++- .../paged/ContactSearchConfiguration.kt | 14 +- .../contacts/paged/ContactSearchData.kt | 6 + .../contacts/paged/ContactSearchItems.kt | 447 ----------------- .../contacts/paged/ContactSearchKey.kt | 7 + .../contacts/paged/ContactSearchMediator.kt | 22 +- .../paged/ContactSearchPagedDataSource.kt | 5 +- .../contacts/paged/ContactSearchRepository.kt | 1 + .../contacts/paged/ContactSearchViewModel.kt | 8 +- .../forward/MultiselectForwardFragment.kt | 12 +- .../forward/SearchConfigurationProvider.kt | 6 + .../v2/stories/ChooseGroupStoryBottomSheet.kt | 4 +- .../ViewAllSignalConnectionsFragment.kt | 4 +- .../story/StoriesPrivacySettingsFragment.kt | 9 +- .../paged/ContactSearchPagedDataSourceTest.kt | 59 ++- 16 files changed, 598 insertions(+), 480 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ArbitraryRepository.kt delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchItems.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ArbitraryRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ArbitraryRepository.kt new file mode 100644 index 0000000000..00af2f1cda --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ArbitraryRepository.kt @@ -0,0 +1,20 @@ +package org.thoughtcrime.securesms.contacts.paged + +import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel + +interface ArbitraryRepository { + /** + * Get the count of arbitrary rows to include for the given query from the given section. + */ + fun getSize(section: ContactSearchConfiguration.Section.Arbitrary, query: String?): Int + + /** + * Get the data for the given arbitrary rows within the start and end index. + */ + fun getData(section: ContactSearchConfiguration.Section.Arbitrary, query: String?, startIndex: Int, endIndex: Int): List + + /** + * Map an arbitrary object to a mapping model + */ + fun getMappingModel(arbitrary: ContactSearchData.Arbitrary): MappingModel<*> +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchAdapter.kt index 4f7d3a4e26..d9b2171e59 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchAdapter.kt @@ -1,23 +1,463 @@ package org.thoughtcrime.securesms.contacts.paged import android.view.View +import android.view.ViewGroup +import android.widget.CheckBox +import android.widget.TextView +import com.google.android.material.button.MaterialButton +import org.signal.core.util.dp +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.badges.BadgeImageView +import org.thoughtcrime.securesms.components.AvatarImageView +import org.thoughtcrime.securesms.components.FromTextView +import org.thoughtcrime.securesms.components.menu.ActionItem +import org.thoughtcrime.securesms.components.menu.SignalContextMenu +import org.thoughtcrime.securesms.contacts.LetterHeaderDecoration +import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto +import org.thoughtcrime.securesms.contacts.avatars.GeneratedContactPhoto +import org.thoughtcrime.securesms.database.model.DistributionListPrivacyMode +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory +import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter +import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel +import org.thoughtcrime.securesms.util.adapter.mapping.MappingModelList +import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder import org.thoughtcrime.securesms.util.adapter.mapping.PagingMappingAdapter +import org.thoughtcrime.securesms.util.visible /** * Default contact search adapter, using the models defined in `ContactSearchItems` */ -class ContactSearchAdapter( +@Suppress("LeakingThis") +open class ContactSearchAdapter( displayCheckBox: Boolean, - displaySmsTag: ContactSearchItems.DisplaySmsTag, + displaySmsTag: DisplaySmsTag, recipientListener: (View, ContactSearchData.KnownRecipient, Boolean) -> Unit, storyListener: (View, ContactSearchData.Story, Boolean) -> Unit, - storyContextMenuCallbacks: ContactSearchItems.StoryContextMenuCallbacks, + storyContextMenuCallbacks: StoryContextMenuCallbacks, expandListener: (ContactSearchData.Expand) -> Unit ) : PagingMappingAdapter() { init { - ContactSearchItems.registerStoryItems(this, displayCheckBox, storyListener, storyContextMenuCallbacks) - ContactSearchItems.registerKnownRecipientItems(this, displayCheckBox, displaySmsTag, recipientListener) - ContactSearchItems.registerHeaders(this) - ContactSearchItems.registerExpands(this, expandListener) + registerStoryItems(this, displayCheckBox, storyListener, storyContextMenuCallbacks) + registerKnownRecipientItems(this, displayCheckBox, displaySmsTag, recipientListener) + registerHeaders(this) + registerExpands(this, expandListener) + } + + companion object { + fun registerStoryItems( + mappingAdapter: MappingAdapter, + displayCheckBox: Boolean = false, + storyListener: (View, ContactSearchData.Story, Boolean) -> Unit, + storyContextMenuCallbacks: StoryContextMenuCallbacks? = null + ) { + mappingAdapter.registerFactory( + StoryModel::class.java, + LayoutFactory({ StoryViewHolder(it, displayCheckBox, storyListener, storyContextMenuCallbacks) }, R.layout.contact_search_item) + ) + } + + fun registerKnownRecipientItems( + mappingAdapter: MappingAdapter, + displayCheckBox: Boolean, + displaySmsTag: DisplaySmsTag, + recipientListener: (View, ContactSearchData.KnownRecipient, Boolean) -> Unit + ) { + mappingAdapter.registerFactory( + RecipientModel::class.java, + LayoutFactory({ KnownRecipientViewHolder(it, displayCheckBox, displaySmsTag, recipientListener) }, R.layout.contact_search_item) + ) + } + + fun registerHeaders(mappingAdapter: MappingAdapter) { + mappingAdapter.registerFactory( + HeaderModel::class.java, + LayoutFactory({ HeaderViewHolder(it) }, R.layout.contact_search_section_header) + ) + } + + fun registerExpands(mappingAdapter: MappingAdapter, expandListener: (ContactSearchData.Expand) -> Unit) { + mappingAdapter.registerFactory( + ExpandModel::class.java, + LayoutFactory({ ExpandViewHolder(it, expandListener) }, R.layout.contacts_expand_item) + ) + } + + fun toMappingModelList(contactSearchData: List, selection: Set, arbitraryRepository: ArbitraryRepository?): MappingModelList { + return MappingModelList( + contactSearchData.filterNotNull().map { + when (it) { + is ContactSearchData.Story -> StoryModel(it, selection.contains(it.contactSearchKey), SignalStore.storyValues().userHasBeenNotifiedAboutStories) + is ContactSearchData.KnownRecipient -> RecipientModel(it, selection.contains(it.contactSearchKey), it.shortSummary) + is ContactSearchData.Expand -> ExpandModel(it) + is ContactSearchData.Header -> HeaderModel(it) + is ContactSearchData.TestRow -> error("This row exists for testing only.") + is ContactSearchData.Arbitrary -> arbitraryRepository?.getMappingModel(it) ?: error("This row must be handled manually") + } + } + ) + } + } + + /** + * Story Model + */ + class StoryModel(val story: ContactSearchData.Story, val isSelected: Boolean, val hasBeenNotified: Boolean) : MappingModel { + + override fun areItemsTheSame(newItem: StoryModel): Boolean { + return newItem.story == story + } + + override fun areContentsTheSame(newItem: StoryModel): Boolean { + return story.recipient.hasSameContent(newItem.story.recipient) && + isSelected == newItem.isSelected && + hasBeenNotified == newItem.hasBeenNotified + } + + override fun getChangePayload(newItem: StoryModel): Any? { + return if (story.recipient.hasSameContent(newItem.story.recipient) && + hasBeenNotified == newItem.hasBeenNotified && + newItem.isSelected != isSelected + ) { + 0 + } else { + null + } + } + } + + private class StoryViewHolder( + itemView: View, + displayCheckBox: Boolean, + onClick: (View, ContactSearchData.Story, Boolean) -> Unit, + private val storyContextMenuCallbacks: StoryContextMenuCallbacks? + ) : BaseRecipientViewHolder(itemView, displayCheckBox, DisplaySmsTag.NEVER, onClick) { + override fun isSelected(model: StoryModel): Boolean = model.isSelected + override fun getData(model: StoryModel): ContactSearchData.Story = model.story + override fun getRecipient(model: StoryModel): Recipient = model.story.recipient + + override fun bindNumberField(model: StoryModel) { + number.visible = true + + val count = if (model.story.recipient.isGroup) { + model.story.recipient.participantIds.size + } else { + model.story.count + } + + if (model.story.recipient.isMyStory && !model.hasBeenNotified) { + number.setText(R.string.ContactSearchItems__tap_to_choose_your_viewers) + } else { + number.text = when { + model.story.recipient.isGroup -> context.resources.getQuantityString(R.plurals.ContactSearchItems__group_story_d_viewers, count, count) + model.story.recipient.isMyStory -> { + if (model.story.privacyMode == DistributionListPrivacyMode.ALL_EXCEPT) { + context.resources.getQuantityString(R.plurals.ContactSearchItems__my_story_s_dot_d_excluded, count, presentPrivacyMode(DistributionListPrivacyMode.ALL), count) + } else { + context.resources.getQuantityString(R.plurals.ContactSearchItems__my_story_s_dot_d_viewers, count, presentPrivacyMode(model.story.privacyMode), count) + } + } + else -> context.resources.getQuantityString(R.plurals.ContactSearchItems__custom_story_d_viewers, count, count) + } + } + } + + override fun bindAvatar(model: StoryModel) { + if (model.story.recipient.isMyStory) { + avatar.setFallbackPhotoProvider(MyStoryFallbackPhotoProvider(Recipient.self().getDisplayName(context), 40.dp)) + avatar.setAvatarUsingProfile(Recipient.self()) + } else { + avatar.setFallbackPhotoProvider(Recipient.DEFAULT_FALLBACK_PHOTO_PROVIDER) + super.bindAvatar(model) + } + } + + override fun bindLongPress(model: StoryModel) { + if (storyContextMenuCallbacks == null) { + return + } + + itemView.setOnLongClickListener { + val actions: List = when { + model.story.recipient.isMyStory -> getMyStoryContextMenuActions(model, storyContextMenuCallbacks) + model.story.recipient.isGroup -> getGroupStoryContextMenuActions(model, storyContextMenuCallbacks) + model.story.recipient.isDistributionList -> getPrivateStoryContextMenuActions(model, storyContextMenuCallbacks) + else -> error("Unsupported story target. Not a group or distribution list.") + } + + SignalContextMenu.Builder(itemView, itemView.rootView as ViewGroup) + .offsetX(context.resources.getDimensionPixelSize(R.dimen.dsl_settings_gutter)) + .show(actions) + + true + } + } + + private fun getMyStoryContextMenuActions(model: StoryModel, callbacks: StoryContextMenuCallbacks): List { + return listOf( + ActionItem(R.drawable.ic_settings_24, context.getString(R.string.ContactSearchItems__story_settings)) { + callbacks.onOpenStorySettings(model.story) + } + ) + } + + private fun getGroupStoryContextMenuActions(model: StoryModel, callbacks: StoryContextMenuCallbacks): List { + return listOf( + ActionItem(R.drawable.ic_minus_circle_20, context.getString(R.string.ContactSearchItems__remove_story)) { + callbacks.onRemoveGroupStory(model.story, model.isSelected) + } + ) + } + + private fun getPrivateStoryContextMenuActions(model: StoryModel, callbacks: StoryContextMenuCallbacks): List { + return listOf( + ActionItem(R.drawable.ic_settings_24, context.getString(R.string.ContactSearchItems__story_settings)) { + callbacks.onOpenStorySettings(model.story) + }, + ActionItem(R.drawable.ic_delete_24, context.getString(R.string.ContactSearchItems__delete_story), R.color.signal_colorError) { + callbacks.onDeletePrivateStory(model.story, model.isSelected) + } + ) + } + + private fun presentPrivacyMode(privacyMode: DistributionListPrivacyMode): String { + return when (privacyMode) { + DistributionListPrivacyMode.ONLY_WITH -> context.getString(R.string.ContactSearchItems__only_share_with) + DistributionListPrivacyMode.ALL_EXCEPT -> context.getString(R.string.ChooseInitialMyStoryMembershipFragment__all_except) + DistributionListPrivacyMode.ALL -> context.getString(R.string.ChooseInitialMyStoryMembershipFragment__all_signal_connections) + } + } + + private class MyStoryFallbackPhotoProvider(private val name: String, private val targetSize: Int) : Recipient.FallbackPhotoProvider() { + override fun getPhotoForLocalNumber(): FallbackContactPhoto { + return GeneratedContactPhoto(name, R.drawable.ic_profile_outline_40, targetSize) + } + } + } + + /** + * Recipient model + */ + class RecipientModel(val knownRecipient: ContactSearchData.KnownRecipient, val isSelected: Boolean, val shortSummary: Boolean) : MappingModel { + + override fun areItemsTheSame(newItem: RecipientModel): Boolean { + return newItem.knownRecipient == knownRecipient + } + + override fun areContentsTheSame(newItem: RecipientModel): Boolean { + return knownRecipient.recipient.hasSameContent(newItem.knownRecipient.recipient) && isSelected == newItem.isSelected + } + + override fun getChangePayload(newItem: RecipientModel): Any? { + return if (knownRecipient.recipient.hasSameContent(newItem.knownRecipient.recipient) && newItem.isSelected != isSelected) { + 0 + } else { + null + } + } + } + + private class KnownRecipientViewHolder( + itemView: View, + displayCheckBox: Boolean, + displaySmsTag: DisplaySmsTag, + onClick: (View, ContactSearchData.KnownRecipient, Boolean) -> Unit + ) : BaseRecipientViewHolder(itemView, displayCheckBox, displaySmsTag, onClick), LetterHeaderDecoration.LetterHeaderItem { + + private var headerLetter: String? = null + + override fun isSelected(model: RecipientModel): Boolean = model.isSelected + override fun getData(model: RecipientModel): ContactSearchData.KnownRecipient = model.knownRecipient + override fun getRecipient(model: RecipientModel): Recipient = model.knownRecipient.recipient + override fun bindNumberField(model: RecipientModel) { + val recipient = getRecipient(model) + + if (model.shortSummary && recipient.isGroup) { + val count = recipient.participantIds.size + number.text = context.resources.getQuantityString(R.plurals.ContactSearchItems__group_d_members, count, count) + } else { + super.bindNumberField(model) + } + + headerLetter = model.knownRecipient.headerLetter + } + + override fun getHeaderLetter(): String? { + return headerLetter + } + } + + /** + * Base Recipient View Holder + */ + private abstract class BaseRecipientViewHolder, D : ContactSearchData>( + itemView: View, + private val displayCheckBox: Boolean, + private val displaySmsTag: DisplaySmsTag, + val onClick: (View, D, Boolean) -> Unit + ) : MappingViewHolder(itemView) { + + protected val avatar: AvatarImageView = itemView.findViewById(R.id.contact_photo_image) + protected val badge: BadgeImageView = itemView.findViewById(R.id.contact_badge) + protected val checkbox: CheckBox = itemView.findViewById(R.id.check_box) + protected val name: FromTextView = itemView.findViewById(R.id.name) + protected val number: TextView = itemView.findViewById(R.id.number) + protected val label: TextView = itemView.findViewById(R.id.label) + protected val smsTag: View = itemView.findViewById(R.id.sms_tag) + + override fun bind(model: T) { + checkbox.visible = displayCheckBox + checkbox.isChecked = isSelected(model) + itemView.setOnClickListener { onClick(avatar, getData(model), isSelected(model)) } + bindLongPress(model) + + if (payload.isNotEmpty()) { + return + } + + name.setText(getRecipient(model)) + badge.setBadgeFromRecipient(getRecipient(model)) + + bindAvatar(model) + bindNumberField(model) + bindLabelField(model) + bindSmsTagField(model) + } + + protected open fun bindAvatar(model: T) { + avatar.setAvatar(getRecipient(model)) + } + + protected open fun bindNumberField(model: T) { + number.visible = getRecipient(model).isGroup + if (getRecipient(model).isGroup) { + number.text = getRecipient(model).participantIds + .take(10) + .map { id -> Recipient.resolved(id) } + .sortedWith(IsSelfComparator()).joinToString(", ") { + if (it.isSelf) { + context.getString(R.string.ConversationTitleView_you) + } else { + it.getShortDisplayName(context) + } + } + } + } + + protected open fun bindLabelField(model: T) { + label.visible = false + } + + protected open fun bindSmsTagField(model: T) { + smsTag.visible = when (displaySmsTag) { + DisplaySmsTag.DEFAULT -> isSmsContact(model) + DisplaySmsTag.IF_NOT_REGISTERED -> isNotRegistered(model) + DisplaySmsTag.NEVER -> false + } + smsTag.visible = isSmsContact(model) + } + + protected open fun bindLongPress(model: T) = Unit + + private fun isSmsContact(model: T): Boolean { + return (getRecipient(model).isForceSmsSelection || getRecipient(model).isUnregistered) && !getRecipient(model).isDistributionList + } + + private fun isNotRegistered(model: T): Boolean { + return getRecipient(model).isUnregistered && !getRecipient(model).isDistributionList + } + + abstract fun isSelected(model: T): Boolean + abstract fun getData(model: T): D + abstract fun getRecipient(model: T): Recipient + } + + /** + * Mapping Model for section headers + */ + class HeaderModel(val header: ContactSearchData.Header) : MappingModel { + override fun areItemsTheSame(newItem: HeaderModel): Boolean { + return header.sectionKey == newItem.header.sectionKey + } + + override fun areContentsTheSame(newItem: HeaderModel): Boolean { + return areItemsTheSame(newItem) && + header.action?.icon == newItem.header.action?.icon && + header.action?.label == newItem.header.action?.label + } + } + + /** + * View Holder for section headers + */ + private class HeaderViewHolder(itemView: View) : MappingViewHolder(itemView) { + + private val headerTextView: TextView = itemView.findViewById(R.id.section_header) + private val headerActionView: MaterialButton = itemView.findViewById(R.id.section_header_action) + + override fun bind(model: HeaderModel) { + headerTextView.setText( + when (model.header.sectionKey) { + ContactSearchConfiguration.SectionKey.STORIES -> R.string.ContactsCursorLoader_my_stories + ContactSearchConfiguration.SectionKey.RECENTS -> R.string.ContactsCursorLoader_recent_chats + ContactSearchConfiguration.SectionKey.INDIVIDUALS -> R.string.ContactsCursorLoader_contacts + ContactSearchConfiguration.SectionKey.GROUPS -> R.string.ContactsCursorLoader_groups + ContactSearchConfiguration.SectionKey.ARBITRARY -> error("This section does not support HEADER") + } + ) + + if (model.header.action != null) { + headerActionView.visible = true + headerActionView.setIconResource(model.header.action.icon) + headerActionView.setText(model.header.action.label) + headerActionView.setOnClickListener { model.header.action.action.run() } + } else { + headerActionView.visible = false + } + } + } + + /** + * Mapping Model for expandable content rows. + */ + class ExpandModel(val expand: ContactSearchData.Expand) : MappingModel { + override fun areItemsTheSame(newItem: ExpandModel): Boolean { + return expand.contactSearchKey == newItem.expand.contactSearchKey + } + + override fun areContentsTheSame(newItem: ExpandModel): Boolean { + return areItemsTheSame(newItem) + } + } + + /** + * View Holder for expandable content rows. + */ + private class ExpandViewHolder(itemView: View, private val expandListener: (ContactSearchData.Expand) -> Unit) : MappingViewHolder(itemView) { + override fun bind(model: ExpandModel) { + itemView.setOnClickListener { expandListener.invoke(model.expand) } + } + } + + private class IsSelfComparator : Comparator { + override fun compare(lhs: Recipient?, rhs: Recipient?): Int { + val isLeftSelf = lhs?.isSelf == true + val isRightSelf = rhs?.isSelf == true + + return if (isLeftSelf == isRightSelf) 0 else if (isLeftSelf) 1 else -1 + } + } + + interface StoryContextMenuCallbacks { + fun onOpenStorySettings(story: ContactSearchData.Story) + fun onRemoveGroupStory(story: ContactSearchData.Story, isSelected: Boolean) + fun onDeletePrivateStory(story: ContactSearchData.Story, isSelected: Boolean) + } + + enum class DisplaySmsTag { + DEFAULT, + IF_NOT_REGISTERED, + NEVER } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchConfiguration.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchConfiguration.kt index 962b6f6d44..71e2c5e634 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchConfiguration.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchConfiguration.kt @@ -69,6 +69,13 @@ class ContactSearchConfiguration private constructor( override val includeHeader: Boolean, override val expandConfig: ExpandConfig? = null ) : Section(SectionKey.GROUPS) + + data class Arbitrary( + val types: Set + ) : Section(SectionKey.ARBITRARY) { + override val includeHeader: Boolean = false + override val expandConfig: ExpandConfig? = null + } } /** @@ -78,7 +85,8 @@ class ContactSearchConfiguration private constructor( STORIES, RECENTS, INDIVIDUALS, - GROUPS + GROUPS, + ARBITRARY } /** @@ -139,6 +147,10 @@ class ContactSearchConfiguration private constructor( */ interface Builder { var query: String? + fun arbitrary(first: String, vararg rest: String) { + addSection(Section.Arbitrary(setOf(first) + rest.toSet())) + } + fun addSection(section: Section) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchData.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchData.kt index 2ed57d774b..5546eb343e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchData.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchData.kt @@ -1,5 +1,6 @@ package org.thoughtcrime.securesms.contacts.paged +import android.os.Bundle import androidx.annotation.VisibleForTesting import org.thoughtcrime.securesms.contacts.HeaderAction import org.thoughtcrime.securesms.database.model.DistributionListPrivacyMode @@ -42,6 +43,11 @@ sealed class ContactSearchData(val contactSearchKey: ContactSearchKey) { */ class Expand(val sectionKey: ContactSearchConfiguration.SectionKey) : ContactSearchData(ContactSearchKey.Expand(sectionKey)) + /** + * A row representing arbitrary data tied to a specific section. + */ + class Arbitrary(val type: String, val data: Bundle? = null) : ContactSearchData(ContactSearchKey.Arbitrary(type)) + /** * A row which contains an integer, for testing. */ diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchItems.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchItems.kt deleted file mode 100644 index b2a6f26569..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchItems.kt +++ /dev/null @@ -1,447 +0,0 @@ -package org.thoughtcrime.securesms.contacts.paged - -import android.view.View -import android.view.ViewGroup -import android.widget.CheckBox -import android.widget.TextView -import com.google.android.material.button.MaterialButton -import org.signal.core.util.dp -import org.thoughtcrime.securesms.R -import org.thoughtcrime.securesms.badges.BadgeImageView -import org.thoughtcrime.securesms.components.AvatarImageView -import org.thoughtcrime.securesms.components.FromTextView -import org.thoughtcrime.securesms.components.menu.ActionItem -import org.thoughtcrime.securesms.components.menu.SignalContextMenu -import org.thoughtcrime.securesms.contacts.LetterHeaderDecoration -import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto -import org.thoughtcrime.securesms.contacts.avatars.GeneratedContactPhoto -import org.thoughtcrime.securesms.database.model.DistributionListPrivacyMode -import org.thoughtcrime.securesms.keyvalue.SignalStore -import org.thoughtcrime.securesms.recipients.Recipient -import org.thoughtcrime.securesms.recipients.Recipient.FallbackPhotoProvider -import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory -import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter -import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel -import org.thoughtcrime.securesms.util.adapter.mapping.MappingModelList -import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder -import org.thoughtcrime.securesms.util.visible - -private typealias StoryClickListener = (View, ContactSearchData.Story, Boolean) -> Unit -private typealias RecipientClickListener = (View, ContactSearchData.KnownRecipient, Boolean) -> Unit - -/** - * Mapping Models and View Holders for ContactSearchData - */ -object ContactSearchItems { - fun registerStoryItems( - mappingAdapter: MappingAdapter, - displayCheckBox: Boolean = false, - storyListener: StoryClickListener, - storyContextMenuCallbacks: StoryContextMenuCallbacks? = null - ) { - mappingAdapter.registerFactory( - StoryModel::class.java, - LayoutFactory({ StoryViewHolder(it, displayCheckBox, storyListener, storyContextMenuCallbacks) }, R.layout.contact_search_item) - ) - } - - fun registerKnownRecipientItems( - mappingAdapter: MappingAdapter, - displayCheckBox: Boolean, - displaySmsTag: DisplaySmsTag, - recipientListener: (View, ContactSearchData.KnownRecipient, Boolean) -> Unit - ) { - mappingAdapter.registerFactory( - RecipientModel::class.java, - LayoutFactory({ KnownRecipientViewHolder(it, displayCheckBox, displaySmsTag, recipientListener) }, R.layout.contact_search_item) - ) - } - - fun registerHeaders(mappingAdapter: MappingAdapter) { - mappingAdapter.registerFactory( - HeaderModel::class.java, - LayoutFactory({ HeaderViewHolder(it) }, R.layout.contact_search_section_header) - ) - } - - fun registerExpands(mappingAdapter: MappingAdapter, expandListener: (ContactSearchData.Expand) -> Unit) { - mappingAdapter.registerFactory( - ExpandModel::class.java, - LayoutFactory({ ExpandViewHolder(it, expandListener) }, R.layout.contacts_expand_item) - ) - } - - fun toMappingModelList(contactSearchData: List, selection: Set): MappingModelList { - return MappingModelList( - contactSearchData.filterNotNull().map { - when (it) { - is ContactSearchData.Story -> StoryModel(it, selection.contains(it.contactSearchKey), SignalStore.storyValues().userHasBeenNotifiedAboutStories) - is ContactSearchData.KnownRecipient -> RecipientModel(it, selection.contains(it.contactSearchKey), it.shortSummary) - is ContactSearchData.Expand -> ExpandModel(it) - is ContactSearchData.Header -> HeaderModel(it) - is ContactSearchData.TestRow -> error("This row exists for testing only.") - } - } - ) - } - - /** - * Story Model - */ - class StoryModel(val story: ContactSearchData.Story, val isSelected: Boolean, val hasBeenNotified: Boolean) : MappingModel { - - override fun areItemsTheSame(newItem: StoryModel): Boolean { - return newItem.story == story - } - - override fun areContentsTheSame(newItem: StoryModel): Boolean { - return story.recipient.hasSameContent(newItem.story.recipient) && - isSelected == newItem.isSelected && - hasBeenNotified == newItem.hasBeenNotified - } - - override fun getChangePayload(newItem: StoryModel): Any? { - return if (story.recipient.hasSameContent(newItem.story.recipient) && - hasBeenNotified == newItem.hasBeenNotified && - newItem.isSelected != isSelected - ) { - 0 - } else { - null - } - } - } - - private class StoryViewHolder( - itemView: View, - displayCheckBox: Boolean, - onClick: StoryClickListener, - private val storyContextMenuCallbacks: StoryContextMenuCallbacks? - ) : BaseRecipientViewHolder(itemView, displayCheckBox, DisplaySmsTag.NEVER, onClick) { - override fun isSelected(model: StoryModel): Boolean = model.isSelected - override fun getData(model: StoryModel): ContactSearchData.Story = model.story - override fun getRecipient(model: StoryModel): Recipient = model.story.recipient - - override fun bindNumberField(model: StoryModel) { - number.visible = true - - val count = if (model.story.recipient.isGroup) { - model.story.recipient.participantIds.size - } else { - model.story.count - } - - if (model.story.recipient.isMyStory && !model.hasBeenNotified) { - number.setText(R.string.ContactSearchItems__tap_to_choose_your_viewers) - } else { - number.text = when { - model.story.recipient.isGroup -> context.resources.getQuantityString(R.plurals.ContactSearchItems__group_story_d_viewers, count, count) - model.story.recipient.isMyStory -> { - if (model.story.privacyMode == DistributionListPrivacyMode.ALL_EXCEPT) { - context.resources.getQuantityString(R.plurals.ContactSearchItems__my_story_s_dot_d_excluded, count, presentPrivacyMode(DistributionListPrivacyMode.ALL), count) - } else { - context.resources.getQuantityString(R.plurals.ContactSearchItems__my_story_s_dot_d_viewers, count, presentPrivacyMode(model.story.privacyMode), count) - } - } - else -> context.resources.getQuantityString(R.plurals.ContactSearchItems__custom_story_d_viewers, count, count) - } - } - } - - override fun bindAvatar(model: StoryModel) { - if (model.story.recipient.isMyStory) { - avatar.setFallbackPhotoProvider(MyStoryFallbackPhotoProvider(Recipient.self().getDisplayName(context), 40.dp)) - avatar.setAvatarUsingProfile(Recipient.self()) - } else { - avatar.setFallbackPhotoProvider(Recipient.DEFAULT_FALLBACK_PHOTO_PROVIDER) - super.bindAvatar(model) - } - } - - override fun bindLongPress(model: StoryModel) { - if (storyContextMenuCallbacks == null) { - return - } - - itemView.setOnLongClickListener { - val actions: List = when { - model.story.recipient.isMyStory -> getMyStoryContextMenuActions(model, storyContextMenuCallbacks) - model.story.recipient.isGroup -> getGroupStoryContextMenuActions(model, storyContextMenuCallbacks) - model.story.recipient.isDistributionList -> getPrivateStoryContextMenuActions(model, storyContextMenuCallbacks) - else -> error("Unsupported story target. Not a group or distribution list.") - } - - SignalContextMenu.Builder(itemView, itemView.rootView as ViewGroup) - .offsetX(context.resources.getDimensionPixelSize(R.dimen.dsl_settings_gutter)) - .show(actions) - - true - } - } - - private fun getMyStoryContextMenuActions(model: StoryModel, callbacks: StoryContextMenuCallbacks): List { - return listOf( - ActionItem(R.drawable.ic_settings_24, context.getString(R.string.ContactSearchItems__story_settings)) { - callbacks.onOpenStorySettings(model.story) - } - ) - } - - private fun getGroupStoryContextMenuActions(model: StoryModel, callbacks: StoryContextMenuCallbacks): List { - return listOf( - ActionItem(R.drawable.ic_minus_circle_20, context.getString(R.string.ContactSearchItems__remove_story)) { - callbacks.onRemoveGroupStory(model.story, model.isSelected) - } - ) - } - - private fun getPrivateStoryContextMenuActions(model: StoryModel, callbacks: StoryContextMenuCallbacks): List { - return listOf( - ActionItem(R.drawable.ic_settings_24, context.getString(R.string.ContactSearchItems__story_settings)) { - callbacks.onOpenStorySettings(model.story) - }, - ActionItem(R.drawable.ic_delete_24, context.getString(R.string.ContactSearchItems__delete_story), R.color.signal_colorError) { - callbacks.onDeletePrivateStory(model.story, model.isSelected) - } - ) - } - - private fun presentPrivacyMode(privacyMode: DistributionListPrivacyMode): String { - return when (privacyMode) { - DistributionListPrivacyMode.ONLY_WITH -> context.getString(R.string.ContactSearchItems__only_share_with) - DistributionListPrivacyMode.ALL_EXCEPT -> context.getString(R.string.ChooseInitialMyStoryMembershipFragment__all_except) - DistributionListPrivacyMode.ALL -> context.getString(R.string.ChooseInitialMyStoryMembershipFragment__all_signal_connections) - } - } - - private class MyStoryFallbackPhotoProvider(private val name: String, private val targetSize: Int) : FallbackPhotoProvider() { - override fun getPhotoForLocalNumber(): FallbackContactPhoto { - return GeneratedContactPhoto(name, R.drawable.ic_profile_outline_40, targetSize) - } - } - } - - /** - * Recipient model - */ - class RecipientModel(val knownRecipient: ContactSearchData.KnownRecipient, val isSelected: Boolean, val shortSummary: Boolean) : MappingModel { - - override fun areItemsTheSame(newItem: RecipientModel): Boolean { - return newItem.knownRecipient == knownRecipient - } - - override fun areContentsTheSame(newItem: RecipientModel): Boolean { - return knownRecipient.recipient.hasSameContent(newItem.knownRecipient.recipient) && isSelected == newItem.isSelected - } - - override fun getChangePayload(newItem: RecipientModel): Any? { - return if (knownRecipient.recipient.hasSameContent(newItem.knownRecipient.recipient) && newItem.isSelected != isSelected) { - 0 - } else { - null - } - } - } - - private class KnownRecipientViewHolder( - itemView: View, - displayCheckBox: Boolean, - displaySmsTag: DisplaySmsTag, - onClick: RecipientClickListener - ) : BaseRecipientViewHolder(itemView, displayCheckBox, displaySmsTag, onClick), LetterHeaderDecoration.LetterHeaderItem { - - private var headerLetter: String? = null - - override fun isSelected(model: RecipientModel): Boolean = model.isSelected - override fun getData(model: RecipientModel): ContactSearchData.KnownRecipient = model.knownRecipient - override fun getRecipient(model: RecipientModel): Recipient = model.knownRecipient.recipient - override fun bindNumberField(model: RecipientModel) { - val recipient = getRecipient(model) - - if (model.shortSummary && recipient.isGroup) { - val count = recipient.participantIds.size - number.text = context.resources.getQuantityString(R.plurals.ContactSearchItems__group_d_members, count, count) - } else { - super.bindNumberField(model) - } - - headerLetter = model.knownRecipient.headerLetter - } - - override fun getHeaderLetter(): String? { - return headerLetter - } - } - - /** - * Base Recipient View Holder - */ - private abstract class BaseRecipientViewHolder, D : ContactSearchData>( - itemView: View, - private val displayCheckBox: Boolean, - private val displaySmsTag: DisplaySmsTag, - val onClick: (View, D, Boolean) -> Unit - ) : MappingViewHolder(itemView) { - - protected val avatar: AvatarImageView = itemView.findViewById(R.id.contact_photo_image) - protected val badge: BadgeImageView = itemView.findViewById(R.id.contact_badge) - protected val checkbox: CheckBox = itemView.findViewById(R.id.check_box) - protected val name: FromTextView = itemView.findViewById(R.id.name) - protected val number: TextView = itemView.findViewById(R.id.number) - protected val label: TextView = itemView.findViewById(R.id.label) - protected val smsTag: View = itemView.findViewById(R.id.sms_tag) - - override fun bind(model: T) { - checkbox.visible = displayCheckBox - checkbox.isChecked = isSelected(model) - itemView.setOnClickListener { onClick(avatar, getData(model), isSelected(model)) } - bindLongPress(model) - - if (payload.isNotEmpty()) { - return - } - - name.setText(getRecipient(model)) - badge.setBadgeFromRecipient(getRecipient(model)) - - bindAvatar(model) - bindNumberField(model) - bindLabelField(model) - bindSmsTagField(model) - } - - protected open fun bindAvatar(model: T) { - avatar.setAvatar(getRecipient(model)) - } - - protected open fun bindNumberField(model: T) { - number.visible = getRecipient(model).isGroup - if (getRecipient(model).isGroup) { - number.text = getRecipient(model).participantIds - .take(10) - .map { id -> Recipient.resolved(id) } - .sortedWith(IsSelfComparator()).joinToString(", ") { - if (it.isSelf) { - context.getString(R.string.ConversationTitleView_you) - } else { - it.getShortDisplayName(context) - } - } - } - } - - protected open fun bindLabelField(model: T) { - label.visible = false - } - - protected open fun bindSmsTagField(model: T) { - smsTag.visible = when (displaySmsTag) { - DisplaySmsTag.DEFAULT -> isSmsContact(model) - DisplaySmsTag.IF_NOT_REGISTERED -> isNotRegistered(model) - DisplaySmsTag.NEVER -> false - } - smsTag.visible = isSmsContact(model) - } - - protected open fun bindLongPress(model: T) = Unit - - private fun isSmsContact(model: T): Boolean { - return (getRecipient(model).isForceSmsSelection || getRecipient(model).isUnregistered) && !getRecipient(model).isDistributionList - } - - private fun isNotRegistered(model: T): Boolean { - return getRecipient(model).isUnregistered && !getRecipient(model).isDistributionList - } - - abstract fun isSelected(model: T): Boolean - abstract fun getData(model: T): D - abstract fun getRecipient(model: T): Recipient - } - - /** - * Mapping Model for section headers - */ - class HeaderModel(val header: ContactSearchData.Header) : MappingModel { - override fun areItemsTheSame(newItem: HeaderModel): Boolean { - return header.sectionKey == newItem.header.sectionKey - } - - override fun areContentsTheSame(newItem: HeaderModel): Boolean { - return areItemsTheSame(newItem) && - header.action?.icon == newItem.header.action?.icon && - header.action?.label == newItem.header.action?.label - } - } - - /** - * View Holder for section headers - */ - private class HeaderViewHolder(itemView: View) : MappingViewHolder(itemView) { - - private val headerTextView: TextView = itemView.findViewById(R.id.section_header) - private val headerActionView: MaterialButton = itemView.findViewById(R.id.section_header_action) - - override fun bind(model: HeaderModel) { - headerTextView.setText( - when (model.header.sectionKey) { - ContactSearchConfiguration.SectionKey.STORIES -> R.string.ContactsCursorLoader_my_stories - ContactSearchConfiguration.SectionKey.RECENTS -> R.string.ContactsCursorLoader_recent_chats - ContactSearchConfiguration.SectionKey.INDIVIDUALS -> R.string.ContactsCursorLoader_contacts - ContactSearchConfiguration.SectionKey.GROUPS -> R.string.ContactsCursorLoader_groups - } - ) - - if (model.header.action != null) { - headerActionView.visible = true - headerActionView.setIconResource(model.header.action.icon) - headerActionView.setText(model.header.action.label) - headerActionView.setOnClickListener { model.header.action.action.run() } - } else { - headerActionView.visible = false - } - } - } - - /** - * Mapping Model for expandable content rows. - */ - class ExpandModel(val expand: ContactSearchData.Expand) : MappingModel { - override fun areItemsTheSame(newItem: ExpandModel): Boolean { - return expand.contactSearchKey == newItem.expand.contactSearchKey - } - - override fun areContentsTheSame(newItem: ExpandModel): Boolean { - return areItemsTheSame(newItem) - } - } - - /** - * View Holder for expandable content rows. - */ - private class ExpandViewHolder(itemView: View, private val expandListener: (ContactSearchData.Expand) -> Unit) : MappingViewHolder(itemView) { - override fun bind(model: ExpandModel) { - itemView.setOnClickListener { expandListener.invoke(model.expand) } - } - } - - private class IsSelfComparator : Comparator { - override fun compare(lhs: Recipient?, rhs: Recipient?): Int { - val isLeftSelf = lhs?.isSelf == true - val isRightSelf = rhs?.isSelf == true - - return if (isLeftSelf == isRightSelf) 0 else if (isLeftSelf) 1 else -1 - } - } - - interface StoryContextMenuCallbacks { - fun onOpenStorySettings(story: ContactSearchData.Story) - fun onRemoveGroupStory(story: ContactSearchData.Story, isSelected: Boolean) - fun onDeletePrivateStory(story: ContactSearchData.Story, isSelected: Boolean) - } - - enum class DisplaySmsTag { - DEFAULT, - IF_NOT_REGISTERED, - NEVER - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchKey.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchKey.kt index eed3dded19..501bf94eb1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchKey.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchKey.kt @@ -35,4 +35,11 @@ sealed class ContactSearchKey { * Key to an expand button for a given section */ data class Expand(val sectionKey: ContactSearchConfiguration.SectionKey) : ContactSearchKey() + + /** + * Arbitrary takes a string type and will map to exactly one ArbitraryData object. + * + * This is used to allow arbitrary extra data to be added to the contact search system. + */ + data class Arbitrary(val type: String) : ContactSearchKey() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchMediator.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchMediator.kt index 737edb15cb..3ec783af76 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchMediator.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchMediator.kt @@ -24,14 +24,18 @@ class ContactSearchMediator( recyclerView: RecyclerView, selectionLimits: SelectionLimits, displayCheckBox: Boolean, - displaySmsTag: ContactSearchItems.DisplaySmsTag, + displaySmsTag: ContactSearchAdapter.DisplaySmsTag, mapStateToConfiguration: (ContactSearchState) -> ContactSearchConfiguration, private val contactSelectionPreFilter: (View?, Set) -> Set = { _, s -> s }, performSafetyNumberChecks: Boolean = true, - adapterFactory: AdapterFactory = DefaultAdapterFactory + adapterFactory: AdapterFactory = DefaultAdapterFactory, + arbitraryRepository: ArbitraryRepository? = null ) { - private val viewModel: ContactSearchViewModel = ViewModelProvider(fragment, ContactSearchViewModel.Factory(selectionLimits, ContactSearchRepository(), performSafetyNumberChecks)).get(ContactSearchViewModel::class.java) + private val viewModel: ContactSearchViewModel = ViewModelProvider( + fragment, + ContactSearchViewModel.Factory(selectionLimits, ContactSearchRepository(), performSafetyNumberChecks, arbitraryRepository) + )[ContactSearchViewModel::class.java] init { val adapter = adapterFactory.create( @@ -51,7 +55,7 @@ class ContactSearchMediator( ) dataAndSelection.observe(fragment.viewLifecycleOwner) { (data, selection) -> - adapter.submitList(ContactSearchItems.toMappingModelList(data, selection)) + adapter.submitList(ContactSearchAdapter.toMappingModelList(data, selection, arbitraryRepository)) } viewModel.controller.observe(fragment.viewLifecycleOwner) { controller -> @@ -111,7 +115,7 @@ class ContactSearchMediator( } } - private inner class StoryContextMenuCallbacks : ContactSearchItems.StoryContextMenuCallbacks { + private inner class StoryContextMenuCallbacks : ContactSearchAdapter.StoryContextMenuCallbacks { override fun onOpenStorySettings(story: ContactSearchData.Story) { if (story.recipient.isMyStory) { MyStorySettingsFragment.createAsDialog() @@ -148,10 +152,10 @@ class ContactSearchMediator( fun interface AdapterFactory { fun create( displayCheckBox: Boolean, - displaySmsTag: ContactSearchItems.DisplaySmsTag, + displaySmsTag: ContactSearchAdapter.DisplaySmsTag, recipientListener: (View, ContactSearchData.KnownRecipient, Boolean) -> Unit, storyListener: (View, ContactSearchData.Story, Boolean) -> Unit, - storyContextMenuCallbacks: ContactSearchItems.StoryContextMenuCallbacks, + storyContextMenuCallbacks: ContactSearchAdapter.StoryContextMenuCallbacks, expandListener: (ContactSearchData.Expand) -> Unit ): PagingMappingAdapter } @@ -159,10 +163,10 @@ class ContactSearchMediator( private object DefaultAdapterFactory : AdapterFactory { override fun create( displayCheckBox: Boolean, - displaySmsTag: ContactSearchItems.DisplaySmsTag, + displaySmsTag: ContactSearchAdapter.DisplaySmsTag, recipientListener: (View, ContactSearchData.KnownRecipient, Boolean) -> Unit, storyListener: (View, ContactSearchData.Story, Boolean) -> Unit, - storyContextMenuCallbacks: ContactSearchItems.StoryContextMenuCallbacks, + storyContextMenuCallbacks: ContactSearchAdapter.StoryContextMenuCallbacks, expandListener: (ContactSearchData.Expand) -> Unit ): PagingMappingAdapter { return ContactSearchAdapter(displayCheckBox, displaySmsTag, recipientListener, storyListener, storyContextMenuCallbacks, expandListener) diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSource.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSource.kt index 043c43f520..dfb6f0ca86 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSource.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSource.kt @@ -19,7 +19,8 @@ import java.util.concurrent.TimeUnit */ class ContactSearchPagedDataSource( private val contactConfiguration: ContactSearchConfiguration, - private val contactSearchPagedDataSourceRepository: ContactSearchPagedDataSourceRepository = ContactSearchPagedDataSourceRepository(ApplicationDependencies.getApplication()) + private val contactSearchPagedDataSourceRepository: ContactSearchPagedDataSourceRepository = ContactSearchPagedDataSourceRepository(ApplicationDependencies.getApplication()), + private val arbitraryRepository: ArbitraryRepository? = null ) : PagedDataSource { companion object { @@ -89,6 +90,7 @@ class ContactSearchPagedDataSource( is ContactSearchConfiguration.Section.Groups -> contactSearchPagedDataSourceRepository.getGroupSearchIterator(section, query).getCollectionSize(section, query, this::canSendToGroup) is ContactSearchConfiguration.Section.Recents -> getRecentsSearchIterator(section, query).getCollectionSize(section, query, null) is ContactSearchConfiguration.Section.Stories -> getStoriesSearchIterator(query).getCollectionSize(section, query, null) + is ContactSearchConfiguration.Section.Arbitrary -> arbitraryRepository?.getSize(section, query) ?: error("Invalid arbitrary section.") } } @@ -119,6 +121,7 @@ class ContactSearchPagedDataSource( is ContactSearchConfiguration.Section.Individuals -> getNonGroupContactsData(section, query, startIndex, endIndex) is ContactSearchConfiguration.Section.Recents -> getRecentsContactData(section, query, startIndex, endIndex) is ContactSearchConfiguration.Section.Stories -> getStoriesContactData(section, query, startIndex, endIndex) + is ContactSearchConfiguration.Section.Arbitrary -> arbitraryRepository?.getData(section, query, startIndex, endIndex) ?: error("Invalid arbitrary section.") } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchRepository.kt index 148137b7c1..237bed7493 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchRepository.kt @@ -22,6 +22,7 @@ class ContactSearchRepository { is ContactSearchKey.Expand -> false is ContactSearchKey.Header -> false is ContactSearchKey.RecipientSearchKey -> canSelectRecipient(it.recipientId) + is ContactSearchKey.Arbitrary -> false } ContactSearchSelectionResult(it, isSelectable) }.toSet() diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchViewModel.kt index 4bdb4f600b..c62aa21610 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchViewModel.kt @@ -27,6 +27,7 @@ class ContactSearchViewModel( private val contactSearchRepository: ContactSearchRepository, private val performSafetyNumberChecks: Boolean, private val safetyNumberRepository: SafetyNumberRepository = SafetyNumberRepository(), + private val arbitraryRepository: ArbitraryRepository? ) : ViewModel() { private val disposables = CompositeDisposable() @@ -53,7 +54,7 @@ class ContactSearchViewModel( } fun setConfiguration(contactSearchConfiguration: ContactSearchConfiguration) { - val pagedDataSource = ContactSearchPagedDataSource(contactSearchConfiguration) + val pagedDataSource = ContactSearchPagedDataSource(contactSearchConfiguration, arbitraryRepository = arbitraryRepository) pagedData.value = PagedData.createForLiveData(pagedDataSource, pagingConfig) } @@ -139,10 +140,11 @@ class ContactSearchViewModel( class Factory( private val selectionLimits: SelectionLimits, private val repository: ContactSearchRepository, - private val performSafetyNumberChecks: Boolean + private val performSafetyNumberChecks: Boolean, + private val arbitraryRepository: ArbitraryRepository? ) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { - return modelClass.cast(ContactSearchViewModel(selectionLimits, repository, performSafetyNumberChecks)) as T + return modelClass.cast(ContactSearchViewModel(selectionLimits, repository, performSafetyNumberChecks, arbitraryRepository = arbitraryRepository)) as T } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardFragment.kt index ce7b9e1123..a167df9fca 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardFragment.kt @@ -33,9 +33,9 @@ import org.thoughtcrime.securesms.color.ViewColorSet import org.thoughtcrime.securesms.components.ContactFilterView import org.thoughtcrime.securesms.components.TooltipPopup import org.thoughtcrime.securesms.components.WrapperDialogFragment +import org.thoughtcrime.securesms.contacts.paged.ContactSearchAdapter import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration import org.thoughtcrime.securesms.contacts.paged.ContactSearchError -import org.thoughtcrime.securesms.contacts.paged.ContactSearchItems import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey import org.thoughtcrime.securesms.contacts.paged.ContactSearchMediator import org.thoughtcrime.securesms.contacts.paged.ContactSearchState @@ -118,7 +118,15 @@ class MultiselectForwardFragment : view.minimumHeight = resources.displayMetrics.heightPixels contactSearchRecycler = view.findViewById(R.id.contact_selection_list) - contactSearchMediator = ContactSearchMediator(this, contactSearchRecycler, FeatureFlags.shareSelectionLimit(), !args.selectSingleRecipient, ContactSearchItems.DisplaySmsTag.DEFAULT, this::getConfiguration, this::filterContacts) + contactSearchMediator = ContactSearchMediator( + this, + contactSearchRecycler, + FeatureFlags.shareSelectionLimit(), + !args.selectSingleRecipient, + ContactSearchAdapter.DisplaySmsTag.DEFAULT, + this::getConfiguration, + this::filterContacts + ) callback = findListener()!! disposables.bindTo(viewLifecycleOwner.lifecycle) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/SearchConfigurationProvider.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/SearchConfigurationProvider.kt index 3b2ace8fe3..a2a18830ac 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/SearchConfigurationProvider.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/SearchConfigurationProvider.kt @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.conversation.mutiselect.forward import androidx.fragment.app.FragmentManager +import org.thoughtcrime.securesms.contacts.paged.ArbitraryRepository import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration import org.thoughtcrime.securesms.contacts.paged.ContactSearchState @@ -15,4 +16,9 @@ interface SearchConfigurationProvider { * @return A configuration or null. Returning null will result in MultiselectForwardFragment using it's default configuration. */ fun getSearchConfiguration(fragmentManager: FragmentManager, contactSearchState: ContactSearchState): ContactSearchConfiguration? = null + + /** + * @return An ArbitraryRepository or null. Returning null will result in not being able to use the Arbitrary section, keys, or data. + */ + fun getArbitraryRepository(): ArbitraryRepository? = null } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/stories/ChooseGroupStoryBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/stories/ChooseGroupStoryBottomSheet.kt index 1bc40033ed..51bb42097a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/stories/ChooseGroupStoryBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/stories/ChooseGroupStoryBottomSheet.kt @@ -15,8 +15,8 @@ import androidx.recyclerview.widget.RecyclerView import org.signal.core.util.DimensionUnit import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment +import org.thoughtcrime.securesms.contacts.paged.ContactSearchAdapter import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration -import org.thoughtcrime.securesms.contacts.paged.ContactSearchItems import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey import org.thoughtcrime.securesms.contacts.paged.ContactSearchMediator import org.thoughtcrime.securesms.contacts.paged.ContactSearchSortOrder @@ -67,7 +67,7 @@ class ChooseGroupStoryBottomSheet : FixedRoundedCornerBottomSheetDialogFragment( recyclerView = contactRecycler, selectionLimits = FeatureFlags.shareSelectionLimit(), displayCheckBox = true, - displaySmsTag = ContactSearchItems.DisplaySmsTag.DEFAULT, + displaySmsTag = ContactSearchAdapter.DisplaySmsTag.DEFAULT, mapStateToConfiguration = { state -> ContactSearchConfiguration.build { query = state.query diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/connections/ViewAllSignalConnectionsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/connections/ViewAllSignalConnectionsFragment.kt index 26f855e7a7..996d04ecf7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/connections/ViewAllSignalConnectionsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/connections/ViewAllSignalConnectionsFragment.kt @@ -8,8 +8,8 @@ import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.ViewBinderDelegate import org.thoughtcrime.securesms.components.WrapperDialogFragment import org.thoughtcrime.securesms.contacts.LetterHeaderDecoration +import org.thoughtcrime.securesms.contacts.paged.ContactSearchAdapter import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration -import org.thoughtcrime.securesms.contacts.paged.ContactSearchItems import org.thoughtcrime.securesms.contacts.paged.ContactSearchMediator import org.thoughtcrime.securesms.databinding.ViewAllSignalConnectionsFragmentBinding import org.thoughtcrime.securesms.groups.SelectionLimits @@ -29,7 +29,7 @@ class ViewAllSignalConnectionsFragment : Fragment(R.layout.view_all_signal_conne recyclerView = binding.recycler, selectionLimits = SelectionLimits(0, 0), displayCheckBox = false, - displaySmsTag = ContactSearchItems.DisplaySmsTag.IF_NOT_REGISTERED, + displaySmsTag = ContactSearchAdapter.DisplaySmsTag.IF_NOT_REGISTERED, mapStateToConfiguration = { getConfiguration() }, performSafetyNumberChecks = false ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/story/StoriesPrivacySettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/story/StoriesPrivacySettingsFragment.kt index 6f83ef1825..a11a6f19de 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/story/StoriesPrivacySettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/story/StoriesPrivacySettingsFragment.kt @@ -13,7 +13,7 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment import org.thoughtcrime.securesms.components.settings.DSLSettingsText import org.thoughtcrime.securesms.components.settings.configure -import org.thoughtcrime.securesms.contacts.paged.ContactSearchItems +import org.thoughtcrime.securesms.contacts.paged.ContactSearchAdapter import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey import org.thoughtcrime.securesms.groups.ParcelableGroupId import org.thoughtcrime.securesms.keyvalue.SignalStore @@ -62,7 +62,7 @@ class StoriesPrivacySettingsFragment : } @Suppress("UNCHECKED_CAST") - ContactSearchItems.registerStoryItems( + ContactSearchAdapter.registerStoryItems( mappingAdapter = middle as PagingMappingAdapter, storyListener = { _, story, _ -> when { @@ -136,9 +136,10 @@ class StoriesPrivacySettingsFragment : private fun getMiddleConfiguration(state: StoriesPrivacySettingsState): DSLConfiguration { return if (state.areStoriesEnabled) { configure { - ContactSearchItems.toMappingModelList( + ContactSearchAdapter.toMappingModelList( state.storyContactItems, - emptySet() + emptySet(), + null ).forEach { customPref(it) } diff --git a/app/src/test/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSourceTest.kt b/app/src/test/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSourceTest.kt index ef6a70b92b..b736026b82 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSourceTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSourceTest.kt @@ -1,21 +1,26 @@ package org.thoughtcrime.securesms.contacts.paged +import android.app.Application +import androidx.core.os.bundleOf import org.junit.Assert import org.junit.Before import org.junit.Test import org.junit.runner.RunWith -import org.junit.runners.JUnit4 import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.isNull import org.mockito.kotlin.mock import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config import org.thoughtcrime.securesms.MockCursor import org.thoughtcrime.securesms.database.model.DistributionListPrivacyMode import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel -@RunWith(JUnit4::class) +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class) class ContactSearchPagedDataSourceTest { private val repository: ContactSearchPagedDataSourceRepository = mock() @@ -117,6 +122,36 @@ class ContactSearchPagedDataSourceTest { Assert.assertEquals(expected, resultKeys) } + @Test + fun `Given only arbitrary elements, when I size, then I expect 3`() { + val testSubject = createArbitrarySubject() + val expected = 3 + val actual = testSubject.size() + + Assert.assertEquals(expected, actual) + } + + @Test + fun `Given only arbitrary elements, when I load 1, then I expect 1`() { + val testSubject = createArbitrarySubject() + val expected = ContactSearchData.Arbitrary("two", bundleOf("n" to "two")) + val actual = testSubject.load(1, 1) { false }[0] as ContactSearchData.Arbitrary + + Assert.assertEquals(expected.data?.getString("n"), actual.data?.getString("n")) + } + + private fun createArbitrarySubject(): ContactSearchPagedDataSource { + val configuration = ContactSearchConfiguration.build { + arbitrary( + "one", + "two", + "three" + ) + } + + return ContactSearchPagedDataSource(configuration, repository, ArbitraryRepoFake()) + } + private fun createStoriesSubject(): ContactSearchPagedDataSource { val configuration = ContactSearchConfiguration.build { addSection( @@ -161,4 +196,24 @@ class ContactSearchPagedDataSourceTest { return ContactSearchPagedDataSource(configuration, repository) } + + private class ArbitraryModel : MappingModel { + override fun areItemsTheSame(newItem: ArbitraryModel): Boolean = true + + override fun areContentsTheSame(newItem: ArbitraryModel): Boolean = true + } + + private class ArbitraryRepoFake : ArbitraryRepository { + override fun getSize(section: ContactSearchConfiguration.Section.Arbitrary, query: String?): Int = section.types.size + + override fun getData(section: ContactSearchConfiguration.Section.Arbitrary, query: String?, startIndex: Int, endIndex: Int): List { + return section.types.toList().slice(startIndex..endIndex).map { + ContactSearchData.Arbitrary(it, bundleOf("n" to it)) + } + } + + override fun getMappingModel(arbitrary: ContactSearchData.Arbitrary): MappingModel<*> { + return ArbitraryModel() + } + } }