Implement Stories feature behind flag.

Co-Authored-By: Greyson Parrelli <37311915+greyson-signal@users.noreply.github.com>
Co-Authored-By: Rashad Sookram <95182499+rashad-signal@users.noreply.github.com>
This commit is contained in:
Alex Hart
2022-02-24 13:40:28 -04:00
parent 765185952e
commit 174cd860a0
416 changed files with 19506 additions and 857 deletions

View File

@@ -115,14 +115,14 @@ public class ContactSelectionListItem extends ConstraintLayout implements Recipi
Recipient recipientSnapshot = recipient != null ? recipient.get() : null;
if (recipientSnapshot != null && !recipientSnapshot.isResolving()) {
if (recipientSnapshot != null && !recipientSnapshot.isResolving() && !recipientSnapshot.isMyStory()) {
contactName = recipientSnapshot.getDisplayName(getContext());
name = contactName;
} else if (recipient != null) {
name = "";
}
if (recipientSnapshot == null || recipientSnapshot.isResolving() || recipientSnapshot.isRegistered()) {
if (recipientSnapshot == null || recipientSnapshot.isResolving() || recipientSnapshot.isRegistered() || recipientSnapshot.isDistributionList()) {
smsTag.setVisibility(GONE);
} else {
smsTag.setVisibility(VISIBLE);
@@ -131,6 +131,9 @@ public class ContactSelectionListItem extends ConstraintLayout implements Recipi
if (recipientSnapshot == null || recipientSnapshot.isResolving()) {
this.contactPhotoImage.setAvatar(glideRequests, null, false);
setText(null, type, name, number, label, about);
} else if (recipientSnapshot.isMyStory()) {
this.contactPhotoImage.setRecipient(Recipient.self(), false);
setText(recipientSnapshot, type, name, number, label, about);
} else {
this.contactPhotoImage.setAvatar(glideRequests, recipientSnapshot, false);
setText(recipientSnapshot, type, name, number, label, about);
@@ -180,6 +183,9 @@ public class ContactSelectionListItem extends ConstraintLayout implements Recipi
this.nameView.setEnabled(true);
this.labelView.setText(label);
this.labelView.setVisibility(View.VISIBLE);
} else if (recipient != null && recipient.isDistributionList()) {
this.numberView.setText(getViewerCount(number));
this.labelView.setVisibility(View.GONE);
} else {
this.numberView.setText(!Util.isEmpty(about) ? about : number);
this.nameView.setEnabled(true);
@@ -212,6 +218,11 @@ public class ContactSelectionListItem extends ConstraintLayout implements Recipi
return getContext().getResources().getQuantityString(R.plurals.contact_selection_list_item__number_of_members, memberCount, memberCount);
}
private String getViewerCount(@NonNull String number) {
int viewerCount = Integer.parseInt(number);
return getContext().getResources().getQuantityString(R.plurals.contact_selection_list_item__number_of_viewers, viewerCount, viewerCount);
}
public @Nullable LiveRecipient getRecipient() {
return recipient;
}
@@ -234,13 +245,18 @@ public class ContactSelectionListItem extends ConstraintLayout implements Recipi
contactNumber = recipient.getGroupId().get().toString();
} else if (recipient.hasE164()) {
contactNumber = PhoneNumberFormatter.prettyPrint(recipient.getE164().or(""));
} else {
} else if (!recipient.isDistributionList()) {
contactNumber = recipient.getEmail().or("");
}
contactPhotoImage.setAvatar(glideRequests, recipient, false);
if (recipient.isMyStory()) {
contactPhotoImage.setRecipient(Recipient.self(), false);
} else {
contactPhotoImage.setAvatar(glideRequests, recipient, false);
}
setText(recipient, contactType, contactName, contactNumber, contactLabel, contactAbout);
smsTag.setVisibility(recipient.isRegistered() ? GONE : VISIBLE);
smsTag.setVisibility(recipient.isRegistered() || recipient.isDistributionList() ? GONE : VISIBLE);
badge.setBadgeFromRecipient(recipient);
} else {
Log.w(TAG, "Bad change! Local recipient doesn't match. Ignoring. Local: " + (this.recipient == null ? "null" : this.recipient.getId()) + ", Changed: " + recipient.getId());

View File

@@ -27,13 +27,18 @@ import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.database.GroupDatabase;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.ThreadDatabase;
import org.thoughtcrime.securesms.database.model.DistributionListPartialRecord;
import org.thoughtcrime.securesms.database.model.ThreadRecord;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.phonenumbers.NumberUtil;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.UsernameUtil;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Set;
/**
* CursorLoader that initializes a ContactsDatabase instance
@@ -55,13 +60,14 @@ public class ContactsCursorLoader extends AbstractContactsCursorLoader {
public static final int FLAG_HIDE_NEW = 1 << 6;
public static final int FLAG_HIDE_RECENT_HEADER = 1 << 7;
public static final int FLAG_GROUPS_AFTER_CONTACTS = 1 << 8;
public static final int FLAG_ALL = FLAG_PUSH | FLAG_SMS | FLAG_ACTIVE_GROUPS | FLAG_INACTIVE_GROUPS | FLAG_SELF;
public static final int FLAG_STORIES = 1 << 9;
public static final int FLAG_ALL = FLAG_PUSH | FLAG_SMS | FLAG_ACTIVE_GROUPS | FLAG_INACTIVE_GROUPS | FLAG_SELF | FLAG_STORIES;
}
private static final int RECENT_CONVERSATION_MAX = 25;
private final int mode;
private final boolean recents;
private final int mode;
private final boolean recents;
private final ContactRepository contactRepository;
@@ -85,6 +91,7 @@ public class ContactsCursorLoader extends AbstractContactsCursorLoader {
addRecentGroupsSection(cursorList);
addGroupsSection(cursorList);
} else {
addStoriesSection(cursorList);
addRecentsSection(cursorList);
addContactsSection(cursorList);
if (addGroupsAfterContacts(mode)) {
@@ -163,6 +170,19 @@ public class ContactsCursorLoader extends AbstractContactsCursorLoader {
}
}
private void addStoriesSection(@NonNull List<Cursor> cursorList) {
if (!FeatureFlags.stories() || !storiesEnabled(mode) || SignalStore.storyValues().isFeatureDisabled()) {
return;
}
Cursor stories = getStoriesCursor();
if (stories.getCount() > 0) {
cursorList.add(ContactsCursorRows.forStoriesHeader(getContext()));
cursorList.add(stories);
}
}
private void addNewNumberSection(@NonNull List<Cursor> cursorList) {
if (FeatureFlags.usernames() && NumberUtil.isVisuallyValidNumberOrEmail(getFilter())) {
cursorList.add(ContactsCursorRows.forPhoneNumberSearchHeader(getContext()));
@@ -223,6 +243,16 @@ public class ContactsCursorLoader extends AbstractContactsCursorLoader {
return groupContacts;
}
private Cursor getStoriesCursor() {
MatrixCursor distributionListsCursor = ContactsCursorRows.createMatrixCursor();
List<DistributionListPartialRecord> distributionLists = SignalDatabase.distributionLists().getAllListsForContactSelectionUi(null, true);
for (final DistributionListPartialRecord distributionList : distributionLists) {
distributionListsCursor.addRow(ContactsCursorRows.forDistributionList(distributionList));
}
return distributionListsCursor;
}
private Cursor getNewNumberCursor() {
return ContactsCursorRows.forNewNumber(getUnknownContactTitle(), getFilter());
}
@@ -293,16 +323,20 @@ public class ContactsCursorLoader extends AbstractContactsCursorLoader {
return flagSet(mode, DisplayMode.FLAG_GROUPS_AFTER_CONTACTS);
}
private static boolean storiesEnabled(int mode) {
return flagSet(mode, DisplayMode.FLAG_STORIES);
}
private static boolean flagSet(int mode, int flag) {
return (mode & flag) > 0;
}
public static class Factory implements AbstractContactsCursorLoader.Factory {
private final Context context;
private final int displayMode;
private final String cursorFilter;
private final boolean displayRecents;
private final Context context;
private final int displayMode;
private final String cursorFilter;
private final boolean displayRecents;
public Factory(Context context, int displayMode, String cursorFilter, boolean displayRecents) {
this.context = context;

View File

@@ -9,8 +9,11 @@ import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.database.GroupDatabase;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.model.DistributionListPartialRecord;
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
/**
* Helper utility for generating cursors and cursor rows for subclasses of {@link AbstractContactsCursorLoader}.
@@ -83,6 +86,16 @@ public final class ContactsCursorRows {
""};
}
public static @NonNull Object[] forDistributionList(@NonNull DistributionListPartialRecord distributionListPartialRecord) {
return new Object[]{ distributionListPartialRecord.getRecipientId().serialize(),
distributionListPartialRecord.getName(),
SignalDatabase.distributionLists().getMemberCount(distributionListPartialRecord.getId()),
ContactsContract.CommonDataKinds.Phone.TYPE_CUSTOM,
"",
ContactRepository.NORMAL_TYPE,
""};
}
/**
* Create a row for a contacts cursor for a new number the user is entering or has entered.
*/
@@ -117,6 +130,10 @@ public final class ContactsCursorRows {
return matrixCursor;
}
public static @NonNull MatrixCursor forStoriesHeader(@NonNull Context context) {
return forHeader(context.getString(R.string.ContactsCursorLoader_my_stories));
}
public static @NonNull MatrixCursor forUsernameSearchHeader(@NonNull Context context) {
return forHeader(context.getString(R.string.ContactsCursorLoader_username_search));
}

View File

@@ -0,0 +1,11 @@
package org.thoughtcrime.securesms.contacts
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
/**
* An action which can be attached to the first item in the list, but only if that item is a divider.
*/
class HeaderAction(@param:StringRes val label: Int, @param:DrawableRes val icon: Int, val action: Runnable) {
constructor(@StringRes label: Int, action: Runnable) : this(label, 0, action) {}
}

View File

@@ -26,6 +26,10 @@ public final class SelectedContact {
return new SelectedContact(recipientId, null, username);
}
public static @NonNull SelectedContact forRecipientId(@NonNull RecipientId recipientId) {
return new SelectedContact(recipientId, null, null);
}
private SelectedContact(@Nullable RecipientId recipientId, @Nullable String number, @Nullable String username) {
this.recipientId = recipientId;
this.number = number;

View File

@@ -0,0 +1,134 @@
package org.thoughtcrime.securesms.contacts.paged
import org.thoughtcrime.securesms.contacts.HeaderAction
/**
* A strongly typed descriptor of how a given list of contacts should be formatted
*/
class ContactSearchConfiguration private constructor(
val query: String?,
val sections: List<Section>
) {
sealed class Section(val sectionKey: SectionKey) {
abstract val includeHeader: Boolean
open val headerAction: HeaderAction? = null
abstract val expandConfig: ExpandConfig?
/**
* Distribution lists and group stories.
*/
data class Stories(
val groupStories: Set<ContactSearchData.Story> = emptySet(),
override val includeHeader: Boolean,
override val headerAction: HeaderAction? = null,
override val expandConfig: ExpandConfig? = null
) : Section(SectionKey.STORIES)
/**
* Recent contacts
*/
data class Recents(
val limit: Int = 25,
val groupsOnly: Boolean = false,
val includeInactiveGroups: Boolean = false,
val includeGroupsV1: Boolean = false,
val includeSms: Boolean = false,
override val includeHeader: Boolean,
override val expandConfig: ExpandConfig? = null
) : Section(SectionKey.RECENTS)
/**
* 1:1 Recipients
*/
data class Individuals(
val includeSelf: Boolean,
val transportType: TransportType,
override val includeHeader: Boolean,
override val expandConfig: ExpandConfig? = null
) : Section(SectionKey.INDIVIDUALS)
/**
* Group Recipients
*/
data class Groups(
val includeMms: Boolean = false,
val includeV1: Boolean = false,
val includeInactive: Boolean = false,
val returnAsGroupStories: Boolean = false,
override val includeHeader: Boolean,
override val expandConfig: ExpandConfig? = null
) : Section(SectionKey.GROUPS)
}
/**
* Describes a given section. Useful for labeling sections and managing expansion state.
*/
enum class SectionKey {
STORIES,
RECENTS,
INDIVIDUALS,
GROUPS
}
/**
* Describes how a given section can be expanded.
*/
data class ExpandConfig(
val isExpanded: Boolean,
val maxCountWhenNotExpanded: Int = 2
)
/**
* Network transport type for individual recipients.
*/
enum class TransportType {
PUSH,
SMS,
ALL
}
companion object {
/**
* DSL Style builder function. Example:
*
* ```
* val configuration = ContactSearchConfiguration.build {
* query = "My Query"
* addSection(Recents(...))
* }
* ```
*/
fun build(builderFunction: Builder.() -> Unit): ContactSearchConfiguration {
return ConfigurationBuilder().let {
it.builderFunction()
it.build()
}
}
}
/**
* Internal builder class with build method.
*/
private class ConfigurationBuilder : Builder {
private val sections: MutableList<Section> = mutableListOf()
override var query: String? = null
override fun addSection(section: Section) {
sections.add(section)
}
fun build(): ContactSearchConfiguration {
return ContactSearchConfiguration(query, sections)
}
}
/**
* Exposed Builder interface without build method.
*/
interface Builder {
var query: String?
fun addSection(section: Section)
}
}

View File

@@ -0,0 +1,34 @@
package org.thoughtcrime.securesms.contacts.paged
import org.thoughtcrime.securesms.contacts.HeaderAction
import org.thoughtcrime.securesms.recipients.Recipient
/**
* Represents the data backed by a ContactSearchKey
*/
sealed class ContactSearchData(val contactSearchKey: ContactSearchKey) {
/**
* A row displaying a story.
*
* Note that if the recipient is a group, it's participant list size is used instead of viewerCount.
*/
data class Story(val recipient: Recipient, val viewerCount: Int) : ContactSearchData(ContactSearchKey.Story(recipient.id))
/**
* A row displaying a known recipient.
*/
data class KnownRecipient(val recipient: Recipient) : ContactSearchData(ContactSearchKey.KnownRecipient(recipient.id))
/**
* A row containing a title for a given section
*/
class Header(
val sectionKey: ContactSearchConfiguration.SectionKey,
val action: HeaderAction?
) : ContactSearchData(ContactSearchKey.Header(sectionKey))
/**
* A row which the user can click to view all entries for a given section.
*/
class Expand(val sectionKey: ContactSearchConfiguration.SectionKey) : ContactSearchData(ContactSearchKey.Expand(sectionKey))
}

View File

@@ -0,0 +1,247 @@
package org.thoughtcrime.securesms.contacts.paged
import android.view.View
import android.widget.CheckBox
import android.widget.TextView
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.BadgeImageView
import org.thoughtcrime.securesms.components.AvatarImageView
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.visible
/**
* Mapping Models and View Holders for ContactSearchData
*/
object ContactSearchItems {
fun register(
mappingAdapter: MappingAdapter,
recipientListener: (ContactSearchData.KnownRecipient, Boolean) -> Unit,
storyListener: (ContactSearchData.Story, Boolean) -> Unit,
expandListener: (ContactSearchData.Expand) -> Unit
) {
mappingAdapter.registerFactory(
StoryModel::class.java,
LayoutFactory({ StoryViewHolder(it, storyListener) }, R.layout.contact_search_item)
)
mappingAdapter.registerFactory(
RecipientModel::class.java,
LayoutFactory({ KnownRecipientViewHolder(it, recipientListener) }, R.layout.contact_search_item)
)
mappingAdapter.registerFactory(
HeaderModel::class.java,
LayoutFactory({ HeaderViewHolder(it) }, R.layout.contact_search_section_header)
)
mappingAdapter.registerFactory(
ExpandModel::class.java,
LayoutFactory({ ExpandViewHolder(it, expandListener) }, R.layout.contacts_expand_item)
)
}
fun toMappingModelList(contactSearchData: List<ContactSearchData?>, selection: Set<ContactSearchKey>): MappingModelList {
return MappingModelList(
contactSearchData.filterNotNull().map {
when (it) {
is ContactSearchData.Story -> StoryModel(it, selection.contains(it.contactSearchKey))
is ContactSearchData.KnownRecipient -> RecipientModel(it, selection.contains(it.contactSearchKey))
is ContactSearchData.Expand -> ExpandModel(it)
is ContactSearchData.Header -> HeaderModel(it)
}
}
)
}
/**
* Story Model
*/
private class StoryModel(val story: ContactSearchData.Story, val isSelected: Boolean) : MappingModel<StoryModel> {
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
}
override fun getChangePayload(newItem: StoryModel): Any? {
return if (story.recipient.hasSameContent(newItem.story.recipient) && newItem.isSelected != isSelected) {
0
} else {
null
}
}
}
private class StoryViewHolder(itemView: View, onClick: (ContactSearchData.Story, Boolean) -> Unit) : BaseRecipientViewHolder<StoryModel, ContactSearchData.Story>(itemView, 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.participants.size
} else {
model.story.viewerCount
}
number.text = context.resources.getQuantityString(R.plurals.SelectViewersFragment__d_viewers, count, count)
}
}
/**
* Recipient model
*/
private class RecipientModel(val knownRecipient: ContactSearchData.KnownRecipient, val isSelected: Boolean) : MappingModel<RecipientModel> {
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, onClick: (ContactSearchData.KnownRecipient, Boolean) -> Unit) : BaseRecipientViewHolder<RecipientModel, ContactSearchData.KnownRecipient>(itemView, onClick) {
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
}
/**
* Base Recipient View Holder
*/
private abstract class BaseRecipientViewHolder<T, D : ContactSearchData>(itemView: View, val onClick: (D, Boolean) -> Unit) : MappingViewHolder<T>(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: TextView = 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.isChecked = isSelected(model)
itemView.setOnClickListener { onClick(getData(model), isSelected(model)) }
if (payload.isNotEmpty()) {
return
}
if (getRecipient(model).isSelf) {
name.setText(R.string.note_to_self)
} else {
name.text = getRecipient(model).getDisplayName(context)
}
avatar.setAvatar(getRecipient(model))
badge.setBadgeFromRecipient(getRecipient(model))
bindNumberField(model)
bindLabelField(model)
bindSmsTagField(model)
}
protected open fun bindNumberField(model: T) {
number.visible = getRecipient(model).isGroup
if (getRecipient(model).isGroup) {
val members = getRecipient(model).participants.size
number.text = context.resources.getQuantityString(R.plurals.ContactSelectionListFragment_d_members, members, members)
}
}
protected open fun bindLabelField(model: T) {
label.visible = false
}
protected open fun bindSmsTagField(model: T) {
smsTag.visible = false
}
abstract fun isSelected(model: T): Boolean
abstract fun getData(model: T): D
abstract fun getRecipient(model: T): Recipient
}
/**
* Mapping Model for section headers
*/
private class HeaderModel(val header: ContactSearchData.Header) : MappingModel<HeaderModel> {
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<HeaderModel>(itemView) {
private val headerTextView: TextView = itemView.findViewById(R.id.section_header)
private val headerActionView: TextView = 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.setCompoundDrawablesRelativeWithIntrinsicBounds(model.header.action.icon, 0, 0, 0)
headerActionView.setText(model.header.action.label)
headerActionView.setOnClickListener { model.header.action.action.run() }
} else {
headerActionView.visible = false
}
}
}
/**
* Mapping Model for expandable content rows.
*/
private class ExpandModel(val expand: ContactSearchData.Expand) : MappingModel<ExpandModel> {
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<ExpandModel>(itemView) {
override fun bind(model: ExpandModel) {
itemView.setOnClickListener { expandListener.invoke(model.expand) }
}
}
}

