From 3fe0e09e3c4c51bb8740b2d1b3d63ac41c224dfa Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Wed, 7 Oct 2026 12:41:19 +0200 Subject: [PATCH] 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 --- .../home/impl/search/GlobalSearchPresenter.kt | 35 ++++ .../impl/search/GlobalSearchPresenterTest.kt | 56 ++++- .../libraries/matrix/api/MatrixClient.kt | 4 + .../libraries/matrix/api/SdkPendingTask.kt | 16 ++ .../api/search/SearchBackfillService.kt | 50 +++++ .../api/search/SearchBackfillStrategy.kt | 22 ++ libraries/matrix/impl/build.gradle.kts | 2 + .../libraries/matrix/impl/RustMatrixClient.kt | 59 +++++- .../matrix/impl/RustMatrixClientFactory.kt | 19 +- .../matrix/impl/RustSdkPendingTask.kt | 27 +++ .../impl/search/RustSearchBackfillService.kt | 71 +++++++ .../search/SearchBackfillStrategyMapping.kt | 21 ++ .../SearchBackfillRequestBuilder.kt | 88 ++++++++ .../workmanager/SearchBackfillWorker.kt | 81 +++++++ .../impl/RustMatrixClientFactoryTest.kt | 57 ++++- .../matrix/impl/RustMatrixClientTest.kt | 16 +- .../RustTemporaryMatrixClientFactoryTest.kt | 36 ---- .../RustMatrixAuthenticationServiceTest.kt | 2 +- .../impl/fixtures/fakes/FakeFfiClient.kt | 4 + .../impl/fixtures/fakes/FakeFfiTaskHandle.kt | 8 +- .../search/RustSearchBackfillServiceTest.kt | 198 ++++++++++++++++++ ...DefaultSearchBackfillRequestBuilderTest.kt | 78 +++++++ .../workmanager/SearchBackfillWorkerTest.kt | 161 ++++++++++++++ .../libraries/matrix/test/FakeMatrixClient.kt | 3 + .../matrix/test/FakeSdkPendingTask.kt | 20 ++ .../test/search/FakeSearchBackfillService.kt | 41 ++++ .../workmanager/api/WorkManagerScheduler.kt | 2 + 27 files changed, 1104 insertions(+), 73 deletions(-) create mode 100644 libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/SdkPendingTask.kt create mode 100644 libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/search/SearchBackfillService.kt create mode 100644 libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/search/SearchBackfillStrategy.kt create mode 100644 libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustSdkPendingTask.kt create mode 100644 libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/search/RustSearchBackfillService.kt create mode 100644 libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/search/SearchBackfillStrategyMapping.kt create mode 100644 libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/search/workmanager/SearchBackfillRequestBuilder.kt create mode 100644 libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/search/workmanager/SearchBackfillWorker.kt create mode 100644 libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/search/RustSearchBackfillServiceTest.kt create mode 100644 libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/search/workmanager/DefaultSearchBackfillRequestBuilderTest.kt create mode 100644 libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/search/workmanager/SearchBackfillWorkerTest.kt create mode 100644 libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeSdkPendingTask.kt create mode 100644 libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/search/FakeSearchBackfillService.kt diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt index f07a169589..bbd7d57ae3 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenter.kt @@ -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>>(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, diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenterTest.kt index 0dda72b0fb..5273e5555b 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenterTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/GlobalSearchPresenterTest.kt @@ -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 { 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( diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt index e43f362e7c..3b4fd40885 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt @@ -40,6 +40,7 @@ import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryServic import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.scanner.ContentScanner import io.element.android.libraries.matrix.api.search.MessageSearchService +import io.element.android.libraries.matrix.api.search.SearchBackfillService import io.element.android.libraries.matrix.api.spaces.SpaceService import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion import io.element.android.libraries.matrix.api.sync.SyncService @@ -133,6 +134,9 @@ interface MatrixClient : ClientUrlContentFetcher { /** Scans media content before it is displayed, or `null` when no content scanner is configured for this session. */ val contentScanner: ContentScanner? + /** Backfills messages from the homeserver for searching for messages in a room. */ + val searchBackfillService: SearchBackfillService + /** Whether the session is in the process of shutting down, either because it is being logged out or its cache is being cleared. */ val isShuttingDown: Boolean diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/SdkPendingTask.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/SdkPendingTask.kt new file mode 100644 index 0000000000..d1c74400c9 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/SdkPendingTask.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api + +/** + * Represents a pending task in the SDK that can be canceled and closed. + */ +interface SdkPendingTask : AutoCloseable { + /** Checks if the task is still running. */ + fun isRunning(): Boolean +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/search/SearchBackfillService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/search/SearchBackfillService.kt new file mode 100644 index 0000000000..e02b9d3f0b --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/search/SearchBackfillService.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.search + +import io.element.android.libraries.matrix.api.SdkPendingTask + +/** + * Service interface for managing search backfill tasks in the Matrix SDK. + * This service allows starting, checking the status of, and canceling search backfill tasks. + */ +interface SearchBackfillService { + /** + * Enqueues a periodic search backfill task based on the provided strategy. + * The strategy parameter determines how the backfill should be performed (e.g., in the foreground or background). + */ + suspend fun schedulePeriodicSearchBackfill(strategy: SearchBackfillStrategy): Result + + /** + * Cancels any currently scheduled periodic search backfill tasks. + */ + fun cancelPeriodicSearchBackfill() + + /** + * Starts a background task to backfill messages for rooms that have been joined but not fully backfilled yet. + * The strategy parameter determines how the backfill should be performed (e.g., in the foreground or background). + * + * @param strategy the strategy to use for backfilling messages. + * @return a [Result] indicating the success or failure of enqueuing the backfill task. + */ + fun startSearchBackfill(strategy: SearchBackfillStrategy): Result + + /** + * Checks if a search backfill task is currently running. + * + * @return `true` if a search backfill task is running, `false` otherwise. + */ + fun isSearchBackfillRunning(): Boolean + + /** + * Cancels the currently running search backfill task, if any. + * + * @return a [Result] indicating the success or failure of the cancellation operation. + */ + fun cancelSearchBackfill(): Result +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/search/SearchBackfillStrategy.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/search/SearchBackfillStrategy.kt new file mode 100644 index 0000000000..c22b0e12bb --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/search/SearchBackfillStrategy.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.search + +enum class SearchBackfillStrategy { + /** + * The app is in the foreground: pause between paginations so this doesn't + * compete with interactive traffic. + */ + FOREGROUND, + + /** + * A time-boxed background task (e.g. iOS `BGAppRefreshTask`) where there's + * no interactive traffic to protect. + */ + BACKGROUND +} diff --git a/libraries/matrix/impl/build.gradle.kts b/libraries/matrix/impl/build.gradle.kts index 3901deb9c9..9d025fc9a8 100644 --- a/libraries/matrix/impl/build.gradle.kts +++ b/libraries/matrix/impl/build.gradle.kts @@ -63,6 +63,7 @@ dependencies { implementation(projects.appconfig) implementation(projects.features.enterprise.api) + implementation(projects.features.networkmonitor.api) implementation(projects.libraries.androidutils) implementation(projects.libraries.architecture) implementation(projects.libraries.di) @@ -80,6 +81,7 @@ dependencies { testCommonDependencies(libs) testImplementation(projects.features.enterprise.test) + testImplementation(projects.features.networkmonitor.test) testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.previewutils) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt index d92509b262..70dbff1209 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -8,6 +8,9 @@ package io.element.android.libraries.matrix.impl +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject import io.element.android.libraries.androidutils.file.getSizeOfFiles import io.element.android.libraries.core.bool.orFalse import io.element.android.libraries.core.coroutine.CoroutineDispatchers @@ -16,7 +19,9 @@ import io.element.android.libraries.core.data.bytes import io.element.android.libraries.core.data.tryOrNull import io.element.android.libraries.core.extensions.mapFailure import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.di.annotations.AppCoroutineScope import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.matrix.api.HomeserverCapabilitiesProvider import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.analytics.SdkStoreSizes @@ -47,6 +52,7 @@ import io.element.android.libraries.matrix.api.room.join.JoinRule import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.scanner.ContentScanner +import io.element.android.libraries.matrix.api.search.SearchBackfillStrategy import io.element.android.libraries.matrix.api.spaces.SpaceService import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion import io.element.android.libraries.matrix.api.sync.SyncState @@ -81,6 +87,8 @@ import io.element.android.libraries.matrix.impl.roomlist.RoomListFactory import io.element.android.libraries.matrix.impl.roomlist.RustRoomListService import io.element.android.libraries.matrix.impl.roomlist.roomOrNull import io.element.android.libraries.matrix.impl.search.RustMessageSearchService +import io.element.android.libraries.matrix.impl.search.RustSearchBackfillService +import io.element.android.libraries.matrix.impl.search.map import io.element.android.libraries.matrix.impl.spaces.RustSpaceService import io.element.android.libraries.matrix.impl.sync.RustSyncService import io.element.android.libraries.matrix.impl.sync.map @@ -110,8 +118,10 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.buffer import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking @@ -149,23 +159,37 @@ import org.matrix.rustcomponents.sdk.SyncService as ClientSyncService private const val AVATAR_THUMBNAIL_SIZE_IN_PIXEL = 240L @Suppress("LargeClass") +@AssistedInject class RustMatrixClient( - override val sessionPaths: SessionPaths, - val innerClient: Client, + @Assisted override val sessionPaths: SessionPaths, + @Assisted val innerClient: Client, + @Assisted private val innerSyncService: ClientSyncService, + @Assisted baseCacheDirectory: File, + @Assisted override val contentScanner: ContentScanner?, + @Assisted override val isMessageSearchAvailable: Boolean, + @Assisted private val sessionDelegate: RustClientSessionDelegate, private val sessionStore: SessionStore, - private val sessionDelegate: RustClientSessionDelegate, - private val innerSyncService: ClientSyncService, - appCoroutineScope: CoroutineScope, + @AppCoroutineScope appCoroutineScope: CoroutineScope, dispatchers: CoroutineDispatchers, - baseCacheDirectory: File, clock: SystemClock, timelineEventFilterFactory: TimelineEventFilterFactory, private val featureFlagService: FeatureFlagService, private val analyticsService: AnalyticsService, private val workManagerScheduler: WorkManagerScheduler, - override val contentScanner: ContentScanner?, - override val isMessageSearchAvailable: Boolean, + private val searchBackfillServiceFactory: RustSearchBackfillService.Factory, ) : MatrixClient { + @AssistedFactory + interface Factory { + fun create( + sessionPaths: SessionPaths, + innerClient: Client, + innerSyncService: ClientSyncService, + baseCacheDirectory: File, + contentScanner: ContentScanner?, + isMessageSearchAvailable: Boolean, + sessionDelegate: RustClientSessionDelegate, + ): RustMatrixClient + } override val sessionId: UserId = UserId(innerClient.userId()) override val deviceId: DeviceId = DeviceId(innerClient.deviceId()) override val server: String? = innerClient.server() @@ -280,6 +304,8 @@ class RustMatrixClient( private var clientDelegateTaskHandle: TaskHandle? = innerClient.setDelegate(sessionDelegate) + override val searchBackfillService = searchBackfillServiceFactory.create(innerClient) + private val _userProfile: MutableStateFlow = MutableStateFlow( MatrixUser( userId = sessionId, @@ -332,6 +358,9 @@ class RustMatrixClient( // Schedule regular database vacuuming to ensure DB performance remains optimal scheduleDatabaseVacuum() + + // Schedule background search backfill if needed + scheduleBackgroundSearchBackfill() } private suspend fun setupUserProfile() { @@ -687,6 +716,7 @@ class RustMatrixClient( sessionCoroutineScope.cancel() clientDelegateTaskHandle?.cancelAndDestroy() ownProfileTaskHandle?.cancelAndDestroy() + searchBackfillService.cancelSearchBackfill() sessionVerificationService.destroy() sessionDelegate.clearCurrentClient() @@ -992,6 +1022,19 @@ class RustMatrixClient( override fun homeserverCapabilities(): HomeserverCapabilitiesProvider { return RustHomeserverCapabilitiesProvider(innerClient.homeserverCapabilities()) } + + private fun scheduleBackgroundSearchBackfill() { + featureFlagService.isFeatureEnabledFlow(FeatureFlags.MessageSearch) + .distinctUntilChanged() + .onEach { isEnabled -> + if (isEnabled) { + searchBackfillService.schedulePeriodicSearchBackfill(SearchBackfillStrategy.BACKGROUND) + } else { + searchBackfillService.cancelPeriodicSearchBackfill() + } + } + .launchIn(sessionCoroutineScope) + } } private fun defaultRoomCreationPowerLevels(isPublic: Boolean, isSpace: Boolean) = PowerLevels( diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt index 9b381460a2..82e134dc24 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt @@ -23,16 +23,13 @@ import io.element.android.libraries.matrix.api.paths.SessionPaths import io.element.android.libraries.matrix.impl.analytics.UtdTracker import io.element.android.libraries.matrix.impl.paths.getSessionPaths import io.element.android.libraries.matrix.impl.proxy.ProxyProvider -import io.element.android.libraries.matrix.impl.room.TimelineEventFilterFactory import io.element.android.libraries.matrix.impl.scanner.RustContentScanner import io.element.android.libraries.matrix.impl.storage.SqliteStoreBuilderProvider import io.element.android.libraries.matrix.impl.util.anonymizedTokens import io.element.android.libraries.network.useragent.UserAgentProvider import io.element.android.libraries.sessionstorage.api.SessionData import io.element.android.libraries.sessionstorage.api.SessionStore -import io.element.android.libraries.workmanager.api.WorkManagerScheduler import io.element.android.services.analytics.api.AnalyticsService -import io.element.android.services.toolbox.api.systemclock.SystemClock import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.withContext import org.matrix.rustcomponents.sdk.ClientBuilder @@ -61,14 +58,12 @@ class RustMatrixClientFactory( private val sessionStore: SessionStore, private val userAgentProvider: UserAgentProvider, private val proxyProvider: ProxyProvider, - private val clock: SystemClock, private val analyticsService: AnalyticsService, private val featureFlagService: FeatureFlagService, - private val timelineEventFilterFactory: TimelineEventFilterFactory, private val clientBuilderProvider: ClientBuilderProvider, private val sqliteStoreBuilderProvider: SqliteStoreBuilderProvider, - private val workManagerScheduler: WorkManagerScheduler, private val clientBuilderEnterpriseHook: ClientBuilderEnterpriseHook, + private val innerMatrixClientFactory: RustMatrixClient.Factory, ) { private val sessionDelegate = RustClientSessionDelegate( sessionStore = sessionStore, @@ -131,22 +126,14 @@ class RustMatrixClientFactory( .withOfflineMode() .finish() - RustMatrixClient( + innerMatrixClientFactory.create( sessionPaths = sessionData.getSessionPaths(), innerClient = client, - sessionStore = sessionStore, - appCoroutineScope = appCoroutineScope, - sessionDelegate = sessionDelegate, innerSyncService = syncService, - dispatchers = coroutineDispatchers, baseCacheDirectory = cacheDirectory, - clock = clock, - timelineEventFilterFactory = timelineEventFilterFactory, - featureFlagService = featureFlagService, - analyticsService = analyticsService, - workManagerScheduler = workManagerScheduler, contentScanner = client.contentScanner()?.let { RustContentScanner(client, it) }, isMessageSearchAvailable = isMessageSearchAvailable, + sessionDelegate = sessionDelegate, ).also { Timber.tag("RustMatrixClient").i("Creating Client with access token '$anonymizedAccessToken' and refresh token '$anonymizedRefreshToken'") } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustSdkPendingTask.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustSdkPendingTask.kt new file mode 100644 index 0000000000..57f6f525c9 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustSdkPendingTask.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl + +import io.element.android.libraries.matrix.api.SdkPendingTask +import io.element.android.libraries.matrix.impl.util.cancelAndDestroy +import org.matrix.rustcomponents.sdk.TaskHandle +import java.util.concurrent.atomic.AtomicBoolean + +class RustSdkPendingTask( + private val taskHandle: TaskHandle, +) : SdkPendingTask { + private val closed = AtomicBoolean(false) + + override fun isRunning(): Boolean = !closed.get() && !taskHandle.isFinished() + + override fun close() { + if (!closed.getAndSet(true)) { + taskHandle.cancelAndDestroy() + } + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/search/RustSearchBackfillService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/search/RustSearchBackfillService.kt new file mode 100644 index 0000000000..69b3c0fe52 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/search/RustSearchBackfillService.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.search + +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.matrix.api.SdkPendingTask +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.search.SearchBackfillService +import io.element.android.libraries.matrix.api.search.SearchBackfillStrategy +import io.element.android.libraries.matrix.impl.RustSdkPendingTask +import io.element.android.libraries.matrix.impl.search.workmanager.SearchBackfillRequestBuilder +import io.element.android.libraries.workmanager.api.WorkManagerRequestType +import io.element.android.libraries.workmanager.api.WorkManagerScheduler +import org.matrix.rustcomponents.sdk.Client +import timber.log.Timber +import java.util.concurrent.atomic.AtomicReference + +@AssistedInject +class RustSearchBackfillService( + @Assisted private val innerClient: Client, + private val searchBackfillRequestBuilderFactory: SearchBackfillRequestBuilder.Factory, + private val workManagerScheduler: WorkManagerScheduler, +) : SearchBackfillService { + @AssistedFactory + interface Factory { + fun create(innerClient: Client): RustSearchBackfillService + } + + private val currentSearchBackfillTaskHandle: AtomicReference = AtomicReference(null) + + override suspend fun schedulePeriodicSearchBackfill(strategy: SearchBackfillStrategy): Result = runCatchingExceptions { + val sessionId = SessionId(innerClient.userId()) + if (workManagerScheduler.hasPendingWork(sessionId, WorkManagerRequestType.SEARCH_BACKFILL)) { + Timber.d("Background search backfill already scheduled for session $sessionId") + return@runCatchingExceptions + } + Timber.d("Scheduling background search backfill for session $sessionId") + val builder = searchBackfillRequestBuilderFactory.create(sessionId) + workManagerScheduler.submit(builder) + } + + override fun cancelPeriodicSearchBackfill() { + val sessionId = SessionId(innerClient.userId()) + Timber.d("Cancelling background search backfill for session $sessionId") + workManagerScheduler.cancel(sessionId, WorkManagerRequestType.SEARCH_BACKFILL) + } + + override fun startSearchBackfill(strategy: SearchBackfillStrategy): Result = runCatchingExceptions { + if (currentSearchBackfillTaskHandle.get()?.isRunning() == true) { + error("Search backfill is already running") + } + RustSdkPendingTask(innerClient.runSearchBackfill(strategy.map())) + .also { currentSearchBackfillTaskHandle.set(it) } + } + + override fun isSearchBackfillRunning(): Boolean { + return currentSearchBackfillTaskHandle.get()?.isRunning() ?: false + } + + override fun cancelSearchBackfill(): Result = runCatchingExceptions { + currentSearchBackfillTaskHandle.getAndSet(null)?.close() + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/search/SearchBackfillStrategyMapping.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/search/SearchBackfillStrategyMapping.kt new file mode 100644 index 0000000000..2b3a1c866a --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/search/SearchBackfillStrategyMapping.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.search + +import io.element.android.libraries.matrix.api.search.SearchBackfillStrategy +import uniffi.matrix_sdk.SearchBackfillStrategy as RustSearchBackfillStrategy + +fun RustSearchBackfillStrategy.map(): SearchBackfillStrategy = when (this) { + RustSearchBackfillStrategy.FOREGROUND -> SearchBackfillStrategy.FOREGROUND + RustSearchBackfillStrategy.BACKGROUND -> SearchBackfillStrategy.BACKGROUND +} + +fun SearchBackfillStrategy.map(): RustSearchBackfillStrategy = when (this) { + SearchBackfillStrategy.FOREGROUND -> RustSearchBackfillStrategy.FOREGROUND + SearchBackfillStrategy.BACKGROUND -> RustSearchBackfillStrategy.BACKGROUND +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/search/workmanager/SearchBackfillRequestBuilder.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/search/workmanager/SearchBackfillRequestBuilder.kt new file mode 100644 index 0000000000..bb80672369 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/search/workmanager/SearchBackfillRequestBuilder.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.search.workmanager + +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import androidx.work.Constraints +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.workDataOf +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.networkmonitor.api.NetworkMonitor +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.impl.search.workmanager.SearchBackfillWorker.Companion.SESSION_ID_PARAM +import io.element.android.libraries.workmanager.api.WorkManagerRequestBuilder +import io.element.android.libraries.workmanager.api.WorkManagerRequestType +import io.element.android.libraries.workmanager.api.WorkManagerRequestWrapper +import io.element.android.libraries.workmanager.api.WorkManagerWorkerType +import io.element.android.libraries.workmanager.api.workManagerTag +import kotlinx.coroutines.flow.first +import timber.log.Timber +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes +import kotlin.time.toJavaDuration + +interface SearchBackfillRequestBuilder : WorkManagerRequestBuilder { + fun interface Factory { + fun create(sessionId: SessionId): SearchBackfillRequestBuilder + } +} + +/** + * Builds a work request that runs the search backfill (back paginations) only when there is network connectivity and the device is charging. + */ +@AssistedInject +class DefaultSearchBackfillRequestBuilder( + @Assisted private val sessionId: SessionId, + private val networkMonitor: NetworkMonitor, +) : SearchBackfillRequestBuilder { + @AssistedFactory + @ContributesBinding(AppScope::class) + interface Factory : SearchBackfillRequestBuilder.Factory { + override fun create(sessionId: SessionId): DefaultSearchBackfillRequestBuilder + } + + override suspend fun build(): Result> { + val tag = workManagerTag(sessionId, WorkManagerRequestType.SEARCH_BACKFILL) + val type = WorkManagerWorkerType.Default + + val networkRequestBuilder = NetworkRequest.Builder() + // Only allow unmetered networks to avoid using mobile data for backfilling. + .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED) + // Some customers use a VPN to connect to their homeserver, so VPN networks must be allowed. + .removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) + + // In an air-gapped environment, validating internet connectivity would fail and the worker would never run. + if (networkMonitor.isInAirGappedEnvironment.first()) { + Timber.d("In an air-gapped environment, not adding NET_CAPABILITY_VALIDATED to the network request") + networkRequestBuilder.removeCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + } else { + networkRequestBuilder.addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + } + + val constraints = Constraints.Builder() + .setRequiredNetworkRequest(networkRequestBuilder.build(), NetworkType.UNMETERED) + .setRequiresCharging(true) + .build() + + val request = PeriodicWorkRequestBuilder(1.hours.toJavaDuration()) + // Add a delay before the first execution to avoid running the backfill immediately after the app starts + .setInitialDelay(5.minutes.toJavaDuration()) + .setInputData(workDataOf(SESSION_ID_PARAM to sessionId.value)) + .addTag(tag) + .setConstraints(constraints) + .build() + + return Result.success(listOf(WorkManagerRequestWrapper(request, type))) + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/search/workmanager/SearchBackfillWorker.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/search/workmanager/SearchBackfillWorker.kt new file mode 100644 index 0000000000..48d1b31992 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/search/workmanager/SearchBackfillWorker.kt @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.search.workmanager + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import dev.zacsweers.metro.ContributesIntoMap +import dev.zacsweers.metro.binding +import io.element.android.libraries.di.annotations.ApplicationContext +import io.element.android.libraries.matrix.api.MatrixClientProvider +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.search.SearchBackfillStrategy +import io.element.android.libraries.workmanager.api.di.MetroWorkerFactory +import io.element.android.libraries.workmanager.api.di.WorkerKey +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.delay +import kotlinx.coroutines.withTimeout +import timber.log.Timber +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +@AssistedInject +class SearchBackfillWorker( + @Assisted params: WorkerParameters, + @ApplicationContext context: Context, + private val matrixClientProvider: MatrixClientProvider, +) : CoroutineWorker(context, params) { + companion object { + const val SESSION_ID_PARAM = "session_id" + private val TIMEOUT = 10.minutes + private val POLL_INTERVAL = 5.seconds + } + + override suspend fun doWork(): Result { + Timber.d("Starting search backfill. This will run up to $TIMEOUT") + val sessionId = inputData.getString(SESSION_ID_PARAM)?.let(::SessionId) ?: return Result.failure() + val client = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return Result.failure() + + if (client.searchBackfillService.isSearchBackfillRunning()) { + Timber.d("Search backfill is already running for session $sessionId, nothing to do") + return Result.success() + } + + Timber.d("Starting search backfill for session $sessionId") + val task = client.searchBackfillService.startSearchBackfill(SearchBackfillStrategy.BACKGROUND).getOrElse { + Timber.e(it, "Failed to start search backfill") + return Result.failure() + } + + return task.use { + try { + withTimeout(TIMEOUT) { + while (task.isRunning()) { + delay(POLL_INTERVAL) + } + } + Timber.d("Search backfill finished") + Result.success() + } catch (_: TimeoutCancellationException) { + Timber.d("Search backfill timed out after $TIMEOUT, cancelling it") + // Run again later to continue backfilling. + Result.retry() + } + } + } + + @ContributesIntoMap(AppScope::class, binding = binding>()) + @WorkerKey(SearchBackfillWorker::class) + @AssistedFactory + interface Factory : MetroWorkerFactory.WorkerInstanceFactory +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt index 4dcb8b1df8..438d9f651b 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt @@ -12,8 +12,12 @@ import com.google.common.truth.Truth.assertThat import io.element.android.features.enterprise.test.FakeClientBuilderEnterpriseHook import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.paths.SessionPaths +import io.element.android.libraries.matrix.api.scanner.ContentScanner import io.element.android.libraries.matrix.impl.auth.FakeProxyProvider import io.element.android.libraries.matrix.impl.room.FakeTimelineEventFilterFactory +import io.element.android.libraries.matrix.impl.search.RustSearchBackfillService +import io.element.android.libraries.matrix.impl.search.workmanager.SearchBackfillRequestBuilder import io.element.android.libraries.matrix.impl.storage.FakeSqliteStoreBuilderProvider import io.element.android.libraries.matrix.impl.storage.SqliteStoreBuilderProvider import io.element.android.libraries.network.useragent.SimpleUserAgentProvider @@ -21,6 +25,7 @@ import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.test.InMemorySessionStore import io.element.android.libraries.sessionstorage.test.aSessionData import io.element.android.libraries.workmanager.api.WorkManagerRequestBuilder +import io.element.android.libraries.workmanager.api.WorkManagerRequestWrapper import io.element.android.libraries.workmanager.test.FakeWorkManagerScheduler import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.toolbox.test.systemclock.FakeSystemClock @@ -29,13 +34,15 @@ import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import org.junit.Test +import org.matrix.rustcomponents.sdk.Client import java.io.File +import org.matrix.rustcomponents.sdk.SyncService as ClientSyncService class RustMatrixClientFactoryTest { @Test fun test() = runTest { val scheduleVacuumLambda = lambdaRecorder {} - val workManagerScheduler = FakeWorkManagerScheduler(submitLambda = scheduleVacuumLambda) + val workManagerScheduler = FakeWorkManagerScheduler(submitLambda = scheduleVacuumLambda, cancelLambda = { _, _ -> }) val sut = createRustMatrixClientFactory(workManagerScheduler = workManagerScheduler) val result = sut.create(aSessionData()) @@ -52,7 +59,7 @@ fun TestScope.createRustMatrixClientFactory( updateUserProfileResult = { _, _, _, _ -> }, ), clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(), - workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(), + workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(cancelLambda = { _, _ -> }), sqliteStoreBuilderProvider: SqliteStoreBuilderProvider = FakeSqliteStoreBuilderProvider(), ) = RustMatrixClientFactory( cacheDirectory = cacheDirectory, @@ -61,12 +68,52 @@ fun TestScope.createRustMatrixClientFactory( sessionStore = sessionStore, userAgentProvider = SimpleUserAgentProvider(), proxyProvider = FakeProxyProvider(), - clock = FakeSystemClock(), analyticsService = FakeAnalyticsService(), featureFlagService = FakeFeatureFlagService(), - timelineEventFilterFactory = FakeTimelineEventFilterFactory(), clientBuilderProvider = clientBuilderProvider, sqliteStoreBuilderProvider = sqliteStoreBuilderProvider, - workManagerScheduler = workManagerScheduler, clientBuilderEnterpriseHook = FakeClientBuilderEnterpriseHook(), + innerMatrixClientFactory = createRustMatrixClientInnerFactory(sessionStore, workManagerScheduler), ) + +private fun TestScope.createRustMatrixClientInnerFactory( + sessionStore: SessionStore, + workManagerScheduler: FakeWorkManagerScheduler, +) = object : RustMatrixClient.Factory { + override fun create( + sessionPaths: SessionPaths, + innerClient: Client, + innerSyncService: ClientSyncService, + baseCacheDirectory: File, + contentScanner: ContentScanner?, + isMessageSearchAvailable: Boolean, + sessionDelegate: RustClientSessionDelegate, + ) = RustMatrixClient( + sessionPaths = sessionPaths, + innerClient = innerClient, + innerSyncService = innerSyncService, + baseCacheDirectory = baseCacheDirectory, + contentScanner = contentScanner, + isMessageSearchAvailable = isMessageSearchAvailable, + sessionDelegate = sessionDelegate, + sessionStore = sessionStore, + appCoroutineScope = backgroundScope, + dispatchers = testCoroutineDispatchers(), + clock = FakeSystemClock(), + timelineEventFilterFactory = FakeTimelineEventFilterFactory(), + featureFlagService = FakeFeatureFlagService(), + analyticsService = FakeAnalyticsService(), + workManagerScheduler = workManagerScheduler, + searchBackfillServiceFactory = object : RustSearchBackfillService.Factory { + override fun create(innerClient: Client) = RustSearchBackfillService( + innerClient = innerClient, + searchBackfillRequestBuilderFactory = { + object : SearchBackfillRequestBuilder { + override suspend fun build(): Result> = Result.success(emptyList()) + } + }, + workManagerScheduler = workManagerScheduler, + ) + }, + ) +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientTest.kt index 4fe61e4d14..2e027930fd 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientTest.kt @@ -18,6 +18,8 @@ import io.element.android.libraries.matrix.impl.fixtures.factories.aRustUserProf import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiClient import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiSyncService import io.element.android.libraries.matrix.impl.room.FakeTimelineEventFilterFactory +import io.element.android.libraries.matrix.impl.search.RustSearchBackfillService +import io.element.android.libraries.matrix.impl.search.workmanager.SearchBackfillRequestBuilder import io.element.android.libraries.matrix.test.AN_AVATAR_URL import io.element.android.libraries.matrix.test.A_DEVICE_ID import io.element.android.libraries.matrix.test.A_ROOM_ID @@ -27,6 +29,7 @@ import io.element.android.libraries.matrix.test.scanner.FakeContentScanner import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.test.InMemorySessionStore import io.element.android.libraries.sessionstorage.test.aSessionData +import io.element.android.libraries.workmanager.api.WorkManagerRequestWrapper import io.element.android.libraries.workmanager.test.FakeWorkManagerScheduler import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.toolbox.test.systemclock.FakeSystemClock @@ -325,8 +328,19 @@ class RustMatrixClientTest { timelineEventFilterFactory = FakeTimelineEventFilterFactory(), featureFlagService = FakeFeatureFlagService(), analyticsService = FakeAnalyticsService(), - workManagerScheduler = FakeWorkManagerScheduler(submitLambda = {}), + workManagerScheduler = FakeWorkManagerScheduler(submitLambda = {}, cancelLambda = { _, _ -> }), contentScanner = FakeContentScanner(), isMessageSearchAvailable = false, + searchBackfillServiceFactory = object : RustSearchBackfillService.Factory { + override fun create(innerClient: Client) = RustSearchBackfillService( + innerClient = innerClient, + searchBackfillRequestBuilderFactory = { + object : SearchBackfillRequestBuilder { + override suspend fun build(): Result> = Result.success(emptyList()) + } + }, + workManagerScheduler = FakeWorkManagerScheduler(submitLambda = {}, cancelLambda = { _, _ -> }), + ) + }, ) } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientFactoryTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientFactoryTest.kt index f4a229b30b..4fb3250071 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientFactoryTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientFactoryTest.kt @@ -8,19 +8,7 @@ package io.element.android.libraries.matrix.impl import com.google.common.truth.Truth.assertThat -import io.element.android.features.enterprise.test.FakeClientBuilderEnterpriseHook -import io.element.android.libraries.featureflag.test.FakeFeatureFlagService -import io.element.android.libraries.matrix.impl.auth.FakeProxyProvider import io.element.android.libraries.matrix.impl.paths.SessionPathsFactory -import io.element.android.libraries.matrix.impl.room.FakeTimelineEventFilterFactory -import io.element.android.libraries.matrix.impl.storage.FakeSqliteStoreBuilderProvider -import io.element.android.libraries.network.useragent.SimpleUserAgentProvider -import io.element.android.libraries.sessionstorage.api.SessionStore -import io.element.android.libraries.sessionstorage.test.InMemorySessionStore -import io.element.android.libraries.workmanager.test.FakeWorkManagerScheduler -import io.element.android.services.analytics.test.FakeAnalyticsService -import io.element.android.services.toolbox.test.systemclock.FakeSystemClock -import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import org.junit.Test @@ -56,28 +44,4 @@ class RustTemporaryMatrixClientFactoryTest { rustMatrixClientFactory = rustMatrixClientFactory, ) } - - private fun TestScope.createRustMatrixClientFactory( - cacheDirectory: File = File("/cache"), - sessionStore: SessionStore = InMemorySessionStore( - updateUserProfileResult = { _, _, _, _ -> }, - ), - clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(), - workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(), - ) = RustMatrixClientFactory( - cacheDirectory = cacheDirectory, - appCoroutineScope = backgroundScope, - coroutineDispatchers = testCoroutineDispatchers(), - sessionStore = sessionStore, - userAgentProvider = SimpleUserAgentProvider(), - proxyProvider = FakeProxyProvider(), - clock = FakeSystemClock(), - analyticsService = FakeAnalyticsService(), - featureFlagService = FakeFeatureFlagService(), - timelineEventFilterFactory = FakeTimelineEventFilterFactory(), - clientBuilderProvider = clientBuilderProvider, - sqliteStoreBuilderProvider = FakeSqliteStoreBuilderProvider(), - workManagerScheduler = workManagerScheduler, - clientBuilderEnterpriseHook = FakeClientBuilderEnterpriseHook(), - ) } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt index 9b589b2281..df564ca710 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt @@ -265,7 +265,7 @@ class RustMatrixAuthenticationServiceTest { cacheDirectory = File("/cache"), sessionStore = sessionStore, clientBuilderProvider = clientBuilderProvider, - workManagerScheduler = FakeWorkManagerScheduler(submitLambda = {}), + workManagerScheduler = FakeWorkManagerScheduler(submitLambda = {}, cancelLambda = { _, _ -> }), sqliteStoreBuilderProvider = sqliteStoreBuilderProvider, ) return RustMatrixAuthenticationService( diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt index c2080747d5..2a908ad885 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt @@ -45,6 +45,7 @@ import org.matrix.rustcomponents.sdk.SyncServiceBuilder import org.matrix.rustcomponents.sdk.TaskHandle import org.matrix.rustcomponents.sdk.UnableToDecryptDelegate import org.matrix.rustcomponents.sdk.UserProfile +import uniffi.matrix_sdk.SearchBackfillStrategy import uniffi.matrix_sdk_base.MediaRetentionPolicy class FakeFfiClient( @@ -73,6 +74,7 @@ class FakeFfiClient( private val getUrlResult: (String) -> ByteArray = { lambdaError() }, private val getMediaThumbnailResult: (MediaSource, ULong, ULong) -> ByteArray = { _, _, _ -> byteArrayOf() }, private val contentScannerResult: () -> ContentScanner = { FakeFfiContentScanner() }, + private val runSearchBackfillResult: (SearchBackfillStrategy) -> TaskHandle = { lambdaError() }, private val closeResult: () -> Unit = {}, ) : Client(NoHandle) { override fun userId(): String = userId @@ -170,5 +172,7 @@ class FakeFfiClient( contentScannerResult() } + override fun runSearchBackfill(strategy: SearchBackfillStrategy): TaskHandle = runSearchBackfillResult(strategy) + override fun close() = closeResult() } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiTaskHandle.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiTaskHandle.kt index 24d7884547..782bebd295 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiTaskHandle.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiTaskHandle.kt @@ -11,7 +11,11 @@ package io.element.android.libraries.matrix.impl.fixtures.fakes import org.matrix.rustcomponents.sdk.NoHandle import org.matrix.rustcomponents.sdk.TaskHandle -class FakeFfiTaskHandle : TaskHandle(NoHandle) { - override fun cancel() = Unit +class FakeFfiTaskHandle( + private val isFinishedResult: () -> Boolean = { false }, + private val cancelResult: () -> Unit = {}, +) : TaskHandle(NoHandle) { + override fun isFinished(): Boolean = isFinishedResult() + override fun cancel() = cancelResult() override fun destroy() = Unit } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/search/RustSearchBackfillServiceTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/search/RustSearchBackfillServiceTest.kt new file mode 100644 index 0000000000..aeac7a506c --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/search/RustSearchBackfillServiceTest.kt @@ -0,0 +1,198 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.search + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.search.SearchBackfillStrategy +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiClient +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiTaskHandle +import io.element.android.libraries.matrix.impl.search.workmanager.SearchBackfillRequestBuilder +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.workmanager.api.WorkManagerRequestBuilder +import io.element.android.libraries.workmanager.api.WorkManagerRequestType +import io.element.android.libraries.workmanager.api.WorkManagerRequestWrapper +import io.element.android.libraries.workmanager.test.FakeWorkManagerScheduler +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.matrix.rustcomponents.sdk.TaskHandle +import uniffi.matrix_sdk.SearchBackfillStrategy as RustSearchBackfillStrategy + +class RustSearchBackfillServiceTest { + @Test + fun `schedulePeriodicSearchBackfill submits a request when nothing is pending`() = runTest { + val builder = aRequestBuilder() + val createBuilderLambda = lambdaRecorder { builder } + val submitLambda = lambdaRecorder { } + val service = createService( + requestBuilderFactory = SearchBackfillRequestBuilder.Factory(createBuilderLambda), + workManagerScheduler = FakeWorkManagerScheduler( + submitLambda = submitLambda, + hasPendingWorkLambda = { _, _ -> false }, + ), + ) + + val result = service.schedulePeriodicSearchBackfill(SearchBackfillStrategy.BACKGROUND) + + assertThat(result.isSuccess).isTrue() + createBuilderLambda.assertions().isCalledOnce().with(value(A_SESSION_ID)) + submitLambda.assertions().isCalledOnce().with(value(builder)) + } + + @Test + fun `schedulePeriodicSearchBackfill does nothing when work is already pending`() = runTest { + val hasPendingWorkLambda = lambdaRecorder { _, _ -> true } + val createBuilderLambda = lambdaRecorder { error("Should not be called") } + val submitLambda = lambdaRecorder { error("Should not be called") } + val service = createService( + requestBuilderFactory = SearchBackfillRequestBuilder.Factory(createBuilderLambda), + workManagerScheduler = FakeWorkManagerScheduler( + submitLambda = submitLambda, + hasPendingWorkLambda = hasPendingWorkLambda, + ), + ) + + val result = service.schedulePeriodicSearchBackfill(SearchBackfillStrategy.BACKGROUND) + + assertThat(result.isSuccess).isTrue() + hasPendingWorkLambda.assertions().isCalledOnce().with(value(A_SESSION_ID), value(WorkManagerRequestType.SEARCH_BACKFILL)) + createBuilderLambda.assertions().isNeverCalled() + submitLambda.assertions().isNeverCalled() + } + + @Test + fun `schedulePeriodicSearchBackfill returns a failure when submitting fails`() = runTest { + val service = createService( + requestBuilderFactory = SearchBackfillRequestBuilder.Factory { aRequestBuilder() }, + workManagerScheduler = FakeWorkManagerScheduler(submitLambda = { throw IllegalStateException("boom") }), + ) + + val result = service.schedulePeriodicSearchBackfill(SearchBackfillStrategy.BACKGROUND) + + assertThat(result.exceptionOrNull()).isInstanceOf(IllegalStateException::class.java) + } + + @Test + fun `cancelPeriodicSearchBackfill cancels the search backfill work of the session`() { + val cancelLambda = lambdaRecorder { _, _ -> } + val service = createService(workManagerScheduler = FakeWorkManagerScheduler(cancelLambda = cancelLambda)) + + service.cancelPeriodicSearchBackfill() + + cancelLambda.assertions().isCalledOnce().with(value(A_SESSION_ID), value(WorkManagerRequestType.SEARCH_BACKFILL)) + } + + @Test + fun `startSearchBackfill maps the strategy and returns a running task`() { + val strategyLambda = lambdaRecorder { FakeFfiTaskHandle(isFinishedResult = { false }) } + val service = createService(client = FakeFfiClient(runSearchBackfillResult = strategyLambda)) + + val task = service.startSearchBackfill(SearchBackfillStrategy.FOREGROUND).getOrThrow() + + strategyLambda.assertions().isCalledOnce().with(value(RustSearchBackfillStrategy.FOREGROUND)) + assertThat(task.isRunning()).isTrue() + assertThat(service.isSearchBackfillRunning()).isTrue() + } + + @Test + fun `startSearchBackfill maps the background strategy`() { + val strategyLambda = lambdaRecorder { FakeFfiTaskHandle() } + val service = createService(client = FakeFfiClient(runSearchBackfillResult = strategyLambda)) + + service.startSearchBackfill(SearchBackfillStrategy.BACKGROUND) + + strategyLambda.assertions().isCalledOnce().with(value(RustSearchBackfillStrategy.BACKGROUND)) + } + + @Test + fun `startSearchBackfill fails when a backfill is already running`() { + val strategyLambda = lambdaRecorder { FakeFfiTaskHandle(isFinishedResult = { false }) } + val service = createService(client = FakeFfiClient(runSearchBackfillResult = strategyLambda)) + service.startSearchBackfill(SearchBackfillStrategy.FOREGROUND) + + val result = service.startSearchBackfill(SearchBackfillStrategy.FOREGROUND) + + assertThat(result.exceptionOrNull()).isInstanceOf(IllegalStateException::class.java) + strategyLambda.assertions().isCalledOnce() + } + + @Test + fun `startSearchBackfill can start again once the previous task has finished`() { + val strategyLambda = lambdaRecorder { FakeFfiTaskHandle(isFinishedResult = { true }) } + val service = createService(client = FakeFfiClient(runSearchBackfillResult = strategyLambda)) + + assertThat(service.startSearchBackfill(SearchBackfillStrategy.FOREGROUND).isSuccess).isTrue() + assertThat(service.isSearchBackfillRunning()).isFalse() + assertThat(service.startSearchBackfill(SearchBackfillStrategy.FOREGROUND).isSuccess).isTrue() + + strategyLambda.assertions().isCalledExactly(2) + } + + @Test + fun `startSearchBackfill returns a failure when the SDK fails`() { + val service = createService(client = FakeFfiClient(runSearchBackfillResult = { error("boom") })) + + val result = service.startSearchBackfill(SearchBackfillStrategy.FOREGROUND) + + assertThat(result.isFailure).isTrue() + assertThat(service.isSearchBackfillRunning()).isFalse() + } + + @Test + fun `isSearchBackfillRunning is false when nothing was started`() { + assertThat(createService().isSearchBackfillRunning()).isFalse() + } + + @Test + fun `cancelSearchBackfill cancels the running task`() { + val cancelLambda = lambdaRecorder { } + val service = createService( + client = FakeFfiClient(runSearchBackfillResult = { FakeFfiTaskHandle(cancelResult = cancelLambda) }), + ) + service.startSearchBackfill(SearchBackfillStrategy.FOREGROUND) + + val result = service.cancelSearchBackfill() + + assertThat(result.isSuccess).isTrue() + cancelLambda.assertions().isCalledOnce() + assertThat(service.isSearchBackfillRunning()).isFalse() + } + + @Test + fun `cancelSearchBackfill without a running task is a no-op`() { + assertThat(createService().cancelSearchBackfill().isSuccess).isTrue() + } + + @Test + fun `startSearchBackfill works again after cancelSearchBackfill`() { + val strategyLambda = lambdaRecorder { FakeFfiTaskHandle() } + val service = createService(client = FakeFfiClient(runSearchBackfillResult = strategyLambda)) + service.startSearchBackfill(SearchBackfillStrategy.FOREGROUND) + service.cancelSearchBackfill() + + assertThat(service.startSearchBackfill(SearchBackfillStrategy.FOREGROUND).isSuccess).isTrue() + + strategyLambda.assertions().isCalledExactly(2) + } + + private fun aRequestBuilder() = object : SearchBackfillRequestBuilder { + override suspend fun build(): Result> = Result.success(emptyList()) + } + + private fun createService( + client: FakeFfiClient = FakeFfiClient(), + requestBuilderFactory: SearchBackfillRequestBuilder.Factory = SearchBackfillRequestBuilder.Factory { aRequestBuilder() }, + workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(), + ) = RustSearchBackfillService( + innerClient = client, + searchBackfillRequestBuilderFactory = requestBuilderFactory, + workManagerScheduler = workManagerScheduler, + ) +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/search/workmanager/DefaultSearchBackfillRequestBuilderTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/search/workmanager/DefaultSearchBackfillRequestBuilderTest.kt new file mode 100644 index 0000000000..6518f93a49 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/search/workmanager/DefaultSearchBackfillRequestBuilderTest.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.search.workmanager + +import android.net.NetworkCapabilities +import androidx.work.PeriodicWorkRequest +import androidx.work.hasKeyWithValueOfType +import com.google.common.truth.Truth.assertThat +import io.element.android.features.networkmonitor.test.FakeNetworkMonitor +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.workmanager.api.WorkManagerRequestType +import io.element.android.libraries.workmanager.api.WorkManagerWorkerType +import io.element.android.libraries.workmanager.api.workManagerTag +import io.element.android.tests.testutils.robolectric.RobolectricTest +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes + +class DefaultSearchBackfillRequestBuilderTest : RobolectricTest() { + private val expectedTag = workManagerTag(A_SESSION_ID, WorkManagerRequestType.SEARCH_BACKFILL) + + @Test + fun `build - creates a periodic request with session id and tag`() = runTest { + val result = createBuilder().build().getOrThrow().single() + + assertThat(result.type).isEqualTo(WorkManagerWorkerType.Default) + result.request.run { + assertThat(this).isInstanceOf(PeriodicWorkRequest::class.java) + assertThat(workSpec.isPeriodic).isTrue() + assertThat(workSpec.intervalDuration).isEqualTo(1.hours.inWholeMilliseconds) + assertThat(workSpec.initialDelay).isEqualTo(5.minutes.inWholeMilliseconds) + assertThat(workSpec.input.hasKeyWithValueOfType(SearchBackfillWorker.SESSION_ID_PARAM)).isTrue() + assertThat(workSpec.input.getString(SearchBackfillWorker.SESSION_ID_PARAM)).isEqualTo(A_SESSION_ID.value) + assertThat(tags).contains(expectedTag) + assertThat(workSpec.workerClassName).isEqualTo(SearchBackfillWorker::class.java.name) + } + } + + @Test + fun `build - requires charging and an unmetered network`() = runTest { + val result = createBuilder().build().getOrThrow().single() + + val constraints = result.request.workSpec.constraints + assertThat(constraints.requiresCharging()).isTrue() + val networkRequest = constraints.requiredNetworkRequest + assertThat(networkRequest).isNotNull() + assertThat(networkRequest!!.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)).isTrue() + } + + @Test + fun `build - validates the network when not in an air-gapped env`() = runTest { + val result = createBuilder(isInAirGapEnvironment = false).build().getOrThrow().single() + + val networkRequest = result.request.workSpec.constraints.requiredNetworkRequest!! + assertThat(networkRequest.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)).isTrue() + } + + @Test + fun `build - does not validate the network when in an air-gapped env`() = runTest { + val result = createBuilder(isInAirGapEnvironment = true).build().getOrThrow().single() + + val networkRequest = result.request.workSpec.constraints.requiredNetworkRequest!! + assertThat(networkRequest.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)).isFalse() + } + + private fun createBuilder( + isInAirGapEnvironment: Boolean = false, + ) = DefaultSearchBackfillRequestBuilder( + sessionId = A_SESSION_ID, + networkMonitor = FakeNetworkMonitor().apply { givenIsInAirGappedEnvironment(isInAirGapEnvironment) }, + ) +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/search/workmanager/SearchBackfillWorkerTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/search/workmanager/SearchBackfillWorkerTest.kt new file mode 100644 index 0000000000..f450b9f68a --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/search/workmanager/SearchBackfillWorkerTest.kt @@ -0,0 +1,161 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.search.workmanager + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.work.Data +import androidx.work.ListenableWorker +import androidx.work.WorkerParameters +import androidx.work.impl.utils.taskexecutor.WorkManagerTaskExecutor +import androidx.work.workDataOf +import com.google.common.truth.Truth.assertThat +import com.google.common.util.concurrent.ListenableFuture +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.SdkPendingTask +import io.element.android.libraries.matrix.api.search.SearchBackfillStrategy +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.FakeMatrixClientProvider +import io.element.android.libraries.matrix.test.FakeSdkPendingTask +import io.element.android.libraries.matrix.test.search.FakeSearchBackfillService +import io.element.android.libraries.workmanager.api.di.MetroWorkerFactory +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import io.element.android.tests.testutils.robolectric.RobolectricTest +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import org.junit.Test +import java.util.UUID +import java.util.concurrent.Executor +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalCoroutinesApi::class) +class SearchBackfillWorkerTest : RobolectricTest() { + @Test + fun `missing session id - fails`() = runTest { + val worker = createWorker(input = null, client = FakeMatrixClient()) + + assertThat(worker.doWork()).isEqualTo(ListenableWorker.Result.failure()) + } + + @Test + fun `client cannot be restored - fails`() = runTest { + val worker = createWorker(client = null) + + assertThat(worker.doWork()).isEqualTo(ListenableWorker.Result.failure()) + } + + @Test + fun `backfill already running - does not start another one`() = runTest { + val startLambda = lambdaRecorder> { error("Should not be called") } + val client = FakeMatrixClient( + searchBackfillService = FakeSearchBackfillService( + isSearchBackfillRunningLambda = { true }, + startSearchBackfillLambda = startLambda, + ), + ) + + val result = createWorker(client = client).doWork() + + assertThat(result).isEqualTo(ListenableWorker.Result.success()) + startLambda.assertions().isNeverCalled() + } + + @Test + fun `start fails - fails`() = runTest { + val client = FakeMatrixClient( + searchBackfillService = FakeSearchBackfillService( + startSearchBackfillLambda = { Result.failure(IllegalStateException("boom")) }, + ), + ) + + assertThat(createWorker(client = client).doWork()).isEqualTo(ListenableWorker.Result.failure()) + } + + @Test + fun `backfill finishes - succeeds and closes the task`() = runTest { + var running = true + val closeLambda = lambdaRecorder { } + val startLambda = lambdaRecorder> { + Result.success(FakeSdkPendingTask(isRunningLambda = { running }, closeLambda = closeLambda)) + } + val client = FakeMatrixClient(searchBackfillService = FakeSearchBackfillService(startSearchBackfillLambda = startLambda)) + + val deferred = async { createWorker(client = client).doWork() } + advanceTimeBy(10.seconds) + running = false + advanceTimeBy(10.seconds) + + assertThat(deferred.await()).isEqualTo(ListenableWorker.Result.success()) + startLambda.assertions().isCalledOnce().with(value(SearchBackfillStrategy.BACKGROUND)) + closeLambda.assertions().isCalledOnce() + } + + @Test + fun `backfill times out after 10 minutes - closes the task and retries`() = runTest { + val closeLambda = lambdaRecorder { } + val client = FakeMatrixClient( + searchBackfillService = FakeSearchBackfillService( + startSearchBackfillLambda = { Result.success(FakeSdkPendingTask(isRunningLambda = { true }, closeLambda = closeLambda)) }, + ), + ) + + val deferred = async { createWorker(client = client).doWork() } + advanceTimeBy(9.minutes) + assertThat(deferred.isCompleted).isFalse() + advanceTimeBy(2.minutes) + + assertThat(deferred.await()).isEqualTo(ListenableWorker.Result.retry()) + closeLambda.assertions().isCalledOnce() + } + + private fun TestScope.createWorker( + input: String? = A_SESSION_ID.value, + client: MatrixClient?, + ) = SearchBackfillWorker( + params = createWorkerParams( + inputData = input?.let { workDataOf(SearchBackfillWorker.SESSION_ID_PARAM to it) } ?: Data.EMPTY, + ), + context = InstrumentationRegistry.getInstrumentation().context, + matrixClientProvider = FakeMatrixClientProvider( + getClient = { client?.let { Result.success(it) } ?: Result.failure(IllegalStateException("No client")) } + ), + ) + + private fun TestScope.createWorkerParams( + inputData: Data = Data.EMPTY, + ): WorkerParameters = WorkerParameters( + UUID.randomUUID(), + inputData, + emptySet(), + WorkerParameters.RuntimeExtras(), + 0, + 0, + Executors.newSingleThreadExecutor(), + backgroundScope.coroutineContext, + WorkManagerTaskExecutor(Executors.newSingleThreadExecutor()), + MetroWorkerFactory(emptyMap()), + { _, _, _ -> FakeListenableFuture() }, + { _, _, _ -> FakeListenableFuture() }, + ) +} + +private class FakeListenableFuture : ListenableFuture { + override fun addListener(listener: Runnable, executor: Executor) = Unit + override fun cancel(mayInterruptIfRunning: Boolean): Boolean = true + override fun get(): T? = null + override fun get(timeout: Long, unit: TimeUnit?): T? = null + override fun isCancelled(): Boolean = false + override fun isDone(): Boolean = false +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt index f176d3b006..6d45db5041 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt @@ -40,6 +40,7 @@ import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryServic import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.scanner.ContentScanner import io.element.android.libraries.matrix.api.search.MessageSearchService +import io.element.android.libraries.matrix.api.search.SearchBackfillService import io.element.android.libraries.matrix.api.spaces.SpaceService import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion import io.element.android.libraries.matrix.api.sync.SyncService @@ -57,6 +58,7 @@ import io.element.android.libraries.matrix.test.pushers.FakePushersService import io.element.android.libraries.matrix.test.roomdirectory.FakeRoomDirectoryService import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService import io.element.android.libraries.matrix.test.search.FakeMessageSearchService +import io.element.android.libraries.matrix.test.search.FakeSearchBackfillService import io.element.android.libraries.matrix.test.spaces.FakeSpaceService import io.element.android.libraries.matrix.test.sync.FakeSyncService import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService @@ -131,6 +133,7 @@ class FakeMatrixClient( private val markRoomAsFullyReadResult: (RoomId, EventId) -> Result = { _, _ -> lambdaError() }, private val markAllRoomsAsReadResult: () -> Result = { Result.success(Unit) }, private val performDatabaseVacuumLambda: () -> Result = { lambdaError() }, + override val searchBackfillService: SearchBackfillService = FakeSearchBackfillService(), private val getDatabaseSizesLambda: () -> Result = { lambdaError() }, private val resetWellKnownConfigLambda: () -> Result = { lambdaError() }, private val enableAutomaticCallStatusLambda: (Boolean) -> Unit = { }, diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeSdkPendingTask.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeSdkPendingTask.kt new file mode 100644 index 0000000000..98f3b00117 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeSdkPendingTask.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.test + +import io.element.android.libraries.matrix.api.SdkPendingTask +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeSdkPendingTask( + private val isRunningLambda: () -> Boolean = { lambdaError() }, + private val closeLambda: () -> Unit = { lambdaError() }, +) : SdkPendingTask { + override fun isRunning(): Boolean = isRunningLambda() + + override fun close() = closeLambda() +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/search/FakeSearchBackfillService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/search/FakeSearchBackfillService.kt new file mode 100644 index 0000000000..33652af13e --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/search/FakeSearchBackfillService.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.test.search + +import io.element.android.libraries.matrix.api.SdkPendingTask +import io.element.android.libraries.matrix.api.search.SearchBackfillService +import io.element.android.libraries.matrix.api.search.SearchBackfillStrategy +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeSearchBackfillService( + private val schedulePeriodicSearchBackfillLambda: (SearchBackfillStrategy) -> Result = { lambdaError() }, + private val cancelPeriodicSearchBackfillLambda: () -> Unit = { lambdaError() }, + private val startSearchBackfillLambda: (SearchBackfillStrategy) -> Result = { lambdaError() }, + private val isSearchBackfillRunningLambda: () -> Boolean = { false }, + private val cancelSearchBackfillLambda: () -> Result = { lambdaError() }, +) : SearchBackfillService { + override suspend fun schedulePeriodicSearchBackfill(strategy: SearchBackfillStrategy): Result { + return schedulePeriodicSearchBackfillLambda(strategy) + } + + override fun cancelPeriodicSearchBackfill() { + cancelPeriodicSearchBackfillLambda() + } + + override fun startSearchBackfill(strategy: SearchBackfillStrategy): Result { + return startSearchBackfillLambda(strategy) + } + + override fun isSearchBackfillRunning(): Boolean { + return isSearchBackfillRunningLambda() + } + + override fun cancelSearchBackfill(): Result { + return cancelSearchBackfillLambda() + } +} diff --git a/libraries/workmanager/api/src/main/kotlin/io/element/android/libraries/workmanager/api/WorkManagerScheduler.kt b/libraries/workmanager/api/src/main/kotlin/io/element/android/libraries/workmanager/api/WorkManagerScheduler.kt index b4c2950cd4..178171953d 100644 --- a/libraries/workmanager/api/src/main/kotlin/io/element/android/libraries/workmanager/api/WorkManagerScheduler.kt +++ b/libraries/workmanager/api/src/main/kotlin/io/element/android/libraries/workmanager/api/WorkManagerScheduler.kt @@ -43,6 +43,7 @@ fun workManagerTag(sessionId: SessionId, requestType: WorkManagerRequestType): S val prefix = when (requestType) { WorkManagerRequestType.NOTIFICATION_SYNC -> "notifications" WorkManagerRequestType.DB_VACUUM -> "db_vacuum" + WorkManagerRequestType.SEARCH_BACKFILL -> "search_backfill" } return "$prefix-$sessionId" } @@ -50,4 +51,5 @@ fun workManagerTag(sessionId: SessionId, requestType: WorkManagerRequestType): S enum class WorkManagerRequestType { NOTIFICATION_SYNC, DB_VACUUM, + SEARCH_BACKFILL, }