Allow conversation search results to come in piecemeal.

This commit is contained in:
Greyson Parrelli
2026-08-19 19:05:47 -04:00
committed by Cody Henthorne
parent 8d42ce3b00
commit 265331f3e2
11 changed files with 325 additions and 42 deletions
@@ -26,6 +26,7 @@ open class ContactSearchAdapter(
ContactSearchModels.registerStoryItems(this, displayOptions.displayCheckBox, onClickCallbacks::onStoryClicked, storyContextMenuCallbacks, displayOptions.displayStoryRing)
ContactSearchModels.registerKnownRecipientItems(this, fixedContacts, displayOptions, onClickCallbacks::onKnownRecipientClicked, longClickCallbacks::onKnownRecipientLongClick, callButtonClickCallbacks)
ContactSearchModels.registerHeaders(this)
ContactSearchModels.registerSectionLoading(this)
ContactSearchModels.registerExpands(this, onClickCallbacks::onExpandClicked)
ContactSearchModels.registerChatTypeItems(this, onClickCallbacks::onChatTypeClicked)
ContactSearchModels.registerUnknownRecipientItems(this, onClickCallbacks::onUnknownRecipientClicked, displayOptions.displayCheckBox)
@@ -86,6 +86,12 @@ sealed class ContactSearchData(val contactSearchKey: ContactSearchKey) {
*/
class Expand(val sectionKey: ContactSearchConfiguration.SectionKey) : ContactSearchData(ContactSearchKey.Expand(sectionKey))
/**
* A row indicating that the section is still being queried. Sections are queried in parallel and
* rendered as they finish, so this stands in for a section whose results have not arrived yet.
*/
data class SectionLoading(val sectionKey: ContactSearchConfiguration.SectionKey) : ContactSearchData(ContactSearchKey.SectionLoading(sectionKey))
/**
* A row representing arbitrary data tied to a specific section.
*/
@@ -54,6 +54,11 @@ sealed class ContactSearchKey {
*/
data class Expand(val sectionKey: ContactSearchConfiguration.SectionKey) : ContactSearchKey()
/**
* Key to the loading placeholder shown while a given section is still being queried
*/
data class SectionLoading(val sectionKey: ContactSearchConfiguration.SectionKey) : ContactSearchKey()
/**
* Arbitrary takes a string type and will map to exactly one ArbitraryData object.
*
@@ -15,16 +15,22 @@ import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.TextView
import androidx.appcompat.widget.AppCompatImageView
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
@@ -51,6 +57,7 @@ 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.components.settings.models.DSLComposePreference
import org.thoughtcrime.securesms.contacts.LetterHeaderDecoration
import org.thoughtcrime.securesms.database.model.DistributionListPrivacyMode
import org.thoughtcrime.securesms.database.model.StoryViewState
@@ -123,6 +130,10 @@ object ContactSearchModels {
)
}
fun registerSectionLoading(mappingAdapter: MappingAdapter) {
DSLComposePreference.register<SectionLoadingModel>(mappingAdapter) { SectionLoadingViewHolder(it) }
}
fun registerExpands(mappingAdapter: MappingAdapter, expandListener: (ContactSearchData.Expand) -> Unit) {
mappingAdapter.registerFactory(
ExpandModel::class.java,
@@ -214,6 +225,11 @@ object ContactSearchModels {
R.layout.contact_search_section_header
).createViewHolder(FrameLayout(ctx))
}
entry<SectionLoadingModel>(
key = { model -> "SECTION_LOADING${model.sectionLoading.sectionKey}" }
) {
SectionLoadingRow()
}
viewHolder<ExpandModel>(
key = { model -> "EXPAND${model.expand.sectionKey}" }
) { ctx ->
@@ -252,6 +268,7 @@ object ContactSearchModels {
is ContactSearchData.Story -> StoryModel(it, selection.contains(it.contactSearchKey), SignalStore.story.userHasBeenNotifiedAboutStories)
is ContactSearchData.KnownRecipient -> RecipientModel(it, selection.contains(it.contactSearchKey), it.shortSummary)
is ContactSearchData.Expand -> ExpandModel(it)
is ContactSearchData.SectionLoading -> SectionLoadingModel(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")
@@ -788,6 +805,19 @@ object ContactSearchModels {
}
}
/**
* Mapping Model for the placeholder shown in place of a section whose query is still running.
*/
class SectionLoadingModel(val sectionLoading: ContactSearchData.SectionLoading) : MappingModel<SectionLoadingModel> {
override fun areItemsTheSame(newItem: SectionLoadingModel): Boolean {
return sectionLoading.sectionKey == newItem.sectionLoading.sectionKey
}
override fun areContentsTheSame(newItem: SectionLoadingModel): Boolean {
return areItemsTheSame(newItem)
}
}
/**
* Mapping Model for messages
*/
@@ -823,6 +853,31 @@ object ContactSearchModels {
override fun areItemsTheSame(newItem: GroupWithMembersModel): Boolean = newItem.groupWithMembers.contactSearchKey == groupWithMembers.contactSearchKey
}
@Composable
private fun SectionLoadingRow() {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp)
) {
CircularProgressIndicator(
strokeWidth = 3.dp,
modifier = Modifier.size(24.dp)
)
}
}
/**
* View Holder for the placeholder shown in place of a section whose query is still running.
*/
private class SectionLoadingViewHolder(composeView: ComposeView) : DSLComposePreference.ViewHolder<SectionLoadingModel>(composeView) {
@Composable
override fun Content(model: SectionLoadingModel) {
SectionLoadingRow()
}
}
/**
* View Holder for section headers
*/
@@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.contacts.paged
import android.database.Cursor
import androidx.annotation.WorkerThread
import org.signal.core.util.requireLong
import org.signal.paging.PagedDataSource
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.contacts.ContactRepository
@@ -10,7 +9,6 @@ import org.thoughtcrime.securesms.contacts.paged.collections.ContactSearchCollec
import org.thoughtcrime.securesms.contacts.paged.collections.ContactSearchIterator
import org.thoughtcrime.securesms.contacts.paged.collections.CursorSearchIterator
import org.thoughtcrime.securesms.contacts.paged.collections.StoriesSearchCollection
import org.thoughtcrime.securesms.database.GroupTable
import org.thoughtcrime.securesms.database.RecipientTable
import org.thoughtcrime.securesms.database.model.DistributionListPrivacyMode
import org.thoughtcrime.securesms.database.model.GroupRecord
@@ -19,6 +17,7 @@ import org.thoughtcrime.securesms.groups.GroupsInCommonSummary
import org.thoughtcrime.securesms.keyvalue.StorySend
import org.thoughtcrime.securesms.phonenumbers.NumberUtil
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.search.MessageResult
import org.thoughtcrime.securesms.search.MessageSearchResult
import org.thoughtcrime.securesms.search.SearchRepository
@@ -33,7 +32,8 @@ class ContactSearchPagedDataSource(
private val contactConfiguration: ContactSearchConfiguration,
private val contactSearchPagedDataSourceRepository: ContactSearchPagedDataSourceRepository,
private val arbitraryRepository: ArbitraryRepository? = null,
private val searchRepository: SearchRepository? = null
private val searchRepository: SearchRepository? = null,
private val sectionResults: ContactSearchSectionResults = ContactSearchSectionResults()
) : PagedDataSource<ContactSearchKey, ContactSearchData> {
companion object {
@@ -130,6 +130,10 @@ class ContactSearchPagedDataSource(
}
private fun getSectionSize(section: ContactSearchConfiguration.Section, query: String?): Int {
if (section.sectionKey in sectionResults.pending) {
return getPendingSectionRows(section).size
}
return when (section) {
is ContactSearchConfiguration.Section.Individuals -> getNonGroupSearchIterator(section, query).getCollectionSizeAndClose(section, query, null)
is ContactSearchConfiguration.Section.Groups -> contactSearchPagedDataSourceRepository.getGroupSearchIterator(section, query).getCollectionSizeAndClose(section, query, this::canSendToGroup)
@@ -172,6 +176,12 @@ class ContactSearchPagedDataSource(
@WorkerThread
private fun getSectionData(section: ContactSearchConfiguration.Section, query: String?, startIndex: Int, endIndex: Int): List<ContactSearchData> {
if (section.sectionKey in sectionResults.pending) {
val rows = getPendingSectionRows(section)
val start = startIndex.coerceIn(0, rows.size)
return rows.subList(start, endIndex.coerceIn(start, rows.size))
}
return when (section) {
is ContactSearchConfiguration.Section.Groups -> getGroupContactsData(section, query, startIndex, endIndex)
is ContactSearchConfiguration.Section.Individuals -> getNonGroupContactsData(section, query, startIndex, endIndex)
@@ -190,6 +200,20 @@ class ContactSearchPagedDataSource(
}
}
/**
* The rows a section occupies while its query is still running: its header, so the user can see
* which section is outstanding, plus a spinner in place of its results.
*/
private fun getPendingSectionRows(section: ContactSearchConfiguration.Section): List<ContactSearchData> {
val loading = ContactSearchData.SectionLoading(section.sectionKey)
return if (section.includeHeader) {
listOf(ContactSearchData.Header(section.sectionKey, section.headerAction), loading)
} else {
listOf(loading)
}
}
private fun isPossiblyPhoneNumber(query: String?): Boolean {
if (query == null) {
return false
@@ -276,19 +300,23 @@ class ContactSearchPagedDataSource(
return CursorSearchIterator(contactSearchPagedDataSourceRepository.getStories(query))
}
private fun getGroupsWithMembersIterator(query: String?): ContactSearchIterator<Cursor> {
private fun getGroupsWithMembersIterator(query: String?): ContactSearchIterator<GroupWithMembersRecord> {
sectionResults.groupsWithMembers?.let { return ListSearchIterator(it) }
return if (query.isNullOrEmpty()) {
CursorSearchIterator(null)
ListSearchIterator(emptyList())
} else {
CursorSearchIterator(contactSearchPagedDataSourceRepository.getGroupsWithMembers(query))
ListSearchIterator(contactSearchPagedDataSourceRepository.getGroupsWithMembers(query))
}
}
private fun getContactsWithoutThreadsIterator(query: String?): ContactSearchIterator<Cursor> {
private fun getContactsWithoutThreadsIterator(query: String?): ContactSearchIterator<RecipientId> {
sectionResults.contactsWithoutThreads?.let { return ListSearchIterator(it) }
return if (query.isNullOrEmpty()) {
CursorSearchIterator(null)
ListSearchIterator(emptyList())
} else {
CursorSearchIterator(contactSearchPagedDataSourceRepository.getContactsWithoutThreads(query))
ListSearchIterator(contactSearchPagedDataSourceRepository.getContactsWithoutThreads(query))
}
}
@@ -348,10 +376,7 @@ class ContactSearchPagedDataSource(
section = section,
startIndex = startIndex,
endIndex = endIndex,
recordMapper = { cursor ->
val record = GroupTable.Reader(cursor).getCurrent()
ContactSearchData.GroupWithMembers(query!!, record!!, cursor.requireLong(GroupTable.THREAD_DATE))
}
recordMapper = { ContactSearchData.GroupWithMembers(query ?: "", it.groupRecord, it.threadDate) }
)
}
}
@@ -395,7 +420,7 @@ class ContactSearchPagedDataSource(
startIndex = startIndex,
endIndex = endIndex,
recordMapper = {
ContactSearchData.KnownRecipient(section.sectionKey, contactSearchPagedDataSourceRepository.getRecipientFromRecipientCursor(it))
ContactSearchData.KnownRecipient(section.sectionKey, contactSearchPagedDataSourceRepository.getRecipient(it))
}
)
}
@@ -485,6 +510,8 @@ class ContactSearchPagedDataSource(
}
private fun getMessageData(query: String?): ContactSearchIterator<MessageResult> {
sectionResults.messages?.let { return ListSearchIterator(it) }
check(searchRepository != null)
if (searchCache.messageSearchResult == null && query != null) {
@@ -514,6 +541,8 @@ class ContactSearchPagedDataSource(
}
private fun getThreadData(query: String?, unreadOnly: Boolean): ContactSearchIterator<ThreadWithRecipient> {
sectionResults.threads?.let { return ListSearchIterator(it) }
check(searchRepository != null)
if (searchCache.threadSearchResult == null && query != null) {
searchCache = searchCache.copy(threadSearchResult = searchRepository.queryThreadsSync(query, unreadOnly))
@@ -7,6 +7,7 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.signal.core.util.CursorUtil
import org.signal.core.util.LRUCache
import org.signal.core.util.requireLong
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.contacts.ContactRepository
import org.thoughtcrime.securesms.contacts.paged.collections.ContactSearchIterator
@@ -34,6 +35,11 @@ open class ContactSearchPagedDataSourceRepository(
selfTitle: String = context.getString(R.string.note_to_self)
) {
companion object {
/** Ceiling on the sections that are read into memory in full, rather than a page at a time. */
private const val MAX_PREFETCHED_ROWS = 500
}
private val contactRepository = ContactRepository(selfTitle)
private val context = context.applicationContext
private val groupRecordCache = LRUCache<GroupId, GroupRecord?>(100)
@@ -82,12 +88,26 @@ open class ContactSearchPagedDataSourceRepository(
return SignalDatabase.distributionLists.getAllListsForContactSelectionUiCursor(query, myStoryContainsQuery(query ?: ""))
}
open fun getGroupsWithMembers(query: String): Cursor {
return SignalDatabase.groups.queryGroupsByMemberName(query)
open fun getGroupsWithMembers(query: String): List<GroupWithMembersRecord> {
val cursor = SignalDatabase.groups.queryGroupsByMemberName(query, MAX_PREFETCHED_ROWS)
return GroupTable.Reader(cursor).use { reader ->
generateSequence { reader.getNext() }
.map { GroupWithMembersRecord(it, cursor.requireLong(GroupTable.THREAD_DATE)) }
.toList()
}
}
open fun getContactsWithoutThreads(query: String): Cursor {
return SignalDatabase.recipients.getAllContactsWithoutThreads(query)
/**
* Ids rather than [Recipient]s, since resolving a recipient costs a query per row and only the
* rows that scroll into view need one.
*/
open fun getContactsWithoutThreads(query: String): List<RecipientId> {
return SignalDatabase.recipients.getAllContactsWithoutThreads(query, MAX_PREFETCHED_ROWS).use { cursor ->
generateSequence { if (cursor.moveToNext()) cursor else null }
.map { RecipientId.from(it.requireLong(RecipientTable.ID)) }
.toList()
}
}
open fun getRecipientFromDistributionListCursor(cursor: Cursor): Recipient {
@@ -106,8 +126,8 @@ open class ContactSearchPagedDataSourceRepository(
return Recipient.resolved(RecipientId.from(CursorUtil.requireLong(cursor, ContactRepository.ID_COLUMN)))
}
open fun getRecipientFromRecipientCursor(cursor: Cursor): Recipient {
return Recipient.resolved(RecipientId.from(CursorUtil.requireLong(cursor, RecipientTable.ID)))
open fun getRecipient(recipientId: RecipientId): Recipient {
return Recipient.resolved(recipientId)
}
@WorkerThread
@@ -0,0 +1,62 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.contacts.paged
import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration.SectionKey
import org.thoughtcrime.securesms.database.model.ThreadWithRecipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.search.MessageResult
/**
* The results of the search sections that [ContactSearchViewModel] queries in parallel, folded in
* as each one lands so that finished sections can render while the rest are still running.
*
* A section listed in [pending] has not finished yet, and renders as a loading placeholder rather
* than as an empty section.
*/
data class ContactSearchSectionResults(
val pending: Set<SectionKey> = emptySet(),
val threads: List<ThreadWithRecipient>? = null,
val messages: List<MessageResult>? = null,
val groupsWithMembers: List<GroupWithMembersRecord>? = null,
val contactsWithoutThreads: List<RecipientId>? = null
) {
fun withSection(result: ContactSearchSectionResult): ContactSearchSectionResults {
val remaining = pending - result.sectionKey
return when (result) {
is ContactSearchSectionResult.Chats -> copy(pending = remaining, threads = result.threads)
is ContactSearchSectionResult.Messages -> copy(pending = remaining, messages = result.messages)
is ContactSearchSectionResult.GroupsWithMembers -> copy(pending = remaining, groupsWithMembers = result.groups)
is ContactSearchSectionResult.ContactsWithoutThreads -> copy(pending = remaining, contactsWithoutThreads = result.recipientIds)
}
}
}
/**
* The result of querying a single section. Rows are held in whatever form is cheapest to produce in
* bulk -- ids where resolving the full model costs a query per row -- and are turned into
* [ContactSearchData] lazily, as they scroll into view.
*/
sealed interface ContactSearchSectionResult {
val sectionKey: SectionKey
data class Chats(val threads: List<ThreadWithRecipient>) : ContactSearchSectionResult {
override val sectionKey: SectionKey = SectionKey.CHATS
}
data class Messages(val messages: List<MessageResult>) : ContactSearchSectionResult {
override val sectionKey: SectionKey = SectionKey.MESSAGES
}
data class GroupsWithMembers(val groups: List<GroupWithMembersRecord>) : ContactSearchSectionResult {
override val sectionKey: SectionKey = SectionKey.GROUPS_WITH_MEMBERS
}
data class ContactsWithoutThreads(val recipientIds: List<RecipientId>) : ContactSearchSectionResult {
override val sectionKey: SectionKey = SectionKey.CONTACTS_WITHOUT_THREADS
}
}
@@ -1,5 +1,6 @@
package org.thoughtcrime.securesms.contacts.paged
import androidx.annotation.WorkerThread
import androidx.compose.runtime.Stable
import androidx.lifecycle.AbstractSavedStateViewModelFactory
import androidx.lifecycle.Lifecycle
@@ -14,11 +15,14 @@ import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.subjects.PublishSubject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.drop
@@ -97,7 +101,11 @@ class ContactSearchViewModel(
val isDisplayingContextMenu: StateFlow<Boolean> = internalDisplayingContextMenu
val scrollRequests: SharedFlow<ScrollRequest> = internalScrollRequests
/** True while a [setConfiguration] call is fetching results off the main thread. Suitable for driving a loading indicator. */
/**
* True while a [setConfiguration] call has yet to publish anything for the current configuration. Suitable for driving a
* whole-list loading indicator. Goes false as soon as the first results are published, even if slower sections are still
* running -- those render their own placeholder.
*/
val searchInProgress: StateFlow<Boolean> = internalSearchInProgress
val query: StateFlow<String?> = rawQuery
@@ -154,25 +162,104 @@ class ContactSearchViewModel(
internalScrollRequests.tryEmit(ScrollRequest(position))
}
/**
* Builds the results for [contactSearchConfiguration] and publishes them to [pagedData].
*
* Each query-driven section runs its own database query, and how long those take varies wildly --
* a message search can take an order of magnitude longer than a contact search. So rather than
* hold every section back until the slowest one finishes, they are queried concurrently and
* published as they land. A section that hasn't reported yet renders as a loading placeholder, so
* an outstanding section can't be mistaken for an empty one.
*
* Nothing is published until the topmost section finishes, even if a lower one is ready sooner.
* Publishing earlier would put a placeholder above the results and then shove them down when it
* resolves, which on a device where the whole search takes a few milliseconds reads as a flicker.
* Until then [searchInProgress] covers the gap, and sections that finish together are published
* together for the same reason.
*/
suspend fun setConfiguration(contactSearchConfiguration: ContactSearchConfiguration) {
internalSearchInProgress.value = true
try {
val (pagedDataSource, size) = withContext(Dispatchers.Default) {
val source = ContactSearchPagedDataSource(
contactSearchConfiguration,
arbitraryRepository = arbitraryRepository,
searchRepository = searchRepository,
contactSearchPagedDataSourceRepository = contactSearchPagedDataSourceRepository
)
source to source.size()
val query = contactSearchConfiguration.query?.takeIf { it.isNotEmpty() }
val parallelSections = if (query == null) emptyList() else contactSearchConfiguration.sections.filter { it.isQueriedInParallel() }
if (query == null || parallelSections.isEmpty()) {
publish(contactSearchConfiguration, ContactSearchSectionResults())
return
}
coroutineScope {
val completedSections = Channel<ContactSearchSectionResult>(capacity = parallelSections.size)
parallelSections.forEach { section ->
launch(Dispatchers.Default) {
completedSections.send(querySection(section, query, contactSearchConfiguration.searchFilter))
}
}
val topSection = parallelSections.first().sectionKey
var results = ContactSearchSectionResults(pending = parallelSections.mapTo(mutableSetOf()) { it.sectionKey })
while (results.pending.isNotEmpty()) {
results = results.withSection(completedSections.receive())
generateSequence { completedSections.tryReceive().getOrNull() }
.forEach { results = results.withSection(it) }
if (topSection !in results.pending) {
publish(contactSearchConfiguration, results)
internalSearchInProgress.value = false
}
}
}
internalTotalCount.value = size
pagedData.value = PagedData.createForStateFlow(pagedDataSource, pagingConfig, data.value)
} finally {
internalSearchInProgress.value = false
}
}
/**
* Whether [querySection] can produce this section's rows up front. The rest are left to
* [ContactSearchPagedDataSource], which reads them a page at a time off of a cursor -- either
* because they're unbounded, or because they aren't query-driven and so aren't what a search is
* waiting on.
*/
private fun ContactSearchConfiguration.Section.isQueriedInParallel(): Boolean {
return when (this) {
is ContactSearchConfiguration.Section.Chats,
is ContactSearchConfiguration.Section.Messages,
is ContactSearchConfiguration.Section.GroupsWithMembers,
is ContactSearchConfiguration.Section.ContactsWithoutThreads -> true
else -> false
}
}
@WorkerThread
private fun querySection(section: ContactSearchConfiguration.Section, query: String, searchFilter: SearchFilter): ContactSearchSectionResult {
return when (section) {
is ContactSearchConfiguration.Section.Chats -> ContactSearchSectionResult.Chats(searchRepository.queryThreadsSync(query, section.isUnreadOnly).results)
is ContactSearchConfiguration.Section.Messages -> ContactSearchSectionResult.Messages(searchRepository.queryMessagesSync(query, searchFilter).results)
is ContactSearchConfiguration.Section.GroupsWithMembers -> ContactSearchSectionResult.GroupsWithMembers(contactSearchPagedDataSourceRepository.getGroupsWithMembers(query))
is ContactSearchConfiguration.Section.ContactsWithoutThreads -> ContactSearchSectionResult.ContactsWithoutThreads(contactSearchPagedDataSourceRepository.getContactsWithoutThreads(query))
else -> error("Section ${section.sectionKey} is not queried in parallel.")
}
}
private suspend fun publish(contactSearchConfiguration: ContactSearchConfiguration, sectionResults: ContactSearchSectionResults) {
val (pagedDataSource, size) = withContext(Dispatchers.Default) {
val source = ContactSearchPagedDataSource(
contactSearchConfiguration,
arbitraryRepository = arbitraryRepository,
searchRepository = searchRepository,
contactSearchPagedDataSourceRepository = contactSearchPagedDataSourceRepository,
sectionResults = sectionResults
)
source to source.size()
}
internalTotalCount.value = size
pagedData.value = PagedData.createForStateFlow(pagedDataSource, pagingConfig, data.value)
}
fun setQuery(query: String?) {
rawQuery.value = query
}
@@ -322,7 +409,7 @@ fun ContactSearchViewModel.bindAdapterToLifecycle(
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
launch { mappingModels.collect { adapter.submitList(it) } }
launch { controller.collect { it?.let { c -> adapter.setPagingController(c) } } }
launch { configurationState.collect { setConfiguration(mapStateToConfiguration(it)) } }
launch { configurationState.collectLatest { setConfiguration(mapStateToConfiguration(it)) } }
}
}
}
@@ -0,0 +1,16 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.contacts.paged
import org.thoughtcrime.securesms.database.model.GroupRecord
/**
* A group whose members matched a search query, paired with the date of its thread.
*/
data class GroupWithMembersRecord(
val groupRecord: GroupRecord,
val threadDate: Long
)
@@ -351,22 +351,23 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) :
return noMetadata && noMembers
}
fun queryGroupsByMemberName(inputQuery: String): Cursor {
fun queryGroupsByMemberName(inputQuery: String, limit: Int): Cursor {
val subquery = recipients.getAllContactsSubquery(inputQuery, RecipientTable.IncludeSelfMode.IncludeWithoutRemap)
val statement = """
SELECT
DISTINCT $TABLE_NAME.*,
SELECT
DISTINCT $TABLE_NAME.*,
GROUP_CONCAT(${MembershipTable.TABLE_NAME}.${MembershipTable.RECIPIENT_ID}) as $MEMBER_GROUP_CONCAT,
${ThreadTable.TABLE_NAME}.${ThreadTable.DATE} as $THREAD_DATE
FROM $TABLE_NAME
FROM $TABLE_NAME
INNER JOIN ${MembershipTable.TABLE_NAME} ON ${MembershipTable.TABLE_NAME}.${MembershipTable.GROUP_ID} = $TABLE_NAME.$GROUP_ID
INNER JOIN ${ThreadTable.TABLE_NAME} ON ${ThreadTable.TABLE_NAME}.${ThreadTable.RECIPIENT_ID} = $TABLE_NAME.$RECIPIENT_ID
WHERE $TABLE_NAME.$IS_MEMBER = 1 AND $TABLE_NAME.$TERMINATED_BY = 0 AND ${MembershipTable.TABLE_NAME}.${MembershipTable.RECIPIENT_ID} IN (${subquery.where})
GROUP BY ${MembershipTable.TABLE_NAME}.${MembershipTable.GROUP_ID}
ORDER BY $TITLE COLLATE NOCASE ASC
LIMIT ?
"""
return databaseHelper.signalReadableDatabase.query(statement, subquery.whereArgs)
return databaseHelper.signalReadableDatabase.query(statement, subquery.whereArgs + limit.toString())
}
fun queryGroupsByTitle(inputQuery: String, includeInactive: Boolean, excludeV1: Boolean, excludeMms: Boolean): Reader {
@@ -3787,7 +3787,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
/**
* Queries all contacts without an active thread.
*/
fun getAllContactsWithoutThreads(inputQuery: String): Cursor {
fun getAllContactsWithoutThreads(inputQuery: String, limit: Int): Cursor {
val query = SqlUtil.buildCaseInsensitiveGlobPattern(inputQuery)
//language=sql
@@ -3795,14 +3795,15 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
SELECT ${searchProjection(IncludeSelfMode.Exclude).joinToString(", ")} FROM $TABLE_NAME
WHERE $BLOCKED = ? AND $HIDDEN = ? AND $REGISTERED != ? AND NOT EXISTS (SELECT 1 FROM ${ThreadTable.TABLE_NAME} WHERE ${ThreadTable.TABLE_NAME}.${ThreadTable.ACTIVE} = 1 AND ${ThreadTable.TABLE_NAME}.${ThreadTable.RECIPIENT_ID} = $TABLE_NAME.$ID LIMIT 1)
AND (
$SORT_NAME GLOB ? OR
$USERNAME GLOB ? OR
${ContactSearchSelection.E164_SEARCH} OR
$SORT_NAME GLOB ? OR
$USERNAME GLOB ? OR
${ContactSearchSelection.E164_SEARCH} OR
$EMAIL GLOB ?
)
LIMIT ?
"""
return readableDatabase.query(subquery, SqlUtil.buildArgs(0, 0, RegisteredState.NOT_REGISTERED.id, query, query, query, query))
return readableDatabase.query(subquery, SqlUtil.buildArgs(0, 0, RegisteredState.NOT_REGISTERED.id, query, query, query, query, limit))
}
@JvmOverloads