View File

@@ -0,0 +1,76 @@
package org.thoughtcrime.securesms.contacts.paged
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.sharing.ShareContact
/**
* Represents a row in a list of Contact results.
*/
sealed class ContactSearchKey {
/**
* Generates a ShareContact object used to display which contacts have been selected. This should *not*
* be used for the final sharing process, as it is not always truthful about, for example, KnownRecipient of
* a group vs. a group's Story.
*/
open fun requireShareContact(): ShareContact = error("This key cannot be converted into a ShareContact")
open fun requireParcelable(): Parcelable = error("This key cannot be parcelized")
/**
* Key to a Story
*/
data class Story(override val recipientId: RecipientId) : ContactSearchKey(), RecipientSearchKey {
override fun requireShareContact(): ShareContact {
return ShareContact(recipientId)
}
override fun requireParcelable(): Parcelable {
return ParcelableContactSearchKey(ParcelableType.STORY, recipientId)
}
override val isStory: Boolean = true
}
/**
* Key to a recipient which already exists in our database
*/
data class KnownRecipient(override val recipientId: RecipientId) : ContactSearchKey(), RecipientSearchKey {
override fun requireShareContact(): ShareContact {
return ShareContact(recipientId)
}
override fun requireParcelable(): Parcelable {
return ParcelableContactSearchKey(ParcelableType.KNOWN_RECIPIENT, recipientId)
}
override val isStory: Boolean = false
}
/**
* Key to a header for a given section
*/
data class Header(val sectionKey: ContactSearchConfiguration.SectionKey) : ContactSearchKey()
/**
* Key to an expand button for a given section
*/
data class Expand(val sectionKey: ContactSearchConfiguration.SectionKey) : ContactSearchKey()
@Parcelize
data class ParcelableContactSearchKey(val type: ParcelableType, val recipientId: RecipientId) : Parcelable {
fun asContactSearchKey(): ContactSearchKey {
return when (type) {
ParcelableType.STORY -> Story(recipientId)
ParcelableType.KNOWN_RECIPIENT -> KnownRecipient(recipientId)
}
}
}
enum class ParcelableType {
STORY,
KNOWN_RECIPIENT
}
}

View File

@@ -0,0 +1,81 @@
package org.thoughtcrime.securesms.contacts.paged
import androidx.fragment.app.Fragment
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModelProvider
import androidx.recyclerview.widget.RecyclerView
import org.thoughtcrime.securesms.groups.SelectionLimits
import org.thoughtcrime.securesms.util.adapter.mapping.PagingMappingAdapter
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil
class ContactSearchMediator(
fragment: Fragment,
recyclerView: RecyclerView,
selectionLimits: SelectionLimits,
mapStateToConfiguration: (ContactSearchState) -> ContactSearchConfiguration
) {
private val viewModel: ContactSearchViewModel = ViewModelProvider(fragment, ContactSearchViewModel.Factory(selectionLimits, ContactSearchRepository())).get(ContactSearchViewModel::class.java)
init {
val adapter = PagingMappingAdapter<ContactSearchKey>()
recyclerView.adapter = adapter
ContactSearchItems.register(
mappingAdapter = adapter,
recipientListener = this::toggleSelection,
storyListener = this::toggleSelection,
expandListener = { viewModel.expandSection(it.sectionKey) }
)
val dataAndSelection: LiveData<Pair<List<ContactSearchData>, Set<ContactSearchKey>>> = LiveDataUtil.combineLatest(
viewModel.data,
viewModel.selectionState,
::Pair
)
dataAndSelection.observe(fragment.viewLifecycleOwner) { (data, selection) ->
adapter.submitList(ContactSearchItems.toMappingModelList(data, selection))
}
viewModel.controller.observe(fragment.viewLifecycleOwner) { controller ->
adapter.setPagingController(controller)
}
viewModel.configurationState.observe(fragment.viewLifecycleOwner) {
viewModel.setConfiguration(mapStateToConfiguration(it))
}
}
fun onFilterChanged(filter: String?) {
viewModel.setQuery(filter)
}
fun setKeysSelected(keys: Set<ContactSearchKey>) {
viewModel.setKeysSelected(keys)
}
fun setKeysNotSelected(keys: Set<ContactSearchKey>) {
viewModel.setKeysNotSelected(keys)
}
fun getSelectedContacts(): Set<ContactSearchKey> {
return viewModel.getSelectedContacts()
}
fun getSelectionState(): LiveData<Set<ContactSearchKey>> {
return viewModel.selectionState
}
fun addToVisibleGroupStories(groupStories: Set<ContactSearchKey.Story>) {
viewModel.addToVisibleGroupStories(groupStories)
}
private fun toggleSelection(contactSearchData: ContactSearchData, isSelected: Boolean) {
if (isSelected) {
viewModel.setKeysNotSelected(setOf(contactSearchData.contactSearchKey))
} else {
viewModel.setKeysSelected(setOf(contactSearchData.contactSearchKey))
}
}
}

