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:
parent
42c775d740
commit
597c9b473a
38 changed files with 968 additions and 98 deletions
|
|
@ -34,10 +34,17 @@
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.startup.InitializationProvider"
|
android:name="androidx.startup.InitializationProvider"
|
||||||
android:authorities="${applicationId}.androidx-startup"
|
android:authorities="${applicationId}.androidx-startup"
|
||||||
android:exported="false">
|
android:exported="false"
|
||||||
|
tools:node="merge">
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name='androidx.lifecycle.ProcessLifecycleInitializer'
|
android:name='androidx.lifecycle.ProcessLifecycleInitializer'
|
||||||
android:value='androidx.startup' />
|
android:value='androidx.startup' />
|
||||||
|
|
||||||
|
<!-- Remove to use Application workManagerConfiguration -->
|
||||||
|
<meta-data
|
||||||
|
android:name="androidx.work.WorkManagerInitializer"
|
||||||
|
android:value="androidx.startup"
|
||||||
|
tools:node="remove" />
|
||||||
</provider>
|
</provider>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
|
|
@ -175,7 +182,6 @@
|
||||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
android:resource="@xml/file_providers" />
|
android:resource="@xml/file_providers" />
|
||||||
</provider>
|
</provider>
|
||||||
|
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|
|
||||||
|
|
@ -9,17 +9,23 @@ package io.element.android.x
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import androidx.startup.AppInitializer
|
import androidx.startup.AppInitializer
|
||||||
|
import androidx.work.Configuration
|
||||||
import dev.zacsweers.metro.createGraphFactory
|
import dev.zacsweers.metro.createGraphFactory
|
||||||
import io.element.android.features.cachecleaner.api.CacheCleanerInitializer
|
import io.element.android.features.cachecleaner.api.CacheCleanerInitializer
|
||||||
import io.element.android.libraries.di.DependencyInjectionGraphOwner
|
import io.element.android.libraries.di.DependencyInjectionGraphOwner
|
||||||
|
import io.element.android.libraries.workmanager.api.di.MetroWorkerFactory
|
||||||
import io.element.android.x.di.AppGraph
|
import io.element.android.x.di.AppGraph
|
||||||
import io.element.android.x.info.logApplicationInfo
|
import io.element.android.x.info.logApplicationInfo
|
||||||
import io.element.android.x.initializer.CrashInitializer
|
import io.element.android.x.initializer.CrashInitializer
|
||||||
import io.element.android.x.initializer.PlatformInitializer
|
import io.element.android.x.initializer.PlatformInitializer
|
||||||
|
|
||||||
class ElementXApplication : Application(), DependencyInjectionGraphOwner {
|
class ElementXApplication : Application(), DependencyInjectionGraphOwner, Configuration.Provider {
|
||||||
override val graph: AppGraph = createGraphFactory<AppGraph.Factory>().create(this)
|
override val graph: AppGraph = createGraphFactory<AppGraph.Factory>().create(this)
|
||||||
|
|
||||||
|
override val workManagerConfiguration: Configuration = Configuration.Builder()
|
||||||
|
.setWorkerFactory(MetroWorkerFactory(graph.workerProviders))
|
||||||
|
.build()
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
AppInitializer.getInstance(this).apply {
|
AppInitializer.getInstance(this).apply {
|
||||||
|
|
@ -27,6 +33,7 @@ class ElementXApplication : Application(), DependencyInjectionGraphOwner {
|
||||||
initializeComponent(PlatformInitializer::class.java)
|
initializeComponent(PlatformInitializer::class.java)
|
||||||
initializeComponent(CacheCleanerInitializer::class.java)
|
initializeComponent(CacheCleanerInitializer::class.java)
|
||||||
}
|
}
|
||||||
|
|
||||||
logApplicationInfo(this)
|
logApplicationInfo(this)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,16 +8,24 @@
|
||||||
package io.element.android.x.di
|
package io.element.android.x.di
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import androidx.work.ListenableWorker
|
||||||
import dev.zacsweers.metro.AppScope
|
import dev.zacsweers.metro.AppScope
|
||||||
import dev.zacsweers.metro.DependencyGraph
|
import dev.zacsweers.metro.DependencyGraph
|
||||||
|
import dev.zacsweers.metro.Multibinds
|
||||||
import dev.zacsweers.metro.Provides
|
import dev.zacsweers.metro.Provides
|
||||||
import io.element.android.libraries.architecture.NodeFactoriesBindings
|
import io.element.android.libraries.architecture.NodeFactoriesBindings
|
||||||
import io.element.android.libraries.di.annotations.ApplicationContext
|
import io.element.android.libraries.di.annotations.ApplicationContext
|
||||||
|
import io.element.android.libraries.workmanager.api.di.MetroWorkerFactory
|
||||||
|
import kotlin.reflect.KClass
|
||||||
|
|
||||||
@DependencyGraph(AppScope::class)
|
@DependencyGraph(AppScope::class)
|
||||||
interface AppGraph : NodeFactoriesBindings {
|
interface AppGraph : NodeFactoriesBindings {
|
||||||
val sessionGraphFactory: SessionGraph.Factory
|
val sessionGraphFactory: SessionGraph.Factory
|
||||||
|
|
||||||
|
@Multibinds
|
||||||
|
val workerProviders:
|
||||||
|
Map<KClass<out ListenableWorker>, MetroWorkerFactory.WorkerInstanceFactory<*>>
|
||||||
|
|
||||||
@DependencyGraph.Factory
|
@DependencyGraph.Factory
|
||||||
interface Factory {
|
interface Factory {
|
||||||
fun create(
|
fun create(
|
||||||
|
|
|
||||||
|
|
@ -34,10 +34,12 @@ dependencies {
|
||||||
implementation(projects.libraries.testtags)
|
implementation(projects.libraries.testtags)
|
||||||
implementation(projects.libraries.uiStrings)
|
implementation(projects.libraries.uiStrings)
|
||||||
implementation(projects.libraries.dateformatter.api)
|
implementation(projects.libraries.dateformatter.api)
|
||||||
|
implementation(projects.libraries.workmanager.api)
|
||||||
api(projects.features.logout.api)
|
api(projects.features.logout.api)
|
||||||
|
|
||||||
testCommonDependencies(libs, true)
|
testCommonDependencies(libs, true)
|
||||||
testImplementation(projects.libraries.matrix.test)
|
testImplementation(projects.libraries.matrix.test)
|
||||||
testImplementation(projects.libraries.featureflag.test)
|
testImplementation(projects.libraries.featureflag.test)
|
||||||
testImplementation(projects.libraries.sessionStorage.test)
|
testImplementation(projects.libraries.sessionStorage.test)
|
||||||
|
testImplementation(projects.libraries.workmanager.test)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
import io.element.android.libraries.matrix.api.encryption.BackupState
|
import io.element.android.libraries.matrix.api.encryption.BackupState
|
||||||
import io.element.android.libraries.matrix.api.encryption.BackupUploadState
|
import io.element.android.libraries.matrix.api.encryption.BackupUploadState
|
||||||
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
||||||
|
import io.element.android.libraries.workmanager.api.WorkManagerScheduler
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
@ -34,6 +35,7 @@ import kotlinx.coroutines.launch
|
||||||
class LogoutPresenter(
|
class LogoutPresenter(
|
||||||
private val matrixClient: MatrixClient,
|
private val matrixClient: MatrixClient,
|
||||||
private val encryptionService: EncryptionService,
|
private val encryptionService: EncryptionService,
|
||||||
|
private val workManagerScheduler: WorkManagerScheduler,
|
||||||
) : Presenter<LogoutState> {
|
) : Presenter<LogoutState> {
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): LogoutState {
|
override fun present(): LogoutState {
|
||||||
|
|
@ -109,6 +111,9 @@ class LogoutPresenter(
|
||||||
ignoreSdkError: Boolean,
|
ignoreSdkError: Boolean,
|
||||||
) = launch {
|
) = launch {
|
||||||
suspend {
|
suspend {
|
||||||
|
// Cancel any pending work (e.g. notification sync)
|
||||||
|
workManagerScheduler.cancel(matrixClient.sessionId)
|
||||||
|
|
||||||
matrixClient.logout(userInitiated = true, ignoreSdkError)
|
matrixClient.logout(userInitiated = true, ignoreSdkError)
|
||||||
}.runCatchingUpdatingState(logoutAction)
|
}.runCatchingUpdatingState(logoutAction)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import app.cash.turbine.test
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.libraries.architecture.AsyncAction
|
import io.element.android.libraries.architecture.AsyncAction
|
||||||
import io.element.android.libraries.matrix.api.MatrixClient
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
import io.element.android.libraries.matrix.api.encryption.BackupState
|
import io.element.android.libraries.matrix.api.encryption.BackupState
|
||||||
import io.element.android.libraries.matrix.api.encryption.BackupUploadState
|
import io.element.android.libraries.matrix.api.encryption.BackupUploadState
|
||||||
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
||||||
|
|
@ -21,7 +22,9 @@ import io.element.android.libraries.matrix.api.encryption.RecoveryState
|
||||||
import io.element.android.libraries.matrix.test.AN_EXCEPTION
|
import io.element.android.libraries.matrix.test.AN_EXCEPTION
|
||||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||||
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
|
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
|
||||||
|
import io.element.android.libraries.workmanager.test.FakeWorkManagerScheduler
|
||||||
import io.element.android.tests.testutils.WarmUpRule
|
import io.element.android.tests.testutils.WarmUpRule
|
||||||
|
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
|
|
@ -145,7 +148,9 @@ class LogoutPresenterTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - logout then confirm`() = runTest {
|
fun `present - logout then confirm`() = runTest {
|
||||||
val presenter = createLogoutPresenter()
|
val cancelWorkManagerJobsLambda = lambdaRecorder<SessionId, Unit> {}
|
||||||
|
val workManagerScheduler = FakeWorkManagerScheduler(cancelLambda = cancelWorkManagerJobsLambda)
|
||||||
|
val presenter = createLogoutPresenter(workManagerScheduler = workManagerScheduler)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
|
|
@ -158,6 +163,8 @@ class LogoutPresenterTest {
|
||||||
assertThat(loadingState.logoutAction).isInstanceOf(AsyncAction.Loading::class.java)
|
assertThat(loadingState.logoutAction).isInstanceOf(AsyncAction.Loading::class.java)
|
||||||
val successState = awaitItem()
|
val successState = awaitItem()
|
||||||
assertThat(successState.logoutAction).isInstanceOf(AsyncAction.Success::class.java)
|
assertThat(successState.logoutAction).isInstanceOf(AsyncAction.Success::class.java)
|
||||||
|
|
||||||
|
cancelWorkManagerJobsLambda.assertions().isCalledOnce()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -230,7 +237,9 @@ class LogoutPresenterTest {
|
||||||
internal fun createLogoutPresenter(
|
internal fun createLogoutPresenter(
|
||||||
matrixClient: MatrixClient = FakeMatrixClient(),
|
matrixClient: MatrixClient = FakeMatrixClient(),
|
||||||
encryptionService: EncryptionService = FakeEncryptionService(),
|
encryptionService: EncryptionService = FakeEncryptionService(),
|
||||||
|
workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(cancelLambda = {}),
|
||||||
): LogoutPresenter = LogoutPresenter(
|
): LogoutPresenter = LogoutPresenter(
|
||||||
matrixClient = matrixClient,
|
matrixClient = matrixClient,
|
||||||
encryptionService = encryptionService,
|
encryptionService = encryptionService,
|
||||||
|
workManagerScheduler = workManagerScheduler,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -163,7 +163,7 @@ class DefaultBugReporterTest {
|
||||||
assertThat(foundValues["file"]).contains(fakePushRules)
|
assertThat(foundValues["file"]).contains(fakePushRules)
|
||||||
|
|
||||||
// device_key now added given they are not null
|
// device_key now added given they are not null
|
||||||
// so is the push_rules value
|
// so is the file value for the included push_rules
|
||||||
assertThat(progressValues.size).isEqualTo(EXPECTED_NUMBER_OF_PROGRESS_VALUE + 2)
|
assertThat(progressValues.size).isEqualTo(EXPECTED_NUMBER_OF_PROGRESS_VALUE + 2)
|
||||||
|
|
||||||
server.shutdown()
|
server.shutdown()
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ lifecycle = "2.9.2"
|
||||||
activity = "1.11.0"
|
activity = "1.11.0"
|
||||||
media3 = "1.8.0"
|
media3 = "1.8.0"
|
||||||
camera = "1.5.1"
|
camera = "1.5.1"
|
||||||
|
work = "2.10.5"
|
||||||
|
|
||||||
# Compose
|
# Compose
|
||||||
compose_bom = "2025.07.00"
|
compose_bom = "2025.07.00"
|
||||||
|
|
@ -94,6 +95,7 @@ androidx_camera_lifecycle = { module = "androidx.camera:camera-lifecycle", versi
|
||||||
androidx_camera_view = { module = "androidx.camera:camera-view", version.ref = "camera" }
|
androidx_camera_view = { module = "androidx.camera:camera-view", version.ref = "camera" }
|
||||||
androidx_camera_camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" }
|
androidx_camera_camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" }
|
||||||
androidx_javascriptengine = "androidx.javascriptengine:javascriptengine:1.0.0"
|
androidx_javascriptengine = "androidx.javascriptengine:javascriptengine:1.0.0"
|
||||||
|
androidx_workmanager_runtime = { module = "androidx.work:work-runtime-ktx", version.ref = "work" }
|
||||||
|
|
||||||
androidx_recyclerview = "androidx.recyclerview:recyclerview:1.4.0"
|
androidx_recyclerview = "androidx.recyclerview:recyclerview:1.4.0"
|
||||||
androidx_browser = "androidx.browser:browser:1.9.0"
|
androidx_browser = "androidx.browser:browser:1.9.0"
|
||||||
|
|
|
||||||
|
|
@ -109,4 +109,12 @@ enum class FeatureFlags(
|
||||||
defaultValue = { false },
|
defaultValue = { false },
|
||||||
isFinished = false,
|
isFinished = false,
|
||||||
),
|
),
|
||||||
|
SyncNotificationsWithWorkManager(
|
||||||
|
key = "feature.sync_notifications_with_workmanager",
|
||||||
|
title = "Sync notifications with WorkManager",
|
||||||
|
description = "Use WorkManager to schedule notification sync tasks when a push is received." +
|
||||||
|
" This should improve reliability and battery usage.",
|
||||||
|
defaultValue = { false },
|
||||||
|
isFinished = false,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.push.api.push
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
|
|
||||||
|
data class NotificationEventRequest(
|
||||||
|
val sessionId: SessionId,
|
||||||
|
val roomId: RoomId,
|
||||||
|
val eventId: EventId,
|
||||||
|
val providerInfo: String,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
/*
|
||||||
|
* 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.push.api.push
|
||||||
|
|
||||||
|
fun interface SyncOnNotifiableEvent {
|
||||||
|
suspend operator fun invoke(requests: List<NotificationEventRequest>)
|
||||||
|
}
|
||||||
|
|
@ -49,10 +49,12 @@ dependencies {
|
||||||
implementation(projects.libraries.network)
|
implementation(projects.libraries.network)
|
||||||
implementation(projects.libraries.matrix.api)
|
implementation(projects.libraries.matrix.api)
|
||||||
implementation(projects.libraries.matrixui)
|
implementation(projects.libraries.matrixui)
|
||||||
|
implementation(projects.features.networkmonitor.api)
|
||||||
implementation(projects.libraries.preferences.api)
|
implementation(projects.libraries.preferences.api)
|
||||||
implementation(projects.libraries.sessionStorage.api)
|
implementation(projects.libraries.sessionStorage.api)
|
||||||
implementation(projects.libraries.uiStrings)
|
implementation(projects.libraries.uiStrings)
|
||||||
implementation(projects.libraries.troubleshoot.api)
|
implementation(projects.libraries.troubleshoot.api)
|
||||||
|
implementation(projects.libraries.workmanager.api)
|
||||||
implementation(projects.features.call.api)
|
implementation(projects.features.call.api)
|
||||||
implementation(projects.features.lockscreen.api)
|
implementation(projects.features.lockscreen.api)
|
||||||
implementation(projects.libraries.featureflag.api)
|
implementation(projects.libraries.featureflag.api)
|
||||||
|
|
@ -73,8 +75,10 @@ dependencies {
|
||||||
testImplementation(projects.libraries.pushproviders.test)
|
testImplementation(projects.libraries.pushproviders.test)
|
||||||
testImplementation(projects.libraries.pushstore.test)
|
testImplementation(projects.libraries.pushstore.test)
|
||||||
testImplementation(projects.libraries.troubleshoot.test)
|
testImplementation(projects.libraries.troubleshoot.test)
|
||||||
|
testImplementation(projects.libraries.workmanager.test)
|
||||||
testImplementation(projects.features.call.test)
|
testImplementation(projects.features.call.test)
|
||||||
testImplementation(projects.features.lockscreen.test)
|
testImplementation(projects.features.lockscreen.test)
|
||||||
|
testImplementation(projects.features.networkmonitor.test)
|
||||||
testImplementation(projects.services.appnavstate.test)
|
testImplementation(projects.services.appnavstate.test)
|
||||||
testImplementation(projects.services.toolbox.impl)
|
testImplementation(projects.services.toolbox.impl)
|
||||||
testImplementation(projects.services.toolbox.test)
|
testImplementation(projects.services.toolbox.test)
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageTy
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
|
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
|
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
|
||||||
import io.element.android.libraries.matrix.ui.messages.toPlainText
|
import io.element.android.libraries.matrix.ui.messages.toPlainText
|
||||||
|
import io.element.android.libraries.push.api.push.NotificationEventRequest
|
||||||
import io.element.android.libraries.push.impl.R
|
import io.element.android.libraries.push.impl.R
|
||||||
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
|
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
|
||||||
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
|
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
|
||||||
|
|
@ -95,7 +96,10 @@ class DefaultNotifiableEventResolver(
|
||||||
val client = matrixClientProvider.getOrRestore(sessionId).getOrElse {
|
val client = matrixClientProvider.getOrRestore(sessionId).getOrElse {
|
||||||
return Result.failure(IllegalStateException("Couldn't get or restore client for session $sessionId"))
|
return Result.failure(IllegalStateException("Couldn't get or restore client for session $sessionId"))
|
||||||
}
|
}
|
||||||
val ids = notificationEventRequests.groupBy { it.roomId }.mapValues { (_, value) -> value.map { it.eventId } }
|
val ids = notificationEventRequests.groupBy { it.roomId }
|
||||||
|
.mapValues { (_, requests) ->
|
||||||
|
requests.map { it.eventId }
|
||||||
|
}
|
||||||
|
|
||||||
// TODO this notificationData is not always valid at the moment, sometimes the Rust SDK can't fetch the matching event
|
// TODO this notificationData is not always valid at the moment, sometimes the Rust SDK can't fetch the matching event
|
||||||
val notificationsResult = client.notificationService.getNotifications(ids)
|
val notificationsResult = client.notificationService.getNotifications(ids)
|
||||||
|
|
|
||||||
|
|
@ -8,13 +8,16 @@
|
||||||
package io.element.android.libraries.push.impl.notifications
|
package io.element.android.libraries.push.impl.notifications
|
||||||
|
|
||||||
import dev.zacsweers.metro.AppScope
|
import dev.zacsweers.metro.AppScope
|
||||||
|
import dev.zacsweers.metro.ContributesBinding
|
||||||
import dev.zacsweers.metro.Inject
|
import dev.zacsweers.metro.Inject
|
||||||
import dev.zacsweers.metro.SingleIn
|
import dev.zacsweers.metro.SingleIn
|
||||||
import io.element.android.libraries.di.annotations.AppCoroutineScope
|
import io.element.android.libraries.di.annotations.AppCoroutineScope
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
import io.element.android.libraries.push.api.push.NotificationEventRequest
|
||||||
import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent
|
import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent
|
||||||
|
import io.element.android.libraries.push.impl.workmanager.SyncNotificationWorkManagerRequest
|
||||||
|
import io.element.android.libraries.workmanager.api.WorkManagerScheduler
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
|
@ -27,18 +30,26 @@ import kotlinx.coroutines.launch
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
|
interface NotificationResolverQueue {
|
||||||
|
val results: SharedFlow<Pair<List<NotificationEventRequest>, Map<NotificationEventRequest, Result<ResolvedPushEvent>>>>
|
||||||
|
suspend fun enqueue(request: NotificationEventRequest)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This class is responsible for periodically batching notification requests and resolving them in a single call,
|
* This class is responsible for periodically batching notification requests and resolving them in a single call,
|
||||||
* so that we can avoid having to resolve each notification individually in the SDK.
|
* so that we can avoid having to resolve each notification individually in the SDK.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
@SingleIn(AppScope::class)
|
@SingleIn(AppScope::class)
|
||||||
|
@ContributesBinding(AppScope::class)
|
||||||
@Inject
|
@Inject
|
||||||
class NotificationResolverQueue(
|
class DefaultNotificationResolverQueue(
|
||||||
private val notifiableEventResolver: NotifiableEventResolver,
|
private val notifiableEventResolver: NotifiableEventResolver,
|
||||||
@AppCoroutineScope
|
@AppCoroutineScope
|
||||||
private val appCoroutineScope: CoroutineScope,
|
private val appCoroutineScope: CoroutineScope,
|
||||||
) {
|
private val workManagerScheduler: WorkManagerScheduler,
|
||||||
|
private val featureFlagService: FeatureFlagService,
|
||||||
|
) : NotificationResolverQueue {
|
||||||
companion object {
|
companion object {
|
||||||
private const val BATCH_WINDOW_MS = 250L
|
private const val BATCH_WINDOW_MS = 250L
|
||||||
}
|
}
|
||||||
|
|
@ -50,7 +61,7 @@ class NotificationResolverQueue(
|
||||||
* A flow that emits pairs of a list of notification event requests and a map of the resolved events.
|
* A flow that emits pairs of a list of notification event requests and a map of the resolved events.
|
||||||
* The map contains the original request as the key and the resolved event as the value.
|
* The map contains the original request as the key and the resolved event as the value.
|
||||||
*/
|
*/
|
||||||
val results: SharedFlow<Pair<List<NotificationEventRequest>, Map<NotificationEventRequest, Result<ResolvedPushEvent>>>> = MutableSharedFlow()
|
override val results = MutableSharedFlow<Pair<List<NotificationEventRequest>, Map<NotificationEventRequest, Result<ResolvedPushEvent>>>>()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enqueues a notification event request to be resolved.
|
* Enqueues a notification event request to be resolved.
|
||||||
|
|
@ -58,7 +69,7 @@ class NotificationResolverQueue(
|
||||||
*
|
*
|
||||||
* @param request The notification event request to enqueue.
|
* @param request The notification event request to enqueue.
|
||||||
*/
|
*/
|
||||||
suspend fun enqueue(request: NotificationEventRequest) {
|
override suspend fun enqueue(request: NotificationEventRequest) {
|
||||||
// Cancel previous processing job if it exists, acting as a debounce operation
|
// Cancel previous processing job if it exists, acting as a debounce operation
|
||||||
Timber.d("Cancelling job: $currentProcessingJob")
|
Timber.d("Cancelling job: $currentProcessingJob")
|
||||||
currentProcessingJob?.cancel()
|
currentProcessingJob?.cancel()
|
||||||
|
|
@ -77,28 +88,27 @@ class NotificationResolverQueue(
|
||||||
appCoroutineScope.launch {
|
appCoroutineScope.launch {
|
||||||
val groupedRequestsById = buildList {
|
val groupedRequestsById = buildList {
|
||||||
while (!requestQueue.isEmpty) {
|
while (!requestQueue.isEmpty) {
|
||||||
requestQueue.receiveCatching().getOrNull()?.let(this::add)
|
requestQueue.receiveCatching().getOrNull()?.let(::add)
|
||||||
}
|
}
|
||||||
}.groupBy { it.sessionId }
|
}.groupBy { it.sessionId }
|
||||||
|
|
||||||
val sessionIds = groupedRequestsById.keys
|
if (featureFlagService.isFeatureEnabled(FeatureFlags.SyncNotificationsWithWorkManager)) {
|
||||||
for (sessionId in sessionIds) {
|
for ((sessionId, requests) in groupedRequestsById) {
|
||||||
val requests = groupedRequestsById[sessionId].orEmpty()
|
workManagerScheduler.submit(SyncNotificationWorkManagerRequest(sessionId, requests))
|
||||||
Timber.d("Fetching notifications for $sessionId: $requests. Pending requests: ${!requestQueue.isEmpty}")
|
}
|
||||||
// Resolving the events in parallel should improve performance since each session id will query a different Client
|
} else {
|
||||||
launch {
|
val sessionIds = groupedRequestsById.keys
|
||||||
// No need for a Mutex since the SDK already has one internally
|
for (sessionId in sessionIds) {
|
||||||
val notifications = notifiableEventResolver.resolveEvents(sessionId, requests).getOrNull().orEmpty()
|
val requests = groupedRequestsById[sessionId].orEmpty()
|
||||||
(results as MutableSharedFlow).emit(requests to notifications)
|
Timber.d("Fetching notifications for $sessionId: $requests. Pending requests: ${!requestQueue.isEmpty}")
|
||||||
|
// Resolving the events in parallel should improve performance since each session id will query a different Client
|
||||||
|
launch {
|
||||||
|
// No need for a Mutex since the SDK already has one internally
|
||||||
|
val notifications = notifiableEventResolver.resolveEvents(sessionId, requests).getOrNull().orEmpty()
|
||||||
|
results.emit(requests to notifications)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class NotificationEventRequest(
|
|
||||||
val sessionId: SessionId,
|
|
||||||
val roomId: RoomId,
|
|
||||||
val eventId: EventId,
|
|
||||||
val providerInfo: String,
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,11 @@ import io.element.android.features.call.api.ElementCallEntryPoint
|
||||||
import io.element.android.libraries.core.log.logger.LoggerTag
|
import io.element.android.libraries.core.log.logger.LoggerTag
|
||||||
import io.element.android.libraries.core.meta.BuildMeta
|
import io.element.android.libraries.core.meta.BuildMeta
|
||||||
import io.element.android.libraries.di.annotations.AppCoroutineScope
|
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.exception.NotificationResolverException
|
import io.element.android.libraries.matrix.api.exception.NotificationResolverException
|
||||||
|
import io.element.android.libraries.push.api.push.NotificationEventRequest
|
||||||
|
import io.element.android.libraries.push.api.push.SyncOnNotifiableEvent
|
||||||
import io.element.android.libraries.push.impl.history.PushHistoryService
|
import io.element.android.libraries.push.impl.history.PushHistoryService
|
||||||
import io.element.android.libraries.push.impl.history.onDiagnosticPush
|
import io.element.android.libraries.push.impl.history.onDiagnosticPush
|
||||||
import io.element.android.libraries.push.impl.history.onInvalidPushReceived
|
import io.element.android.libraries.push.impl.history.onInvalidPushReceived
|
||||||
|
|
@ -24,7 +28,6 @@ import io.element.android.libraries.push.impl.history.onSuccess
|
||||||
import io.element.android.libraries.push.impl.history.onUnableToResolveEvent
|
import io.element.android.libraries.push.impl.history.onUnableToResolveEvent
|
||||||
import io.element.android.libraries.push.impl.history.onUnableToRetrieveSession
|
import io.element.android.libraries.push.impl.history.onUnableToRetrieveSession
|
||||||
import io.element.android.libraries.push.impl.notifications.FallbackNotificationFactory
|
import io.element.android.libraries.push.impl.notifications.FallbackNotificationFactory
|
||||||
import io.element.android.libraries.push.impl.notifications.NotificationEventRequest
|
|
||||||
import io.element.android.libraries.push.impl.notifications.NotificationResolverQueue
|
import io.element.android.libraries.push.impl.notifications.NotificationResolverQueue
|
||||||
import io.element.android.libraries.push.impl.notifications.channels.NotificationChannels
|
import io.element.android.libraries.push.impl.notifications.channels.NotificationChannels
|
||||||
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
|
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
|
||||||
|
|
@ -65,6 +68,8 @@ class DefaultPushHandler(
|
||||||
@AppCoroutineScope
|
@AppCoroutineScope
|
||||||
private val appCoroutineScope: CoroutineScope,
|
private val appCoroutineScope: CoroutineScope,
|
||||||
private val fallbackNotificationFactory: FallbackNotificationFactory,
|
private val fallbackNotificationFactory: FallbackNotificationFactory,
|
||||||
|
private val syncOnNotifiableEvent: SyncOnNotifiableEvent,
|
||||||
|
private val featureFlagService: FeatureFlagService,
|
||||||
) : PushHandler {
|
) : PushHandler {
|
||||||
init {
|
init {
|
||||||
processPushEventResults()
|
processPushEventResults()
|
||||||
|
|
@ -196,6 +201,10 @@ class DefaultPushHandler(
|
||||||
if (nonRingingCallEvents.isNotEmpty()) {
|
if (nonRingingCallEvents.isNotEmpty()) {
|
||||||
onNotifiableEventReceived.onNotifiableEventsReceived(nonRingingCallEvents)
|
onNotifiableEventReceived.onNotifiableEventsReceived(nonRingingCallEvents)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!featureFlagService.isFeatureEnabled(FeatureFlags.SyncNotificationsWithWorkManager)) {
|
||||||
|
syncOnNotifiableEvent(requests)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.launchIn(appCoroutineScope)
|
.launchIn(appCoroutineScope)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,41 +7,45 @@
|
||||||
|
|
||||||
package io.element.android.libraries.push.impl.push
|
package io.element.android.libraries.push.impl.push
|
||||||
|
|
||||||
|
import dev.zacsweers.metro.AppScope
|
||||||
|
import dev.zacsweers.metro.ContributesBinding
|
||||||
import dev.zacsweers.metro.Inject
|
import dev.zacsweers.metro.Inject
|
||||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||||
import io.element.android.libraries.matrix.api.MatrixClientProvider
|
import io.element.android.libraries.matrix.api.MatrixClientProvider
|
||||||
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
|
import io.element.android.libraries.push.api.push.NotificationEventRequest
|
||||||
|
import io.element.android.libraries.push.api.push.SyncOnNotifiableEvent
|
||||||
import io.element.android.services.appnavstate.api.AppForegroundStateService
|
import io.element.android.services.appnavstate.api.AppForegroundStateService
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
@ContributesBinding(AppScope::class)
|
||||||
@Inject
|
@Inject
|
||||||
class SyncOnNotifiableEvent(
|
class DefaultSyncOnNotifiableEvent(
|
||||||
private val matrixClientProvider: MatrixClientProvider,
|
private val matrixClientProvider: MatrixClientProvider,
|
||||||
private val featureFlagService: FeatureFlagService,
|
private val featureFlagService: FeatureFlagService,
|
||||||
private val appForegroundStateService: AppForegroundStateService,
|
private val appForegroundStateService: AppForegroundStateService,
|
||||||
private val dispatchers: CoroutineDispatchers,
|
private val dispatchers: CoroutineDispatchers,
|
||||||
) {
|
) : SyncOnNotifiableEvent {
|
||||||
suspend operator fun invoke(notifiableEvents: List<NotifiableEvent>) = withContext(dispatchers.io) {
|
override suspend operator fun invoke(requests: List<NotificationEventRequest>) = withContext(dispatchers.io) {
|
||||||
if (!featureFlagService.isFeatureEnabled(FeatureFlags.SyncOnPush)) {
|
if (!featureFlagService.isFeatureEnabled(FeatureFlags.SyncOnPush)) {
|
||||||
return@withContext
|
return@withContext
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val eventsBySession = notifiableEvents.groupBy { it.sessionId }
|
val eventsBySession = requests.groupBy { it.sessionId }
|
||||||
|
|
||||||
appForegroundStateService.updateIsSyncingNotificationEvent(true)
|
appForegroundStateService.updateIsSyncingNotificationEvent(true)
|
||||||
Timber.d("Starting opportunistic room list sync | In foreground: ${appForegroundStateService.isInForeground.value}")
|
Timber.d("Starting opportunistic room list sync | In foreground: ${appForegroundStateService.isInForeground.value}")
|
||||||
|
|
||||||
for ((sessionId, events) in eventsBySession) {
|
for ((sessionId, events) in eventsBySession) {
|
||||||
val client = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: continue
|
val client = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: continue
|
||||||
val eventsByRoomId = events.groupBy { it.roomId }
|
val roomIds = events.map { it.roomId }.distinct()
|
||||||
|
|
||||||
client.roomListService.subscribeToVisibleRooms(eventsByRoomId.keys.toList())
|
client.roomListService.subscribeToVisibleRooms(roomIds)
|
||||||
|
|
||||||
if (!appForegroundStateService.isInForeground.value) {
|
if (!appForegroundStateService.isInForeground.value) {
|
||||||
// Give the sync some time to complete in background
|
// Give the sync some time to complete in background
|
||||||
|
|
@ -27,11 +27,9 @@ class DefaultOnNotifiableEventReceived(
|
||||||
private val defaultNotificationDrawerManager: DefaultNotificationDrawerManager,
|
private val defaultNotificationDrawerManager: DefaultNotificationDrawerManager,
|
||||||
@AppCoroutineScope
|
@AppCoroutineScope
|
||||||
private val coroutineScope: CoroutineScope,
|
private val coroutineScope: CoroutineScope,
|
||||||
private val syncOnNotifiableEvent: SyncOnNotifiableEvent,
|
|
||||||
) : OnNotifiableEventReceived {
|
) : OnNotifiableEventReceived {
|
||||||
override fun onNotifiableEventsReceived(notifiableEvents: List<NotifiableEvent>) {
|
override fun onNotifiableEventsReceived(notifiableEvents: List<NotifiableEvent>) {
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
launch { syncOnNotifiableEvent(notifiableEvents) }
|
|
||||||
defaultNotificationDrawerManager.onNotifiableEventsReceived(notifiableEvents.filter { it !is NotifiableRingingCallEvent })
|
defaultNotificationDrawerManager.onNotifiableEventsReceived(notifiableEvents.filter { it !is NotifiableRingingCallEvent })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,123 @@
|
||||||
|
/*
|
||||||
|
* 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.push.impl.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.features.networkmonitor.api.NetworkMonitor
|
||||||
|
import io.element.android.features.networkmonitor.api.NetworkStatus
|
||||||
|
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||||
|
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||||
|
import io.element.android.libraries.di.annotations.ApplicationContext
|
||||||
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
|
import io.element.android.libraries.push.api.push.NotificationEventRequest
|
||||||
|
import io.element.android.libraries.push.api.push.SyncOnNotifiableEvent
|
||||||
|
import io.element.android.libraries.push.impl.notifications.NotifiableEventResolver
|
||||||
|
import io.element.android.libraries.push.impl.notifications.NotificationResolverQueue
|
||||||
|
import io.element.android.libraries.workmanager.api.WorkManagerScheduler
|
||||||
|
import io.element.android.libraries.workmanager.api.di.MetroWorkerFactory
|
||||||
|
import io.element.android.libraries.workmanager.api.di.WorkerKey
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import timber.log.Timber
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
@AssistedInject
|
||||||
|
class FetchNotificationsWorker(
|
||||||
|
@Assisted workerParams: WorkerParameters,
|
||||||
|
@ApplicationContext context: Context,
|
||||||
|
private val networkMonitor: NetworkMonitor,
|
||||||
|
private val eventResolver: NotifiableEventResolver,
|
||||||
|
private val queue: NotificationResolverQueue,
|
||||||
|
private val workManagerScheduler: WorkManagerScheduler,
|
||||||
|
private val syncOnNotifiableEvent: SyncOnNotifiableEvent,
|
||||||
|
private val coroutineDispatchers: CoroutineDispatchers,
|
||||||
|
private val json: Json,
|
||||||
|
) : CoroutineWorker(context, workerParams) {
|
||||||
|
override suspend fun doWork(): Result = withContext(coroutineDispatchers.io) {
|
||||||
|
Timber.d("FetchNotificationsWorker started")
|
||||||
|
val rawRequestsJson = inputData.getString("requests") ?: return@withContext Result.failure()
|
||||||
|
val requests = runCatchingExceptions {
|
||||||
|
json.decodeFromString<List<SyncNotificationWorkManagerRequest.Data>>(rawRequestsJson).map { it.toRequest() }
|
||||||
|
}.getOrElse {
|
||||||
|
Timber.e(it, "Failed to deserialize notification requests")
|
||||||
|
return@withContext Result.failure()
|
||||||
|
}
|
||||||
|
|
||||||
|
Timber.d("Deserialized ${requests.size} requests")
|
||||||
|
|
||||||
|
// Wait for network to be available, but not more than 10 seconds
|
||||||
|
val hasNetwork = withTimeoutOrNull(10.seconds) {
|
||||||
|
networkMonitor.connectivity.first { it == NetworkStatus.Connected }
|
||||||
|
} != null
|
||||||
|
if (!hasNetwork) {
|
||||||
|
Timber.w("No network, retrying later")
|
||||||
|
return@withContext Result.retry()
|
||||||
|
}
|
||||||
|
|
||||||
|
val failedSyncForSessions = mutableSetOf<SessionId>()
|
||||||
|
|
||||||
|
val groupedRequests = requests.groupBy { it.sessionId }
|
||||||
|
for ((sessionId, notificationRequests) in groupedRequests) {
|
||||||
|
Timber.d("Processing notification requests for session $sessionId")
|
||||||
|
eventResolver.resolveEvents(sessionId, notificationRequests)
|
||||||
|
.fold(
|
||||||
|
onSuccess = { result ->
|
||||||
|
// Update the resolved results in the queue
|
||||||
|
(queue.results as MutableSharedFlow).emit(requests to result)
|
||||||
|
},
|
||||||
|
onFailure = {
|
||||||
|
failedSyncForSessions += sessionId
|
||||||
|
Timber.e(it, "Failed to resolve notification events for session $sessionId")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If there were failures for whole sessions, we retry all their requests
|
||||||
|
if (failedSyncForSessions.isNotEmpty()) {
|
||||||
|
for (failedSessionId in failedSyncForSessions) {
|
||||||
|
val requestsToRetry = groupedRequests[failedSessionId] ?: continue
|
||||||
|
Timber.d("Re-scheduling ${requestsToRetry.size} failed notification requests for session $failedSessionId")
|
||||||
|
workManagerScheduler.submit(SyncNotificationWorkManagerRequest(failedSessionId, requestsToRetry))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Timber.d("Notifications processed successfully")
|
||||||
|
|
||||||
|
performOpportunisticSyncIfNeeded(groupedRequests)
|
||||||
|
|
||||||
|
Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun performOpportunisticSyncIfNeeded(
|
||||||
|
groupedRequests: Map<SessionId, List<NotificationEventRequest>>,
|
||||||
|
) {
|
||||||
|
for ((sessionId, notificationRequests) in groupedRequests) {
|
||||||
|
runCatchingExceptions {
|
||||||
|
syncOnNotifiableEvent(notificationRequests)
|
||||||
|
}.onFailure {
|
||||||
|
Timber.e(it, "Failed to sync on notifiable events for session $sessionId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ContributesIntoMap(AppScope::class, binding = binding<MetroWorkerFactory.WorkerInstanceFactory<*>>())
|
||||||
|
@WorkerKey(FetchNotificationsWorker::class)
|
||||||
|
@AssistedFactory
|
||||||
|
abstract class Factory : MetroWorkerFactory.WorkerInstanceFactory<FetchNotificationsWorker>
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
/*
|
||||||
|
* 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.push.impl.workmanager
|
||||||
|
|
||||||
|
import androidx.work.OneTimeWorkRequestBuilder
|
||||||
|
import androidx.work.OutOfQuotaPolicy
|
||||||
|
import androidx.work.WorkRequest
|
||||||
|
import androidx.work.workDataOf
|
||||||
|
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||||
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
|
import io.element.android.libraries.push.api.push.NotificationEventRequest
|
||||||
|
import io.element.android.libraries.workmanager.api.WorkManagerRequest
|
||||||
|
import io.element.android.libraries.workmanager.api.WorkManagerRequestType
|
||||||
|
import io.element.android.libraries.workmanager.api.workManagerTag
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.security.InvalidParameterException
|
||||||
|
|
||||||
|
class SyncNotificationWorkManagerRequest(
|
||||||
|
private val sessionId: SessionId,
|
||||||
|
private val notificationEventRequests: List<NotificationEventRequest>,
|
||||||
|
) : WorkManagerRequest {
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
override fun build(): Result<WorkRequest> {
|
||||||
|
if (notificationEventRequests.isEmpty()) {
|
||||||
|
return Result.failure(InvalidParameterException("notificationEventRequests cannot be empty"))
|
||||||
|
}
|
||||||
|
|
||||||
|
val json = runCatchingExceptions { json.encodeToString(notificationEventRequests.map { it.toData() }) }
|
||||||
|
.getOrElse {
|
||||||
|
Timber.e(it, "Failed to serialize notification requests")
|
||||||
|
return Result.failure(it)
|
||||||
|
}
|
||||||
|
|
||||||
|
Timber.d("Scheduling ${notificationEventRequests.size} notification requests with WorkManager for $sessionId")
|
||||||
|
|
||||||
|
return Result.success(
|
||||||
|
OneTimeWorkRequestBuilder<FetchNotificationsWorker>()
|
||||||
|
.setInputData(workDataOf("requests" to json))
|
||||||
|
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
|
||||||
|
.setTraceTag(workManagerTag(sessionId, WorkManagerRequestType.NOTIFICATION_SYNC))
|
||||||
|
// TODO investigate using this instead of the resolver queue
|
||||||
|
// .setInputMerger()
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Data(
|
||||||
|
@SerialName("session_id")
|
||||||
|
val sessionId: String,
|
||||||
|
@SerialName("room_id")
|
||||||
|
val roomId: String,
|
||||||
|
@SerialName("event_id")
|
||||||
|
val eventId: String,
|
||||||
|
@SerialName("provider_info")
|
||||||
|
val providerInfo: String,
|
||||||
|
) {
|
||||||
|
fun toRequest(): NotificationEventRequest {
|
||||||
|
return NotificationEventRequest(
|
||||||
|
sessionId = SessionId(sessionId),
|
||||||
|
roomId = RoomId(roomId),
|
||||||
|
eventId = EventId(eventId),
|
||||||
|
providerInfo = providerInfo,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun NotificationEventRequest.toData(): SyncNotificationWorkManagerRequest.Data {
|
||||||
|
return SyncNotificationWorkManagerRequest.Data(
|
||||||
|
sessionId = sessionId.value,
|
||||||
|
roomId = roomId.value,
|
||||||
|
eventId = eventId.value,
|
||||||
|
providerInfo = providerInfo,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -44,6 +44,7 @@ import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
|
||||||
import io.element.android.libraries.matrix.test.notification.FakeNotificationService
|
import io.element.android.libraries.matrix.test.notification.FakeNotificationService
|
||||||
import io.element.android.libraries.matrix.test.notification.aNotificationData
|
import io.element.android.libraries.matrix.test.notification.aNotificationData
|
||||||
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
|
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
|
||||||
|
import io.element.android.libraries.push.api.push.NotificationEventRequest
|
||||||
import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationMediaRepo
|
import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationMediaRepo
|
||||||
import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent
|
import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent
|
||||||
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
|
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
package io.element.android.libraries.push.impl.notifications
|
package io.element.android.libraries.push.impl.notifications
|
||||||
|
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
|
import io.element.android.libraries.push.api.push.NotificationEventRequest
|
||||||
import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent
|
import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent
|
||||||
import io.element.android.tests.testutils.lambda.lambdaError
|
import io.element.android.tests.testutils.lambda.lambdaError
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
/*
|
||||||
|
* 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.push.impl.notifications.fixtures
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
|
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||||
|
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||||
|
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||||
|
import io.element.android.libraries.push.api.push.NotificationEventRequest
|
||||||
|
|
||||||
|
fun aNotificationEventRequest(
|
||||||
|
sessionId: SessionId = A_SESSION_ID,
|
||||||
|
roomId: RoomId = A_ROOM_ID,
|
||||||
|
eventId: EventId = AN_EVENT_ID,
|
||||||
|
providerInfo: String = "providerInfo",
|
||||||
|
) = NotificationEventRequest(
|
||||||
|
sessionId = sessionId,
|
||||||
|
roomId = roomId,
|
||||||
|
eventId = eventId,
|
||||||
|
providerInfo = providerInfo,
|
||||||
|
)
|
||||||
|
|
@ -14,6 +14,8 @@ import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.features.call.api.CallType
|
import io.element.android.features.call.api.CallType
|
||||||
import io.element.android.features.call.test.FakeElementCallEntryPoint
|
import io.element.android.features.call.test.FakeElementCallEntryPoint
|
||||||
import io.element.android.libraries.core.meta.BuildMeta
|
import io.element.android.libraries.core.meta.BuildMeta
|
||||||
|
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||||
|
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
|
|
@ -28,12 +30,13 @@ import io.element.android.libraries.matrix.test.A_SECRET
|
||||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||||
|
import io.element.android.libraries.push.api.push.NotificationEventRequest
|
||||||
|
import io.element.android.libraries.push.api.push.SyncOnNotifiableEvent
|
||||||
import io.element.android.libraries.push.impl.history.FakePushHistoryService
|
import io.element.android.libraries.push.impl.history.FakePushHistoryService
|
||||||
import io.element.android.libraries.push.impl.history.PushHistoryService
|
import io.element.android.libraries.push.impl.history.PushHistoryService
|
||||||
|
import io.element.android.libraries.push.impl.notifications.DefaultNotificationResolverQueue
|
||||||
import io.element.android.libraries.push.impl.notifications.FakeNotifiableEventResolver
|
import io.element.android.libraries.push.impl.notifications.FakeNotifiableEventResolver
|
||||||
import io.element.android.libraries.push.impl.notifications.FallbackNotificationFactory
|
import io.element.android.libraries.push.impl.notifications.FallbackNotificationFactory
|
||||||
import io.element.android.libraries.push.impl.notifications.NotificationEventRequest
|
|
||||||
import io.element.android.libraries.push.impl.notifications.NotificationResolverQueue
|
|
||||||
import io.element.android.libraries.push.impl.notifications.channels.FakeNotificationChannels
|
import io.element.android.libraries.push.impl.notifications.channels.FakeNotificationChannels
|
||||||
import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableCallEvent
|
import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableCallEvent
|
||||||
import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent
|
import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent
|
||||||
|
|
@ -48,6 +51,8 @@ import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret
|
||||||
import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStore
|
import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStore
|
||||||
import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStoreFactory
|
import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStoreFactory
|
||||||
import io.element.android.libraries.pushstore.test.userpushstore.clientsecret.FakePushClientSecret
|
import io.element.android.libraries.pushstore.test.userpushstore.clientsecret.FakePushClientSecret
|
||||||
|
import io.element.android.libraries.workmanager.api.WorkManagerRequest
|
||||||
|
import io.element.android.libraries.workmanager.test.FakeWorkManagerScheduler
|
||||||
import io.element.android.services.toolbox.test.strings.FakeStringProvider
|
import io.element.android.services.toolbox.test.strings.FakeStringProvider
|
||||||
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
|
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
|
||||||
import io.element.android.tests.testutils.lambda.any
|
import io.element.android.tests.testutils.lambda.any
|
||||||
|
|
@ -131,6 +136,45 @@ class DefaultPushHandlerTest {
|
||||||
.isCalledOnce()
|
.isCalledOnce()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when classical PushData is received and the workmanager flag is enabled, the work is scheduled`() = runTest {
|
||||||
|
val aNotifiableMessageEvent = aNotifiableMessageEvent()
|
||||||
|
val notifiableEventResult =
|
||||||
|
lambdaRecorder<SessionId, List<NotificationEventRequest>, Result<Map<NotificationEventRequest, Result<ResolvedPushEvent>>>> { _, _ ->
|
||||||
|
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, A_PUSHER_INFO)
|
||||||
|
Result.success(mapOf(request to Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent))))
|
||||||
|
}
|
||||||
|
val incrementPushCounterResult = lambdaRecorder<Unit> {}
|
||||||
|
val aPushData = PushData(
|
||||||
|
eventId = AN_EVENT_ID,
|
||||||
|
roomId = A_ROOM_ID,
|
||||||
|
unread = 0,
|
||||||
|
clientSecret = A_SECRET,
|
||||||
|
)
|
||||||
|
|
||||||
|
val featureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.SyncNotificationsWithWorkManager.key to true))
|
||||||
|
val submitWorkLambda = lambdaRecorder<WorkManagerRequest, Unit> {}
|
||||||
|
val workManagerScheduler = FakeWorkManagerScheduler(submitLambda = submitWorkLambda)
|
||||||
|
|
||||||
|
val defaultPushHandler = createDefaultPushHandler(
|
||||||
|
notifiableEventsResult = notifiableEventResult,
|
||||||
|
pushClientSecret = FakePushClientSecret(
|
||||||
|
getUserIdFromSecretResult = { A_USER_ID }
|
||||||
|
),
|
||||||
|
incrementPushCounterResult = incrementPushCounterResult,
|
||||||
|
featureFlagService = featureFlagService,
|
||||||
|
workManagerScheduler = workManagerScheduler,
|
||||||
|
)
|
||||||
|
defaultPushHandler.handle(aPushData, A_PUSHER_INFO)
|
||||||
|
|
||||||
|
advanceTimeBy(300.milliseconds)
|
||||||
|
|
||||||
|
submitWorkLambda.assertions().isCalledOnce()
|
||||||
|
|
||||||
|
incrementPushCounterResult.assertions()
|
||||||
|
.isCalledOnce()
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `when classical PushData is received, but notifications are disabled, nothing happen`() =
|
fun `when classical PushData is received, but notifications are disabled, nothing happen`() =
|
||||||
runTest {
|
runTest {
|
||||||
|
|
@ -644,6 +688,9 @@ class DefaultPushHandlerTest {
|
||||||
elementCallEntryPoint: FakeElementCallEntryPoint = FakeElementCallEntryPoint(),
|
elementCallEntryPoint: FakeElementCallEntryPoint = FakeElementCallEntryPoint(),
|
||||||
notificationChannels: FakeNotificationChannels = FakeNotificationChannels(),
|
notificationChannels: FakeNotificationChannels = FakeNotificationChannels(),
|
||||||
pushHistoryService: PushHistoryService = FakePushHistoryService(),
|
pushHistoryService: PushHistoryService = FakePushHistoryService(),
|
||||||
|
syncOnNotifiableEvent: SyncOnNotifiableEvent = SyncOnNotifiableEvent {},
|
||||||
|
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(),
|
||||||
|
workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(),
|
||||||
): DefaultPushHandler {
|
): DefaultPushHandler {
|
||||||
return DefaultPushHandler(
|
return DefaultPushHandler(
|
||||||
onNotifiableEventReceived = FakeOnNotifiableEventReceived(onNotifiableEventsReceived),
|
onNotifiableEventReceived = FakeOnNotifiableEventReceived(onNotifiableEventsReceived),
|
||||||
|
|
@ -661,12 +708,20 @@ class DefaultPushHandlerTest {
|
||||||
elementCallEntryPoint = elementCallEntryPoint,
|
elementCallEntryPoint = elementCallEntryPoint,
|
||||||
notificationChannels = notificationChannels,
|
notificationChannels = notificationChannels,
|
||||||
pushHistoryService = pushHistoryService,
|
pushHistoryService = pushHistoryService,
|
||||||
resolverQueue = NotificationResolverQueue(notifiableEventResolver = FakeNotifiableEventResolver(notifiableEventsResult), backgroundScope),
|
// We don't use a fake here so we can perform tests that are a bit more end to end
|
||||||
|
resolverQueue = DefaultNotificationResolverQueue(
|
||||||
|
notifiableEventResolver = FakeNotifiableEventResolver(notifiableEventsResult),
|
||||||
|
appCoroutineScope = backgroundScope,
|
||||||
|
workManagerScheduler = workManagerScheduler,
|
||||||
|
featureFlagService = featureFlagService,
|
||||||
|
),
|
||||||
appCoroutineScope = backgroundScope,
|
appCoroutineScope = backgroundScope,
|
||||||
fallbackNotificationFactory = FallbackNotificationFactory(
|
fallbackNotificationFactory = FallbackNotificationFactory(
|
||||||
clock = FakeSystemClock(),
|
clock = FakeSystemClock(),
|
||||||
stringProvider = FakeStringProvider(),
|
stringProvider = FakeStringProvider(),
|
||||||
)
|
),
|
||||||
|
syncOnNotifiableEvent = syncOnNotifiableEvent,
|
||||||
|
featureFlagService = featureFlagService,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,22 +18,17 @@ import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||||
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
|
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
|
||||||
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
|
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
|
||||||
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
||||||
import io.element.android.libraries.matrix.test.room.aRoomInfo
|
|
||||||
import io.element.android.libraries.matrix.test.sync.FakeSyncService
|
import io.element.android.libraries.matrix.test.sync.FakeSyncService
|
||||||
import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableCallEvent
|
import io.element.android.libraries.push.api.push.SyncOnNotifiableEvent
|
||||||
import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent
|
import io.element.android.libraries.push.impl.notifications.fixtures.aNotificationEventRequest
|
||||||
import io.element.android.services.appnavstate.test.FakeAppForegroundStateService
|
import io.element.android.services.appnavstate.test.FakeAppForegroundStateService
|
||||||
import io.element.android.tests.testutils.lambda.assert
|
import io.element.android.tests.testutils.lambda.assert
|
||||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.test.TestScope
|
import kotlinx.coroutines.test.TestScope
|
||||||
import kotlinx.coroutines.test.advanceTimeBy
|
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
|
||||||
import kotlin.time.Duration.Companion.seconds
|
|
||||||
|
|
||||||
class SyncOnNotifiableEventTest {
|
class SyncOnNotifiableEventTest {
|
||||||
private val startSyncLambda = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
|
private val startSyncLambda = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
|
||||||
|
|
@ -57,60 +52,19 @@ class SyncOnNotifiableEventTest {
|
||||||
givenGetRoomResult(A_ROOM_ID, room)
|
givenGetRoomResult(A_ROOM_ID, room)
|
||||||
}
|
}
|
||||||
|
|
||||||
private val notifiableEvent = aNotifiableMessageEvent()
|
private val notificationRequest = aNotificationEventRequest()
|
||||||
private val incomingCallNotifiableEvent = aNotifiableCallEvent()
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `when feature flag is disabled, nothing happens`() = runTest {
|
fun `when feature flag is disabled, nothing happens`() = runTest {
|
||||||
val sut = createSyncOnNotifiableEvent(client = client, isSyncOnPushEnabled = false)
|
val sut = createSyncOnNotifiableEvent(client = client, isSyncOnPushEnabled = false)
|
||||||
|
|
||||||
sut(listOf(notifiableEvent))
|
sut(listOf(notificationRequest))
|
||||||
|
|
||||||
assert(startSyncLambda).isNeverCalled()
|
assert(startSyncLambda).isNeverCalled()
|
||||||
assert(stopSyncLambda).isNeverCalled()
|
assert(stopSyncLambda).isNeverCalled()
|
||||||
assert(subscribeToSyncLambda).isNeverCalled()
|
assert(subscribeToSyncLambda).isNeverCalled()
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
@Test
|
|
||||||
fun `when feature flag is enabled, a ringing call waits until the room is in 'in-call' state`() = runTest {
|
|
||||||
val appForegroundStateService = FakeAppForegroundStateService(
|
|
||||||
initialForegroundValue = false,
|
|
||||||
)
|
|
||||||
val sut = createSyncOnNotifiableEvent(client = client, appForegroundStateService = appForegroundStateService, isSyncOnPushEnabled = true)
|
|
||||||
|
|
||||||
val unlocked = AtomicBoolean(false)
|
|
||||||
launch {
|
|
||||||
advanceTimeBy(1.seconds)
|
|
||||||
unlocked.set(true)
|
|
||||||
room.givenRoomInfo(aRoomInfo(hasRoomCall = true))
|
|
||||||
}
|
|
||||||
sut(listOf(incomingCallNotifiableEvent))
|
|
||||||
|
|
||||||
// The process was completed before the timeout
|
|
||||||
assertThat(unlocked.get()).isTrue()
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
@Test
|
|
||||||
fun `when feature flag is enabled, a ringing call waits until the room is in 'in-call' state or timeouts`() = runTest {
|
|
||||||
val appForegroundStateService = FakeAppForegroundStateService(
|
|
||||||
initialForegroundValue = false,
|
|
||||||
)
|
|
||||||
val sut = createSyncOnNotifiableEvent(client = client, appForegroundStateService = appForegroundStateService, isSyncOnPushEnabled = true)
|
|
||||||
|
|
||||||
val unlocked = AtomicBoolean(false)
|
|
||||||
launch {
|
|
||||||
advanceTimeBy(120.seconds)
|
|
||||||
unlocked.set(true)
|
|
||||||
room.givenRoomInfo(aRoomInfo(hasRoomCall = true))
|
|
||||||
}
|
|
||||||
sut(listOf(incomingCallNotifiableEvent))
|
|
||||||
|
|
||||||
// Didn't unlock before the timeout
|
|
||||||
assertThat(unlocked.get()).isFalse()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `when feature flag is enabled and app is in background, sync is started and stopped`() = runTest {
|
fun `when feature flag is enabled and app is in background, sync is started and stopped`() = runTest {
|
||||||
val appForegroundStateService = FakeAppForegroundStateService(
|
val appForegroundStateService = FakeAppForegroundStateService(
|
||||||
|
|
@ -120,7 +74,7 @@ class SyncOnNotifiableEventTest {
|
||||||
|
|
||||||
appForegroundStateService.isSyncingNotificationEvent.test {
|
appForegroundStateService.isSyncingNotificationEvent.test {
|
||||||
syncService.emitSyncState(SyncState.Running)
|
syncService.emitSyncState(SyncState.Running)
|
||||||
sut(listOf(notifiableEvent))
|
sut(listOf(notificationRequest))
|
||||||
|
|
||||||
// It's initially false
|
// It's initially false
|
||||||
assertThat(awaitItem()).isFalse()
|
assertThat(awaitItem()).isFalse()
|
||||||
|
|
@ -141,8 +95,8 @@ class SyncOnNotifiableEventTest {
|
||||||
val sut = createSyncOnNotifiableEvent(client = client, appForegroundStateService = appForegroundStateService, isSyncOnPushEnabled = true)
|
val sut = createSyncOnNotifiableEvent(client = client, appForegroundStateService = appForegroundStateService, isSyncOnPushEnabled = true)
|
||||||
|
|
||||||
appForegroundStateService.isSyncingNotificationEvent.test {
|
appForegroundStateService.isSyncingNotificationEvent.test {
|
||||||
launch { sut(listOf(notifiableEvent)) }
|
launch { sut(listOf(notificationRequest)) }
|
||||||
launch { sut(listOf(notifiableEvent)) }
|
launch { sut(listOf(notificationRequest)) }
|
||||||
|
|
||||||
// It's initially false
|
// It's initially false
|
||||||
assertThat(awaitItem()).isFalse()
|
assertThat(awaitItem()).isFalse()
|
||||||
|
|
@ -168,7 +122,7 @@ class SyncOnNotifiableEventTest {
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
val matrixClientProvider = FakeMatrixClientProvider { Result.success(client) }
|
val matrixClientProvider = FakeMatrixClientProvider { Result.success(client) }
|
||||||
return SyncOnNotifiableEvent(
|
return DefaultSyncOnNotifiableEvent(
|
||||||
matrixClientProvider = matrixClientProvider,
|
matrixClientProvider = matrixClientProvider,
|
||||||
featureFlagService = featureFlagService,
|
featureFlagService = featureFlagService,
|
||||||
appForegroundStateService = appForegroundStateService,
|
appForegroundStateService = appForegroundStateService,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,206 @@
|
||||||
|
/*
|
||||||
|
* 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.push.impl.workmanager
|
||||||
|
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
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.features.networkmonitor.api.NetworkStatus
|
||||||
|
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
|
||||||
|
import io.element.android.libraries.push.api.push.SyncOnNotifiableEvent
|
||||||
|
import io.element.android.libraries.push.impl.notifications.FakeNotifiableEventResolver
|
||||||
|
import io.element.android.libraries.push.impl.notifications.NotificationResolverQueue
|
||||||
|
import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent
|
||||||
|
import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent
|
||||||
|
import io.element.android.libraries.push.test.notifications.FakeNotificationResolverQueue
|
||||||
|
import io.element.android.libraries.workmanager.api.WorkManagerRequest
|
||||||
|
import io.element.android.libraries.workmanager.api.di.MetroWorkerFactory
|
||||||
|
import io.element.android.libraries.workmanager.test.FakeWorkManagerScheduler
|
||||||
|
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||||
|
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.TestScope
|
||||||
|
import kotlinx.coroutines.test.advanceTimeBy
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import java.util.UUID
|
||||||
|
import java.util.concurrent.Executor
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class FetchNotificationWorkerTest {
|
||||||
|
@Test
|
||||||
|
fun `test - success`() = runTest {
|
||||||
|
var synced = false
|
||||||
|
val syncOnNotifiableEventLambda = SyncOnNotifiableEvent { synced = true }
|
||||||
|
|
||||||
|
val queue = FakeNotificationResolverQueue(
|
||||||
|
processingLambda = { Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent())) }
|
||||||
|
)
|
||||||
|
val worker = createWorker(
|
||||||
|
input = """
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"session_id": "@alice:matrix.org",
|
||||||
|
"room_id": "!roomid:matrix.org",
|
||||||
|
"event_id": "$1436ebk:matrix.org",
|
||||||
|
"provider_info": "some_info"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
""".trimIndent(),
|
||||||
|
queue = queue,
|
||||||
|
syncOnNotifiableEvent = syncOnNotifiableEventLambda,
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = worker.doWork()
|
||||||
|
|
||||||
|
// The process finished successfully
|
||||||
|
assertThat(result).isEqualTo(ListenableWorker.Result.success())
|
||||||
|
|
||||||
|
// A result was emitted
|
||||||
|
assertThat(queue.results.replayCache).isNotEmpty()
|
||||||
|
|
||||||
|
// An opportunistic sync was triggered
|
||||||
|
assertThat(synced).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `test - invalid input fails the work`() = runTest {
|
||||||
|
val worker = createWorker(
|
||||||
|
input = """
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"session_id": "!alice:matrix.org",
|
||||||
|
"room_id": "!roomid:matrix.org",
|
||||||
|
"event_id": "$1436ebk:matrix.org",
|
||||||
|
"provider_info": "some_info"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = worker.doWork()
|
||||||
|
|
||||||
|
// The process failed
|
||||||
|
assertThat(result).isEqualTo(ListenableWorker.Result.failure())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `test - no network connectivity fails the work`() = runTest {
|
||||||
|
val networkMonitor = FakeNetworkMonitor(initialStatus = NetworkStatus.Disconnected)
|
||||||
|
val worker = createWorker(
|
||||||
|
input = """
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"session_id": "@alice:matrix.org",
|
||||||
|
"room_id": "!roomid:matrix.org",
|
||||||
|
"event_id": "$1436ebk:matrix.org",
|
||||||
|
"provider_info": "some_info"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
""".trimIndent(),
|
||||||
|
networkMonitor = networkMonitor,
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = worker.doWork()
|
||||||
|
|
||||||
|
advanceTimeBy(10.seconds)
|
||||||
|
|
||||||
|
// The process failed due to a timeout in getting the network connectivity, a retry is scheduled
|
||||||
|
assertThat(result).isEqualTo(ListenableWorker.Result.retry())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `test - failing to resolve events re-schedules the work`() = runTest {
|
||||||
|
val submitWorkerLambda = lambdaRecorder<WorkManagerRequest, Unit> {}
|
||||||
|
val scheduler = FakeWorkManagerScheduler(submitLambda = submitWorkerLambda)
|
||||||
|
|
||||||
|
val resolver = FakeNotifiableEventResolver(
|
||||||
|
resolveEventsResult = { _, _ -> Result.failure(Exception("Failed to resolve events")) }
|
||||||
|
)
|
||||||
|
|
||||||
|
val worker = createWorker(
|
||||||
|
input = """
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"session_id": "@alice:matrix.org",
|
||||||
|
"room_id": "!roomid:matrix.org",
|
||||||
|
"event_id": "$1436ebk:matrix.org",
|
||||||
|
"provider_info": "some_info"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
""".trimIndent(),
|
||||||
|
eventResolver = resolver,
|
||||||
|
workManagerScheduler = scheduler,
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = worker.doWork()
|
||||||
|
|
||||||
|
// The process was considered successful, but a retry was scheduled due to the failure to resolve events
|
||||||
|
assertThat(result).isEqualTo(ListenableWorker.Result.success())
|
||||||
|
submitWorkerLambda.assertions().isCalledOnce()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun TestScope.createWorker(
|
||||||
|
input: String,
|
||||||
|
networkMonitor: FakeNetworkMonitor = FakeNetworkMonitor(),
|
||||||
|
eventResolver: FakeNotifiableEventResolver = FakeNotifiableEventResolver(resolveEventsResult = { _, _ -> Result.success(emptyMap()) }),
|
||||||
|
queue: NotificationResolverQueue = FakeNotificationResolverQueue(
|
||||||
|
processingLambda = { Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent())) }
|
||||||
|
),
|
||||||
|
workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(),
|
||||||
|
syncOnNotifiableEvent: SyncOnNotifiableEvent = SyncOnNotifiableEvent {},
|
||||||
|
) = FetchNotificationsWorker(
|
||||||
|
workerParams = createWorkerParams(workDataOf("requests" to input)),
|
||||||
|
context = InstrumentationRegistry.getInstrumentation().context,
|
||||||
|
networkMonitor = networkMonitor,
|
||||||
|
eventResolver = eventResolver,
|
||||||
|
queue = queue,
|
||||||
|
workManagerScheduler = workManagerScheduler,
|
||||||
|
syncOnNotifiableEvent = syncOnNotifiableEvent,
|
||||||
|
coroutineDispatchers = testCoroutineDispatchers(),
|
||||||
|
json = Json {},
|
||||||
|
)
|
||||||
|
|
||||||
|
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()),
|
||||||
|
{ context, id, data -> FakeListenableFuture() },
|
||||||
|
{ context, id, foregroundInfo -> FakeListenableFuture() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
/*
|
||||||
|
* 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.push.impl.workmanager
|
||||||
|
|
||||||
|
import androidx.work.OneTimeWorkRequest
|
||||||
|
import androidx.work.hasKeyWithValueOfType
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||||
|
import io.element.android.libraries.push.impl.notifications.fixtures.aNotificationEventRequest
|
||||||
|
import io.element.android.libraries.workmanager.api.WorkManagerRequestType
|
||||||
|
import io.element.android.libraries.workmanager.api.workManagerTag
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class SyncNotificationWorkManagerRequestTest {
|
||||||
|
@Test
|
||||||
|
fun `build - success`() = runTest {
|
||||||
|
val request = SyncNotificationWorkManagerRequest(
|
||||||
|
sessionId = A_SESSION_ID,
|
||||||
|
notificationEventRequests = listOf(aNotificationEventRequest())
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = request.build()
|
||||||
|
assertThat(result.isSuccess).isTrue()
|
||||||
|
result.getOrNull()!!.run {
|
||||||
|
assertThat(this).isInstanceOf(OneTimeWorkRequest::class.java)
|
||||||
|
assertThat(workSpec.input.hasKeyWithValueOfType<String>("requests")).isTrue()
|
||||||
|
assertThat(workSpec.expedited).isTrue()
|
||||||
|
assertThat(workSpec.traceTag).isEqualTo(workManagerTag(A_SESSION_ID, WorkManagerRequestType.NOTIFICATION_SYNC))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `build - empty list of requests fails`() = runTest {
|
||||||
|
val request = SyncNotificationWorkManagerRequest(
|
||||||
|
sessionId = A_SESSION_ID,
|
||||||
|
notificationEventRequests = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = request.build()
|
||||||
|
assertThat(result.isFailure).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO add test for invalid serialization (how?)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
/*
|
||||||
|
* 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.push.test.notifications
|
||||||
|
|
||||||
|
import io.element.android.libraries.push.api.push.NotificationEventRequest
|
||||||
|
import io.element.android.libraries.push.impl.notifications.NotificationResolverQueue
|
||||||
|
import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
|
||||||
|
class FakeNotificationResolverQueue(
|
||||||
|
private val processingLambda: suspend (NotificationEventRequest) -> Result<ResolvedPushEvent>,
|
||||||
|
) : NotificationResolverQueue {
|
||||||
|
override val results = MutableSharedFlow<Pair<List<NotificationEventRequest>, Map<NotificationEventRequest, Result<ResolvedPushEvent>>>>(replay = 1)
|
||||||
|
|
||||||
|
override suspend fun enqueue(request: NotificationEventRequest) {
|
||||||
|
results.emit(listOf(request) to mapOf(request to processingLambda(request)))
|
||||||
|
}
|
||||||
|
}
|
||||||
23
libraries/workmanager/api/build.gradle.kts
Normal file
23
libraries/workmanager/api/build.gradle.kts
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>)
|
||||||
24
libraries/workmanager/impl/build.gradle.kts
Normal file
24
libraries/workmanager/impl/build.gradle.kts
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
libraries/workmanager/test/build.gradle.kts
Normal file
19
libraries/workmanager/test/build.gradle.kts
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -118,6 +118,7 @@ fun DependencyHandlerScope.allLibrariesImpl() {
|
||||||
implementation(project(":libraries:fullscreenintent:impl"))
|
implementation(project(":libraries:fullscreenintent:impl"))
|
||||||
implementation(project(":libraries:wellknown:impl"))
|
implementation(project(":libraries:wellknown:impl"))
|
||||||
implementation(project(":libraries:oidc:impl"))
|
implementation(project(":libraries:oidc:impl"))
|
||||||
|
implementation(project(":libraries:workmanager:impl"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun DependencyHandlerScope.allServicesImpl() {
|
fun DependencyHandlerScope.allServicesImpl() {
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,7 @@ class KonsistClassNameTest {
|
||||||
.withoutName(
|
.withoutName(
|
||||||
"FakeFileSystem",
|
"FakeFileSystem",
|
||||||
"FakeImageLoader",
|
"FakeImageLoader",
|
||||||
|
"FakeListenableFuture",
|
||||||
)
|
)
|
||||||
.assertTrue {
|
.assertTrue {
|
||||||
val interfaceName = it.name
|
val interfaceName = it.name
|
||||||
|
|
@ -149,6 +150,7 @@ class KonsistClassNameTest {
|
||||||
"Factory",
|
"Factory",
|
||||||
"TimelineController",
|
"TimelineController",
|
||||||
"TimelineMediaGalleryDataSource",
|
"TimelineMediaGalleryDataSource",
|
||||||
|
"MetroWorkerFactory",
|
||||||
)
|
)
|
||||||
.withoutNameStartingWith(
|
.withoutNameStartingWith(
|
||||||
"Accompanist",
|
"Accompanist",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue