mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-04 10:32:34 +00:00
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:
committed by
GitHub
parent
9805422a8a
commit
3fe0e09e3c
+35
@@ -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.clearText
|
||||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.produceState
|
import androidx.compose.runtime.produceState
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import dev.zacsweers.metro.Inject
|
import dev.zacsweers.metro.Inject
|
||||||
import io.element.android.features.home.impl.search.history.SearchHistoryResult
|
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.MessageSearchPaginationState
|
||||||
import io.element.android.libraries.matrix.api.search.MessageSearchResult
|
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.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.AudioMessageType
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
|
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
|
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
|
||||||
@@ -81,6 +84,7 @@ class GlobalSearchPresenter(
|
|||||||
val isEnabled by produceState(false) {
|
val isEnabled by produceState(false) {
|
||||||
featureFlagService.isFeatureEnabledFlow(FeatureFlags.MessageSearch).collectLatest { value = it }
|
featureFlagService.isFeatureEnabledFlow(FeatureFlags.MessageSearch).collectLatest { value = it }
|
||||||
}
|
}
|
||||||
|
|
||||||
val roomListSearchDataSource = remember { roomListSearchDataSourceFactory.create(coroutineScope = coroutineScope) }
|
val roomListSearchDataSource = remember { roomListSearchDataSourceFactory.create(coroutineScope = coroutineScope) }
|
||||||
val queryState = rememberTextFieldState()
|
val queryState = rememberTextFieldState()
|
||||||
var isSearchActive by remember { mutableStateOf(false) }
|
var isSearchActive by remember { mutableStateOf(false) }
|
||||||
@@ -88,6 +92,8 @@ class GlobalSearchPresenter(
|
|||||||
var currentTarget: GlobalSearchTarget by remember { mutableStateOf(GlobalSearchTarget.ROOMS) }
|
var currentTarget: GlobalSearchTarget by remember { mutableStateOf(GlobalSearchTarget.ROOMS) }
|
||||||
val currentMessageSearch: MessageSearch = remember { messageSearchService.createMessageSearch(scope = coroutineScope) }
|
val currentMessageSearch: MessageSearch = remember { messageSearchService.createMessageSearch(scope = coroutineScope) }
|
||||||
|
|
||||||
|
StartSearchBackfillWhenVisible(isEnabled = isEnabled, isSearchActive = isSearchActive)
|
||||||
|
|
||||||
val searchHistory by produceState<AsyncData<ImmutableList<SearchHistoryListItem>>>(initialValue = AsyncData.Uninitialized) {
|
val searchHistory by produceState<AsyncData<ImmutableList<SearchHistoryListItem>>>(initialValue = AsyncData.Uninitialized) {
|
||||||
searchHistoryStore.history.collectLatest { history ->
|
searchHistoryStore.history.collectLatest { history ->
|
||||||
val mappedHistoryItems = history.mapNotNull { result ->
|
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(
|
private fun mapMediaContent(
|
||||||
result: MessageSearchResult,
|
result: MessageSearchResult,
|
||||||
roomInfo: RoomInfo,
|
roomInfo: RoomInfo,
|
||||||
|
|||||||
+52
-4
@@ -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.api.search.MessageSearchService
|
||||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
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.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.permalink.FakePermalinkParser
|
||||||
import io.element.android.libraries.matrix.test.room.aRoomSummary
|
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.FakeDynamicRoomList
|
||||||
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
|
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.FakeMessageSearch
|
||||||
import io.element.android.libraries.matrix.test.search.FakeMessageSearchService
|
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.consumeItemsUntilPredicate
|
||||||
|
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||||
import io.element.android.tests.testutils.test
|
import io.element.android.tests.testutils.test
|
||||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
@@ -205,7 +208,9 @@ class GlobalSearchPresenterTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `present - message search results are mapped when the message search emits`() = runTest {
|
fun `present - message search results are mapped when the message search emits`() = runTest {
|
||||||
val messageSearch = FakeMessageSearch()
|
val messageSearch = FakeMessageSearch()
|
||||||
val matrixClient = FakeMatrixClient().apply {
|
val matrixClient = FakeMatrixClient(
|
||||||
|
searchBackfillService = FakeSearchBackfillService(startSearchBackfillLambda = { Result.success(FakeSdkPendingTask()) }),
|
||||||
|
).apply {
|
||||||
getRoomInfoLambda = { Result.success(aRoomInfo()) }
|
getRoomInfoLambda = { Result.success(aRoomInfo()) }
|
||||||
}
|
}
|
||||||
val presenter = createGlobalSearchPresenter(
|
val presenter = createGlobalSearchPresenter(
|
||||||
@@ -232,7 +237,9 @@ class GlobalSearchPresenterTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `present - message search displays the uninitialized state after removing the query`() = runTest {
|
fun `present - message search displays the uninitialized state after removing the query`() = runTest {
|
||||||
val messageSearch = FakeMessageSearch()
|
val messageSearch = FakeMessageSearch()
|
||||||
val matrixClient = FakeMatrixClient().apply {
|
val matrixClient = FakeMatrixClient(
|
||||||
|
searchBackfillService = FakeSearchBackfillService(startSearchBackfillLambda = { Result.success(FakeSdkPendingTask()) }),
|
||||||
|
).apply {
|
||||||
getRoomInfoLambda = { Result.success(aRoomInfo()) }
|
getRoomInfoLambda = { Result.success(aRoomInfo()) }
|
||||||
}
|
}
|
||||||
val presenter = createGlobalSearchPresenter(
|
val presenter = createGlobalSearchPresenter(
|
||||||
@@ -267,7 +274,9 @@ class GlobalSearchPresenterTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `present - UpdateVisibleRange triggers pagination for messages when near the end`() = runTest {
|
fun `present - UpdateVisibleRange triggers pagination for messages when near the end`() = runTest {
|
||||||
val messageSearch = FakeMessageSearch()
|
val messageSearch = FakeMessageSearch()
|
||||||
val matrixClient = FakeMatrixClient().apply {
|
val matrixClient = FakeMatrixClient(
|
||||||
|
searchBackfillService = FakeSearchBackfillService(startSearchBackfillLambda = { Result.success(FakeSdkPendingTask()) }),
|
||||||
|
).apply {
|
||||||
getRoomInfoLambda = { Result.success(aRoomInfo()) }
|
getRoomInfoLambda = { Result.success(aRoomInfo()) }
|
||||||
}
|
}
|
||||||
val presenter = createGlobalSearchPresenter(
|
val presenter = createGlobalSearchPresenter(
|
||||||
@@ -432,6 +441,44 @@ class GlobalSearchPresenterTest {
|
|||||||
assertThat(awaitItem().results.isLoading()).isTrue()
|
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(
|
private fun TestScope.createGlobalSearchPresenter(
|
||||||
@@ -442,7 +489,8 @@ private fun TestScope.createGlobalSearchPresenter(
|
|||||||
dateFormatter: DateFormatter = FakeDateFormatter(),
|
dateFormatter: DateFormatter = FakeDateFormatter(),
|
||||||
fileSizeFormatter: FileSizeFormatter = FakeFileSizeFormatter(),
|
fileSizeFormatter: FileSizeFormatter = FakeFileSizeFormatter(),
|
||||||
permalinkParser: PermalinkParser = FakePermalinkParser(),
|
permalinkParser: PermalinkParser = FakePermalinkParser(),
|
||||||
matrixClient: MatrixClient = FakeMatrixClient(),
|
matrixClient: MatrixClient =
|
||||||
|
FakeMatrixClient(searchBackfillService = FakeSearchBackfillService(startSearchBackfillLambda = { Result.success(FakeSdkPendingTask()) })),
|
||||||
searchHistoryStore: SearchHistoryStore = FakeSearchHistoryStore(),
|
searchHistoryStore: SearchHistoryStore = FakeSearchHistoryStore(),
|
||||||
): GlobalSearchPresenter {
|
): GlobalSearchPresenter {
|
||||||
return GlobalSearchPresenter(
|
return GlobalSearchPresenter(
|
||||||
|
|||||||
+4
@@ -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.roomlist.RoomListService
|
||||||
import io.element.android.libraries.matrix.api.scanner.ContentScanner
|
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.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.spaces.SpaceService
|
||||||
import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion
|
import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion
|
||||||
import io.element.android.libraries.matrix.api.sync.SyncService
|
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. */
|
/** Scans media content before it is displayed, or `null` when no content scanner is configured for this session. */
|
||||||
val contentScanner: ContentScanner?
|
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. */
|
/** 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
|
val isShuttingDown: Boolean
|
||||||
|
|
||||||
|
|||||||
+16
@@ -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
|
||||||
|
}
|
||||||
+50
@@ -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<Unit>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<SdkPendingTask>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<Unit>
|
||||||
|
}
|
||||||
+22
@@ -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
|
||||||
|
}
|
||||||
@@ -63,6 +63,7 @@ dependencies {
|
|||||||
|
|
||||||
implementation(projects.appconfig)
|
implementation(projects.appconfig)
|
||||||
implementation(projects.features.enterprise.api)
|
implementation(projects.features.enterprise.api)
|
||||||
|
implementation(projects.features.networkmonitor.api)
|
||||||
implementation(projects.libraries.androidutils)
|
implementation(projects.libraries.androidutils)
|
||||||
implementation(projects.libraries.architecture)
|
implementation(projects.libraries.architecture)
|
||||||
implementation(projects.libraries.di)
|
implementation(projects.libraries.di)
|
||||||
@@ -80,6 +81,7 @@ dependencies {
|
|||||||
|
|
||||||
testCommonDependencies(libs)
|
testCommonDependencies(libs)
|
||||||
testImplementation(projects.features.enterprise.test)
|
testImplementation(projects.features.enterprise.test)
|
||||||
|
testImplementation(projects.features.networkmonitor.test)
|
||||||
testImplementation(projects.libraries.featureflag.test)
|
testImplementation(projects.libraries.featureflag.test)
|
||||||
testImplementation(projects.libraries.matrix.test)
|
testImplementation(projects.libraries.matrix.test)
|
||||||
testImplementation(projects.libraries.previewutils)
|
testImplementation(projects.libraries.previewutils)
|
||||||
|
|||||||
+51
-8
@@ -8,6 +8,9 @@
|
|||||||
|
|
||||||
package io.element.android.libraries.matrix.impl
|
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.androidutils.file.getSizeOfFiles
|
||||||
import io.element.android.libraries.core.bool.orFalse
|
import io.element.android.libraries.core.bool.orFalse
|
||||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
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.data.tryOrNull
|
||||||
import io.element.android.libraries.core.extensions.mapFailure
|
import io.element.android.libraries.core.extensions.mapFailure
|
||||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
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.FeatureFlagService
|
||||||
|
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||||
import io.element.android.libraries.matrix.api.HomeserverCapabilitiesProvider
|
import io.element.android.libraries.matrix.api.HomeserverCapabilitiesProvider
|
||||||
import io.element.android.libraries.matrix.api.MatrixClient
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
import io.element.android.libraries.matrix.api.analytics.SdkStoreSizes
|
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.roomdirectory.RoomVisibility
|
||||||
import io.element.android.libraries.matrix.api.roomlist.RoomListService
|
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.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.spaces.SpaceService
|
||||||
import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion
|
import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion
|
||||||
import io.element.android.libraries.matrix.api.sync.SyncState
|
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.RustRoomListService
|
||||||
import io.element.android.libraries.matrix.impl.roomlist.roomOrNull
|
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.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.spaces.RustSpaceService
|
||||||
import io.element.android.libraries.matrix.impl.sync.RustSyncService
|
import io.element.android.libraries.matrix.impl.sync.RustSyncService
|
||||||
import io.element.android.libraries.matrix.impl.sync.map
|
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.buffer
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.mapNotNull
|
import kotlinx.coroutines.flow.mapNotNull
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.runBlocking
|
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
|
private const val AVATAR_THUMBNAIL_SIZE_IN_PIXEL = 240L
|
||||||
|
|
||||||
@Suppress("LargeClass")
|
@Suppress("LargeClass")
|
||||||
|
@AssistedInject
|
||||||
class RustMatrixClient(
|
class RustMatrixClient(
|
||||||
override val sessionPaths: SessionPaths,
|
@Assisted override val sessionPaths: SessionPaths,
|
||||||
val innerClient: Client,
|
@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 sessionStore: SessionStore,
|
||||||
private val sessionDelegate: RustClientSessionDelegate,
|
@AppCoroutineScope appCoroutineScope: CoroutineScope,
|
||||||
private val innerSyncService: ClientSyncService,
|
|
||||||
appCoroutineScope: CoroutineScope,
|
|
||||||
dispatchers: CoroutineDispatchers,
|
dispatchers: CoroutineDispatchers,
|
||||||
baseCacheDirectory: File,
|
|
||||||
clock: SystemClock,
|
clock: SystemClock,
|
||||||
timelineEventFilterFactory: TimelineEventFilterFactory,
|
timelineEventFilterFactory: TimelineEventFilterFactory,
|
||||||
private val featureFlagService: FeatureFlagService,
|
private val featureFlagService: FeatureFlagService,
|
||||||
private val analyticsService: AnalyticsService,
|
private val analyticsService: AnalyticsService,
|
||||||
private val workManagerScheduler: WorkManagerScheduler,
|
private val workManagerScheduler: WorkManagerScheduler,
|
||||||
override val contentScanner: ContentScanner?,
|
private val searchBackfillServiceFactory: RustSearchBackfillService.Factory,
|
||||||
override val isMessageSearchAvailable: Boolean,
|
|
||||||
) : MatrixClient {
|
) : 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 sessionId: UserId = UserId(innerClient.userId())
|
||||||
override val deviceId: DeviceId = DeviceId(innerClient.deviceId())
|
override val deviceId: DeviceId = DeviceId(innerClient.deviceId())
|
||||||
override val server: String? = innerClient.server()
|
override val server: String? = innerClient.server()
|
||||||
@@ -280,6 +304,8 @@ class RustMatrixClient(
|
|||||||
|
|
||||||
private var clientDelegateTaskHandle: TaskHandle? = innerClient.setDelegate(sessionDelegate)
|
private var clientDelegateTaskHandle: TaskHandle? = innerClient.setDelegate(sessionDelegate)
|
||||||
|
|
||||||
|
override val searchBackfillService = searchBackfillServiceFactory.create(innerClient)
|
||||||
|
|
||||||
private val _userProfile: MutableStateFlow<MatrixUser> = MutableStateFlow(
|
private val _userProfile: MutableStateFlow<MatrixUser> = MutableStateFlow(
|
||||||
MatrixUser(
|
MatrixUser(
|
||||||
userId = sessionId,
|
userId = sessionId,
|
||||||
@@ -332,6 +358,9 @@ class RustMatrixClient(
|
|||||||
|
|
||||||
// Schedule regular database vacuuming to ensure DB performance remains optimal
|
// Schedule regular database vacuuming to ensure DB performance remains optimal
|
||||||
scheduleDatabaseVacuum()
|
scheduleDatabaseVacuum()
|
||||||
|
|
||||||
|
// Schedule background search backfill if needed
|
||||||
|
scheduleBackgroundSearchBackfill()
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun setupUserProfile() {
|
private suspend fun setupUserProfile() {
|
||||||
@@ -687,6 +716,7 @@ class RustMatrixClient(
|
|||||||
sessionCoroutineScope.cancel()
|
sessionCoroutineScope.cancel()
|
||||||
clientDelegateTaskHandle?.cancelAndDestroy()
|
clientDelegateTaskHandle?.cancelAndDestroy()
|
||||||
ownProfileTaskHandle?.cancelAndDestroy()
|
ownProfileTaskHandle?.cancelAndDestroy()
|
||||||
|
searchBackfillService.cancelSearchBackfill()
|
||||||
sessionVerificationService.destroy()
|
sessionVerificationService.destroy()
|
||||||
|
|
||||||
sessionDelegate.clearCurrentClient()
|
sessionDelegate.clearCurrentClient()
|
||||||
@@ -992,6 +1022,19 @@ class RustMatrixClient(
|
|||||||
override fun homeserverCapabilities(): HomeserverCapabilitiesProvider {
|
override fun homeserverCapabilities(): HomeserverCapabilitiesProvider {
|
||||||
return RustHomeserverCapabilitiesProvider(innerClient.homeserverCapabilities())
|
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(
|
private fun defaultRoomCreationPowerLevels(isPublic: Boolean, isSpace: Boolean) = PowerLevels(
|
||||||
|
|||||||
+3
-16
@@ -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.analytics.UtdTracker
|
||||||
import io.element.android.libraries.matrix.impl.paths.getSessionPaths
|
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.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.scanner.RustContentScanner
|
||||||
import io.element.android.libraries.matrix.impl.storage.SqliteStoreBuilderProvider
|
import io.element.android.libraries.matrix.impl.storage.SqliteStoreBuilderProvider
|
||||||
import io.element.android.libraries.matrix.impl.util.anonymizedTokens
|
import io.element.android.libraries.matrix.impl.util.anonymizedTokens
|
||||||
import io.element.android.libraries.network.useragent.UserAgentProvider
|
import io.element.android.libraries.network.useragent.UserAgentProvider
|
||||||
import io.element.android.libraries.sessionstorage.api.SessionData
|
import io.element.android.libraries.sessionstorage.api.SessionData
|
||||||
import io.element.android.libraries.sessionstorage.api.SessionStore
|
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.analytics.api.AnalyticsService
|
||||||
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.matrix.rustcomponents.sdk.ClientBuilder
|
import org.matrix.rustcomponents.sdk.ClientBuilder
|
||||||
@@ -61,14 +58,12 @@ class RustMatrixClientFactory(
|
|||||||
private val sessionStore: SessionStore,
|
private val sessionStore: SessionStore,
|
||||||
private val userAgentProvider: UserAgentProvider,
|
private val userAgentProvider: UserAgentProvider,
|
||||||
private val proxyProvider: ProxyProvider,
|
private val proxyProvider: ProxyProvider,
|
||||||
private val clock: SystemClock,
|
|
||||||
private val analyticsService: AnalyticsService,
|
private val analyticsService: AnalyticsService,
|
||||||
private val featureFlagService: FeatureFlagService,
|
private val featureFlagService: FeatureFlagService,
|
||||||
private val timelineEventFilterFactory: TimelineEventFilterFactory,
|
|
||||||
private val clientBuilderProvider: ClientBuilderProvider,
|
private val clientBuilderProvider: ClientBuilderProvider,
|
||||||
private val sqliteStoreBuilderProvider: SqliteStoreBuilderProvider,
|
private val sqliteStoreBuilderProvider: SqliteStoreBuilderProvider,
|
||||||
private val workManagerScheduler: WorkManagerScheduler,
|
|
||||||
private val clientBuilderEnterpriseHook: ClientBuilderEnterpriseHook,
|
private val clientBuilderEnterpriseHook: ClientBuilderEnterpriseHook,
|
||||||
|
private val innerMatrixClientFactory: RustMatrixClient.Factory,
|
||||||
) {
|
) {
|
||||||
private val sessionDelegate = RustClientSessionDelegate(
|
private val sessionDelegate = RustClientSessionDelegate(
|
||||||
sessionStore = sessionStore,
|
sessionStore = sessionStore,
|
||||||
@@ -131,22 +126,14 @@ class RustMatrixClientFactory(
|
|||||||
.withOfflineMode()
|
.withOfflineMode()
|
||||||
.finish()
|
.finish()
|
||||||
|
|
||||||
RustMatrixClient(
|
innerMatrixClientFactory.create(
|
||||||
sessionPaths = sessionData.getSessionPaths(),
|
sessionPaths = sessionData.getSessionPaths(),
|
||||||
innerClient = client,
|
innerClient = client,
|
||||||
sessionStore = sessionStore,
|
|
||||||
appCoroutineScope = appCoroutineScope,
|
|
||||||
sessionDelegate = sessionDelegate,
|
|
||||||
innerSyncService = syncService,
|
innerSyncService = syncService,
|
||||||
dispatchers = coroutineDispatchers,
|
|
||||||
baseCacheDirectory = cacheDirectory,
|
baseCacheDirectory = cacheDirectory,
|
||||||
clock = clock,
|
|
||||||
timelineEventFilterFactory = timelineEventFilterFactory,
|
|
||||||
featureFlagService = featureFlagService,
|
|
||||||
analyticsService = analyticsService,
|
|
||||||
workManagerScheduler = workManagerScheduler,
|
|
||||||
contentScanner = client.contentScanner()?.let { RustContentScanner(client, it) },
|
contentScanner = client.contentScanner()?.let { RustContentScanner(client, it) },
|
||||||
isMessageSearchAvailable = isMessageSearchAvailable,
|
isMessageSearchAvailable = isMessageSearchAvailable,
|
||||||
|
sessionDelegate = sessionDelegate,
|
||||||
).also {
|
).also {
|
||||||
Timber.tag("RustMatrixClient").i("Creating Client with access token '$anonymizedAccessToken' and refresh token '$anonymizedRefreshToken'")
|
Timber.tag("RustMatrixClient").i("Creating Client with access token '$anonymizedAccessToken' and refresh token '$anonymizedRefreshToken'")
|
||||||
}
|
}
|
||||||
|
|||||||
+27
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+71
@@ -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<SdkPendingTask?> = AtomicReference(null)
|
||||||
|
|
||||||
|
override suspend fun schedulePeriodicSearchBackfill(strategy: SearchBackfillStrategy): Result<Unit> = 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<SdkPendingTask> = 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<Unit> = runCatchingExceptions {
|
||||||
|
currentSearchBackfillTaskHandle.getAndSet(null)?.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
@@ -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
|
||||||
|
}
|
||||||
+88
@@ -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<List<WorkManagerRequestWrapper>> {
|
||||||
|
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<SearchBackfillWorker>(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)))
|
||||||
|
}
|
||||||
|
}
|
||||||
+81
@@ -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<MetroWorkerFactory.WorkerInstanceFactory<*>>())
|
||||||
|
@WorkerKey(SearchBackfillWorker::class)
|
||||||
|
@AssistedFactory
|
||||||
|
interface Factory : MetroWorkerFactory.WorkerInstanceFactory<SearchBackfillWorker>
|
||||||
|
}
|
||||||
+52
-5
@@ -12,8 +12,12 @@ import com.google.common.truth.Truth.assertThat
|
|||||||
import io.element.android.features.enterprise.test.FakeClientBuilderEnterpriseHook
|
import io.element.android.features.enterprise.test.FakeClientBuilderEnterpriseHook
|
||||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
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.auth.FakeProxyProvider
|
||||||
import io.element.android.libraries.matrix.impl.room.FakeTimelineEventFilterFactory
|
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.FakeSqliteStoreBuilderProvider
|
||||||
import io.element.android.libraries.matrix.impl.storage.SqliteStoreBuilderProvider
|
import io.element.android.libraries.matrix.impl.storage.SqliteStoreBuilderProvider
|
||||||
import io.element.android.libraries.network.useragent.SimpleUserAgentProvider
|
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.InMemorySessionStore
|
||||||
import io.element.android.libraries.sessionstorage.test.aSessionData
|
import io.element.android.libraries.sessionstorage.test.aSessionData
|
||||||
import io.element.android.libraries.workmanager.api.WorkManagerRequestBuilder
|
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.libraries.workmanager.test.FakeWorkManagerScheduler
|
||||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||||
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
|
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.TestScope
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
import org.matrix.rustcomponents.sdk.Client
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import org.matrix.rustcomponents.sdk.SyncService as ClientSyncService
|
||||||
|
|
||||||
class RustMatrixClientFactoryTest {
|
class RustMatrixClientFactoryTest {
|
||||||
@Test
|
@Test
|
||||||
fun test() = runTest {
|
fun test() = runTest {
|
||||||
val scheduleVacuumLambda = lambdaRecorder<WorkManagerRequestBuilder, Unit> {}
|
val scheduleVacuumLambda = lambdaRecorder<WorkManagerRequestBuilder, Unit> {}
|
||||||
val workManagerScheduler = FakeWorkManagerScheduler(submitLambda = scheduleVacuumLambda)
|
val workManagerScheduler = FakeWorkManagerScheduler(submitLambda = scheduleVacuumLambda, cancelLambda = { _, _ -> })
|
||||||
val sut = createRustMatrixClientFactory(workManagerScheduler = workManagerScheduler)
|
val sut = createRustMatrixClientFactory(workManagerScheduler = workManagerScheduler)
|
||||||
|
|
||||||
val result = sut.create(aSessionData())
|
val result = sut.create(aSessionData())
|
||||||
@@ -52,7 +59,7 @@ fun TestScope.createRustMatrixClientFactory(
|
|||||||
updateUserProfileResult = { _, _, _, _ -> },
|
updateUserProfileResult = { _, _, _, _ -> },
|
||||||
),
|
),
|
||||||
clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(),
|
clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(),
|
||||||
workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(),
|
workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(cancelLambda = { _, _ -> }),
|
||||||
sqliteStoreBuilderProvider: SqliteStoreBuilderProvider = FakeSqliteStoreBuilderProvider(),
|
sqliteStoreBuilderProvider: SqliteStoreBuilderProvider = FakeSqliteStoreBuilderProvider(),
|
||||||
) = RustMatrixClientFactory(
|
) = RustMatrixClientFactory(
|
||||||
cacheDirectory = cacheDirectory,
|
cacheDirectory = cacheDirectory,
|
||||||
@@ -61,12 +68,52 @@ fun TestScope.createRustMatrixClientFactory(
|
|||||||
sessionStore = sessionStore,
|
sessionStore = sessionStore,
|
||||||
userAgentProvider = SimpleUserAgentProvider(),
|
userAgentProvider = SimpleUserAgentProvider(),
|
||||||
proxyProvider = FakeProxyProvider(),
|
proxyProvider = FakeProxyProvider(),
|
||||||
clock = FakeSystemClock(),
|
|
||||||
analyticsService = FakeAnalyticsService(),
|
analyticsService = FakeAnalyticsService(),
|
||||||
featureFlagService = FakeFeatureFlagService(),
|
featureFlagService = FakeFeatureFlagService(),
|
||||||
timelineEventFilterFactory = FakeTimelineEventFilterFactory(),
|
|
||||||
clientBuilderProvider = clientBuilderProvider,
|
clientBuilderProvider = clientBuilderProvider,
|
||||||
sqliteStoreBuilderProvider = sqliteStoreBuilderProvider,
|
sqliteStoreBuilderProvider = sqliteStoreBuilderProvider,
|
||||||
workManagerScheduler = workManagerScheduler,
|
|
||||||
clientBuilderEnterpriseHook = FakeClientBuilderEnterpriseHook(),
|
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<List<WorkManagerRequestWrapper>> = Result.success(emptyList())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
workManagerScheduler = workManagerScheduler,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
+15
-1
@@ -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.FakeFfiClient
|
||||||
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiSyncService
|
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.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.AN_AVATAR_URL
|
||||||
import io.element.android.libraries.matrix.test.A_DEVICE_ID
|
import io.element.android.libraries.matrix.test.A_DEVICE_ID
|
||||||
import io.element.android.libraries.matrix.test.A_ROOM_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.api.SessionStore
|
||||||
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
|
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
|
||||||
import io.element.android.libraries.sessionstorage.test.aSessionData
|
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.libraries.workmanager.test.FakeWorkManagerScheduler
|
||||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||||
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
|
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
|
||||||
@@ -325,8 +328,19 @@ class RustMatrixClientTest {
|
|||||||
timelineEventFilterFactory = FakeTimelineEventFilterFactory(),
|
timelineEventFilterFactory = FakeTimelineEventFilterFactory(),
|
||||||
featureFlagService = FakeFeatureFlagService(),
|
featureFlagService = FakeFeatureFlagService(),
|
||||||
analyticsService = FakeAnalyticsService(),
|
analyticsService = FakeAnalyticsService(),
|
||||||
workManagerScheduler = FakeWorkManagerScheduler(submitLambda = {}),
|
workManagerScheduler = FakeWorkManagerScheduler(submitLambda = {}, cancelLambda = { _, _ -> }),
|
||||||
contentScanner = FakeContentScanner(),
|
contentScanner = FakeContentScanner(),
|
||||||
isMessageSearchAvailable = false,
|
isMessageSearchAvailable = false,
|
||||||
|
searchBackfillServiceFactory = object : RustSearchBackfillService.Factory {
|
||||||
|
override fun create(innerClient: Client) = RustSearchBackfillService(
|
||||||
|
innerClient = innerClient,
|
||||||
|
searchBackfillRequestBuilderFactory = {
|
||||||
|
object : SearchBackfillRequestBuilder {
|
||||||
|
override suspend fun build(): Result<List<WorkManagerRequestWrapper>> = Result.success(emptyList())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
workManagerScheduler = FakeWorkManagerScheduler(submitLambda = {}, cancelLambda = { _, _ -> }),
|
||||||
|
)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
-36
@@ -8,19 +8,7 @@
|
|||||||
package io.element.android.libraries.matrix.impl
|
package io.element.android.libraries.matrix.impl
|
||||||
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
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.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.TestScope
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
@@ -56,28 +44,4 @@ class RustTemporaryMatrixClientFactoryTest {
|
|||||||
rustMatrixClientFactory = rustMatrixClientFactory,
|
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(),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -265,7 +265,7 @@ class RustMatrixAuthenticationServiceTest {
|
|||||||
cacheDirectory = File("/cache"),
|
cacheDirectory = File("/cache"),
|
||||||
sessionStore = sessionStore,
|
sessionStore = sessionStore,
|
||||||
clientBuilderProvider = clientBuilderProvider,
|
clientBuilderProvider = clientBuilderProvider,
|
||||||
workManagerScheduler = FakeWorkManagerScheduler(submitLambda = {}),
|
workManagerScheduler = FakeWorkManagerScheduler(submitLambda = {}, cancelLambda = { _, _ -> }),
|
||||||
sqliteStoreBuilderProvider = sqliteStoreBuilderProvider,
|
sqliteStoreBuilderProvider = sqliteStoreBuilderProvider,
|
||||||
)
|
)
|
||||||
return RustMatrixAuthenticationService(
|
return RustMatrixAuthenticationService(
|
||||||
|
|||||||
+4
@@ -45,6 +45,7 @@ import org.matrix.rustcomponents.sdk.SyncServiceBuilder
|
|||||||
import org.matrix.rustcomponents.sdk.TaskHandle
|
import org.matrix.rustcomponents.sdk.TaskHandle
|
||||||
import org.matrix.rustcomponents.sdk.UnableToDecryptDelegate
|
import org.matrix.rustcomponents.sdk.UnableToDecryptDelegate
|
||||||
import org.matrix.rustcomponents.sdk.UserProfile
|
import org.matrix.rustcomponents.sdk.UserProfile
|
||||||
|
import uniffi.matrix_sdk.SearchBackfillStrategy
|
||||||
import uniffi.matrix_sdk_base.MediaRetentionPolicy
|
import uniffi.matrix_sdk_base.MediaRetentionPolicy
|
||||||
|
|
||||||
class FakeFfiClient(
|
class FakeFfiClient(
|
||||||
@@ -73,6 +74,7 @@ class FakeFfiClient(
|
|||||||
private val getUrlResult: (String) -> ByteArray = { lambdaError() },
|
private val getUrlResult: (String) -> ByteArray = { lambdaError() },
|
||||||
private val getMediaThumbnailResult: (MediaSource, ULong, ULong) -> ByteArray = { _, _, _ -> byteArrayOf() },
|
private val getMediaThumbnailResult: (MediaSource, ULong, ULong) -> ByteArray = { _, _, _ -> byteArrayOf() },
|
||||||
private val contentScannerResult: () -> ContentScanner = { FakeFfiContentScanner() },
|
private val contentScannerResult: () -> ContentScanner = { FakeFfiContentScanner() },
|
||||||
|
private val runSearchBackfillResult: (SearchBackfillStrategy) -> TaskHandle = { lambdaError() },
|
||||||
private val closeResult: () -> Unit = {},
|
private val closeResult: () -> Unit = {},
|
||||||
) : Client(NoHandle) {
|
) : Client(NoHandle) {
|
||||||
override fun userId(): String = userId
|
override fun userId(): String = userId
|
||||||
@@ -170,5 +172,7 @@ class FakeFfiClient(
|
|||||||
contentScannerResult()
|
contentScannerResult()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun runSearchBackfill(strategy: SearchBackfillStrategy): TaskHandle = runSearchBackfillResult(strategy)
|
||||||
|
|
||||||
override fun close() = closeResult()
|
override fun close() = closeResult()
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-2
@@ -11,7 +11,11 @@ package io.element.android.libraries.matrix.impl.fixtures.fakes
|
|||||||
import org.matrix.rustcomponents.sdk.NoHandle
|
import org.matrix.rustcomponents.sdk.NoHandle
|
||||||
import org.matrix.rustcomponents.sdk.TaskHandle
|
import org.matrix.rustcomponents.sdk.TaskHandle
|
||||||
|
|
||||||
class FakeFfiTaskHandle : TaskHandle(NoHandle) {
|
class FakeFfiTaskHandle(
|
||||||
override fun cancel() = Unit
|
private val isFinishedResult: () -> Boolean = { false },
|
||||||
|
private val cancelResult: () -> Unit = {},
|
||||||
|
) : TaskHandle(NoHandle) {
|
||||||
|
override fun isFinished(): Boolean = isFinishedResult()
|
||||||
|
override fun cancel() = cancelResult()
|
||||||
override fun destroy() = Unit
|
override fun destroy() = Unit
|
||||||
}
|
}
|
||||||
|
|||||||
+198
@@ -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<SessionId, SearchBackfillRequestBuilder> { builder }
|
||||||
|
val submitLambda = lambdaRecorder<WorkManagerRequestBuilder, Unit> { }
|
||||||
|
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<SessionId, WorkManagerRequestType, Boolean> { _, _ -> true }
|
||||||
|
val createBuilderLambda = lambdaRecorder<SessionId, SearchBackfillRequestBuilder> { error("Should not be called") }
|
||||||
|
val submitLambda = lambdaRecorder<WorkManagerRequestBuilder, Unit> { 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<SessionId, WorkManagerRequestType?, Unit> { _, _ -> }
|
||||||
|
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<RustSearchBackfillStrategy, TaskHandle> { 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<RustSearchBackfillStrategy, TaskHandle> { 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<RustSearchBackfillStrategy, TaskHandle> { 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<RustSearchBackfillStrategy, TaskHandle> { 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<Unit> { }
|
||||||
|
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<RustSearchBackfillStrategy, TaskHandle> { 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<List<WorkManagerRequestWrapper>> = 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
+78
@@ -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<String>(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) },
|
||||||
|
)
|
||||||
|
}
|
||||||
+161
@@ -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<SearchBackfillStrategy, Result<SdkPendingTask>> { 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<Unit> { }
|
||||||
|
val startLambda = lambdaRecorder<SearchBackfillStrategy, Result<SdkPendingTask>> {
|
||||||
|
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<Unit> { }
|
||||||
|
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<T> : ListenableFuture<T> {
|
||||||
|
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
|
||||||
|
}
|
||||||
+3
@@ -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.roomlist.RoomListService
|
||||||
import io.element.android.libraries.matrix.api.scanner.ContentScanner
|
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.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.spaces.SpaceService
|
||||||
import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion
|
import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion
|
||||||
import io.element.android.libraries.matrix.api.sync.SyncService
|
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.roomdirectory.FakeRoomDirectoryService
|
||||||
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
|
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.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.spaces.FakeSpaceService
|
||||||
import io.element.android.libraries.matrix.test.sync.FakeSyncService
|
import io.element.android.libraries.matrix.test.sync.FakeSyncService
|
||||||
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
|
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
|
||||||
@@ -131,6 +133,7 @@ class FakeMatrixClient(
|
|||||||
private val markRoomAsFullyReadResult: (RoomId, EventId) -> Result<Unit> = { _, _ -> lambdaError() },
|
private val markRoomAsFullyReadResult: (RoomId, EventId) -> Result<Unit> = { _, _ -> lambdaError() },
|
||||||
private val markAllRoomsAsReadResult: () -> Result<Unit> = { Result.success(Unit) },
|
private val markAllRoomsAsReadResult: () -> Result<Unit> = { Result.success(Unit) },
|
||||||
private val performDatabaseVacuumLambda: () -> Result<Unit> = { lambdaError() },
|
private val performDatabaseVacuumLambda: () -> Result<Unit> = { lambdaError() },
|
||||||
|
override val searchBackfillService: SearchBackfillService = FakeSearchBackfillService(),
|
||||||
private val getDatabaseSizesLambda: () -> Result<SdkStoreSizes> = { lambdaError() },
|
private val getDatabaseSizesLambda: () -> Result<SdkStoreSizes> = { lambdaError() },
|
||||||
private val resetWellKnownConfigLambda: () -> Result<Unit> = { lambdaError() },
|
private val resetWellKnownConfigLambda: () -> Result<Unit> = { lambdaError() },
|
||||||
private val enableAutomaticCallStatusLambda: (Boolean) -> Unit = { },
|
private val enableAutomaticCallStatusLambda: (Boolean) -> Unit = { },
|
||||||
|
|||||||
+20
@@ -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()
|
||||||
|
}
|
||||||
+41
@@ -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<Unit> = { lambdaError() },
|
||||||
|
private val cancelPeriodicSearchBackfillLambda: () -> Unit = { lambdaError() },
|
||||||
|
private val startSearchBackfillLambda: (SearchBackfillStrategy) -> Result<SdkPendingTask> = { lambdaError() },
|
||||||
|
private val isSearchBackfillRunningLambda: () -> Boolean = { false },
|
||||||
|
private val cancelSearchBackfillLambda: () -> Result<Unit> = { lambdaError() },
|
||||||
|
) : SearchBackfillService {
|
||||||
|
override suspend fun schedulePeriodicSearchBackfill(strategy: SearchBackfillStrategy): Result<Unit> {
|
||||||
|
return schedulePeriodicSearchBackfillLambda(strategy)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun cancelPeriodicSearchBackfill() {
|
||||||
|
cancelPeriodicSearchBackfillLambda()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun startSearchBackfill(strategy: SearchBackfillStrategy): Result<SdkPendingTask> {
|
||||||
|
return startSearchBackfillLambda(strategy)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isSearchBackfillRunning(): Boolean {
|
||||||
|
return isSearchBackfillRunningLambda()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun cancelSearchBackfill(): Result<Unit> {
|
||||||
|
return cancelSearchBackfillLambda()
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
@@ -43,6 +43,7 @@ fun workManagerTag(sessionId: SessionId, requestType: WorkManagerRequestType): S
|
|||||||
val prefix = when (requestType) {
|
val prefix = when (requestType) {
|
||||||
WorkManagerRequestType.NOTIFICATION_SYNC -> "notifications"
|
WorkManagerRequestType.NOTIFICATION_SYNC -> "notifications"
|
||||||
WorkManagerRequestType.DB_VACUUM -> "db_vacuum"
|
WorkManagerRequestType.DB_VACUUM -> "db_vacuum"
|
||||||
|
WorkManagerRequestType.SEARCH_BACKFILL -> "search_backfill"
|
||||||
}
|
}
|
||||||
return "$prefix-$sessionId"
|
return "$prefix-$sessionId"
|
||||||
}
|
}
|
||||||
@@ -50,4 +51,5 @@ fun workManagerTag(sessionId: SessionId, requestType: WorkManagerRequestType): S
|
|||||||
enum class WorkManagerRequestType {
|
enum class WorkManagerRequestType {
|
||||||
NOTIFICATION_SYNC,
|
NOTIFICATION_SYNC,
|
||||||
DB_VACUUM,
|
DB_VACUUM,
|
||||||
|
SEARCH_BACKFILL,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user