View File

@@ -0,0 +1,260 @@
package org.thoughtcrime.securesms.contacts.paged
import android.database.Cursor
import org.signal.paging.PagedDataSource
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import kotlin.math.min
/**
* Manages the querying of contact information based off a configuration.
*/
class ContactSearchPagedDataSource(
private val contactConfiguration: ContactSearchConfiguration,
private val contactSearchPagedDataSourceRepository: ContactSearchPagedDataSourceRepository = ContactSearchPagedDataSourceRepository(ApplicationDependencies.getApplication())
) : PagedDataSource<ContactSearchKey, ContactSearchData> {
override fun size(): Int {
return contactConfiguration.sections.sumBy {
getSectionSize(it, contactConfiguration.query)
}
}
override fun load(start: Int, length: Int, cancellationSignal: PagedDataSource.CancellationSignal): MutableList<ContactSearchData> {
val sizeMap: Map<ContactSearchConfiguration.Section, Int> = contactConfiguration.sections.associateWith { getSectionSize(it, contactConfiguration.query) }
val startIndex: Index = findIndex(sizeMap, start)
val endIndex: Index = findIndex(sizeMap, start + length)
val indexOfStartSection = contactConfiguration.sections.indexOf(startIndex.category)
val indexOfEndSection = contactConfiguration.sections.indexOf(endIndex.category)
val results: List<List<ContactSearchData>> = contactConfiguration.sections.mapIndexed { index, section ->
if (index in indexOfStartSection..indexOfEndSection) {
getSectionData(
section = section,
query = contactConfiguration.query,
startIndex = if (index == indexOfStartSection) startIndex.offset else 0,
endIndex = if (index == indexOfEndSection) endIndex.offset else sizeMap[section] ?: error("Unknown section")
)
} else {
emptyList()
}
}
return results.flatten().toMutableList()
}
private fun findIndex(sizeMap: Map<ContactSearchConfiguration.Section, Int>, target: Int): Index {
var offset = 0
sizeMap.forEach { (key, size) ->
if (offset + size > target) {
return Index(key, target - offset)
}
offset += size
}
return Index(sizeMap.keys.last(), sizeMap.values.last())
}
data class Index(val category: ContactSearchConfiguration.Section, val offset: Int)
override fun load(key: ContactSearchKey?): ContactSearchData? {
throw UnsupportedOperationException()
}
override fun getKey(data: ContactSearchData): ContactSearchKey {
return data.contactSearchKey
}
private fun getSectionSize(section: ContactSearchConfiguration.Section, query: String?): Int {
val cursor: Cursor = when (section) {
is ContactSearchConfiguration.Section.Individuals -> getNonGroupContactsCursor(section, query)
is ContactSearchConfiguration.Section.Groups -> contactSearchPagedDataSourceRepository.getGroupContacts(section, query)
is ContactSearchConfiguration.Section.Recents -> getRecentsCursor(section, query)
is ContactSearchConfiguration.Section.Stories -> getStoriesCursor(query)
}!!
val extras: List<ContactSearchData> = when (section) {
is ContactSearchConfiguration.Section.Stories -> getFilteredGroupStories(section, query)
else -> emptyList()
}
val collection = ResultsCollection(
section = section,
cursor = cursor,
extraData = extras,
cursorMapper = { error("Unsupported") }
)
return collection.getSize()
}
private fun getFilteredGroupStories(section: ContactSearchConfiguration.Section.Stories, query: String?): List<ContactSearchData> {
return section.groupStories.filter { contactSearchPagedDataSourceRepository.recipientNameContainsQuery(it.recipient, query) }
}
private fun getSectionData(section: ContactSearchConfiguration.Section, query: String?, startIndex: Int, endIndex: Int): List<ContactSearchData> {
return when (section) {
is ContactSearchConfiguration.Section.Groups -> getGroupContactsData(section, query, startIndex, endIndex)
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)
}
}
private fun getNonGroupContactsCursor(section: ContactSearchConfiguration.Section.Individuals, query: String?): Cursor? {
return when (section.transportType) {
ContactSearchConfiguration.TransportType.PUSH -> contactSearchPagedDataSourceRepository.querySignalContacts(query, section.includeSelf)
ContactSearchConfiguration.TransportType.SMS -> contactSearchPagedDataSourceRepository.queryNonSignalContacts(query)
ContactSearchConfiguration.TransportType.ALL -> contactSearchPagedDataSourceRepository.queryNonGroupContacts(query, section.includeSelf)
}
}
private fun getStoriesCursor(query: String?): Cursor? {
return contactSearchPagedDataSourceRepository.getStories(query)
}
private fun getRecentsCursor(section: ContactSearchConfiguration.Section.Recents, query: String?): Cursor? {
if (!query.isNullOrEmpty()) {
throw IllegalArgumentException("Searching Recents is not supported")
}
return contactSearchPagedDataSourceRepository.getRecents(section)
}
private fun readContactDataFromCursor(
cursor: Cursor,
section: ContactSearchConfiguration.Section,
startIndex: Int,
endIndex: Int,
cursorRowToData: (Cursor) -> ContactSearchData,
extraData: List<ContactSearchData> = emptyList()
): List<ContactSearchData> {
val results = mutableListOf<ContactSearchData>()
val collection = ResultsCollection(section, cursor, extraData, cursorRowToData)
results.addAll(collection.getSublist(startIndex, endIndex))
return results
}
private fun getStoriesContactData(section: ContactSearchConfiguration.Section.Stories, query: String?, startIndex: Int, endIndex: Int): List<ContactSearchData> {
return getStoriesCursor(query)?.use { cursor ->
readContactDataFromCursor(
cursor = cursor,
section = section,
startIndex = startIndex,
endIndex = endIndex,
cursorRowToData = {
val recipient = contactSearchPagedDataSourceRepository.getRecipientFromDistributionListCursor(it)
ContactSearchData.Story(recipient, contactSearchPagedDataSourceRepository.getDistributionListMembershipCount(recipient))
},
extraData = getFilteredGroupStories(section, query)
)
} ?: emptyList()
}
private fun getRecentsContactData(section: ContactSearchConfiguration.Section.Recents, query: String?, startIndex: Int, endIndex: Int): List<ContactSearchData> {
return getRecentsCursor(section, query)?.use { cursor ->
readContactDataFromCursor(
cursor = cursor,
section = section,
startIndex = startIndex,
endIndex = endIndex,
cursorRowToData = {
ContactSearchData.KnownRecipient(contactSearchPagedDataSourceRepository.getRecipientFromThreadCursor(cursor))
}
)
} ?: emptyList()
}
private fun getNonGroupContactsData(section: ContactSearchConfiguration.Section.Individuals, query: String?, startIndex: Int, endIndex: Int): List<ContactSearchData> {
return getNonGroupContactsCursor(section, query)?.use { cursor ->
readContactDataFromCursor(
cursor = cursor,
section = section,
startIndex = startIndex,
endIndex = endIndex,
cursorRowToData = {
ContactSearchData.KnownRecipient(contactSearchPagedDataSourceRepository.getRecipientFromRecipientCursor(cursor))
}
)
} ?: emptyList()
}
private fun getGroupContactsData(section: ContactSearchConfiguration.Section.Groups, query: String?, startIndex: Int, endIndex: Int): List<ContactSearchData> {
return contactSearchPagedDataSourceRepository.getGroupContacts(section, query)?.use { cursor ->
readContactDataFromCursor(
cursor = cursor,
section = section,
startIndex = startIndex,
endIndex = endIndex,
cursorRowToData = {
if (section.returnAsGroupStories) {
ContactSearchData.Story(contactSearchPagedDataSourceRepository.getRecipientFromGroupCursor(cursor), 0)
} else {
ContactSearchData.KnownRecipient(contactSearchPagedDataSourceRepository.getRecipientFromGroupCursor(cursor))
}
}
)
} ?: emptyList()
}
/**
* We assume that the collection is [cursor contents] + [extraData contents]
*/
private data class ResultsCollection(
val section: ContactSearchConfiguration.Section,
val cursor: Cursor,
val extraData: List<ContactSearchData>,
val cursorMapper: (Cursor) -> ContactSearchData
) {
private val contentSize = cursor.count + extraData.count()
fun getSize(): Int {
val contentsAndExpand = min(
section.expandConfig?.let {
if (it.isExpanded) Int.MAX_VALUE else (it.maxCountWhenNotExpanded + 1)
} ?: Int.MAX_VALUE,
contentSize
)
return contentsAndExpand + (if (contentsAndExpand > 0 && section.includeHeader) 1 else 0)
}
fun getSublist(start: Int, end: Int): List<ContactSearchData> {
val results = mutableListOf<ContactSearchData>()
for (i in start until end) {
results.add(getItemAt(i))
}
return results
}
private fun getItemAt(index: Int): ContactSearchData {
return when {
index == 0 && section.includeHeader -> ContactSearchData.Header(section.sectionKey, section.headerAction)
index == getSize() - 1 && shouldDisplayExpandRow() -> ContactSearchData.Expand(section.sectionKey)
else -> {
val correctedIndex = if (section.includeHeader) index - 1 else index
if (correctedIndex < cursor.count) {
cursor.moveToPosition(correctedIndex)
cursorMapper.invoke(cursor)
} else {
val extraIndex = correctedIndex - cursor.count
extraData[extraIndex]
}
}
}
}
private fun shouldDisplayExpandRow(): Boolean {
val expandConfig = section.expandConfig
return when {
expandConfig == null || expandConfig.isExpanded -> false
else -> contentSize > expandConfig.maxCountWhenNotExpanded + 1
}
}
}
}

