Simplify push notification flow by using locally stored values for pending pushes (#6258)

* Create `PushRequest` in push history DB: this will be used to store requests for push notifications, either pending or completed ones.

* Rename `WorkManagerRequest` to `WorkManagerRequestBuilder`: make its `build` method return a list of `WorkManagerRequestWrapper`, which can be used to enqueue normal or unique workers.

* Rename `PerformDatabaseVacuumRequestBuilder` and adapt it to the new API.

* Adjust other components using `WorkManagerRequest`.

* Replace `SyncNotificationWorkManagerRequestBuilder` with `SyncPendingNotificationsRequestBuilder` and `FetchNotificationsWorker` with `FetchPendingNotificationsWorker`: this new pair of request builder and worker allow enqueuing requests for a session id and, once the worker runs, retrieve all the pending request data and use it to fetch the associated events. This simplifies quite a bit how this data had to be passed or grouped, since it's no longer necessary to do so

* Add new methods to `PushHistoryService` to modify the `PushDatabase`:

- insertOrUpdatePushRequest
- insertOrUpdatePushRequests
- getPendingPushRequests
- removeOldPushRequests

* Make `PushHandler` just handle incoming pushes: those will be inserted into the pending push request table in DB, then handled by the new worker. Once the process finished, a new `NotificationResultProcessor` will handle the results and what needs to be done with them (call ringing, displaying notifications, etc.)

* Add `requestType` optional parameter to `WorkManagerScheduler.cancel` so we can decide to only cancel some kinds of requests.

* Add migration to remove existing work manager requests for fetching notifications, since the previous worker class no longer exists.
This commit is contained in:
Jorge Martin Espinosa 2026-03-03 16:14:36 +01:00 committed by GitHub
parent a8c66381f2
commit 721add707c
48 changed files with 1715 additions and 1892 deletions

View file

@ -1,15 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector 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.workmanager.api
import androidx.work.WorkRequest
interface WorkManagerRequest {
fun build(): Result<List<WorkRequest>>
}

View file

@ -0,0 +1,45 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector 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.workmanager.api
import androidx.work.ExistingWorkPolicy
import androidx.work.WorkRequest
/**
* A base class that can be customized to [build] work requests to schedule in `WorkManager`.
*/
interface WorkManagerRequestBuilder {
/**
* Builds a work request wrapper using the provided data.
*/
suspend fun build(): Result<List<WorkManagerRequestWrapper>>
}
/**
* A wrapper that allows us to avoid using Android APIs directly when scheduling workers.
*/
data class WorkManagerRequestWrapper(
val request: WorkRequest,
val type: WorkManagerWorkerType = WorkManagerWorkerType.Default,
)
/**
* The type of worker to use when scheduling the task.
*/
sealed interface WorkManagerWorkerType {
/**
* This allows a single worker instance with the [name] id to run at the same time. Its [policy] can be customized.
*/
data class Unique(val name: String, val policy: ExistingWorkPolicy) : WorkManagerWorkerType
/**
* The default worker type, with no custom rules.
*/
data object Default : WorkManagerWorkerType
}

View file

@ -11,9 +11,21 @@ package io.element.android.libraries.workmanager.api
import io.element.android.libraries.matrix.api.core.SessionId
interface WorkManagerScheduler {
fun submit(workManagerRequest: WorkManagerRequest)
/**
* Submits a new work request built from [workManagerRequestBuilder] to run in `WorkManager`.
*/
suspend fun submit(workManagerRequestBuilder: WorkManagerRequestBuilder)
/**
* Checks if there are any pending requests scheduled for the provided [sessionId] and [requestType].
*/
fun hasPendingWork(sessionId: SessionId, requestType: WorkManagerRequestType): Boolean
fun cancel(sessionId: SessionId)
/**
* Cancel pending work requests for the session [SessionId].
* If [requestType] is provided, it will only cancel requests for that type, otherwise it will cancel all requests.
*/
fun cancel(sessionId: SessionId, requestType: WorkManagerRequestType? = null)
}
fun workManagerTag(sessionId: SessionId, requestType: WorkManagerRequestType): String {

View file

@ -8,6 +8,7 @@
package io.element.android.libraries.workmanager.impl
import androidx.work.OneTimeWorkRequest
import androidx.work.WorkInfo
import androidx.work.WorkManager
import dev.zacsweers.metro.AppScope
@ -16,9 +17,10 @@ import dev.zacsweers.metro.SingleIn
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.sessionstorage.api.observer.SessionListener
import io.element.android.libraries.sessionstorage.api.observer.SessionObserver
import io.element.android.libraries.workmanager.api.WorkManagerRequest
import io.element.android.libraries.workmanager.api.WorkManagerRequestBuilder
import io.element.android.libraries.workmanager.api.WorkManagerRequestType
import io.element.android.libraries.workmanager.api.WorkManagerScheduler
import io.element.android.libraries.workmanager.api.WorkManagerWorkerType
import io.element.android.libraries.workmanager.api.workManagerTag
import timber.log.Timber
@ -41,13 +43,22 @@ class DefaultWorkManagerScheduler(
})
}
override fun submit(workManagerRequest: WorkManagerRequest) {
workManagerRequest.build().fold(
onSuccess = { workRequests ->
workManager.enqueue(workRequests)
override suspend fun submit(workManagerRequestBuilder: WorkManagerRequestBuilder) {
workManagerRequestBuilder.build().fold(
onSuccess = { wrappers ->
for (wrapper in wrappers) {
when (wrapper.type) {
WorkManagerWorkerType.Default -> workManager.enqueue(wrapper.request)
is WorkManagerWorkerType.Unique -> {
val type = wrapper.type as WorkManagerWorkerType.Unique
val requests = wrapper.request as OneTimeWorkRequest
workManager.enqueueUniqueWork(type.name, type.policy, requests)
}
}
}
},
onFailure = {
Timber.e(it, "Failed to build WorkManager request $workManagerRequest")
Timber.e(it, "Failed to build WorkManager request $workManagerRequestBuilder")
}
)
}
@ -64,10 +75,15 @@ class DefaultWorkManagerScheduler(
}
}
override fun cancel(sessionId: SessionId) {
override fun cancel(sessionId: SessionId, requestType: WorkManagerRequestType?) {
Timber.d("Cancelling work for sessionId: $sessionId")
for (requestType in WorkManagerRequestType.entries) {
if (requestType != null) {
workManager.cancelAllWorkByTag(workManagerTag(sessionId, requestType))
} else {
for (requestType in WorkManagerRequestType.entries) {
workManager.cancelAllWorkByTag(workManagerTag(sessionId, requestType))
}
}
}
}

View file

@ -7,12 +7,17 @@
package io.element.android.libraries.workmanager.impl
import android.content.Context
import androidx.work.OneTimeWorkRequest
import androidx.work.WorkManager
import androidx.work.WorkRequest
import androidx.work.Worker
import androidx.work.WorkerParameters
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.sessionstorage.test.observer.FakeSessionObserver
import io.element.android.libraries.workmanager.api.WorkManagerRequest
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.workManagerTag
import io.mockk.every
import io.mockk.mockk
@ -55,7 +60,7 @@ class DefaultWorkManagerSchedulerTest {
sessionObserver = FakeSessionObserver(),
)
scheduler.submit(FakeWorkManagerRequest())
scheduler.submit(FakeWorkManagerRequestBuilder())
verify { workManager.enqueue(any<List<WorkRequest>>()) }
}
@ -69,7 +74,7 @@ class DefaultWorkManagerSchedulerTest {
sessionObserver = FakeSessionObserver(),
)
scheduler.submit(FakeWorkManagerRequest(result = Result.failure(IllegalStateException("Test error"))))
scheduler.submit(FakeWorkManagerRequestBuilder(result = Result.failure(IllegalStateException("Test error"))))
verify(exactly = 0) { workManager.enqueue(any<List<WorkRequest>>()) }
}
@ -88,7 +93,7 @@ class DefaultWorkManagerSchedulerTest {
val mockSessionA = mockk<WorkRequest> {
every { tags } returns setOf(tagToRemove)
}
scheduler.submit(FakeWorkManagerRequest(result = Result.success(listOf(mockSessionA))))
scheduler.submit(FakeWorkManagerRequestBuilder(result = Result.success(listOf(WorkManagerRequestWrapper(mockSessionA)))))
scheduler.cancel(sessionId)
@ -96,10 +101,16 @@ class DefaultWorkManagerSchedulerTest {
}
}
private class FakeWorkManagerRequest(
private val result: Result<List<WorkRequest>> = Result.success(listOf()),
) : WorkManagerRequest {
override fun build(): Result<List<WorkRequest>> {
private val workRequest = OneTimeWorkRequest.Builder(FakeWorker::class.java).build()
private class FakeWorkManagerRequestBuilder(
private val result: Result<List<WorkManagerRequestWrapper>> = Result.success(listOf(WorkManagerRequestWrapper(workRequest))),
) : WorkManagerRequestBuilder {
override suspend fun build(): Result<List<WorkManagerRequestWrapper>> {
return result
}
}
internal class FakeWorker(context: Context, params: WorkerParameters) : Worker(context, params) {
override fun doWork(): Result = Result.success()
}

View file

@ -9,25 +9,25 @@
package io.element.android.libraries.workmanager.test
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.workmanager.api.WorkManagerRequest
import io.element.android.libraries.workmanager.api.WorkManagerRequestBuilder
import io.element.android.libraries.workmanager.api.WorkManagerRequestType
import io.element.android.libraries.workmanager.api.WorkManagerScheduler
import io.element.android.tests.testutils.lambda.lambdaError
class FakeWorkManagerScheduler(
private val submitLambda: (WorkManagerRequest) -> Unit = { lambdaError() },
private val submitLambda: (WorkManagerRequestBuilder) -> Unit = { lambdaError() },
private val hasPendingWorkLambda: (SessionId, WorkManagerRequestType) -> Boolean = { _, _ -> false },
private val cancelLambda: (SessionId) -> Unit = { lambdaError() },
private val cancelLambda: (SessionId, WorkManagerRequestType?) -> Unit = { _, _ -> lambdaError() },
) : WorkManagerScheduler {
override fun submit(workManagerRequest: WorkManagerRequest) {
submitLambda(workManagerRequest)
override suspend fun submit(workManagerRequestBuilder: WorkManagerRequestBuilder) {
submitLambda(workManagerRequestBuilder)
}
override fun hasPendingWork(sessionId: SessionId, requestType: WorkManagerRequestType): Boolean {
return hasPendingWorkLambda(sessionId, requestType)
}
override fun cancel(sessionId: SessionId) {
cancelLambda(sessionId)
override fun cancel(sessionId: SessionId, requestType: WorkManagerRequestType?) {
cancelLambda(sessionId, requestType)
}
}