mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-02 12:07:28 +00:00
Merge branch 'develop' into feature/fga/timeline-multi-forward
This commit is contained in:
@@ -63,12 +63,12 @@ dependencies {
|
||||
|
||||
implementation(projects.appconfig)
|
||||
implementation(projects.features.enterprise.api)
|
||||
implementation(projects.features.networkmonitor.api)
|
||||
implementation(projects.libraries.androidutils)
|
||||
implementation(projects.libraries.architecture)
|
||||
implementation(projects.libraries.di)
|
||||
implementation(projects.libraries.featureflag.api)
|
||||
implementation(projects.libraries.network)
|
||||
implementation(projects.libraries.preferences.api)
|
||||
implementation(projects.libraries.sessionStorage.api)
|
||||
implementation(projects.libraries.workmanager.api)
|
||||
implementation(projects.services.analytics.api)
|
||||
@@ -77,14 +77,13 @@ dependencies {
|
||||
implementation(projects.libraries.core)
|
||||
implementation(variantOf(libs.jna) { artifactType("aar") })
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
implementation(libs.serialization.json)
|
||||
implementation(libs.kotlinx.collections.immutable)
|
||||
|
||||
testCommonDependencies(libs)
|
||||
testImplementation(projects.features.enterprise.test)
|
||||
testImplementation(projects.features.networkmonitor.test)
|
||||
testImplementation(projects.libraries.featureflag.test)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.libraries.preferences.test)
|
||||
testImplementation(projects.libraries.previewutils)
|
||||
testImplementation(projects.libraries.sessionStorage.test)
|
||||
testImplementation(projects.libraries.workmanager.test)
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* 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 org.matrix.rustcomponents.sdk.ClientSessionDelegate
|
||||
import org.matrix.rustcomponents.sdk.Session
|
||||
|
||||
/**
|
||||
* A [ClientSessionDelegate] which does nothing, to be used for the clients which are not associated to a stored session,
|
||||
* for instance the temporary clients, or the clients used to perform a login. The session is only stored once the login
|
||||
* is done, with a client created by [RustMatrixClientFactory.create], which uses a [RustClientSessionDelegate].
|
||||
*/
|
||||
internal object NoOpClientSessionDelegate : ClientSessionDelegate {
|
||||
override fun saveSessionInKeychain(session: Session) = Unit
|
||||
|
||||
override fun retrieveSessionFromKeychain(userId: String): Session {
|
||||
// This should never be called, as it's only used for multi-process setups
|
||||
error("retrieveSessionFromKeychain should never be called for Android")
|
||||
}
|
||||
}
|
||||
+1
@@ -37,6 +37,7 @@ private val loggerTag = LoggerTag("RustClientSessionDelegate")
|
||||
* It implements both [ClientSessionDelegate] and [ClientDelegate] to react to session data updates and auth errors.
|
||||
*
|
||||
* IMPORTANT: you must set the [client] property as soon as possible so [didReceiveAuthError] can work properly.
|
||||
* Since [didReceiveAuthError] does not tell which session is concerned, each client must have its own instance.
|
||||
*/
|
||||
class RustClientSessionDelegate(
|
||||
private val sessionStore: SessionStore,
|
||||
|
||||
+51
-8
@@ -8,6 +8,9 @@
|
||||
|
||||
package io.element.android.libraries.matrix.impl
|
||||
|
||||
import dev.zacsweers.metro.Assisted
|
||||
import dev.zacsweers.metro.AssistedFactory
|
||||
import dev.zacsweers.metro.AssistedInject
|
||||
import io.element.android.libraries.androidutils.file.getSizeOfFiles
|
||||
import io.element.android.libraries.core.bool.orFalse
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
@@ -16,7 +19,9 @@ import io.element.android.libraries.core.data.bytes
|
||||
import io.element.android.libraries.core.data.tryOrNull
|
||||
import io.element.android.libraries.core.extensions.mapFailure
|
||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||
import io.element.android.libraries.di.annotations.AppCoroutineScope
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.matrix.api.HomeserverCapabilitiesProvider
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.analytics.SdkStoreSizes
|
||||
@@ -47,6 +52,7 @@ import io.element.android.libraries.matrix.api.room.join.JoinRule
|
||||
import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility
|
||||
import io.element.android.libraries.matrix.api.roomlist.RoomListService
|
||||
import io.element.android.libraries.matrix.api.scanner.ContentScanner
|
||||
import io.element.android.libraries.matrix.api.search.SearchBackfillStrategy
|
||||
import io.element.android.libraries.matrix.api.spaces.SpaceService
|
||||
import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion
|
||||
import io.element.android.libraries.matrix.api.sync.SyncState
|
||||
@@ -81,6 +87,8 @@ import io.element.android.libraries.matrix.impl.roomlist.RoomListFactory
|
||||
import io.element.android.libraries.matrix.impl.roomlist.RustRoomListService
|
||||
import io.element.android.libraries.matrix.impl.roomlist.roomOrNull
|
||||
import io.element.android.libraries.matrix.impl.search.RustMessageSearchService
|
||||
import io.element.android.libraries.matrix.impl.search.RustSearchBackfillService
|
||||
import io.element.android.libraries.matrix.impl.search.map
|
||||
import io.element.android.libraries.matrix.impl.spaces.RustSpaceService
|
||||
import io.element.android.libraries.matrix.impl.sync.RustSyncService
|
||||
import io.element.android.libraries.matrix.impl.sync.map
|
||||
@@ -110,8 +118,10 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.buffer
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.mapNotNull
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
@@ -149,23 +159,37 @@ import org.matrix.rustcomponents.sdk.SyncService as ClientSyncService
|
||||
private const val AVATAR_THUMBNAIL_SIZE_IN_PIXEL = 240L
|
||||
|
||||
@Suppress("LargeClass")
|
||||
@AssistedInject
|
||||
class RustMatrixClient(
|
||||
override val sessionPaths: SessionPaths,
|
||||
val innerClient: Client,
|
||||
@Assisted override val sessionPaths: SessionPaths,
|
||||
@Assisted val innerClient: Client,
|
||||
@Assisted private val innerSyncService: ClientSyncService,
|
||||
@Assisted baseCacheDirectory: File,
|
||||
@Assisted override val contentScanner: ContentScanner?,
|
||||
@Assisted override val isMessageSearchAvailable: Boolean,
|
||||
@Assisted private val sessionDelegate: RustClientSessionDelegate,
|
||||
private val sessionStore: SessionStore,
|
||||
private val sessionDelegate: RustClientSessionDelegate,
|
||||
private val innerSyncService: ClientSyncService,
|
||||
appCoroutineScope: CoroutineScope,
|
||||
@AppCoroutineScope appCoroutineScope: CoroutineScope,
|
||||
dispatchers: CoroutineDispatchers,
|
||||
baseCacheDirectory: File,
|
||||
clock: SystemClock,
|
||||
timelineEventFilterFactory: TimelineEventFilterFactory,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
private val analyticsService: AnalyticsService,
|
||||
private val workManagerScheduler: WorkManagerScheduler,
|
||||
override val contentScanner: ContentScanner?,
|
||||
override val isMessageSearchAvailable: Boolean,
|
||||
private val searchBackfillServiceFactory: RustSearchBackfillService.Factory,
|
||||
) : MatrixClient {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(
|
||||
sessionPaths: SessionPaths,
|
||||
innerClient: Client,
|
||||
innerSyncService: ClientSyncService,
|
||||
baseCacheDirectory: File,
|
||||
contentScanner: ContentScanner?,
|
||||
isMessageSearchAvailable: Boolean,
|
||||
sessionDelegate: RustClientSessionDelegate,
|
||||
): RustMatrixClient
|
||||
}
|
||||
override val sessionId: UserId = UserId(innerClient.userId())
|
||||
override val deviceId: DeviceId = DeviceId(innerClient.deviceId())
|
||||
override val server: String? = innerClient.server()
|
||||
@@ -280,6 +304,8 @@ class RustMatrixClient(
|
||||
|
||||
private var clientDelegateTaskHandle: TaskHandle? = innerClient.setDelegate(sessionDelegate)
|
||||
|
||||
override val searchBackfillService = searchBackfillServiceFactory.create(innerClient)
|
||||
|
||||
private val _userProfile: MutableStateFlow<MatrixUser> = MutableStateFlow(
|
||||
MatrixUser(
|
||||
userId = sessionId,
|
||||
@@ -332,6 +358,9 @@ class RustMatrixClient(
|
||||
|
||||
// Schedule regular database vacuuming to ensure DB performance remains optimal
|
||||
scheduleDatabaseVacuum()
|
||||
|
||||
// Schedule background search backfill if needed
|
||||
scheduleBackgroundSearchBackfill()
|
||||
}
|
||||
|
||||
private suspend fun setupUserProfile() {
|
||||
@@ -687,6 +716,7 @@ class RustMatrixClient(
|
||||
sessionCoroutineScope.cancel()
|
||||
clientDelegateTaskHandle?.cancelAndDestroy()
|
||||
ownProfileTaskHandle?.cancelAndDestroy()
|
||||
searchBackfillService.cancelSearchBackfill()
|
||||
sessionVerificationService.destroy()
|
||||
|
||||
sessionDelegate.clearCurrentClient()
|
||||
@@ -992,6 +1022,19 @@ class RustMatrixClient(
|
||||
override fun homeserverCapabilities(): HomeserverCapabilitiesProvider {
|
||||
return RustHomeserverCapabilitiesProvider(innerClient.homeserverCapabilities())
|
||||
}
|
||||
|
||||
private fun scheduleBackgroundSearchBackfill() {
|
||||
featureFlagService.isFeatureEnabledFlow(FeatureFlags.MessageSearch)
|
||||
.distinctUntilChanged()
|
||||
.onEach { isEnabled ->
|
||||
if (isEnabled) {
|
||||
searchBackfillService.schedulePeriodicSearchBackfill(SearchBackfillStrategy.BACKGROUND)
|
||||
} else {
|
||||
searchBackfillService.cancelPeriodicSearchBackfill()
|
||||
}
|
||||
}
|
||||
.launchIn(sessionCoroutineScope)
|
||||
}
|
||||
}
|
||||
|
||||
private fun defaultRoomCreationPowerLevels(isPublic: Boolean, isSpace: Boolean) = PowerLevels(
|
||||
|
||||
+13
-17
@@ -23,19 +23,17 @@ import io.element.android.libraries.matrix.api.paths.SessionPaths
|
||||
import io.element.android.libraries.matrix.impl.analytics.UtdTracker
|
||||
import io.element.android.libraries.matrix.impl.paths.getSessionPaths
|
||||
import io.element.android.libraries.matrix.impl.proxy.ProxyProvider
|
||||
import io.element.android.libraries.matrix.impl.room.TimelineEventFilterFactory
|
||||
import io.element.android.libraries.matrix.impl.scanner.RustContentScanner
|
||||
import io.element.android.libraries.matrix.impl.storage.SqliteStoreBuilderProvider
|
||||
import io.element.android.libraries.matrix.impl.util.anonymizedTokens
|
||||
import io.element.android.libraries.network.useragent.UserAgentProvider
|
||||
import io.element.android.libraries.sessionstorage.api.SessionData
|
||||
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||
import io.element.android.libraries.workmanager.api.WorkManagerScheduler
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.matrix.rustcomponents.sdk.ClientBuilder
|
||||
import org.matrix.rustcomponents.sdk.ClientSessionDelegate
|
||||
import org.matrix.rustcomponents.sdk.CrossProcessLockConfig
|
||||
import org.matrix.rustcomponents.sdk.RequestConfig
|
||||
import org.matrix.rustcomponents.sdk.Session
|
||||
@@ -61,16 +59,18 @@ class RustMatrixClientFactory(
|
||||
private val sessionStore: SessionStore,
|
||||
private val userAgentProvider: UserAgentProvider,
|
||||
private val proxyProvider: ProxyProvider,
|
||||
private val clock: SystemClock,
|
||||
private val analyticsService: AnalyticsService,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
private val timelineEventFilterFactory: TimelineEventFilterFactory,
|
||||
private val clientBuilderProvider: ClientBuilderProvider,
|
||||
private val sqliteStoreBuilderProvider: SqliteStoreBuilderProvider,
|
||||
private val workManagerScheduler: WorkManagerScheduler,
|
||||
private val clientBuilderEnterpriseHook: ClientBuilderEnterpriseHook,
|
||||
private val innerMatrixClientFactory: RustMatrixClient.Factory,
|
||||
) {
|
||||
private val sessionDelegate = RustClientSessionDelegate(
|
||||
/**
|
||||
* Create a new [RustClientSessionDelegate]. Each client must have its own instance, since the delegate
|
||||
* keeps a reference to the client it handles the auth errors for.
|
||||
*/
|
||||
private fun createSessionDelegate() = RustClientSessionDelegate(
|
||||
sessionStore = sessionStore,
|
||||
appCoroutineScope = appCoroutineScope,
|
||||
analyticsService = analyticsService,
|
||||
@@ -91,11 +91,14 @@ class RustMatrixClientFactory(
|
||||
indexDirectory.deleteRecursively()
|
||||
}
|
||||
|
||||
// Use a dedicated delegate for this client, so that an auth error only affects this session
|
||||
val sessionDelegate = createSessionDelegate()
|
||||
val client = getBaseClientBuilder(
|
||||
sessionPaths = sessionPaths,
|
||||
clientSecret = clientSecret,
|
||||
slidingSyncType = ClientBuilderSlidingSync.Restored,
|
||||
isMessageSearchAvailable = isMessageSearchAvailable,
|
||||
sessionDelegate = sessionDelegate,
|
||||
)
|
||||
.homeserverUrl(sessionData.homeserverUrl)
|
||||
.enableAutomaticBackPagination(featureFlagService.isFeatureEnabled(FeatureFlags.AutomaticBackPagination))
|
||||
@@ -131,22 +134,14 @@ class RustMatrixClientFactory(
|
||||
.withOfflineMode()
|
||||
.finish()
|
||||
|
||||
RustMatrixClient(
|
||||
innerMatrixClientFactory.create(
|
||||
sessionPaths = sessionData.getSessionPaths(),
|
||||
innerClient = client,
|
||||
sessionStore = sessionStore,
|
||||
appCoroutineScope = appCoroutineScope,
|
||||
sessionDelegate = sessionDelegate,
|
||||
innerSyncService = syncService,
|
||||
dispatchers = coroutineDispatchers,
|
||||
baseCacheDirectory = cacheDirectory,
|
||||
clock = clock,
|
||||
timelineEventFilterFactory = timelineEventFilterFactory,
|
||||
featureFlagService = featureFlagService,
|
||||
analyticsService = analyticsService,
|
||||
workManagerScheduler = workManagerScheduler,
|
||||
contentScanner = client.contentScanner()?.let { RustContentScanner(client, it) },
|
||||
isMessageSearchAvailable = isMessageSearchAvailable,
|
||||
sessionDelegate = sessionDelegate,
|
||||
).also {
|
||||
Timber.tag("RustMatrixClient").i("Creating Client with access token '$anonymizedAccessToken' and refresh token '$anonymizedRefreshToken'")
|
||||
}
|
||||
@@ -157,6 +152,7 @@ class RustMatrixClientFactory(
|
||||
clientSecret: ClientSecret?,
|
||||
slidingSyncType: ClientBuilderSlidingSync,
|
||||
isMessageSearchAvailable: Boolean,
|
||||
sessionDelegate: ClientSessionDelegate,
|
||||
): ClientBuilder {
|
||||
return clientBuilderProvider.provide()
|
||||
.run {
|
||||
|
||||
+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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
@@ -35,6 +35,7 @@ class RustTemporaryMatrixClientFactory(
|
||||
clientSecret = null,
|
||||
slidingSyncType = ClientBuilderSlidingSync.Native,
|
||||
isMessageSearchAvailable = false,
|
||||
sessionDelegate = NoOpClientSessionDelegate,
|
||||
)
|
||||
.serverName(formattedServerName)
|
||||
.build()
|
||||
|
||||
+3
@@ -34,6 +34,7 @@ import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.paths.SessionPaths
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
|
||||
import io.element.android.libraries.matrix.impl.ClientBuilderSlidingSync
|
||||
import io.element.android.libraries.matrix.impl.NoOpClientSessionDelegate
|
||||
import io.element.android.libraries.matrix.impl.RustMatrixClientFactory
|
||||
import io.element.android.libraries.matrix.impl.RustTemporaryMatrixClient
|
||||
import io.element.android.libraries.matrix.impl.auth.qrlogin.QrErrorMapper
|
||||
@@ -407,6 +408,7 @@ class RustMatrixAuthenticationService(
|
||||
clientSecret = pendingKey,
|
||||
slidingSyncType = ClientBuilderSlidingSync.Discovered,
|
||||
isMessageSearchAvailable = isMessageSearchAvailable(),
|
||||
sessionDelegate = NoOpClientSessionDelegate,
|
||||
)
|
||||
.config()
|
||||
.build()
|
||||
@@ -440,6 +442,7 @@ class RustMatrixAuthenticationService(
|
||||
clientSecret = pendingKey,
|
||||
slidingSyncType = ClientBuilderSlidingSync.Discovered,
|
||||
isMessageSearchAvailable = isMessageSearchAvailable(),
|
||||
sessionDelegate = NoOpClientSessionDelegate,
|
||||
)
|
||||
.serverNameOrHomeserverUrl(baseUrlOrServerName)
|
||||
.build()
|
||||
|
||||
+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>
|
||||
}
|
||||
+4
@@ -125,6 +125,10 @@ fun RustEventSendState?.map(): LocalEventSendState? {
|
||||
is QueueWedgeError.MissingMediaContent -> {
|
||||
LocalEventSendState.Failed.MissingMediaContent
|
||||
}
|
||||
is QueueWedgeError.ExpiredAccessToken -> {
|
||||
// This will automatically recover, so map it to the sending state
|
||||
LocalEventSendState.Sending.Event
|
||||
}
|
||||
}
|
||||
}
|
||||
is RustEventSendState.Sent -> LocalEventSendState.Sent(EventId(eventId))
|
||||
|
||||
+95
-5
@@ -12,8 +12,14 @@ import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.enterprise.test.FakeClientBuilderEnterpriseHook
|
||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.paths.SessionPaths
|
||||
import io.element.android.libraries.matrix.api.scanner.ContentScanner
|
||||
import io.element.android.libraries.matrix.impl.auth.FakeProxyProvider
|
||||
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiClient
|
||||
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiClientBuilder
|
||||
import io.element.android.libraries.matrix.impl.room.FakeTimelineEventFilterFactory
|
||||
import io.element.android.libraries.matrix.impl.search.RustSearchBackfillService
|
||||
import io.element.android.libraries.matrix.impl.search.workmanager.SearchBackfillRequestBuilder
|
||||
import io.element.android.libraries.matrix.impl.storage.FakeSqliteStoreBuilderProvider
|
||||
import io.element.android.libraries.matrix.impl.storage.SqliteStoreBuilderProvider
|
||||
import io.element.android.libraries.network.useragent.SimpleUserAgentProvider
|
||||
@@ -21,6 +27,7 @@ import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
|
||||
import io.element.android.libraries.sessionstorage.test.aSessionData
|
||||
import io.element.android.libraries.workmanager.api.WorkManagerRequestBuilder
|
||||
import io.element.android.libraries.workmanager.api.WorkManagerRequestWrapper
|
||||
import io.element.android.libraries.workmanager.test.FakeWorkManagerScheduler
|
||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
|
||||
@@ -29,13 +36,16 @@ import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import org.matrix.rustcomponents.sdk.Client
|
||||
import org.matrix.rustcomponents.sdk.ClientDelegate
|
||||
import java.io.File
|
||||
import org.matrix.rustcomponents.sdk.SyncService as ClientSyncService
|
||||
|
||||
class RustMatrixClientFactoryTest {
|
||||
@Test
|
||||
fun test() = runTest {
|
||||
val scheduleVacuumLambda = lambdaRecorder<WorkManagerRequestBuilder, Unit> {}
|
||||
val workManagerScheduler = FakeWorkManagerScheduler(submitLambda = scheduleVacuumLambda)
|
||||
val workManagerScheduler = FakeWorkManagerScheduler(submitLambda = scheduleVacuumLambda, cancelLambda = { _, _ -> })
|
||||
val sut = createRustMatrixClientFactory(workManagerScheduler = workManagerScheduler)
|
||||
|
||||
val result = sut.create(aSessionData())
|
||||
@@ -44,6 +54,46 @@ class RustMatrixClientFactoryTest {
|
||||
scheduleVacuumLambda.assertions().isCalledOnce()
|
||||
result.destroy()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an auth error on a client only invalidates its own session`() = runTest {
|
||||
val aliceSessionId = "@alice:server.org"
|
||||
val bobSessionId = "@bob:server.org"
|
||||
val sessionStore = InMemorySessionStore(
|
||||
initialList = listOf(
|
||||
aSessionData(sessionId = aliceSessionId, isTokenValid = true),
|
||||
aSessionData(sessionId = bobSessionId, isTokenValid = true),
|
||||
),
|
||||
updateUserProfileResult = { _, _, _, _ -> },
|
||||
)
|
||||
val userIds = ArrayDeque(listOf(aliceSessionId, bobSessionId))
|
||||
val delegates = mutableMapOf<String, ClientDelegate?>()
|
||||
val sut = createRustMatrixClientFactory(
|
||||
sessionStore = sessionStore,
|
||||
workManagerScheduler = FakeWorkManagerScheduler(submitLambda = {}, cancelLambda = { _, _ -> }),
|
||||
clientBuilderProvider = FakeClientBuilderProvider {
|
||||
val userId = userIds.removeFirst()
|
||||
FakeFfiClientBuilder(
|
||||
buildResult = {
|
||||
FakeFfiClient(
|
||||
userId = userId,
|
||||
withUtdHook = {},
|
||||
setDelegateResult = { delegates[userId] = it },
|
||||
)
|
||||
}
|
||||
)
|
||||
},
|
||||
)
|
||||
val aliceClient = sut.create(sessionStore.getSession(aliceSessionId)!!)
|
||||
val bobClient = sut.create(sessionStore.getSession(bobSessionId)!!)
|
||||
|
||||
delegates.getValue(aliceSessionId)!!.didReceiveAuthError(isSoftLogout = false)
|
||||
|
||||
assertThat(sessionStore.getSession(aliceSessionId)!!.isTokenValid).isFalse()
|
||||
assertThat(sessionStore.getSession(bobSessionId)!!.isTokenValid).isTrue()
|
||||
aliceClient.destroy()
|
||||
bobClient.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
fun TestScope.createRustMatrixClientFactory(
|
||||
@@ -52,7 +102,7 @@ fun TestScope.createRustMatrixClientFactory(
|
||||
updateUserProfileResult = { _, _, _, _ -> },
|
||||
),
|
||||
clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(),
|
||||
workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(),
|
||||
workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(cancelLambda = { _, _ -> }),
|
||||
sqliteStoreBuilderProvider: SqliteStoreBuilderProvider = FakeSqliteStoreBuilderProvider(),
|
||||
) = RustMatrixClientFactory(
|
||||
cacheDirectory = cacheDirectory,
|
||||
@@ -61,12 +111,52 @@ fun TestScope.createRustMatrixClientFactory(
|
||||
sessionStore = sessionStore,
|
||||
userAgentProvider = SimpleUserAgentProvider(),
|
||||
proxyProvider = FakeProxyProvider(),
|
||||
clock = FakeSystemClock(),
|
||||
analyticsService = FakeAnalyticsService(),
|
||||
featureFlagService = FakeFeatureFlagService(),
|
||||
timelineEventFilterFactory = FakeTimelineEventFilterFactory(),
|
||||
clientBuilderProvider = clientBuilderProvider,
|
||||
sqliteStoreBuilderProvider = sqliteStoreBuilderProvider,
|
||||
workManagerScheduler = workManagerScheduler,
|
||||
clientBuilderEnterpriseHook = FakeClientBuilderEnterpriseHook(),
|
||||
innerMatrixClientFactory = createRustMatrixClientInnerFactory(sessionStore, workManagerScheduler),
|
||||
)
|
||||
|
||||
private fun TestScope.createRustMatrixClientInnerFactory(
|
||||
sessionStore: SessionStore,
|
||||
workManagerScheduler: FakeWorkManagerScheduler,
|
||||
) = object : RustMatrixClient.Factory {
|
||||
override fun create(
|
||||
sessionPaths: SessionPaths,
|
||||
innerClient: Client,
|
||||
innerSyncService: ClientSyncService,
|
||||
baseCacheDirectory: File,
|
||||
contentScanner: ContentScanner?,
|
||||
isMessageSearchAvailable: Boolean,
|
||||
sessionDelegate: RustClientSessionDelegate,
|
||||
) = RustMatrixClient(
|
||||
sessionPaths = sessionPaths,
|
||||
innerClient = innerClient,
|
||||
innerSyncService = innerSyncService,
|
||||
baseCacheDirectory = baseCacheDirectory,
|
||||
contentScanner = contentScanner,
|
||||
isMessageSearchAvailable = isMessageSearchAvailable,
|
||||
sessionDelegate = sessionDelegate,
|
||||
sessionStore = sessionStore,
|
||||
appCoroutineScope = backgroundScope,
|
||||
dispatchers = testCoroutineDispatchers(),
|
||||
clock = FakeSystemClock(),
|
||||
timelineEventFilterFactory = FakeTimelineEventFilterFactory(),
|
||||
featureFlagService = FakeFeatureFlagService(),
|
||||
analyticsService = FakeAnalyticsService(),
|
||||
workManagerScheduler = workManagerScheduler,
|
||||
searchBackfillServiceFactory = object : RustSearchBackfillService.Factory {
|
||||
override fun create(innerClient: Client) = RustSearchBackfillService(
|
||||
innerClient = innerClient,
|
||||
searchBackfillRequestBuilderFactory = {
|
||||
object : SearchBackfillRequestBuilder {
|
||||
override suspend fun build(): Result<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.FakeFfiSyncService
|
||||
import io.element.android.libraries.matrix.impl.room.FakeTimelineEventFilterFactory
|
||||
import io.element.android.libraries.matrix.impl.search.RustSearchBackfillService
|
||||
import io.element.android.libraries.matrix.impl.search.workmanager.SearchBackfillRequestBuilder
|
||||
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
|
||||
import io.element.android.libraries.matrix.test.A_DEVICE_ID
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||
@@ -27,6 +29,7 @@ import io.element.android.libraries.matrix.test.scanner.FakeContentScanner
|
||||
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
|
||||
import io.element.android.libraries.sessionstorage.test.aSessionData
|
||||
import io.element.android.libraries.workmanager.api.WorkManagerRequestWrapper
|
||||
import io.element.android.libraries.workmanager.test.FakeWorkManagerScheduler
|
||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
|
||||
@@ -325,8 +328,19 @@ class RustMatrixClientTest {
|
||||
timelineEventFilterFactory = FakeTimelineEventFilterFactory(),
|
||||
featureFlagService = FakeFeatureFlagService(),
|
||||
analyticsService = FakeAnalyticsService(),
|
||||
workManagerScheduler = FakeWorkManagerScheduler(submitLambda = {}),
|
||||
workManagerScheduler = FakeWorkManagerScheduler(submitLambda = {}, cancelLambda = { _, _ -> }),
|
||||
contentScanner = FakeContentScanner(),
|
||||
isMessageSearchAvailable = false,
|
||||
searchBackfillServiceFactory = object : RustSearchBackfillService.Factory {
|
||||
override fun create(innerClient: Client) = RustSearchBackfillService(
|
||||
innerClient = innerClient,
|
||||
searchBackfillRequestBuilderFactory = {
|
||||
object : SearchBackfillRequestBuilder {
|
||||
override suspend fun build(): Result<List<WorkManagerRequestWrapper>> = Result.success(emptyList())
|
||||
}
|
||||
},
|
||||
workManagerScheduler = FakeWorkManagerScheduler(submitLambda = {}, cancelLambda = { _, _ -> }),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
-36
@@ -8,19 +8,7 @@
|
||||
package io.element.android.libraries.matrix.impl
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.enterprise.test.FakeClientBuilderEnterpriseHook
|
||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||
import io.element.android.libraries.matrix.impl.auth.FakeProxyProvider
|
||||
import io.element.android.libraries.matrix.impl.paths.SessionPathsFactory
|
||||
import io.element.android.libraries.matrix.impl.room.FakeTimelineEventFilterFactory
|
||||
import io.element.android.libraries.matrix.impl.storage.FakeSqliteStoreBuilderProvider
|
||||
import io.element.android.libraries.network.useragent.SimpleUserAgentProvider
|
||||
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
|
||||
import io.element.android.libraries.workmanager.test.FakeWorkManagerScheduler
|
||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
|
||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
@@ -56,28 +44,4 @@ class RustTemporaryMatrixClientFactoryTest {
|
||||
rustMatrixClientFactory = rustMatrixClientFactory,
|
||||
)
|
||||
}
|
||||
|
||||
private fun TestScope.createRustMatrixClientFactory(
|
||||
cacheDirectory: File = File("/cache"),
|
||||
sessionStore: SessionStore = InMemorySessionStore(
|
||||
updateUserProfileResult = { _, _, _, _ -> },
|
||||
),
|
||||
clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(),
|
||||
workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(),
|
||||
) = RustMatrixClientFactory(
|
||||
cacheDirectory = cacheDirectory,
|
||||
appCoroutineScope = backgroundScope,
|
||||
coroutineDispatchers = testCoroutineDispatchers(),
|
||||
sessionStore = sessionStore,
|
||||
userAgentProvider = SimpleUserAgentProvider(),
|
||||
proxyProvider = FakeProxyProvider(),
|
||||
clock = FakeSystemClock(),
|
||||
analyticsService = FakeAnalyticsService(),
|
||||
featureFlagService = FakeFeatureFlagService(),
|
||||
timelineEventFilterFactory = FakeTimelineEventFilterFactory(),
|
||||
clientBuilderProvider = clientBuilderProvider,
|
||||
sqliteStoreBuilderProvider = FakeSqliteStoreBuilderProvider(),
|
||||
workManagerScheduler = workManagerScheduler,
|
||||
clientBuilderEnterpriseHook = FakeClientBuilderEnterpriseHook(),
|
||||
)
|
||||
}
|
||||
|
||||
+1
-1
@@ -265,7 +265,7 @@ class RustMatrixAuthenticationServiceTest {
|
||||
cacheDirectory = File("/cache"),
|
||||
sessionStore = sessionStore,
|
||||
clientBuilderProvider = clientBuilderProvider,
|
||||
workManagerScheduler = FakeWorkManagerScheduler(submitLambda = {}),
|
||||
workManagerScheduler = FakeWorkManagerScheduler(submitLambda = {}, cancelLambda = { _, _ -> }),
|
||||
sqliteStoreBuilderProvider = sqliteStoreBuilderProvider,
|
||||
)
|
||||
return RustMatrixAuthenticationService(
|
||||
|
||||
+9
-1
@@ -45,6 +45,7 @@ import org.matrix.rustcomponents.sdk.SyncServiceBuilder
|
||||
import org.matrix.rustcomponents.sdk.TaskHandle
|
||||
import org.matrix.rustcomponents.sdk.UnableToDecryptDelegate
|
||||
import org.matrix.rustcomponents.sdk.UserProfile
|
||||
import uniffi.matrix_sdk.SearchBackfillStrategy
|
||||
import uniffi.matrix_sdk_base.MediaRetentionPolicy
|
||||
|
||||
class FakeFfiClient(
|
||||
@@ -73,7 +74,9 @@ class FakeFfiClient(
|
||||
private val getUrlResult: (String) -> ByteArray = { lambdaError() },
|
||||
private val getMediaThumbnailResult: (MediaSource, ULong, ULong) -> ByteArray = { _, _, _ -> byteArrayOf() },
|
||||
private val contentScannerResult: () -> ContentScanner = { FakeFfiContentScanner() },
|
||||
private val runSearchBackfillResult: (SearchBackfillStrategy) -> TaskHandle = { lambdaError() },
|
||||
private val closeResult: () -> Unit = {},
|
||||
private val setDelegateResult: (ClientDelegate?) -> Unit = {},
|
||||
) : Client(NoHandle) {
|
||||
override fun userId(): String = userId
|
||||
override fun deviceId(): String = deviceId
|
||||
@@ -83,7 +86,10 @@ class FakeFfiClient(
|
||||
override suspend fun getNotificationSettings(): NotificationSettings = notificationSettings
|
||||
override fun encryption(): Encryption = encryption
|
||||
override fun session(): Session = session
|
||||
override fun setDelegate(delegate: ClientDelegate?): TaskHandle = FakeFfiTaskHandle()
|
||||
override fun setDelegate(delegate: ClientDelegate?): TaskHandle {
|
||||
setDelegateResult(delegate)
|
||||
return FakeFfiTaskHandle()
|
||||
}
|
||||
override suspend fun cachedAvatarUrl(): String? = null
|
||||
override suspend fun restoreSession(session: Session) = Unit
|
||||
override fun syncService(): SyncServiceBuilder = FakeFfiSyncServiceBuilder()
|
||||
@@ -170,5 +176,7 @@ class FakeFfiClient(
|
||||
contentScannerResult()
|
||||
}
|
||||
|
||||
override fun runSearchBackfill(strategy: SearchBackfillStrategy): TaskHandle = runSearchBackfillResult(strategy)
|
||||
|
||||
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.TaskHandle
|
||||
|
||||
class FakeFfiTaskHandle : TaskHandle(NoHandle) {
|
||||
override fun cancel() = Unit
|
||||
class FakeFfiTaskHandle(
|
||||
private val isFinishedResult: () -> Boolean = { false },
|
||||
private val cancelResult: () -> Unit = {},
|
||||
) : TaskHandle(NoHandle) {
|
||||
override fun isFinished(): Boolean = isFinishedResult()
|
||||
override fun cancel() = cancelResult()
|
||||
override fun destroy() = Unit
|
||||
}
|
||||
|
||||
+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
|
||||
}
|
||||
Reference in New Issue
Block a user