View File

@@ -0,0 +1,87 @@
package org.thoughtcrime.securesms.contacts.paged
import android.content.Context
import android.database.Cursor
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.contacts.ContactRepository
import org.thoughtcrime.securesms.database.DistributionListDatabase
import org.thoughtcrime.securesms.database.GroupDatabase
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.CursorUtil
/**
* Database boundary interface which allows us to safely unit test the data source without
* having to deal with database access.
*/
open class ContactSearchPagedDataSourceRepository(
private val context: Context
) {
private val contactRepository = ContactRepository(context, context.getString(R.string.note_to_self))
open fun querySignalContacts(query: String?, includeSelf: Boolean): Cursor? {
return contactRepository.querySignalContacts(query ?: "", includeSelf)
}
open fun queryNonSignalContacts(query: String?): Cursor? {
return contactRepository.queryNonSignalContacts(query ?: "")
}
open fun queryNonGroupContacts(query: String?, includeSelf: Boolean): Cursor? {
return contactRepository.queryNonGroupContacts(query ?: "", includeSelf)
}
open fun getGroupContacts(section: ContactSearchConfiguration.Section.Groups, query: String?): Cursor? {
return SignalDatabase.groups.getGroupsFilteredByTitle(query ?: "", section.includeInactive, !section.includeV1, !section.includeMms).cursor
}
open fun getRecents(section: ContactSearchConfiguration.Section.Recents): Cursor? {
return SignalDatabase.threads.getRecentConversationList(
section.limit,
section.includeInactiveGroups,
section.groupsOnly,
!section.includeGroupsV1,
!section.includeSms
)
}
open fun getStories(query: String?): Cursor? {
return SignalDatabase.distributionLists.getAllListsForContactSelectionUiCursor(query, myStoryContainsQuery(query ?: ""))
}
open fun getRecipientFromDistributionListCursor(cursor: Cursor): Recipient {
return Recipient.resolved(RecipientId.from(CursorUtil.requireLong(cursor, DistributionListDatabase.RECIPIENT_ID)))
}
open fun getRecipientFromThreadCursor(cursor: Cursor): Recipient {
return Recipient.resolved(RecipientId.from(CursorUtil.requireLong(cursor, ThreadDatabase.RECIPIENT_ID)))
}
open fun getRecipientFromRecipientCursor(cursor: Cursor): Recipient {
return Recipient.resolved(RecipientId.from(CursorUtil.requireLong(cursor, ContactRepository.ID_COLUMN)))
}
open fun getRecipientFromGroupCursor(cursor: Cursor): Recipient {
return Recipient.resolved(RecipientId.from(CursorUtil.requireLong(cursor, GroupDatabase.RECIPIENT_ID)))
}
open fun getDistributionListMembershipCount(recipient: Recipient): Int {
return SignalDatabase.distributionLists.getMemberCount(recipient.requireDistributionListId())
}
open fun recipientNameContainsQuery(recipient: Recipient, query: String?): Boolean {
return query.isNullOrBlank() || recipient.getDisplayName(context).contains(query)
}
open fun myStoryContainsQuery(query: String): Boolean {
if (query.isEmpty()) {
return true
}
val myStory = context.getString(R.string.Recipient_my_story)
return myStory.contains(query)
}
}

