Add global search backfill worker (#7860)

* Add search backfill worker

This will run on background at most once an hour while the device has internet connectivity and the device is charging and start back-paginating through the user's rooms to make sure the search results are populated.

It'll run for the maximum time the default workers allow, 10 minutes.

* Create `RustMatrixClient.Factory` AssistedFactory to help with DI and add `SearchBackfillService`

* Start search backfill when entering the search screen, just once. This should help getting initial search results if none or few are found at first
This commit is contained in:
Jorge Martin Espinosa
2026-10-07 12:41:19 +02:00
committed by GitHub
parent 9805422a8a
commit 3fe0e09e3c
27 changed files with 1104 additions and 73 deletions
@@ -10,12 +10,14 @@ package io.element.android.features.home.impl.search
import androidx.compose.foundation.text.input.clearText
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject
import io.element.android.features.home.impl.search.history.SearchHistoryResult
@@ -38,6 +40,7 @@ import io.element.android.libraries.matrix.api.search.MessageSearch
import io.element.android.libraries.matrix.api.search.MessageSearchPaginationState
import io.element.android.libraries.matrix.api.search.MessageSearchResult
import io.element.android.libraries.matrix.api.search.MessageSearchService
import io.element.android.libraries.matrix.api.search.SearchBackfillStrategy
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
@@ -81,6 +84,7 @@ class GlobalSearchPresenter(
val isEnabled by produceState(false) {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.MessageSearch).collectLatest { value = it }
}
val roomListSearchDataSource = remember { roomListSearchDataSourceFactory.create(coroutineScope = coroutineScope) }
val queryState = rememberTextFieldState()
var isSearchActive by remember { mutableStateOf(false) }
@@ -88,6 +92,8 @@ class GlobalSearchPresenter(
var currentTarget: GlobalSearchTarget by remember { mutableStateOf(GlobalSearchTarget.ROOMS) }
val currentMessageSearch: MessageSearch = remember { messageSearchService.createMessageSearch(scope = coroutineScope) }
StartSearchBackfillWhenVisible(isEnabled = isEnabled, isSearchActive = isSearchActive)
val searchHistory by produceState<AsyncData<ImmutableList<SearchHistoryListItem>>>(initialValue = AsyncData.Uninitialized) {
searchHistoryStore.history.collectLatest { history ->
val mappedHistoryItems = history.mapNotNull { result ->
@@ -297,6 +303,35 @@ class GlobalSearchPresenter(
)
}
@Composable
private fun StartSearchBackfillWhenVisible(
isEnabled: Boolean,
isSearchActive: Boolean,
) {
val coroutineScope = rememberCoroutineScope()
var hasBackfillRun by rememberSaveable { mutableStateOf(false) }
if (isEnabled && isSearchActive && !hasBackfillRun) {
DisposableEffect(Unit) {
// When we open the message search, we want to backfill the search results so that we can show the user more results if they scroll down.
val handle = matrixClient.searchBackfillService.startSearchBackfill(SearchBackfillStrategy.FOREGROUND).getOrNull()
?: return@DisposableEffect onDispose {
Timber.w("Could not start search backfill, it may already be running.")
}
val job = coroutineScope.launch(coroutineDispatchers.io) {
while (handle.isRunning()) {
delay(100.milliseconds)
}
hasBackfillRun = true
}
onDispose {
job.cancel()
handle.close()
}
}
}
}
private fun mapMediaContent(
result: MessageSearchResult,
roomInfo: RoomInfo,
@@ -33,13 +33,16 @@ import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.search.MessageSearchService
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.FakeSdkPendingTask
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.matrix.test.room.aRoomSummary
import io.element.android.libraries.matrix.test.roomlist.FakeDynamicRoomList
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.libraries.matrix.test.search.FakeMessageSearch
import io.element.android.libraries.matrix.test.search.FakeMessageSearchService
import io.element.android.libraries.matrix.test.search.FakeSearchBackfillService
import io.element.android.tests.testutils.consumeItemsUntilPredicate
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.test
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.collections.immutable.persistentListOf
@@ -205,7 +208,9 @@ class GlobalSearchPresenterTest {
@Test
fun `present - message search results are mapped when the message search emits`() = runTest {
val messageSearch = FakeMessageSearch()
val matrixClient = FakeMatrixClient().apply {
val matrixClient = FakeMatrixClient(
searchBackfillService = FakeSearchBackfillService(startSearchBackfillLambda = { Result.success(FakeSdkPendingTask()) }),
).apply {
getRoomInfoLambda = { Result.success(aRoomInfo()) }
}
val presenter = createGlobalSearchPresenter(
@@ -232,7 +237,9 @@ class GlobalSearchPresenterTest {
@Test
fun `present - message search displays the uninitialized state after removing the query`() = runTest {
val messageSearch = FakeMessageSearch()
val matrixClient = FakeMatrixClient().apply {
val matrixClient = FakeMatrixClient(
searchBackfillService = FakeSearchBackfillService(startSearchBackfillLambda = { Result.success(FakeSdkPendingTask()) }),
).apply {
getRoomInfoLambda = { Result.success(aRoomInfo()) }
}
val presenter = createGlobalSearchPresenter(
@@ -267,7 +274,9 @@ class GlobalSearchPresenterTest {
@Test
fun `present - UpdateVisibleRange triggers pagination for messages when near the end`() = runTest {
val messageSearch = FakeMessageSearch()
val matrixClient = FakeMatrixClient().apply {
val matrixClient = FakeMatrixClient(
searchBackfillService = FakeSearchBackfillService(startSearchBackfillLambda = { Result.success(FakeSdkPendingTask()) }),
).apply {
getRoomInfoLambda = { Result.success(aRoomInfo()) }
}
val presenter = createGlobalSearchPresenter(
@@ -432,6 +441,44 @@ class GlobalSearchPresenterTest {
assertThat(awaitItem().results.isLoading()).isTrue()
}
}
@Test
fun `present - will automatically start search backfill and it will be cancelled when the presenter is closed`() = runTest {
var isRunning = false
val closeLambda = lambdaRecorder<Unit> { isRunning = false }
val matrixClient = FakeMatrixClient(
searchBackfillService = FakeSearchBackfillService(
startSearchBackfillLambda = {
isRunning = true
Result.success(FakeSdkPendingTask(isRunningLambda = { isRunning }, closeLambda = closeLambda))
},
),
)
val presenter = createGlobalSearchPresenter(
matrixClient = matrixClient,
featureFlagService = FakeFeatureFlagService(
initialState = mapOf(FeatureFlags.MessageSearch.key to true),
),
)
presenter.test {
// Await the initial state, with the feature enabled
val initialState = consumeItemsUntilPredicate { it.isEnabled }.last()
assertThat(isRunning).isFalse()
// Opening the search starts the backfill
initialState.eventSink(GlobalSearchEvent.ToggleSearchVisibility)
consumeItemsUntilPredicate { it.isSearchActive }
// The backfill polling loop never ends while the task is running, so don't wait until the scheduler is idle
testScheduler.runCurrent()
assertThat(isRunning).isTrue()
closeLambda.assertions().isNeverCalled()
// Closing the presenter closes the task
cancelAndIgnoreRemainingEvents()
closeLambda.assertions().isCalledOnce()
assertThat(isRunning).isFalse()
}
}
}
private fun TestScope.createGlobalSearchPresenter(
@@ -442,7 +489,8 @@ private fun TestScope.createGlobalSearchPresenter(
dateFormatter: DateFormatter = FakeDateFormatter(),
fileSizeFormatter: FileSizeFormatter = FakeFileSizeFormatter(),
permalinkParser: PermalinkParser = FakePermalinkParser(),
matrixClient: MatrixClient = FakeMatrixClient(),
matrixClient: MatrixClient =
FakeMatrixClient(searchBackfillService = FakeSearchBackfillService(startSearchBackfillLambda = { Result.success(FakeSdkPendingTask()) })),
searchHistoryStore: SearchHistoryStore = FakeSearchHistoryStore(),
): GlobalSearchPresenter {
return GlobalSearchPresenter(