From c022172aceefe0201909ce112ffba5fc7e910033 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Tue, 24 Jan 2023 10:50:28 -0400 Subject: [PATCH] Add group member results to contact search. --- .../securesms/contacts/ContactRepository.java | 8 +++ .../contacts/paged/ContactSearchAdapter.kt | 8 ++- .../paged/ContactSearchConfiguration.kt | 32 +++++++++- .../contacts/paged/ContactSearchData.kt | 4 +- .../paged/ContactSearchPagedDataSource.kt | 29 ++++++++- .../ContactSearchPagedDataSourceRepository.kt | 18 ++++++ .../contacts/paged/GroupsInCommon.kt | 23 +++++++ .../forward/MultiselectForwardFragment.kt | 8 +++ .../securesms/database/RecipientTable.kt | 61 ++++++++++++++++++- app/src/main/res/values/strings.xml | 2 + 10 files changed, 184 insertions(+), 9 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/contacts/paged/GroupsInCommon.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactRepository.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactRepository.java index 52f137a184..e46f1dd159 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/ContactRepository.java @@ -131,6 +131,14 @@ public class ContactRepository { return new SearchCursorWrapper(cursor, SEARCH_CURSOR_MAPPERS); } + @WorkerThread + public @NonNull Cursor queryGroupMemberContacts(@NonNull String query) { + Cursor cursor = TextUtils.isEmpty(query) ? recipientTable.getGroupMemberContacts() + : recipientTable.queryGroupMemberContacts(query); + + return new SearchCursorWrapper(cursor, SEARCH_CURSOR_MAPPERS); + } + private @NonNull Cursor handleNoteToSelfQuery(@NonNull String query, boolean includeSelf, Cursor cursor) { if (includeSelf && noteToSelfTitle.toLowerCase().contains(query.toLowerCase())) { Recipient self = Recipient.self(); 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 d9b2171e59..0cad9249a9 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 @@ -272,10 +272,13 @@ open class ContactSearchAdapter( override fun getRecipient(model: RecipientModel): Recipient = model.knownRecipient.recipient override fun bindNumberField(model: RecipientModel) { val recipient = getRecipient(model) - - if (model.shortSummary && recipient.isGroup) { + if (model.knownRecipient.sectionKey == ContactSearchConfiguration.SectionKey.GROUP_MEMBERS) { + number.text = model.knownRecipient.groupsInCommon.toDisplayText(context) + number.visible = true + } else if (model.shortSummary && recipient.isGroup) { val count = recipient.participantIds.size number.text = context.resources.getQuantityString(R.plurals.ContactSearchItems__group_d_members, count, count) + number.visible = true } else { super.bindNumberField(model) } @@ -404,6 +407,7 @@ open class ContactSearchAdapter( ContactSearchConfiguration.SectionKey.INDIVIDUALS -> R.string.ContactsCursorLoader_contacts ContactSearchConfiguration.SectionKey.GROUPS -> R.string.ContactsCursorLoader_groups ContactSearchConfiguration.SectionKey.ARBITRARY -> error("This section does not support HEADER") + ContactSearchConfiguration.SectionKey.GROUP_MEMBERS -> R.string.ContactsCursorLoader_group_members } ) 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 71e2c5e634..923ce7beb6 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 @@ -76,17 +76,47 @@ class ContactSearchConfiguration private constructor( override val includeHeader: Boolean = false override val expandConfig: ExpandConfig? = null } + + data class GroupMembers( + override val includeHeader: Boolean = true, + override val expandConfig: ExpandConfig? = null + ) : Section(SectionKey.GROUP_MEMBERS) } /** * Describes a given section. Useful for labeling sections and managing expansion state. */ enum class SectionKey { + /** + * Lists My Stories, distribution lists, as well as group stories. + */ STORIES, + + /** + * Recent chats. + */ RECENTS, + + /** + * 1:1 Contacts with whom I've started a chat. + */ INDIVIDUALS, + + /** + * Active groups the user is a member of + */ GROUPS, - ARBITRARY + + /** + * Arbitrary row (think new group button, username row, etc) + */ + ARBITRARY, + + /** + * Contacts that are members of groups user is in that they've not explicitly + * started a conversation with. + */ + GROUP_MEMBERS } /** 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 5546eb343e..57960598ee 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 @@ -25,9 +25,11 @@ sealed class ContactSearchData(val contactSearchKey: ContactSearchKey) { * A row displaying a known recipient. */ data class KnownRecipient( + val sectionKey: ContactSearchConfiguration.SectionKey, val recipient: Recipient, val shortSummary: Boolean = false, - val headerLetter: String? = null + val headerLetter: String? = null, + val groupsInCommon: GroupsInCommon = GroupsInCommon(0, listOf()) ) : ContactSearchData(ContactSearchKey.RecipientSearchKey(recipient.id, false)) /** 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 dfb6f0ca86..ed3135514e 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 @@ -91,6 +91,7 @@ class ContactSearchPagedDataSource( 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.") + is ContactSearchConfiguration.Section.GroupMembers -> getGroupMembersSearchIterator(query).getCollectionSize(section, query, null) } } @@ -122,6 +123,7 @@ class ContactSearchPagedDataSource( 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.") + is ContactSearchConfiguration.Section.GroupMembers -> getGroupMembersContactData(section, query, startIndex, endIndex) } } @@ -152,6 +154,10 @@ class ContactSearchPagedDataSource( return CursorSearchIterator(contactSearchPagedDataSourceRepository.getRecents(section)) } + private fun getGroupMembersSearchIterator(query: String?): ContactSearchIterator { + return CursorSearchIterator(contactSearchPagedDataSourceRepository.queryGroupMemberContacts(query)) + } + private fun readContactData( records: ContactSearchIterator, recordsPredicate: ((R) -> Boolean)?, @@ -197,7 +203,7 @@ class ContactSearchPagedDataSource( startIndex = startIndex, endIndex = endIndex, recordMapper = { - ContactSearchData.KnownRecipient(contactSearchPagedDataSourceRepository.getRecipientFromThreadCursor(it)) + ContactSearchData.KnownRecipient(section.sectionKey, contactSearchPagedDataSourceRepository.getRecipientFromThreadCursor(it)) } ) } @@ -219,7 +225,7 @@ class ContactSearchPagedDataSource( endIndex = endIndex, recordMapper = { val recipient = contactSearchPagedDataSourceRepository.getRecipientFromRecipientCursor(it) - ContactSearchData.KnownRecipient(recipient, headerLetter = headerMap[recipient.id]) + ContactSearchData.KnownRecipient(section.sectionKey, recipient, headerLetter = headerMap[recipient.id]) } ) } @@ -237,7 +243,7 @@ class ContactSearchPagedDataSource( if (section.returnAsGroupStories) { ContactSearchData.Story(contactSearchPagedDataSourceRepository.getRecipientFromGroupRecord(it), 0, DistributionListPrivacyMode.ALL) } else { - ContactSearchData.KnownRecipient(contactSearchPagedDataSourceRepository.getRecipientFromGroupRecord(it), shortSummary = section.shortSummary) + ContactSearchData.KnownRecipient(section.sectionKey, contactSearchPagedDataSourceRepository.getRecipientFromGroupRecord(it), shortSummary = section.shortSummary) } } ) @@ -254,6 +260,23 @@ class ContactSearchPagedDataSource( } } + private fun getGroupMembersContactData(section: ContactSearchConfiguration.Section.GroupMembers, query: String?, startIndex: Int, endIndex: Int): List { + return getGroupMembersSearchIterator(query).use { records -> + readContactData( + records = records, + recordsPredicate = null, + section = section, + startIndex = startIndex, + endIndex = endIndex, + recordMapper = { + val recipient = contactSearchPagedDataSourceRepository.getRecipientFromRecipientCursor(it) + val groupsInCommon = contactSearchPagedDataSourceRepository.getGroupsInCommon(recipient) + ContactSearchData.KnownRecipient(section.sectionKey, recipient, groupsInCommon = groupsInCommon) + } + ) + } + } + private fun createResultsCollection( section: ContactSearchConfiguration.Section, records: ContactSearchIterator, diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSourceRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSourceRepository.kt index e14f51deaa..cf0247eb65 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSourceRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSourceRepository.kt @@ -48,6 +48,10 @@ open class ContactSearchPagedDataSourceRepository( return contactRepository.queryNonGroupContacts(query ?: "", includeSelf) } + open fun queryGroupMemberContacts(query: String?): Cursor? { + return contactRepository.queryGroupMemberContacts(query ?: "") + } + open fun getGroupSearchIterator( section: ContactSearchConfiguration.Section.Groups, query: String? @@ -95,6 +99,20 @@ open class ContactSearchPagedDataSourceRepository( return Recipient.resolved(RecipientId.from(CursorUtil.requireLong(cursor, ContactRepository.ID_COLUMN))) } + open fun getGroupsInCommon(recipient: Recipient): GroupsInCommon { + val groupsInCommon = SignalDatabase.groups.getPushGroupsContainingMember(recipient.id) + val groupRecipientIds = groupsInCommon.take(2).map { it.recipientId } + val names = Recipient.resolvedList(groupRecipientIds) + .map { it.getDisplayName(context) } + .sorted() + + return GroupsInCommon(groupsInCommon.size, names) + } + + open fun hasGroupsInCommon(recipient: Recipient): Boolean { + return SignalDatabase.groups.getPushGroupsContainingMember(recipient.id).isNotEmpty() + } + open fun getRecipientFromGroupRecord(groupRecord: GroupRecord): Recipient { return Recipient.resolved(groupRecord.recipientId) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/GroupsInCommon.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/GroupsInCommon.kt new file mode 100644 index 0000000000..3a908744c1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/GroupsInCommon.kt @@ -0,0 +1,23 @@ +package org.thoughtcrime.securesms.contacts.paged + +import android.content.Context +import org.thoughtcrime.securesms.R + +/** + * Groups in common helper class + */ +data class GroupsInCommon( + private val total: Int, + private val names: List +) { + fun toDisplayText(context: Context): String { + return when (total) { + 1 -> context.getString(R.string.MessageRequestProfileView_member_of_one_group, names[0]) + 2 -> context.getString(R.string.MessageRequestProfileView_member_of_two_groups, names[0], names[1]) + else -> context.getString( + R.string.MessageRequestProfileView_member_of_many_groups, names[0], names[1], + context.resources.getQuantityString(R.plurals.MessageRequestProfileView_member_of_d_additional_groups, total - 2, total - 2) + ) + } + } +} 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 a167df9fca..47b6896fae 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 @@ -454,6 +454,14 @@ class MultiselectForwardFragment : ) ) + if (!query.isNullOrEmpty()) { + addSection( + ContactSearchConfiguration.Section.GroupMembers( + includeHeader = true + ) + ) + } + addSection( ContactSearchConfiguration.Section.Groups( includeHeader = true, diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt index 7d91a420f6..6a432947cd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt @@ -3120,6 +3120,31 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da return readableDatabase.query(TABLE_NAME, SEARCH_PROJECTION, selection, args, null, null, orderBy) } + fun getGroupMemberContacts(): Cursor? { + val searchSelection = ContactSearchSelection.Builder() + .withGroupMembers(true) + .excludeId(Recipient.self().id) + .build() + + val orderBy = orderByPreferringAlphaOverNumeric(SORT_NAME) + ", " + PHONE + return readableDatabase.query(TABLE_NAME, SEARCH_PROJECTION, searchSelection.where, searchSelection.args, null, null, orderBy) + } + + fun queryGroupMemberContacts(inputQuery: String): Cursor? { + val query = SqlUtil.buildCaseInsensitiveGlobPattern(inputQuery) + val searchSelection = ContactSearchSelection.Builder() + .withGroupMembers(true) + .excludeId(Recipient.self().id) + .withSearchQuery(query) + .build() + + val selection = searchSelection.where + val args = searchSelection.args + val orderBy = orderByPreferringAlphaOverNumeric(SORT_NAME) + ", " + PHONE + + return readableDatabase.query(TABLE_NAME, SEARCH_PROJECTION, selection, args, null, null, orderBy) + } + fun queryAllContacts(inputQuery: String): Cursor? { val query = SqlUtil.buildCaseInsensitiveGlobPattern(inputQuery) val selection = @@ -4126,6 +4151,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da internal class Builder { private var includeRegistered = false private var includeNonRegistered = false + private var includeGroupMembers = false private var excludeId: RecipientId? = null private var excludeGroups = false private var searchQuery: String? = null @@ -4140,6 +4166,11 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da return this } + fun withGroupMembers(includeGroupMembers: Boolean): Builder { + this.includeGroupMembers = includeGroupMembers + return this + } + fun excludeId(recipientId: RecipientId?): Builder { excludeId = recipientId return this @@ -4156,11 +4187,13 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da } fun build(): ContactSearchSelection { - check(!(!includeRegistered && !includeNonRegistered)) { "Must include either registered or non-registered recipients in search" } + check(!(!includeRegistered && !includeNonRegistered && !includeGroupMembers)) { "Must include either registered, non-registered, or group member recipients in search" } val stringBuilder = StringBuilder("(") val args: MutableList = LinkedList() + var hasPreceedingSection = false if (includeRegistered) { + hasPreceedingSection = true stringBuilder.append("(") args.add(RegisteredState.REGISTERED.id) args.add(1) @@ -4175,11 +4208,12 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da stringBuilder.append(")") } - if (includeRegistered && includeNonRegistered) { + if (hasPreceedingSection && includeNonRegistered) { stringBuilder.append(" OR ") } if (includeNonRegistered) { + hasPreceedingSection = true stringBuilder.append("(") args.add(RegisteredState.REGISTERED.id) @@ -4195,6 +4229,26 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da stringBuilder.append(")") } + if (hasPreceedingSection && includeGroupMembers) { + stringBuilder.append(" OR ") + } + + if (includeGroupMembers) { + stringBuilder.append("(") + args.add(RegisteredState.REGISTERED.id) + args.add(1) + if (Util.isEmpty(searchQuery)) { + stringBuilder.append(GROUP_MEMBER_CONTACT) + } else { + stringBuilder.append(QUERY_GROUP_MEMBER_CONTACT) + args.add(searchQuery) + args.add(searchQuery) + args.add(searchQuery) + } + + stringBuilder.append(")") + } + stringBuilder.append(")") stringBuilder.append(FILTER_BLOCKED) args.add(0) @@ -4216,6 +4270,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da } companion object { + const val HAS_GROUP_IN_COMMON = "EXISTS (SELECT 1 FROM ${GroupTable.MembershipTable.TABLE_NAME} WHERE ${GroupTable.MembershipTable.TABLE_NAME}.${GroupTable.MembershipTable.RECIPIENT_ID} = $ID)" const val FILTER_GROUPS = " AND $GROUP_ID IS NULL" const val FILTER_ID = " AND $ID != ?" const val FILTER_BLOCKED = " AND $BLOCKED = ?" @@ -4224,6 +4279,8 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da const val QUERY_NON_SIGNAL_CONTACT = "$NON_SIGNAL_CONTACT AND ($PHONE GLOB ? OR $EMAIL GLOB ? OR $SYSTEM_JOINED_NAME GLOB ?)" const val SIGNAL_CONTACT = "$REGISTERED = ? AND (NULLIF($SYSTEM_JOINED_NAME, '') NOT NULL OR $PROFILE_SHARING = ?) AND ($SORT_NAME NOT NULL OR $USERNAME NOT NULL)" const val QUERY_SIGNAL_CONTACT = "$SIGNAL_CONTACT AND ($PHONE GLOB ? OR $SORT_NAME GLOB ? OR $USERNAME GLOB ?)" + const val GROUP_MEMBER_CONTACT = "$REGISTERED = ? AND $HAS_GROUP_IN_COMMON AND NOT (NULLIF($SYSTEM_JOINED_NAME, '') NOT NULL OR $PROFILE_SHARING = ?) AND ($SORT_NAME NOT NULL OR $USERNAME NOT NULL)" + const val QUERY_GROUP_MEMBER_CONTACT = "$GROUP_MEMBER_CONTACT AND ($PHONE GLOB ? OR $SORT_NAME GLOB ? OR $USERNAME GLOB ?)" } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9c50259b70..f8485d46ba 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -224,6 +224,8 @@ Recent chats Contacts Groups + + Group members Phone number search Find by username