View File

@@ -0,0 +1,32 @@
package org.thoughtcrime.securesms.contacts.paged
import io.reactivex.rxjava3.core.Single
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
class ContactSearchRepository {
fun filterOutUnselectableContactSearchKeys(contactSearchKeys: Set<ContactSearchKey>): Single<Set<ContactSearchSelectionResult>> {
return Single.fromCallable {
contactSearchKeys.map {
val isSelectable = when (it) {
is ContactSearchKey.Expand -> false
is ContactSearchKey.Header -> false
is ContactSearchKey.KnownRecipient -> canSelectRecipient(it.recipientId)
is ContactSearchKey.Story -> canSelectRecipient(it.recipientId)
}
ContactSearchSelectionResult(it, isSelectable)
}.toSet()
}
}
private fun canSelectRecipient(recipientId: RecipientId): Boolean {
val recipient = Recipient.resolved(recipientId)
return if (recipient.isPushV2Group) {
val record = SignalDatabase.groups.getGroup(recipient.requireGroupId())
!(record.isPresent && record.get().isAnnouncementGroup && !record.get().isAdmin(Recipient.self()))
} else {
true
}
}
}

View File

@@ -0,0 +1,3 @@
package org.thoughtcrime.securesms.contacts.paged
data class ContactSearchSelectionResult(val key: ContactSearchKey, val isSelectable: Boolean)

