Sync notifications using WorkManager (#5545)

* Initial implementation of notification sync using `WorkManager`

* Use custom `MetroWorkerFactory` to allow assisted injection in WorkManager Workers

* Add tests for `FetchNotificationWorker`. Create `FakeNotificationResolverQueue` to help testing.

* Add more tests, fix Konsist checks

* Add tests for `SyncNotificationWorkManagerRequest`

* Simplify `FakeNotificationResolverQueue`
This commit is contained in:
Jorge Martin Espinosa 2025-10-17 11:51:27 +02:00 committed by GitHub
parent f3d75ee85c
commit ebe94f873e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 968 additions and 98 deletions

View file

@ -0,0 +1,23 @@
import extension.setupDependencyInjection
/*
* 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.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.libraries.workmanager.api"
}
setupDependencyInjection()
dependencies {
api(libs.androidx.workmanager.runtime)
implementation(projects.libraries.matrix.api)
}

View file

@ -0,0 +1,14 @@
/*
* 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<WorkRequest>
}

View file

@ -0,0 +1,26 @@
/*
* 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 io.element.android.libraries.matrix.api.core.SessionId
interface WorkManagerScheduler {
fun submit(workManagerRequest: WorkManagerRequest)
fun cancel(sessionId: SessionId)
}
fun workManagerTag(sessionId: SessionId, requestType: WorkManagerRequestType): String {
val prefix = when (requestType) {
WorkManagerRequestType.NOTIFICATION_SYNC -> "notifications"
}
return "$prefix-$sessionId"
}
enum class WorkManagerRequestType {
NOTIFICATION_SYNC,
}

View file

@ -0,0 +1,35 @@
/*
* 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.di
import android.content.Context
import androidx.work.ListenableWorker
import androidx.work.WorkerFactory
import androidx.work.WorkerParameters
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import kotlin.reflect.KClass
@ContributesBinding(AppScope::class)
@Inject
class MetroWorkerFactory(
val workerProviders: Map<KClass<out ListenableWorker>, WorkerInstanceFactory<*>>
) : WorkerFactory() {
override fun createWorker(
appContext: Context,
workerClassName: String,
workerParameters: WorkerParameters,
): ListenableWorker? {
return workerProviders[Class.forName(workerClassName).kotlin]?.create(workerParameters)
}
interface WorkerInstanceFactory<T : ListenableWorker> {
fun create(params: WorkerParameters): T
}
}

View file

@ -0,0 +1,18 @@
/*
* 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.di
import androidx.work.ListenableWorker
import dev.zacsweers.metro.MapKey
import kotlin.reflect.KClass
/** A [MapKey] annotation for binding Worker in a multibinding map. */
@MapKey
@Target(AnnotationTarget.CLASS, AnnotationTarget.PROPERTY)
@Retention(AnnotationRetention.RUNTIME)
annotation class WorkerKey(val value: KClass<out ListenableWorker>)

View file

@ -0,0 +1,24 @@
import extension.setupDependencyInjection
/*
* 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.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.libraries.workmanager.impl"
}
setupDependencyInjection()
dependencies {
api(projects.libraries.workmanager.api)
implementation(projects.libraries.core)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.di)
}

View file

@ -0,0 +1,47 @@
/*
* 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.impl
import android.content.Context
import androidx.work.WorkManager
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import io.element.android.libraries.di.annotations.ApplicationContext
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.workmanager.api.WorkManagerRequest
import io.element.android.libraries.workmanager.api.WorkManagerRequestType
import io.element.android.libraries.workmanager.api.WorkManagerScheduler
import io.element.android.libraries.workmanager.api.workManagerTag
import timber.log.Timber
@ContributesBinding(AppScope::class)
@Inject
class DefaultWorkManagerScheduler(
@ApplicationContext private val context: Context,
) : WorkManagerScheduler {
private val workManager by lazy { WorkManager.getInstance(context) }
override fun submit(workManagerRequest: WorkManagerRequest) {
workManagerRequest.build().fold(
onSuccess = {
workManager.enqueue(it)
},
onFailure = {
Timber.e(it, "Failed to build WorkManager request $workManagerRequest")
}
)
}
override fun cancel(sessionId: SessionId) {
Timber.d("Cancelling work for sessionId: $sessionId")
for (requestType in WorkManagerRequestType.entries) {
workManager.cancelAllWorkByTag(workManagerTag(sessionId, requestType))
}
}
}

View file

@ -0,0 +1,19 @@
/*
* 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.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.libraries.workmanager.test"
}
dependencies {
api(projects.libraries.workmanager.api)
implementation(projects.libraries.matrix.api)
implementation(projects.tests.testutils)
}

View file

@ -0,0 +1,26 @@
/*
* 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.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.WorkManagerScheduler
import io.element.android.tests.testutils.lambda.lambdaError
class FakeWorkManagerScheduler(
private val submitLambda: (WorkManagerRequest) -> Unit = { lambdaError() },
private val cancelLambda: (SessionId) -> Unit = { lambdaError() },
) : WorkManagerScheduler {
override fun submit(workManagerRequest: WorkManagerRequest) {
submitLambda(workManagerRequest)
}
override fun cancel(sessionId: SessionId) {
cancelLambda(sessionId)
}
}