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 c2ee072717..7780043c1f 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 @@ -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) 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 9e6c76b44f..d4fec3ab43 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 @@ -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. */ diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchKey.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchKey.kt index bed4eec8a6..9e3eb1ecc0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchKey.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchKey.kt @@ -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. * diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchModels.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchModels.kt index 71ae7e4d89..c5f4d4f4d0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchModels.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchModels.kt @@ -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(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( + key = { model -> "SECTION_LOADING${model.sectionLoading.sectionKey}" } + ) { + SectionLoadingRow() + } viewHolder( 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 { + 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(composeView) { + @Composable + override fun Content(model: SectionLoadingModel) { + SectionLoadingRow() + } + } + /** * View Holder for section headers */ 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 339778e2a5..b0df92bc9a 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 @@ -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 { 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 { + 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 { + 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 { + private fun getGroupsWithMembersIterator(query: String?): ContactSearchIterator { + 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 { + private fun getContactsWithoutThreadsIterator(query: String?): ContactSearchIterator { + 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 { + 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 { + sectionResults.threads?.let { return ListSearchIterator(it) } + check(searchRepository != null) if (searchCache.threadSearchResult == null && query != null) { searchCache = searchCache.copy(threadSearchResult = searchRepository.queryThreadsSync(query, unreadOnly)) 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 f12cc3ff37..c8421e9f3f 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 @@ -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(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 { + 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 { + 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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchSectionResults.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchSectionResults.kt new file mode 100644 index 0000000000..11ffd0eba3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchSectionResults.kt @@ -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 = emptySet(), + val threads: List? = null, + val messages: List? = null, + val groupsWithMembers: List? = null, + val contactsWithoutThreads: List? = 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) : ContactSearchSectionResult { + override val sectionKey: SectionKey = SectionKey.CHATS + } + + data class Messages(val messages: List) : ContactSearchSectionResult { + override val sectionKey: SectionKey = SectionKey.MESSAGES + } + + data class GroupsWithMembers(val groups: List) : ContactSearchSectionResult { + override val sectionKey: SectionKey = SectionKey.GROUPS_WITH_MEMBERS + } + + data class ContactsWithoutThreads(val recipientIds: List) : ContactSearchSectionResult { + override val sectionKey: SectionKey = SectionKey.CONTACTS_WITHOUT_THREADS + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchViewModel.kt index 8810b5d78a..bafbb3f6c4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchViewModel.kt @@ -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 = internalDisplayingContextMenu val scrollRequests: SharedFlow = 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 = internalSearchInProgress val query: StateFlow = 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(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)) } } } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/GroupWithMembersRecord.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/GroupWithMembersRecord.kt new file mode 100644 index 0000000000..0309aeba56 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/GroupWithMembersRecord.kt @@ -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 +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/GroupTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/GroupTable.kt index b0dac915c6..5f6811d7cd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/GroupTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/GroupTable.kt @@ -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 { 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 40df849a50..0e3c3c5312 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt @@ -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