View File

@@ -0,0 +1,10 @@
package org.thoughtcrime.securesms.contacts.paged
/**
* Simple search state for contacts.
*/
data class ContactSearchState(
val query: String? = null,
val expandedSections: Set<ContactSearchConfiguration.SectionKey> = emptySet(),
val groupStories: Set<ContactSearchData.Story> = emptySet()
)

View File

@@ -0,0 +1,104 @@
package org.thoughtcrime.securesms.contacts.paged
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Transformations
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import org.signal.paging.PagedData
import org.signal.paging.PagingConfig
import org.signal.paging.PagingController
import org.thoughtcrime.securesms.groups.SelectionLimits
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.livedata.Store
/**
* Simple, reusable view model that manages a ContactSearchPagedDataSource as well as filter and expansion state.
*/
class ContactSearchViewModel(
private val selectionLimits: SelectionLimits,
private val contactSearchRepository: ContactSearchRepository
) : ViewModel() {
private val disposables = CompositeDisposable()
private val pagingConfig = PagingConfig.Builder()
.setBufferPages(1)
.setPageSize(20)
.setStartIndex(0)
.build()
private val pagedData = MutableLiveData<PagedData<ContactSearchKey, ContactSearchData>>()
private val configurationStore = Store(ContactSearchState())
private val selectionStore = Store<Set<ContactSearchKey>>(emptySet())
val controller: LiveData<PagingController<ContactSearchKey>> = Transformations.map(pagedData) { it.controller }
val data: LiveData<List<ContactSearchData>> = Transformations.switchMap(pagedData) { it.data }
val configurationState: LiveData<ContactSearchState> = configurationStore.stateLiveData
val selectionState: LiveData<Set<ContactSearchKey>> = selectionStore.stateLiveData
override fun onCleared() {
disposables.clear()
}
fun setConfiguration(contactSearchConfiguration: ContactSearchConfiguration) {
val pagedDataSource = ContactSearchPagedDataSource(contactSearchConfiguration)
pagedData.value = PagedData.create(pagedDataSource, pagingConfig)
}
fun setQuery(query: String?) {
configurationStore.update { it.copy(query = query) }
}
fun expandSection(sectionKey: ContactSearchConfiguration.SectionKey) {
configurationStore.update { it.copy(expandedSections = it.expandedSections + sectionKey) }
}
fun setKeysSelected(contactSearchKeys: Set<ContactSearchKey>) {
disposables += contactSearchRepository.filterOutUnselectableContactSearchKeys(contactSearchKeys).subscribe { results ->
if (results.any { !it.isSelectable }) {
// TODO [alex] -- Pop an error.
return@subscribe
}
val newSelectionEntries = results.filter { it.isSelectable }.map { it.key } - getSelectedContacts()
val newSelectionSize = newSelectionEntries.size + getSelectedContacts().size
if (selectionLimits.hasRecommendedLimit() && getSelectedContacts().size < selectionLimits.recommendedLimit && newSelectionSize >= selectionLimits.recommendedLimit) {
// Pop a warning
} else if (selectionLimits.hasHardLimit() && newSelectionSize > selectionLimits.hardLimit) {
// Pop an error
return@subscribe
}
selectionStore.update { state -> state + newSelectionEntries }
}
}
fun setKeysNotSelected(contactSearchKeys: Set<ContactSearchKey>) {
selectionStore.update { it - contactSearchKeys }
}
fun getSelectedContacts(): Set<ContactSearchKey> {
return selectionStore.state
}
fun addToVisibleGroupStories(groupStories: Set<ContactSearchKey.Story>) {
configurationStore.update { state ->
state.copy(
groupStories = state.groupStories + groupStories.map {
val recipient = Recipient.resolved(it.recipientId)
ContactSearchData.Story(recipient, recipient.participants.size)
}
)
}
}
class Factory(private val selectionLimits: SelectionLimits, private val repository: ContactSearchRepository) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return modelClass.cast(ContactSearchViewModel(selectionLimits, repository)) as T
}
}
}

