diff --git a/libraries/matrix/impl/build.gradle.kts b/libraries/matrix/impl/build.gradle.kts index 91b0b9c5c3..17f5c37ef9 100644 --- a/libraries/matrix/impl/build.gradle.kts +++ b/libraries/matrix/impl/build.gradle.kts @@ -82,6 +82,7 @@ dependencies { implementation(variantOf(libs.jna) { artifactType("aar") }) implementation(libs.androidx.datastore.preferences) implementation(libs.kotlinx.collections.immutable) + implementation(libs.androidx.lifecycle.process) testCommonDependencies(libs) testImplementation(projects.features.enterprise.test) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/AppMatrixBindingContainer.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/AppMatrixBindingContainer.kt new file mode 100644 index 0000000000..3146ad6fbb --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/AppMatrixBindingContainer.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.di + +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.ContributesTo +import dev.zacsweers.metro.Provides + +@BindingContainer +@ContributesTo(AppScope::class) +object AppMatrixBindingContainer { + @Provides + fun provideLifecycleOwner(): LifecycleOwner = ProcessLifecycleOwner.get() +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/search/workmanager/SearchBackfillRequestBuilder.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/search/workmanager/SearchBackfillRequestBuilder.kt index bb80672369..df9feee80b 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/search/workmanager/SearchBackfillRequestBuilder.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/search/workmanager/SearchBackfillRequestBuilder.kt @@ -9,6 +9,7 @@ package io.element.android.libraries.matrix.impl.search.workmanager import android.net.NetworkCapabilities import android.net.NetworkRequest +import androidx.work.BackoffPolicy import androidx.work.Constraints import androidx.work.NetworkType import androidx.work.PeriodicWorkRequestBuilder @@ -28,8 +29,8 @@ 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 java.util.concurrent.TimeUnit import kotlin.time.Duration.Companion.hours -import kotlin.time.Duration.Companion.minutes import kotlin.time.toJavaDuration interface SearchBackfillRequestBuilder : WorkManagerRequestBuilder { @@ -76,11 +77,14 @@ class DefaultSearchBackfillRequestBuilder( .build() val request = PeriodicWorkRequestBuilder(1.hours.toJavaDuration()) - // Add a delay before the first execution to avoid running the backfill immediately after the app starts - .setInitialDelay(5.minutes.toJavaDuration()) .setInputData(workDataOf(SESSION_ID_PARAM to sessionId.value)) .addTag(tag) .setConstraints(constraints) + .setBackoffCriteria( + backoffPolicy = BackoffPolicy.LINEAR, + backoffDelay = 5, + timeUnit = TimeUnit.MINUTES, + ) .build() return Result.success(listOf(WorkManagerRequestWrapper(request, type))) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/search/workmanager/SearchBackfillWorker.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/search/workmanager/SearchBackfillWorker.kt index 48d1b31992..a188e76439 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/search/workmanager/SearchBackfillWorker.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/search/workmanager/SearchBackfillWorker.kt @@ -8,6 +8,9 @@ package io.element.android.libraries.matrix.impl.search.workmanager import android.content.Context +import androidx.annotation.VisibleForTesting +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner import androidx.work.CoroutineWorker import androidx.work.WorkerParameters import dev.zacsweers.metro.AppScope @@ -34,14 +37,22 @@ class SearchBackfillWorker( @Assisted params: WorkerParameters, @ApplicationContext context: Context, private val matrixClientProvider: MatrixClientProvider, + private val processLifecycleOwner: LifecycleOwner, ) : CoroutineWorker(context, params) { companion object { const val SESSION_ID_PARAM = "session_id" private val TIMEOUT = 10.minutes - private val POLL_INTERVAL = 5.seconds + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal val POLL_INTERVAL = 5.seconds } override suspend fun doWork(): Result { + val appLifecycle = processLifecycleOwner.lifecycle + if (appLifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) { + Timber.w("App is in foreground, skipping search backfill as it would degrade performance") + return Result.retry() + } + 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() @@ -62,6 +73,11 @@ class SearchBackfillWorker( withTimeout(TIMEOUT) { while (task.isRunning()) { delay(POLL_INTERVAL) + + if (task.isRunning() && appLifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) { + Timber.w("App is now in foreground, cancelling search backfill as it would degrade performance") + return@withTimeout + } } } Timber.d("Search backfill finished") diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/search/workmanager/DefaultSearchBackfillRequestBuilderTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/search/workmanager/DefaultSearchBackfillRequestBuilderTest.kt index 6518f93a49..c16063ada6 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/search/workmanager/DefaultSearchBackfillRequestBuilderTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/search/workmanager/DefaultSearchBackfillRequestBuilderTest.kt @@ -8,6 +8,7 @@ package io.element.android.libraries.matrix.impl.search.workmanager import android.net.NetworkCapabilities +import androidx.work.BackoffPolicy import androidx.work.PeriodicWorkRequest import androidx.work.hasKeyWithValueOfType import com.google.common.truth.Truth.assertThat @@ -34,9 +35,10 @@ class DefaultSearchBackfillRequestBuilderTest : RobolectricTest() { assertThat(this).isInstanceOf(PeriodicWorkRequest::class.java) assertThat(workSpec.isPeriodic).isTrue() assertThat(workSpec.intervalDuration).isEqualTo(1.hours.inWholeMilliseconds) - assertThat(workSpec.initialDelay).isEqualTo(5.minutes.inWholeMilliseconds) assertThat(workSpec.input.hasKeyWithValueOfType(SearchBackfillWorker.SESSION_ID_PARAM)).isTrue() assertThat(workSpec.input.getString(SearchBackfillWorker.SESSION_ID_PARAM)).isEqualTo(A_SESSION_ID.value) + assertThat(workSpec.backoffPolicy).isEqualTo(BackoffPolicy.LINEAR) + assertThat(workSpec.backoffDelayDuration).isEqualTo(5.minutes.inWholeMilliseconds) assertThat(tags).contains(expectedTag) assertThat(workSpec.workerClassName).isEqualTo(SearchBackfillWorker::class.java.name) } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/search/workmanager/SearchBackfillWorkerTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/search/workmanager/SearchBackfillWorkerTest.kt index f450b9f68a..09d1d7784f 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/search/workmanager/SearchBackfillWorkerTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/search/workmanager/SearchBackfillWorkerTest.kt @@ -7,6 +7,7 @@ package io.element.android.libraries.matrix.impl.search.workmanager +import androidx.lifecycle.Lifecycle import androidx.test.platform.app.InstrumentationRegistry import androidx.work.Data import androidx.work.ListenableWorker @@ -24,6 +25,7 @@ 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.FakeLifecycleOwner import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.robolectric.RobolectricTest @@ -37,6 +39,7 @@ import java.util.UUID import java.util.concurrent.Executor import java.util.concurrent.Executors import java.util.concurrent.TimeUnit +import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds @@ -120,9 +123,64 @@ class SearchBackfillWorkerTest : RobolectricTest() { closeLambda.assertions().isCalledOnce() } + @Test + fun `backfill will not run if the app is in foreground`() = runTest { + val startSearchBackfillLambda = lambdaRecorder> { + Result.success(FakeSdkPendingTask(isRunningLambda = { true })) + } + val client = FakeMatrixClient( + searchBackfillService = FakeSearchBackfillService( + startSearchBackfillLambda = startSearchBackfillLambda, + ), + ) + + val lifecycleOwner = FakeLifecycleOwner(initialState = Lifecycle.State.RESUMED) + val deferred = async { + createWorker(client = client, lifecycleOwner = lifecycleOwner).doWork() + } + + // Advance time to allow the worker to check the lifecycle state. + advanceTimeBy(1.milliseconds) + assertThat(deferred.isCompleted).isTrue() + + // The worker should return 'retry' immediately without starting the backfill. + assertThat(deferred.await()).isEqualTo(ListenableWorker.Result.retry()) + startSearchBackfillLambda.assertions().isNeverCalled() + } + + @Test + fun `backfill will stop if the app comes to foreground while it's running`() = runTest { + val closeLambda = lambdaRecorder { } + val startSearchBackfillLambda = lambdaRecorder> { + Result.success(FakeSdkPendingTask(isRunningLambda = { true }, closeLambda = closeLambda)) + } + val client = FakeMatrixClient( + searchBackfillService = FakeSearchBackfillService( + startSearchBackfillLambda = startSearchBackfillLambda, + ), + ) + + val lifecycleOwner = FakeLifecycleOwner(initialState = Lifecycle.State.CREATED) + val deferred = async { + createWorker(client = client, lifecycleOwner = lifecycleOwner).doWork() + } + advanceTimeBy(1.seconds) + assertThat(deferred.isCompleted).isFalse() + + lifecycleOwner.givenState(Lifecycle.State.RESUMED) + + advanceTimeBy(SearchBackfillWorker.POLL_INTERVAL) + + assertThat(deferred.isCompleted).isTrue() + assertThat(deferred.await()).isEqualTo(ListenableWorker.Result.success()) + startSearchBackfillLambda.assertions().isCalledOnce() + closeLambda.assertions().isCalledOnce() + } + private fun TestScope.createWorker( input: String? = A_SESSION_ID.value, client: MatrixClient?, + lifecycleOwner: FakeLifecycleOwner = FakeLifecycleOwner(), ) = SearchBackfillWorker( params = createWorkerParams( inputData = input?.let { workDataOf(SearchBackfillWorker.SESSION_ID_PARAM to it) } ?: Data.EMPTY, @@ -131,6 +189,7 @@ class SearchBackfillWorkerTest : RobolectricTest() { matrixClientProvider = FakeMatrixClientProvider( getClient = { client?.let { Result.success(it) } ?: Result.failure(IllegalStateException("No client")) } ), + processLifecycleOwner = lifecycleOwner, ) private fun TestScope.createWorkerParams(