View File

@@ -0,0 +1,11 @@
package org.thoughtcrime.securesms.contacts.paged
import org.thoughtcrime.securesms.recipients.RecipientId
/**
* A Contact Search Key that is backed by a recipient, along with information about whether it is a story.
*/
interface RecipientSearchKey {
val recipientId: RecipientId
val isStory: Boolean
}

View File

@@ -0,0 +1,48 @@
package org.thoughtcrime.securesms.contacts.selection
import android.os.Bundle
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader
import org.thoughtcrime.securesms.groups.SelectionLimits
import org.thoughtcrime.securesms.recipients.RecipientId
data class ContactSelectionArguments(
val displayMode: Int = ContactsCursorLoader.DisplayMode.FLAG_ALL,
val isRefreshable: Boolean = true,
val displayRecents: Boolean = false,
val selectionLimits: SelectionLimits? = null,
val currentSelection: List<RecipientId> = emptyList(),
val displaySelectionCount: Boolean = true,
val canSelectSelf: Boolean = selectionLimits == null,
val displayChips: Boolean = true,
val recyclerPadBottom: Int = -1,
val recyclerChildClipping: Boolean = true
) {
fun toArgumentBundle(): Bundle {
return Bundle().apply {
putInt(DISPLAY_MODE, displayMode)
putBoolean(REFRESHABLE, isRefreshable)
putBoolean(RECENTS, displayRecents)
putParcelable(SELECTION_LIMITS, selectionLimits)
putBoolean(HIDE_COUNT, !displaySelectionCount)
putBoolean(CAN_SELECT_SELF, canSelectSelf)
putBoolean(DISPLAY_CHIPS, displayChips)
putInt(RV_PADDING_BOTTOM, recyclerPadBottom)
putBoolean(RV_CLIP, recyclerChildClipping)
putParcelableArrayList(CURRENT_SELECTION, ArrayList(currentSelection))
}
}
companion object {
const val DISPLAY_MODE = "display_mode"
const val REFRESHABLE = "refreshable"
const val RECENTS = "recents"
const val SELECTION_LIMITS = "selection_limits"
const val CURRENT_SELECTION = "current_selection"
const val HIDE_COUNT = "hide_count"
const val CAN_SELECT_SELF = "can_select_self"
const val DISPLAY_CHIPS = "display_chips"
const val RV_PADDING_BOTTOM = "recycler_view_padding_bottom"
const val RV_CLIP = "recycler_view_clipping"
}
}