diff --git a/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPresenterTest.kt b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPresenterTest.kt index 236b013837..274234a338 100644 --- a/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPresenterTest.kt +++ b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPresenterTest.kt @@ -23,6 +23,7 @@ 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.FakeMatrixClient import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService +import io.element.android.libraries.workmanager.api.WorkManagerRequestType import io.element.android.libraries.workmanager.test.FakeWorkManagerScheduler import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.lambda.lambdaRecorder @@ -149,7 +150,7 @@ class LogoutPresenterTest { @Test fun `present - logout then confirm`() = runTest { - val cancelWorkManagerJobsLambda = lambdaRecorder {} + val cancelWorkManagerJobsLambda = lambdaRecorder { _, _ -> } val workManagerScheduler = FakeWorkManagerScheduler(cancelLambda = cancelWorkManagerJobsLambda) val presenter = createLogoutPresenter(workManagerScheduler = workManagerScheduler) moleculeFlow(RecompositionMode.Immediate) { @@ -238,7 +239,7 @@ class LogoutPresenterTest { internal fun createLogoutPresenter( matrixClient: MatrixClient = FakeMatrixClient(), encryptionService: EncryptionService = FakeEncryptionService(), - workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(cancelLambda = {}), + workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(cancelLambda = { _, _ -> }), ): LogoutPresenter = LogoutPresenter( matrixClient = matrixClient, encryptionService = encryptionService, diff --git a/features/migration/impl/build.gradle.kts b/features/migration/impl/build.gradle.kts index eb22d06399..a37c3be882 100644 --- a/features/migration/impl/build.gradle.kts +++ b/features/migration/impl/build.gradle.kts @@ -31,6 +31,7 @@ dependencies { implementation(projects.libraries.matrix.api) implementation(projects.libraries.sessionStorage.api) implementation(projects.libraries.uiStrings) + implementation(projects.libraries.workmanager.api) testCommonDependencies(libs) testImplementation(projects.libraries.matrix.test) diff --git a/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration10.kt b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration10.kt new file mode 100644 index 0000000000..26f033c604 --- /dev/null +++ b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration10.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.migration.impl.migrations + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesIntoSet +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.sessionstorage.api.SessionStore +import io.element.android.libraries.workmanager.api.WorkManagerRequestType +import io.element.android.libraries.workmanager.api.WorkManagerScheduler + +/** + * Remove existing fetch notifications work manager requests since their format has changed. + */ +@ContributesIntoSet(AppScope::class) +class AppMigration10( + private val sessionStore: SessionStore, + private val workManagerScheduler: WorkManagerScheduler, +) : AppMigration { + override val order: Int = 10 + + override suspend fun migrate(isFreshInstall: Boolean) { + if (isFreshInstall) return + + val sessions = sessionStore.getAllSessions() + + for (session in sessions) { + workManagerScheduler.cancel( + sessionId = SessionId(session.userId), + requestType = WorkManagerRequestType.NOTIFICATION_SYNC + ) + } + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt index 124db3dd1f..1c87e73ba2 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -83,7 +83,7 @@ import io.element.android.libraries.matrix.impl.util.SessionPathsProvider import io.element.android.libraries.matrix.impl.util.cancelAndDestroy import io.element.android.libraries.matrix.impl.util.mxCallbackFlow import io.element.android.libraries.matrix.impl.verification.RustSessionVerificationService -import io.element.android.libraries.matrix.impl.workmanager.PerformDatabaseVacuumWorkManagerRequest +import io.element.android.libraries.matrix.impl.workmanager.PerformDatabaseVacuumRequestBuilder import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.workmanager.api.WorkManagerRequestType import io.element.android.libraries.workmanager.api.WorkManagerScheduler @@ -832,8 +832,8 @@ class RustMatrixClient( if (workManagerScheduler.hasPendingWork(sessionId, WorkManagerRequestType.DB_VACUUM)) return Timber.i("Scheduling periodic database vacuuming for session $sessionId") - val request = PerformDatabaseVacuumWorkManagerRequest(sessionId) - workManagerScheduler.submit(request) + val request = PerformDatabaseVacuumRequestBuilder(sessionId) + sessionCoroutineScope.launch { workManagerScheduler.submit(request) } } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/workmanager/PerformDatabaseVacuumWorkManagerRequest.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/workmanager/PerformDatabaseVacuumRequestBuilder.kt similarity index 83% rename from libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/workmanager/PerformDatabaseVacuumWorkManagerRequest.kt rename to libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/workmanager/PerformDatabaseVacuumRequestBuilder.kt index a8636eb5d9..fdaae25535 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/workmanager/PerformDatabaseVacuumWorkManagerRequest.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/workmanager/PerformDatabaseVacuumRequestBuilder.kt @@ -10,18 +10,18 @@ package io.element.android.libraries.matrix.impl.workmanager import androidx.work.Constraints import androidx.work.Data import androidx.work.PeriodicWorkRequest -import androidx.work.WorkRequest import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.impl.workmanager.VacuumDatabaseWorker.Companion.SESSION_ID_PARAM -import io.element.android.libraries.workmanager.api.WorkManagerRequest +import io.element.android.libraries.workmanager.api.WorkManagerRequestBuilder import io.element.android.libraries.workmanager.api.WorkManagerRequestType +import io.element.android.libraries.workmanager.api.WorkManagerRequestWrapper import io.element.android.libraries.workmanager.api.workManagerTag import java.util.concurrent.TimeUnit -class PerformDatabaseVacuumWorkManagerRequest( +class PerformDatabaseVacuumRequestBuilder( private val sessionId: SessionId, -) : WorkManagerRequest { - override fun build(): Result> { +) : WorkManagerRequestBuilder { + override suspend fun build(): Result> { val data = Data.Builder().putString(SESSION_ID_PARAM, sessionId.value).build() val workRequest = PeriodicWorkRequest.Builder( workerClass = VacuumDatabaseWorker::class, @@ -41,6 +41,6 @@ class PerformDatabaseVacuumWorkManagerRequest( ) .build() - return Result.success(listOf(workRequest)) + return Result.success(listOf(WorkManagerRequestWrapper(workRequest))) } } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt index 5e53d015e9..670430e23e 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt @@ -19,7 +19,7 @@ import io.element.android.libraries.network.useragent.SimpleUserAgentProvider import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.test.InMemorySessionStore import io.element.android.libraries.sessionstorage.test.aSessionData -import io.element.android.libraries.workmanager.api.WorkManagerRequest +import io.element.android.libraries.workmanager.api.WorkManagerRequestBuilder import io.element.android.libraries.workmanager.test.FakeWorkManagerScheduler import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.toolbox.test.systemclock.FakeSystemClock @@ -33,7 +33,7 @@ import java.io.File class RustMatrixClientFactoryTest { @Test fun test() = runTest { - val scheduleVacuumLambda = lambdaRecorder {} + val scheduleVacuumLambda = lambdaRecorder {} val workManagerScheduler = FakeWorkManagerScheduler(submitLambda = scheduleVacuumLambda) val sut = createRustMatrixClientFactory(workManagerScheduler = workManagerScheduler) diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/push/NotificationEventRequest.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/push/NotificationEventRequest.kt deleted file mode 100644 index ff38c7a726..0000000000 --- a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/push/NotificationEventRequest.kt +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.libraries.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, -) diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/push/SyncOnNotifiableEvent.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/push/SyncOnNotifiableEvent.kt deleted file mode 100644 index bc7bf44ae2..0000000000 --- a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/push/SyncOnNotifiableEvent.kt +++ /dev/null @@ -1,13 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.libraries.push.api.push - -fun interface SyncOnNotifiableEvent { - suspend operator fun invoke(requests: List) -} diff --git a/libraries/push/impl/build.gradle.kts b/libraries/push/impl/build.gradle.kts index 705c1c713c..e8acda59da 100644 --- a/libraries/push/impl/build.gradle.kts +++ b/libraries/push/impl/build.gradle.kts @@ -97,6 +97,7 @@ sqldelight { databases { create("PushDatabase") { schemaOutputDirectory = File("src/main/sqldelight/databases") + verifyMigrations = true } } } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/history/DefaultPushHistoryService.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/history/DefaultPushHistoryService.kt index 2c8dc5480a..ab6f01e423 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/history/DefaultPushHistoryService.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/history/DefaultPushHistoryService.kt @@ -14,13 +14,16 @@ import android.os.PowerManager import androidx.core.content.getSystemService import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.di.annotations.ApplicationContext 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.impl.PushDatabase import io.element.android.libraries.push.impl.db.PushHistory +import io.element.android.libraries.push.impl.db.PushRequest import io.element.android.services.toolbox.api.systemclock.SystemClock +import kotlin.time.Instant @ContributesBinding(AppScope::class) class DefaultPushHistoryService( @@ -31,7 +34,37 @@ class DefaultPushHistoryService( private val powerManager = context.getSystemService() private val packageName = context.packageName - override fun onPushReceived( + override suspend fun insertOrUpdatePushRequest(pushRequest: PushRequest): Result { + return runCatchingExceptions { pushDatabase.pushRequestQueries.insertPushRequest(pushRequest).await() } + } + + override suspend fun insertOrUpdatePushRequests(pushRequests: List): Result { + return runCatchingExceptions { + pushDatabase.transaction { + for (request in pushRequests) { + pushDatabase.pushRequestQueries.insertPushRequest(request) + } + } + } + } + + override suspend fun getPendingPushRequests(sessionId: SessionId, since: Instant?): Result> { + return runCatchingExceptions { + pushDatabase.transactionWithResult { + val sinceTimeMillis = since?.toEpochMilliseconds() ?: 0 + pushDatabase.pushRequestQueries.selectAllPendingForSession(sessionId.value, sinceTimeMillis).executeAsList() + } + } + } + + override suspend fun removeOldPushRequests(sessionId: SessionId): Result { + return runCatchingExceptions { + val keepAmount = 100L + pushDatabase.pushRequestQueries.removeOldest(keepAmount) + } + } + + override fun onPushResult( providerInfo: String, eventId: EventId?, roomId: RoomId?, diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/history/PushHistoryService.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/history/PushHistoryService.kt index 8096ad222e..3996924322 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/history/PushHistoryService.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/history/PushHistoryService.kt @@ -11,13 +11,16 @@ package io.element.android.libraries.push.impl.history 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.impl.db.PushRequest +import io.element.android.libraries.push.impl.push.PushRequestStatus +import kotlin.time.Instant interface PushHistoryService { /** * Create a new push history entry. * Do not use directly, prefer using the extension functions. */ - fun onPushReceived( + fun onPushResult( providerInfo: String, eventId: EventId?, roomId: RoomId?, @@ -26,12 +29,33 @@ interface PushHistoryService { includeDeviceState: Boolean, comment: String?, ) + + /** + * Adds or replaces an existing [PushRequest] in the local database. + */ + suspend fun insertOrUpdatePushRequest(pushRequest: PushRequest): Result + + /** + * Replace a list of [PushRequest] in the database. + */ + suspend fun insertOrUpdatePushRequests(pushRequests: List): Result + + /** + * Gets [PushRequestStatus.PENDING] push requests from the local database for a [SessionId]. + * A [since] param can optionally be provided to only return those received after that date. + */ + suspend fun getPendingPushRequests(sessionId: SessionId, since: Instant?): Result> + + /** + * Removes the oldest push requests for a [SessionId]. + */ + suspend fun removeOldPushRequests(sessionId: SessionId): Result } fun PushHistoryService.onInvalidPushReceived( providerInfo: String, data: String, -) = onPushReceived( +) = onPushResult( providerInfo = providerInfo, eventId = null, roomId = null, @@ -46,7 +70,7 @@ fun PushHistoryService.onUnableToRetrieveSession( eventId: EventId, roomId: RoomId, reason: String, -) = onPushReceived( +) = onPushResult( providerInfo = providerInfo, eventId = eventId, roomId = roomId, @@ -62,7 +86,7 @@ fun PushHistoryService.onUnableToResolveEvent( roomId: RoomId, sessionId: SessionId, reason: String, -) = onPushReceived( +) = onPushResult( providerInfo = providerInfo, eventId = eventId, roomId = roomId, @@ -78,7 +102,7 @@ fun PushHistoryService.onSuccess( roomId: RoomId, sessionId: SessionId, comment: String?, -) = onPushReceived( +) = onPushResult( providerInfo = providerInfo, eventId = eventId, roomId = roomId, @@ -95,7 +119,7 @@ fun PushHistoryService.onSuccess( fun PushHistoryService.onDiagnosticPush( providerInfo: String, -) = onPushReceived( +) = onPushResult( providerInfo = providerInfo, eventId = null, roomId = null, diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt index c0eb07b091..cf76b26e64 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt @@ -50,8 +50,8 @@ 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.VoiceMessageType 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.db.PushRequest 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.ResolvedPushEvent @@ -64,10 +64,10 @@ private val loggerTag = LoggerTag("DefaultNotifiableEventResolver", LoggerTag.No /** * Result of resolving a batch of push events. * The outermost [Result] indicates whether the setup to resolve the events was successful. - * The results for each push notification will be a map of [NotificationEventRequest] to [Result] of [ResolvedPushEvent]. + * The results for each push notification will be a map of [PushRequest] to [Result] of [ResolvedPushEvent]. * If the resolution of a specific event fails, the innermost [Result] will contain an exception. */ -typealias ResolvePushEventsResult = Result>> +typealias ResolvePushEventsResult = Result>> /** * The notifiable event resolver is able to create a NotifiableEvent (view model for notifications) from an sdk Event. @@ -78,7 +78,7 @@ typealias ResolvePushEventsResult = Result + notificationEventRequests: List ): ResolvePushEventsResult } @@ -96,15 +96,15 @@ class DefaultNotifiableEventResolver( ) : NotifiableEventResolver { override suspend fun resolveEvents( sessionId: SessionId, - notificationEventRequests: List + notificationEventRequests: List ): ResolvePushEventsResult { Timber.d("Queueing notifications: $notificationEventRequests") val client = matrixClientProvider.getOrRestore(sessionId).getOrElse { return Result.failure(it) } - val ids = notificationEventRequests.groupBy { it.roomId } + val ids = notificationEventRequests.groupBy { RoomId(it.roomId) } .mapValues { (_, requests) -> - requests.map { it.eventId } + requests.map { EventId(it.eventId) } } // TODO this notificationData is not always valid at the moment, sometimes the Rust SDK can't fetch the matching event @@ -125,7 +125,7 @@ class DefaultNotifiableEventResolver( return Result.success( notificationEventRequests.associate { request -> - val notificationDataResult = notificationDataMap[request.eventId] + val notificationDataResult = notificationDataMap[EventId(request.eventId)] if (notificationDataResult == null) { request to Result.failure(NotificationResolverException.UnknownError("No notification data for ${request.roomId} - ${request.eventId}")) } else { diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationResolverQueue.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationResolverQueue.kt deleted file mode 100644 index b40b3fe79f..0000000000 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationResolverQueue.kt +++ /dev/null @@ -1,125 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.libraries.push.impl.notifications - -import dev.zacsweers.metro.AppScope -import dev.zacsweers.metro.ContributesBinding -import dev.zacsweers.metro.SingleIn -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.push.api.push.NotificationEventRequest -import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent -import io.element.android.libraries.push.impl.workmanager.SyncNotificationWorkManagerRequest -import io.element.android.libraries.push.impl.workmanager.SyncNotificationsWorkerDataConverter -import io.element.android.libraries.workmanager.api.WorkManagerScheduler -import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.launch -import timber.log.Timber -import kotlin.time.Duration.Companion.milliseconds - -interface NotificationResolverQueue { - val results: SharedFlow, Map>>> - suspend fun enqueue(request: NotificationEventRequest) -} - -/** - * 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. - */ -@OptIn(ExperimentalCoroutinesApi::class) -@SingleIn(AppScope::class) -@ContributesBinding(AppScope::class) -class DefaultNotificationResolverQueue( - private val notifiableEventResolver: NotifiableEventResolver, - @AppCoroutineScope - private val appCoroutineScope: CoroutineScope, - private val workManagerScheduler: WorkManagerScheduler, - private val featureFlagService: FeatureFlagService, - private val workerDataConverter: SyncNotificationsWorkerDataConverter, - private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider, -) : NotificationResolverQueue { - companion object { - private const val BATCH_WINDOW_MS = 250L - } - - private val requestQueue = Channel(capacity = 100) - - private var currentProcessingJob: Job? = null - - /** - * 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. - */ - override val results = MutableSharedFlow, Map>>>() - - /** - * Enqueues a notification event request to be resolved. - * The request will be processed in batches, so it may not be resolved immediately. - * - * @param request The notification event request to enqueue. - */ - override suspend fun enqueue(request: NotificationEventRequest) { - // Cancel previous processing job if it exists, acting as a debounce operation - Timber.d("Cancelling job: $currentProcessingJob") - currentProcessingJob?.cancel() - - // Enqueue the request and start a delayed processing job - requestQueue.send(request) - currentProcessingJob = processQueue() - Timber.d("Starting processing job for request: $request") - } - - private fun processQueue() = appCoroutineScope.launch(SupervisorJob()) { - delay(BATCH_WINDOW_MS.milliseconds) - - // If this job is still active (so this is the latest job), we launch a separate one that won't be cancelled when enqueueing new items - // to process the existing queued items. - appCoroutineScope.launch { - val groupedRequestsById = buildList { - while (!requestQueue.isEmpty) { - requestQueue.receiveCatching().getOrNull()?.let(::add) - } - }.groupBy { it.sessionId } - - if (featureFlagService.isFeatureEnabled(FeatureFlags.SyncNotificationsWithWorkManager)) { - for ((sessionId, requests) in groupedRequestsById) { - workManagerScheduler.submit( - SyncNotificationWorkManagerRequest( - sessionId = sessionId, - notificationEventRequests = requests, - workerDataConverter = workerDataConverter, - buildVersionSdkIntProvider = buildVersionSdkIntProvider, - ) - ) - } - } else { - val sessionIds = groupedRequestsById.keys - for (sessionId in sessionIds) { - val requests = groupedRequestsById[sessionId].orEmpty() - 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) - } - } - } - } - } -} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationResultProcessor.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationResultProcessor.kt new file mode 100644 index 0000000000..d799cc414d --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationResultProcessor.kt @@ -0,0 +1,238 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.notifications + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.SingleIn +import io.element.android.features.call.api.CallType +import io.element.android.features.call.api.ElementCallEntryPoint +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.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.api.exception.NotificationResolverException +import io.element.android.libraries.push.impl.db.PushRequest +import io.element.android.libraries.push.impl.history.PushHistoryService +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.notifications.channels.NotificationChannels +import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.NotifiableRingingCallEvent +import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent +import io.element.android.libraries.push.impl.push.MutableBatteryOptimizationStore +import io.element.android.libraries.push.impl.push.OnNotifiableEventReceived +import io.element.android.libraries.push.impl.push.OnRedactedEventReceived +import io.element.android.libraries.push.impl.push.SyncOnNotifiableEvent +import io.element.android.libraries.pushstore.api.UserPushStoreFactory +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import timber.log.Timber + +private const val TAG = "NotifResultProcessor" + +interface NotificationResultProcessor { + suspend fun emit(results: Map>) + fun start() + fun stop() +} + +@ContributesBinding(AppScope::class) +@SingleIn(AppScope::class) +class DefaultNotificationResultProcessor( + private val pushHistoryService: PushHistoryService, + private val batteryOptimizationStore: MutableBatteryOptimizationStore, + private val fallbackNotificationFactory: FallbackNotificationFactory, + private val userPushStoreFactory: UserPushStoreFactory, + private val onRedactedEventReceived: OnRedactedEventReceived, + private val onNotifiableEventReceived: OnNotifiableEventReceived, + private val featureFlagService: FeatureFlagService, + private val syncOnNotifiableEvent: SyncOnNotifiableEvent, + private val elementCallEntryPoint: ElementCallEntryPoint, + private val notificationChannels: NotificationChannels, + @AppCoroutineScope private val coroutineScope: CoroutineScope, +) : NotificationResultProcessor { + private val resultFlow = MutableSharedFlow>>(extraBufferCapacity = Int.MAX_VALUE) + private var processJob: Job? = null + + override suspend fun emit(results: Map>) { + resultFlow.emit(results) + } + + override fun start() { + if (processJob?.isActive == true) { + Timber.tag(TAG).w("Is already processing, not starting again") + return + } + processJob = resultFlow + .onEach(::processResults) + .launchIn(coroutineScope) + } + + override fun stop() { + if (processJob?.isActive != true) { + Timber.tag(TAG).w("Is not processing, not stopping") + return + } + + processJob?.cancel() + processJob = null + } + + private suspend fun processResults(results: Map>) { + // TODO what happens with items that weren't reported back? + for ((request, result) in results) { + result.fold( + onSuccess = { + if (it is ResolvedPushEvent.Event && it.notifiableEvent is FallbackNotifiableEvent) { + pushHistoryService.onUnableToResolveEvent( + providerInfo = request.providerInfo, + eventId = EventId(request.eventId), + roomId = RoomId(request.roomId), + sessionId = SessionId(request.sessionId), + reason = it.notifiableEvent.cause.orEmpty(), + ) + } else { + pushHistoryService.onSuccess( + providerInfo = request.providerInfo, + eventId = EventId(request.eventId), + roomId = RoomId(request.roomId), + sessionId = SessionId(request.sessionId), + comment = "Push handled successfully", + ) + } + }, + onFailure = { exception -> + if (exception is NotificationResolverException.EventFilteredOut) { + pushHistoryService.onSuccess( + providerInfo = request.providerInfo, + eventId = EventId(request.eventId), + roomId = RoomId(request.roomId), + sessionId = SessionId(request.sessionId), + comment = "Push handled successfully but notification was filtered out", + ) + } else if (exception is NotificationResolverException.EventRedacted) { + pushHistoryService.onSuccess( + providerInfo = request.providerInfo, + eventId = EventId(request.eventId), + roomId = RoomId(request.roomId), + sessionId = SessionId(request.sessionId), + comment = "Push handled successfully but event has been redacted", + ) + } else { + val reason = when (exception) { + is NotificationResolverException.EventNotFound -> "Event not found" + else -> "Unknown error: ${exception.message}" + } + pushHistoryService.onUnableToResolveEvent( + providerInfo = request.providerInfo, + eventId = EventId(request.eventId), + roomId = RoomId(request.roomId), + sessionId = SessionId(request.sessionId), + reason = "$reason - Showing fallback notification", + ) + batteryOptimizationStore.showBatteryOptimizationBanner() + } + } + ) + } + + val events = mutableListOf() + val redactions = mutableListOf() + + @Suppress("LoopWithTooManyJumpStatements") + for ((request, result) in results) { + val event = result.recover { exception -> + // If the event could not be resolved, we create a fallback notification + when (exception) { + is NotificationResolverException.EventFilteredOut -> { + // Do nothing, we don't want to show a notification for filtered out events + null + } + is NotificationResolverException.EventRedacted -> { + // Do nothing, we don't want to show a notification for redacted events + null + } + else -> { + Timber.tag(TAG).e(exception, "Failed to resolve push event") + ResolvedPushEvent.Event( + fallbackNotificationFactory.create( + sessionId = SessionId(request.sessionId), + roomId = RoomId(request.roomId), + eventId = EventId(request.eventId), + cause = exception.message, + ) + ) + } + } + }.getOrNull() ?: continue + + val userPushStore = userPushStoreFactory.getOrCreate(event.sessionId) + val areNotificationsEnabled = userPushStore.getNotificationEnabledForDevice().first() + // If notifications are disabled for this session and device, we don't want to show the notification + // But if it's a ringing call, we want to show it anyway + val isRingingCall = (event as? ResolvedPushEvent.Event)?.notifiableEvent is NotifiableRingingCallEvent + if (!areNotificationsEnabled && !isRingingCall) continue + + // We categorise each result into either a NotifiableEvent or a Redaction + when (event) { + is ResolvedPushEvent.Event -> { + events.add(event.notifiableEvent) + } + is ResolvedPushEvent.Redaction -> { + redactions.add(event) + } + } + } + + // Process redactions of messages in background to not block operations with higher priority + if (redactions.isNotEmpty()) { + coroutineScope.launch { onRedactedEventReceived.onRedactedEventsReceived(redactions) } + } + + // Find and process ringing call notifications separately + val (ringingCallEvents, nonRingingCallEvents) = events.partition { it is NotifiableRingingCallEvent } + for (ringingCallEvent in ringingCallEvents) { + Timber.tag(TAG).d("Ringing call event: $ringingCallEvent") + handleRingingCallEvent(ringingCallEvent as NotifiableRingingCallEvent) + } + + // Finally, process other notifications (messages, invites, generic notifications, etc.) + if (nonRingingCallEvents.isNotEmpty()) { + onNotifiableEventReceived.onNotifiableEventsReceived(nonRingingCallEvents) + } + + if (!featureFlagService.isFeatureEnabled(FeatureFlags.SyncNotificationsWithWorkManager)) { + syncOnNotifiableEvent(results.keys.toList()) + } + } + + private suspend fun handleRingingCallEvent(notifiableEvent: NotifiableRingingCallEvent) { + Timber.i("## handleInternal() : Incoming call.") + elementCallEntryPoint.handleIncomingCall( + callType = CallType.RoomCall(notifiableEvent.sessionId, notifiableEvent.roomId), + eventId = notifiableEvent.eventId, + senderId = notifiableEvent.senderId, + roomName = notifiableEvent.roomName, + senderName = notifiableEvent.senderDisambiguatedDisplayName, + avatarUrl = notifiableEvent.roomAvatarUrl, + timestamp = notifiableEvent.timestamp, + expirationTimestamp = notifiableEvent.expirationTimestamp, + notificationChannelId = notificationChannels.getChannelForIncomingCall(ring = true), + textContent = notifiableEvent.description, + ) + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandler.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandler.kt index 0053a18838..5ed4223616 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandler.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandler.kt @@ -11,42 +11,28 @@ package io.element.android.libraries.push.impl.push import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.SingleIn -import io.element.android.features.call.api.CallType -import io.element.android.features.call.api.ElementCallEntryPoint import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.core.meta.BuildMeta -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.push.api.push.NotificationEventRequest -import io.element.android.libraries.push.api.push.SyncOnNotifiableEvent +import io.element.android.libraries.push.impl.db.PushRequest 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.onInvalidPushReceived -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.onUnableToRetrieveSession -import io.element.android.libraries.push.impl.notifications.FallbackNotificationFactory -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.model.FallbackNotifiableEvent -import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent -import io.element.android.libraries.push.impl.notifications.model.NotifiableRingingCallEvent -import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent +import io.element.android.libraries.push.impl.notifications.NotificationResultProcessor import io.element.android.libraries.push.impl.test.DefaultTestPush import io.element.android.libraries.push.impl.troubleshoot.DiagnosticPushHandler +import io.element.android.libraries.push.impl.workmanager.SyncPendingNotificationsRequestBuilder import io.element.android.libraries.pushproviders.api.PushData import io.element.android.libraries.pushproviders.api.PushHandler import io.element.android.libraries.pushstore.api.UserPushStoreFactory import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret +import io.element.android.libraries.workmanager.api.WorkManagerRequestType +import io.element.android.libraries.workmanager.api.WorkManagerScheduler import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction import io.element.android.services.analytics.api.AnalyticsService -import kotlinx.coroutines.CoroutineScope +import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider +import io.element.android.services.toolbox.api.systemclock.SystemClock import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.launch import timber.log.Timber private val loggerTag = LoggerTag("PushHandler", LoggerTag.PushLoggerTag) @@ -54,173 +40,20 @@ private val loggerTag = LoggerTag("PushHandler", LoggerTag.PushLoggerTag) @SingleIn(AppScope::class) @ContributesBinding(AppScope::class) class DefaultPushHandler( - private val onNotifiableEventReceived: OnNotifiableEventReceived, - private val onRedactedEventReceived: OnRedactedEventReceived, private val incrementPushDataStore: IncrementPushDataStore, - private val mutableBatteryOptimizationStore: MutableBatteryOptimizationStore, - private val userPushStoreFactory: UserPushStoreFactory, private val pushClientSecret: PushClientSecret, private val buildMeta: BuildMeta, private val diagnosticPushHandler: DiagnosticPushHandler, - private val elementCallEntryPoint: ElementCallEntryPoint, - private val notificationChannels: NotificationChannels, private val pushHistoryService: PushHistoryService, - private val resolverQueue: NotificationResolverQueue, - @AppCoroutineScope - private val appCoroutineScope: CoroutineScope, - private val fallbackNotificationFactory: FallbackNotificationFactory, - private val syncOnNotifiableEvent: SyncOnNotifiableEvent, - private val featureFlagService: FeatureFlagService, + private val userPushStoreFactory: UserPushStoreFactory, private val analyticsService: AnalyticsService, + private val systemClock: SystemClock, + private val workManagerScheduler: WorkManagerScheduler, + private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider, + resultProcessor: NotificationResultProcessor, ) : PushHandler { init { - processPushEventResults() - } - - /** - * Process the push notification event results emitted by the [resolverQueue]. - */ - private fun processPushEventResults() { - resolverQueue.results - .map { (requests, resolvedEvents) -> - for (request in requests) { - // Log the result of the push notification event - val result = resolvedEvents[request] - if (result == null) { - pushHistoryService.onUnableToResolveEvent( - providerInfo = request.providerInfo, - eventId = request.eventId, - roomId = request.roomId, - sessionId = request.sessionId, - reason = "Push not handled: no result found for request", - ) - } else { - result.fold( - onSuccess = { - if (it is ResolvedPushEvent.Event && it.notifiableEvent is FallbackNotifiableEvent) { - pushHistoryService.onUnableToResolveEvent( - providerInfo = request.providerInfo, - eventId = request.eventId, - roomId = request.roomId, - sessionId = request.sessionId, - reason = it.notifiableEvent.cause.orEmpty(), - ) - } else { - pushHistoryService.onSuccess( - providerInfo = request.providerInfo, - eventId = request.eventId, - roomId = request.roomId, - sessionId = request.sessionId, - comment = "Push handled successfully", - ) - } - }, - onFailure = { exception -> - if (exception is NotificationResolverException.EventFilteredOut) { - pushHistoryService.onSuccess( - providerInfo = request.providerInfo, - eventId = request.eventId, - roomId = request.roomId, - sessionId = request.sessionId, - comment = "Push handled successfully but notification was filtered out", - ) - } else if (exception is NotificationResolverException.EventRedacted) { - pushHistoryService.onSuccess( - providerInfo = request.providerInfo, - eventId = request.eventId, - roomId = request.roomId, - sessionId = request.sessionId, - comment = "Push handled successfully but event has been redacted", - ) - } else { - val reason = when (exception) { - is NotificationResolverException.EventNotFound -> "Event not found" - else -> "Unknown error: ${exception.message}" - } - pushHistoryService.onUnableToResolveEvent( - providerInfo = request.providerInfo, - eventId = request.eventId, - roomId = request.roomId, - sessionId = request.sessionId, - reason = "$reason - Showing fallback notification", - ) - mutableBatteryOptimizationStore.showBatteryOptimizationBanner() - } - } - ) - } - } - - val events = mutableListOf() - val redactions = mutableListOf() - - @Suppress("LoopWithTooManyJumpStatements") - for ((request, result) in resolvedEvents) { - val event = result.recover { exception -> - // If the event could not be resolved, we create a fallback notification - when (exception) { - is NotificationResolverException.EventFilteredOut -> { - // Do nothing, we don't want to show a notification for filtered out events - null - } - is NotificationResolverException.EventRedacted -> { - // Do nothing, we don't want to show a notification for redacted events - null - } - else -> { - Timber.tag(loggerTag.value).e(exception, "Failed to resolve push event") - ResolvedPushEvent.Event( - fallbackNotificationFactory.create( - sessionId = request.sessionId, - roomId = request.roomId, - eventId = request.eventId, - cause = exception.message, - ) - ) - } - } - }.getOrNull() ?: continue - - val userPushStore = userPushStoreFactory.getOrCreate(event.sessionId) - val areNotificationsEnabled = userPushStore.getNotificationEnabledForDevice().first() - // If notifications are disabled for this session and device, we don't want to show the notification - // But if it's a ringing call, we want to show it anyway - val isRingingCall = (event as? ResolvedPushEvent.Event)?.notifiableEvent is NotifiableRingingCallEvent - if (!areNotificationsEnabled && !isRingingCall) continue - - // We categorise each result into either a NotifiableEvent or a Redaction - when (event) { - is ResolvedPushEvent.Event -> { - events.add(event.notifiableEvent) - } - is ResolvedPushEvent.Redaction -> { - redactions.add(event) - } - } - } - - // Process redactions of messages in background to not block operations with higher priority - if (redactions.isNotEmpty()) { - appCoroutineScope.launch { onRedactedEventReceived.onRedactedEventsReceived(redactions) } - } - - // Find and process ringing call notifications separately - val (ringingCallEvents, nonRingingCallEvents) = events.partition { it is NotifiableRingingCallEvent } - for (ringingCallEvent in ringingCallEvents) { - Timber.tag(loggerTag.value).d("Ringing call event: $ringingCallEvent") - handleRingingCallEvent(ringingCallEvent as NotifiableRingingCallEvent) - } - - // Finally, process other notifications (messages, invites, generic notifications, etc.) - if (nonRingingCallEvents.isNotEmpty()) { - onNotifiableEventReceived.onNotifiableEventsReceived(nonRingingCallEvents) - } - - if (!featureFlagService.isFeatureEnabled(FeatureFlags.SyncNotificationsWithWorkManager)) { - syncOnNotifiableEvent(requests) - } - } - .launchIn(appCoroutineScope) + resultProcessor.start() } /** @@ -233,9 +66,7 @@ class DefaultPushHandler( // Start measuring how long it takes to display a notification from when the push is received Timber.d("Calculating push-to-notification for event ${pushData.eventId}") val parent = analyticsService.startLongRunningTransaction(AnalyticsLongRunningTransaction.PushToNotification(pushData.eventId.value)) - if (featureFlagService.isFeatureEnabled(FeatureFlags.SyncNotificationsWithWorkManager)) { - analyticsService.startLongRunningTransaction(AnalyticsLongRunningTransaction.PushToWorkManager(pushData.eventId.value), parent) - } + analyticsService.startLongRunningTransaction(AnalyticsLongRunningTransaction.PushToWorkManager(pushData.eventId.value), parent) Timber.tag(loggerTag.value).d("## handling pushData: ${pushData.roomId}/${pushData.eventId}") if (buildMeta.lowPrivacyLoggingEnabled) { @@ -282,34 +113,56 @@ class DefaultPushHandler( return } - appCoroutineScope.launch { - val notificationEventRequest = NotificationEventRequest( - sessionId = userId, - roomId = pushData.roomId, - eventId = pushData.eventId, - providerInfo = providerInfo, + val areNotificationsEnabled = userPushStoreFactory.getOrCreate(userId).getNotificationEnabledForDevice().first() + if (!areNotificationsEnabled) { + Timber.w("Push notification received when push notifications are disabled.") + return + } + + val pushRequest = PushRequest( + pushDate = systemClock.epochMillis(), + providerInfo = providerInfo, + eventId = pushData.eventId.value, + roomId = pushData.roomId.value, + sessionId = userId.value, + status = PushRequestStatus.PENDING.value, + retries = 0L, + ) + + Timber.d("Queueing notification: $pushRequest") + pushHistoryService.insertOrUpdatePushRequest(pushRequest) + + if (!workManagerScheduler.hasPendingWork(userId, WorkManagerRequestType.NOTIFICATION_SYNC)) { + Timber.d("No pending worker for push notifications found") + workManagerScheduler.submit( + SyncPendingNotificationsRequestBuilder( + sessionId = userId, + buildVersionSdkIntProvider = buildVersionSdkIntProvider, + ) ) - Timber.d("Queueing notification: $notificationEventRequest") - resolverQueue.enqueue(notificationEventRequest) } } catch (e: Exception) { Timber.tag(loggerTag.value).e(e, "## handleInternal() failed") } } - - private suspend fun handleRingingCallEvent(notifiableEvent: NotifiableRingingCallEvent) { - Timber.i("## handleInternal() : Incoming call.") - elementCallEntryPoint.handleIncomingCall( - callType = CallType.RoomCall(notifiableEvent.sessionId, notifiableEvent.roomId), - eventId = notifiableEvent.eventId, - senderId = notifiableEvent.senderId, - roomName = notifiableEvent.roomName, - senderName = notifiableEvent.senderDisambiguatedDisplayName, - avatarUrl = notifiableEvent.roomAvatarUrl, - timestamp = notifiableEvent.timestamp, - expirationTimestamp = notifiableEvent.expirationTimestamp, - notificationChannelId = notificationChannels.getChannelForIncomingCall(ring = true), - textContent = notifiableEvent.description, - ) - } +} + +/** + * Represents the status of a [PushRequest]. + */ +enum class PushRequestStatus(val value: Long) { + /** + * Either it was enqueued, and we never tried to fetch it, or it failed with a recoverable error. + */ + PENDING(0), + + /** + * The event for the [PushRequest] was fetched successfully. + */ + SUCCESS(1), + + /** + * Fetching the event for the [PushRequest] failed with an unrecoverable error, and it won't be retried. + */ + FAILED(2), } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultSyncOnNotifiableEvent.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultSyncOnNotifiableEvent.kt index 8b8e671fcc..f3a52f9e15 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultSyncOnNotifiableEvent.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultSyncOnNotifiableEvent.kt @@ -14,8 +14,9 @@ import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.matrix.api.MatrixClientProvider -import io.element.android.libraries.push.api.push.NotificationEventRequest -import io.element.android.libraries.push.api.push.SyncOnNotifiableEvent +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.push.impl.db.PushRequest import io.element.android.services.appnavstate.api.AppForegroundStateService import kotlinx.coroutines.delay import kotlinx.coroutines.withContext @@ -29,7 +30,7 @@ class DefaultSyncOnNotifiableEvent( private val appForegroundStateService: AppForegroundStateService, private val dispatchers: CoroutineDispatchers, ) : SyncOnNotifiableEvent { - override suspend operator fun invoke(requests: List) = withContext(dispatchers.io) { + override suspend operator fun invoke(requests: List) = withContext(dispatchers.io) { if (!featureFlagService.isFeatureEnabled(FeatureFlags.SyncOnPush)) { return@withContext } @@ -41,8 +42,8 @@ class DefaultSyncOnNotifiableEvent( Timber.d("Starting opportunistic room list sync | In foreground: ${appForegroundStateService.isInForeground.value}") for ((sessionId, events) in eventsBySession) { - val client = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: continue - val roomIds = events.map { it.roomId }.distinct() + val client = matrixClientProvider.getOrRestore(SessionId(sessionId)).getOrNull() ?: continue + val roomIds = events.map { RoomId(it.roomId) }.distinct() client.roomListService.subscribeToVisibleRooms(roomIds) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/SyncOnNotifiableEvent.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/SyncOnNotifiableEvent.kt new file mode 100644 index 0000000000..5d584986f1 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/SyncOnNotifiableEvent.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.push + +import io.element.android.libraries.push.impl.db.PushRequest + +fun interface SyncOnNotifiableEvent { + suspend operator fun invoke(requests: List) +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/FetchNotificationsWorker.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/FetchNotificationsWorker.kt deleted file mode 100644 index 23220cf366..0000000000 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/FetchNotificationsWorker.kt +++ /dev/null @@ -1,191 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.libraries.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.extensions.runCatchingExceptions -import io.element.android.libraries.di.annotations.ApplicationContext -import io.element.android.libraries.matrix.api.auth.SessionRestorationException -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 io.element.android.services.analytics.api.AnalyticsLongRunningTransaction -import io.element.android.services.analytics.api.AnalyticsService -import io.element.android.services.analytics.api.finishLongRunningTransaction -import io.element.android.services.analytics.api.recordTransaction -import io.element.android.services.analyticsproviders.api.AnalyticsTransaction -import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.withTimeoutOrNull -import timber.log.Timber -import kotlin.time.Duration.Companion.seconds - -@AssistedInject -class FetchNotificationsWorker( - @Assisted params: WorkerParameters, - @ApplicationContext private val context: Context, - private val networkMonitor: NetworkMonitor, - private val eventResolver: NotifiableEventResolver, - private val queue: NotificationResolverQueue, - private val workManagerScheduler: WorkManagerScheduler, - private val syncOnNotifiableEvent: SyncOnNotifiableEvent, - private val workerDataConverter: SyncNotificationsWorkerDataConverter, - private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider, - private val analyticsService: AnalyticsService, -) : CoroutineWorker(context, params) { - override suspend fun doWork(): Result { - Timber.d("FetchNotificationsWorker started") - val requests = workerDataConverter.deserialize(inputData) ?: return Result.failure() - - val networkTimeoutSpans = requests.mapNotNull { request -> - val parent = analyticsService.getLongRunningTransaction(AnalyticsLongRunningTransaction.PushToWorkManager(request.eventId.value)) - parent?.startChild("Waiting for network connectivity", "await_network") - } - - // Wait for network to be available, but not more than 10 seconds - val hasNetwork = withTimeoutOrNull(10.seconds) { - networkMonitor.connectivity.first { it == NetworkStatus.Connected } - } != null - - networkTimeoutSpans.finish() - - // If there is a problem with the updated network values, report it and retry if needed - if (reportConnectivityError(requests = requests, hasNetwork = hasNetwork, isNetworkBlocked = networkMonitor.isNetworkBlocked())) { - return Result.retry() - } - - val pendingAnalyticTransactions = requests.mapNotNull { request -> - analyticsService.finishLongRunningTransaction(AnalyticsLongRunningTransaction.PushToWorkManager(request.eventId.value)) - val parent = analyticsService.getLongRunningTransaction(AnalyticsLongRunningTransaction.PushToNotification(request.eventId.value)) - val transactionName = "WorkManager to event fetched" - parent?.startChild(transactionName)?.let { request.eventId to it } - }.toMap() - - val failedSyncForSessions = mutableMapOf() - - val groupedRequests = requests.groupBy { it.sessionId }.toMutableMap() - for ((sessionId, notificationRequests) in groupedRequests) { - Timber.d("Processing notification requests for session $sessionId") - eventResolver.resolveEvents(sessionId, notificationRequests) - .fold( - onSuccess = { result -> - for ((_, transaction) in pendingAnalyticTransactions) { - transaction.finish() - } - // Update the resolved results in the queue - (queue.results as MutableSharedFlow).emit(requests to result) - }, - onFailure = { - for ((_, transaction) in pendingAnalyticTransactions) { - transaction.attachError(it) - transaction.finish() - } - failedSyncForSessions[sessionId] = it - 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()) { - @Suppress("LoopWithTooManyJumpStatements") - for ((failedSessionId, exception) in failedSyncForSessions) { - if (exception.cause is SessionRestorationException) { - Timber.e(exception, "Session $failedSessionId could not be restored, not retrying notification fetching") - groupedRequests.remove(failedSessionId) - continue - } - val requestsToRetry = groupedRequests[failedSessionId] ?: continue - - for (request in requestsToRetry) { - val failedTransaction = pendingAnalyticTransactions[request.eventId] - failedTransaction?.attachError(exception) - failedTransaction?.finish() - - val eventId = request.eventId.value - val parent = analyticsService.getLongRunningTransaction(AnalyticsLongRunningTransaction.PushToNotification(eventId)) - // Since we're retrying, start a new transaction - analyticsService.startLongRunningTransaction(AnalyticsLongRunningTransaction.PushToWorkManager(eventId), parent) - } - - Timber.d("Re-scheduling ${requestsToRetry.size} failed notification requests for session $failedSessionId") - workManagerScheduler.submit( - SyncNotificationWorkManagerRequest( - sessionId = failedSessionId, - notificationEventRequests = requestsToRetry, - workerDataConverter = workerDataConverter, - buildVersionSdkIntProvider = buildVersionSdkIntProvider, - ) - ) - } - } - - Timber.d("Notifications processed successfully") - - analyticsService.recordTransaction("Opportunistic sync", "opportunistic_sync") { - performOpportunisticSyncIfNeeded(groupedRequests) - } - - return Result.success() - } - - private fun reportConnectivityError(requests: List, hasNetwork: Boolean, isNetworkBlocked: Boolean): Boolean { - return if (!hasNetwork || isNetworkBlocked) { - for (request in requests) { - val eventId = request.eventId.value - analyticsService.finishLongRunningTransaction(AnalyticsLongRunningTransaction.PushToWorkManager(eventId)) { - it.putExtraData("has_network_connection", hasNetwork.toString()) - it.putExtraData("is_network_blocked", isNetworkBlocked.toString()) - } - val parent = analyticsService.getLongRunningTransaction(AnalyticsLongRunningTransaction.PushToNotification(eventId)) - // Since we're retrying, start a new transaction - analyticsService.startLongRunningTransaction(AnalyticsLongRunningTransaction.PushToWorkManager(eventId), parent) - } - Timber.w("FetchNotificationsWorker will retry. Has network connectivity: $hasNetwork. Is network blocked: $isNetworkBlocked") - true - } else { - false - } - } - - private suspend fun performOpportunisticSyncIfNeeded( - groupedRequests: Map>, - ) { - 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>()) - @WorkerKey(FetchNotificationsWorker::class) - @AssistedFactory - interface Factory : MetroWorkerFactory.WorkerInstanceFactory -} - -private fun Collection.finish() = forEach { it.finish() } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/FetchPendingNotificationsWorker.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/FetchPendingNotificationsWorker.kt new file mode 100644 index 0000000000..fd9008839f --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/FetchPendingNotificationsWorker.kt @@ -0,0 +1,239 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.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.extensions.runCatchingExceptions +import io.element.android.libraries.di.annotations.ApplicationContext +import io.element.android.libraries.matrix.api.auth.SessionRestorationException +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.exception.ClientException +import io.element.android.libraries.matrix.api.exception.isNetworkError +import io.element.android.libraries.push.impl.db.PushRequest +import io.element.android.libraries.push.impl.history.PushHistoryService +import io.element.android.libraries.push.impl.notifications.NotifiableEventResolver +import io.element.android.libraries.push.impl.notifications.NotificationResultProcessor +import io.element.android.libraries.push.impl.push.PushRequestStatus +import io.element.android.libraries.push.impl.push.SyncOnNotifiableEvent +import io.element.android.libraries.workmanager.api.di.MetroWorkerFactory +import io.element.android.libraries.workmanager.api.di.WorkerKey +import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction +import io.element.android.services.analytics.api.AnalyticsService +import io.element.android.services.analytics.api.finishLongRunningTransaction +import io.element.android.services.analytics.api.recordTransaction +import io.element.android.services.analyticsproviders.api.AnalyticsTransaction +import io.element.android.services.toolbox.api.systemclock.SystemClock +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeoutOrNull +import timber.log.Timber +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant + +@AssistedInject +class FetchPendingNotificationsWorker( + @Assisted private val params: WorkerParameters, + @ApplicationContext private val context: Context, + private val pushHistoryService: PushHistoryService, + private val networkMonitor: NetworkMonitor, + private val eventResolver: NotifiableEventResolver, + private val syncOnNotifiableEvent: SyncOnNotifiableEvent, + private val resultProcessor: NotificationResultProcessor, + private val analyticsService: AnalyticsService, + private val systemClock: SystemClock, +) : CoroutineWorker(context, params) { + override suspend fun doWork(): Result { + Timber.d("FetchNotificationsWorker started") + // RunCatching for test in debug mode + val sessionId = runCatchingExceptions { + inputData.getString(SyncPendingNotificationsRequestBuilder.SESSION_ID)?.let(::SessionId) + }.getOrNull() ?: return Result.failure() + + // Fetch pending requests in the last 24 hours + val fetchSince = Instant.fromEpochMilliseconds(systemClock.epochMillis()).minus(1.days) + val requests = pushHistoryService.getPendingPushRequests(sessionId, fetchSince).getOrNull() ?: return Result.failure() + + pushHistoryService.removeOldPushRequests(sessionId).onFailure { + Timber.e(it, "Could not remove outdated push requests") + } + + if (requests.isEmpty()) { + Timber.d("No pending notifications to fetch, returning early") + return Result.success() + } + + checkNetworkConnection(requests)?.let { failure -> return failure } + + Timber.d("Fetching ${requests.size} push requests") + + val pendingAnalyticTransactions = requests.mapNotNull { request -> + analyticsService.finishLongRunningTransaction(AnalyticsLongRunningTransaction.PushToWorkManager(request.eventId)) + val parent = analyticsService.getLongRunningTransaction(AnalyticsLongRunningTransaction.PushToNotification(request.eventId)) + val transactionName = "WorkManager to event fetched" + parent?.startChild(transactionName)?.let { request.eventId to it } + }.toMap() + + Timber.d("Processing notification requests for session $sessionId") + val results = eventResolver.resolveEvents(sessionId, requests) + .fold( + onSuccess = { results -> + for ((_, transaction) in pendingAnalyticTransactions) { + transaction.finish() + } + // Update the resolved results in the queue + resultProcessor.emit(results) + + results + }, + onFailure = { + // This is a failure at the fetch notification setup, not a failure for a single fetch notification operation + return handleSetupError(sessionId, requests, pendingAnalyticTransactions, it) + } + ) + + val updatedRequests = mutableListOf() + for (request in requests) { + val result = results[request] ?: continue + result.fold( + onSuccess = { updatedRequests.add(request.copy(status = PushRequestStatus.SUCCESS.value)) }, + onFailure = { exception -> + if (exception is ClientException && exception.isNetworkError()) { + // Reset to pending so we can retry it later + updatedRequests.add(request.copy(status = PushRequestStatus.PENDING.value)) + } else { + updatedRequests.add(request.copy(status = PushRequestStatus.FAILED.value)) + } + } + ) + } + + Timber.d("Notifications processed successfully") + + pushHistoryService.insertOrUpdatePushRequests(updatedRequests) + + analyticsService.recordTransaction("Opportunistic sync", "opportunistic_sync") { + performOpportunisticSyncIfNeeded(mapOf(sessionId to requests)) + } + + return if (updatedRequests.any { it.status == PushRequestStatus.PENDING.value }) Result.retry() else Result.success() + } + + private suspend fun performOpportunisticSyncIfNeeded( + groupedRequests: Map>, + ) { + for ((sessionId, notificationRequests) in groupedRequests) { + runCatchingExceptions { + syncOnNotifiableEvent(notificationRequests) + }.onFailure { + Timber.e(it, "Failed to sync on notifiable events for session $sessionId") + } + } + } + + private suspend fun checkNetworkConnection(requests: List): Result? { + val networkTimeoutSpans = requests.mapNotNull { request -> + val parent = analyticsService.getLongRunningTransaction(AnalyticsLongRunningTransaction.PushToWorkManager(request.eventId)) + parent?.startChild("Waiting for network connectivity", "await_network") + } + + // Wait for network to be available, but not more than 10 seconds + val hasNetwork = withTimeoutOrNull(10.seconds) { + networkMonitor.connectivity.first { it == NetworkStatus.Connected } + } != null + + networkTimeoutSpans.finish() + + // If there is a problem with the updated network values, report it and retry if needed + if (reportConnectivityError(requests = requests, hasNetwork = hasNetwork, isNetworkBlocked = networkMonitor.isNetworkBlocked())) { + pushHistoryService.insertOrUpdatePushRequests(requests.map { request -> + request.copy(retries = request.retries + 1) + }) + return Result.retry() + } + + return null + } + + private fun reportConnectivityError(requests: List, hasNetwork: Boolean, isNetworkBlocked: Boolean): Boolean { + return if (!hasNetwork || isNetworkBlocked) { + for (request in requests) { + val eventId = request.eventId + analyticsService.finishLongRunningTransaction(AnalyticsLongRunningTransaction.PushToWorkManager(eventId)) { + it.putExtraData("has_network_connection", hasNetwork.toString()) + it.putExtraData("is_network_blocked", isNetworkBlocked.toString()) + } + val parent = analyticsService.getLongRunningTransaction(AnalyticsLongRunningTransaction.PushToNotification(eventId)) + // Since we're retrying, start a new transaction + analyticsService.startLongRunningTransaction(AnalyticsLongRunningTransaction.PushToWorkManager(eventId), parent) + } + Timber.w("FetchNotificationsWorker will retry. Has network connectivity: $hasNetwork. Is network blocked: $isNetworkBlocked") + true + } else { + false + } + } + + private suspend fun handleSetupError( + sessionId: SessionId, + requests: List, + pendingAnalyticTransactions: Map, + throwable: Throwable, + ): Result { + for ((_, transaction) in pendingAnalyticTransactions) { + transaction.attachError(throwable) + transaction.finish() + } + + // If there were failures on the setup step and they weren't recoverable, update the requests and fail + if (throwable.cause is SessionRestorationException) { + Timber.e(throwable, "Session $sessionId could not be restored, not retrying notification fetching") + pushHistoryService.insertOrUpdatePushRequests(requests.map { request -> + request.copy(status = PushRequestStatus.FAILED.value) + }) + return Result.failure() + } + + // If the failure is recoverable, retry + for (request in requests) { + val failedTransaction = pendingAnalyticTransactions[request.eventId] + failedTransaction?.attachError(throwable) + failedTransaction?.finish() + + val eventId = request.eventId + val parent = analyticsService.getLongRunningTransaction(AnalyticsLongRunningTransaction.PushToNotification(eventId)) + // Since we're retrying, start a new transaction + analyticsService.startLongRunningTransaction(AnalyticsLongRunningTransaction.PushToWorkManager(eventId), parent) + } + + Timber.d("Re-scheduling ${requests.size} failed notification requests for session $sessionId") + + pushHistoryService.insertOrUpdatePushRequests(requests.map { request -> + request.copy(retries = request.retries + 1) + }) + + return Result.retry() + } + + @ContributesIntoMap(AppScope::class, binding = binding>()) + @WorkerKey(FetchPendingNotificationsWorker::class) + @AssistedFactory + interface Factory : MetroWorkerFactory.WorkerInstanceFactory +} + +private fun Collection.finish() = forEach { it.finish() } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/SyncNotificationWorkManagerRequest.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/SyncNotificationWorkManagerRequest.kt deleted file mode 100644 index 50ef28903c..0000000000 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/SyncNotificationWorkManagerRequest.kt +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.libraries.push.impl.workmanager - -import android.os.Build -import androidx.work.OneTimeWorkRequestBuilder -import androidx.work.OutOfQuotaPolicy -import androidx.work.WorkRequest -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 io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import timber.log.Timber -import java.security.InvalidParameterException - -class SyncNotificationWorkManagerRequest( - private val sessionId: SessionId, - private val notificationEventRequests: List, - private val workerDataConverter: SyncNotificationsWorkerDataConverter, - private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider, -) : WorkManagerRequest { - override fun build(): Result> { - if (notificationEventRequests.isEmpty()) { - return Result.failure(InvalidParameterException("notificationEventRequests cannot be empty")) - } - Timber.d("Scheduling ${notificationEventRequests.size} notification requests with WorkManager for $sessionId") - return workerDataConverter.serialize(notificationEventRequests).map { dataList -> - dataList.map { data -> - OneTimeWorkRequestBuilder() - .setInputData(data) - .apply { - // Expedited workers aren't needed on Android 12 or lower: - // They force displaying a foreground sync notification for no good reason, since they sync almost immediately anyway - // See https://developer.android.com/develop/background-work/background-tasks/persistent/getting-started/define-work#backwards-compat - if (buildVersionSdkIntProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) { - 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, - ) -} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/SyncNotificationsWorkerDataConverter.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/SyncNotificationsWorkerDataConverter.kt deleted file mode 100644 index 46b7d760c0..0000000000 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/SyncNotificationsWorkerDataConverter.kt +++ /dev/null @@ -1,129 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.libraries.push.impl.workmanager - -import androidx.work.Data -import androidx.work.workDataOf -import dev.zacsweers.metro.Inject -import io.element.android.libraries.androidutils.json.JsonProvider -import io.element.android.libraries.core.extensions.mapCatchingExceptions -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 timber.log.Timber - -@Inject -class SyncNotificationsWorkerDataConverter( - private val json: JsonProvider, -) { - fun serialize(notificationEventRequests: List): Result> { - // First try to serialize all requests at once. In the vast majority of cases this will work. - return serializeRequests(notificationEventRequests) - .map { listOf(it) } - .recoverCatching { t -> - if (t is DataForWorkManagerIsTooBig) { - // Perform serialization on sublists, workDataOf have failed because of size limit - Timber.w(t, "Failed to serialize ${notificationEventRequests.size} notification requests, split the requests per room.") - // Group the requests per rooms - val requestsSortedPerRoom = notificationEventRequests.groupBy { it.roomId }.values - // Build a list of sublist with size at most CHUNK_SIZE, and with all rooms kept together - buildList { - val currentChunk = mutableListOf() - for (requests in requestsSortedPerRoom) { - if (currentChunk.size + requests.size <= CHUNK_SIZE) { - // Can add the whole room requests to the current chunk - currentChunk.addAll(requests) - } else { - // Add the current chunk - add(currentChunk.toList()) - // Start a new chunk with the current room requests - currentChunk.clear() - // If a room has more requests than CHUNK_SIZE, we need to split them - requests.chunked(CHUNK_SIZE) { chunk -> - if (chunk.size == CHUNK_SIZE) { - add(chunk.toList()) - } else { - currentChunk.addAll(chunk) - } - } - } - } - // Add any remaining requests - add(currentChunk.toList()) - } - .filter { it.isNotEmpty() } - .also { - Timber.d("Split notification requests into ${it.size} chunks for WorkManager serialization") - it.forEach { requests -> - Timber.d(" - Chunk with ${requests.size} requests") - } - } - .mapNotNull { serializeRequests(it).getOrNull() } - } else { - throw t - } - } - } - - private fun serializeRequests(notificationEventRequests: List): Result { - return runCatchingExceptions { json().encodeToString(notificationEventRequests.map { it.toData() }) } - .onFailure { - Timber.e(it, "Failed to serialize notification requests") - } - .mapCatchingExceptions { str -> - // Note: workDataOf can fail if the data is too large - try { - workDataOf(REQUESTS_KEY to str) - } catch (_: IllegalStateException) { - throw DataForWorkManagerIsTooBig() - } - } - } - - fun deserialize(data: Data): List? { - val rawRequestsJson = data.getString(REQUESTS_KEY) ?: return null - return runCatchingExceptions { - json().decodeFromString>(rawRequestsJson).map { it.toRequest() } - }.fold( - onSuccess = { - Timber.d("Deserialized ${it.size} requests") - it - }, - onFailure = { - Timber.e(it, "Failed to deserialize notification requests") - null - } - ) - } - - companion object { - private const val REQUESTS_KEY = "requests" - internal const val CHUNK_SIZE = 20 - } -} - -private fun NotificationEventRequest.toData(): SyncNotificationWorkManagerRequest.Data { - return SyncNotificationWorkManagerRequest.Data( - sessionId = sessionId.value, - roomId = roomId.value, - eventId = eventId.value, - providerInfo = providerInfo, - ) -} - -private fun SyncNotificationWorkManagerRequest.Data.toRequest(): NotificationEventRequest { - return NotificationEventRequest( - sessionId = SessionId(sessionId), - roomId = RoomId(roomId), - eventId = EventId(eventId), - providerInfo = providerInfo, - ) -} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/SyncPendingNotificationsRequestBuilder.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/SyncPendingNotificationsRequestBuilder.kt new file mode 100644 index 0000000000..5aa40cadb5 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/SyncPendingNotificationsRequestBuilder.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.workmanager + +import android.os.Build +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.OutOfQuotaPolicy +import androidx.work.workDataOf +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.workmanager.api.WorkManagerRequestBuilder +import io.element.android.libraries.workmanager.api.WorkManagerRequestType +import io.element.android.libraries.workmanager.api.WorkManagerRequestWrapper +import io.element.android.libraries.workmanager.api.WorkManagerWorkerType +import io.element.android.libraries.workmanager.api.workManagerTag +import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider + +class SyncPendingNotificationsRequestBuilder( + private val sessionId: SessionId, + private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider, +) : WorkManagerRequestBuilder { + companion object { + const val SESSION_ID = "session_id" + } + + override suspend fun build(): Result> { + val type = WorkManagerWorkerType.Unique( + name = workManagerTag(sessionId = sessionId, requestType = WorkManagerRequestType.NOTIFICATION_SYNC), + policy = ExistingWorkPolicy.APPEND_OR_REPLACE, + ) + val request = OneTimeWorkRequestBuilder() + .setInputData(workDataOf(SESSION_ID to sessionId.value)) + .apply { + // Expedited workers aren't needed on Android 12 or lower: + // They force displaying a foreground sync notification for no good reason, since they sync almost immediately anyway + // See https://developer.android.com/develop/background-work/background-tasks/persistent/getting-started/define-work#backwards-compat + if (buildVersionSdkIntProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) { + setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + } + } + .setTraceTag(workManagerTag(sessionId, WorkManagerRequestType.NOTIFICATION_SYNC)) + .build() + return Result.success(listOf(WorkManagerRequestWrapper(request, type))) + } +} diff --git a/libraries/push/impl/src/main/sqldelight/databases/1.db b/libraries/push/impl/src/main/sqldelight/databases/1.db new file mode 100644 index 0000000000..fa2978d82d Binary files /dev/null and b/libraries/push/impl/src/main/sqldelight/databases/1.db differ diff --git a/libraries/push/impl/src/main/sqldelight/databases/2.db b/libraries/push/impl/src/main/sqldelight/databases/2.db new file mode 100644 index 0000000000..42e0dfaa90 Binary files /dev/null and b/libraries/push/impl/src/main/sqldelight/databases/2.db differ diff --git a/libraries/push/impl/src/main/sqldelight/io/element/android/libraries/push/impl/db/PushHistory.sq b/libraries/push/impl/src/main/sqldelight/io/element/android/libraries/push/impl/db/PushHistory.sq index 7a355baeb5..6dcd553a18 100644 --- a/libraries/push/impl/src/main/sqldelight/io/element/android/libraries/push/impl/db/PushHistory.sq +++ b/libraries/push/impl/src/main/sqldelight/io/element/android/libraries/push/impl/db/PushHistory.sq @@ -17,6 +17,5 @@ INSERT INTO PushHistory VALUES ?; removeAll: DELETE FROM PushHistory; --- add query to keep only the last x entries removeOldest: DELETE FROM PushHistory WHERE rowid NOT IN (SELECT rowid FROM PushHistory ORDER BY pushDate DESC LIMIT ?); diff --git a/libraries/push/impl/src/main/sqldelight/io/element/android/libraries/push/impl/db/PushRequest.sq b/libraries/push/impl/src/main/sqldelight/io/element/android/libraries/push/impl/db/PushRequest.sq new file mode 100644 index 0000000000..877ee3b247 --- /dev/null +++ b/libraries/push/impl/src/main/sqldelight/io/element/android/libraries/push/impl/db/PushRequest.sq @@ -0,0 +1,24 @@ +CREATE TABLE PushRequest ( + pushDate INTEGER NOT NULL, + providerInfo TEXT NOT NULL, + eventId TEXT NOT NULL, + roomId TEXT NOT NULL, + sessionId TEXT NOT NULL, + status INTEGER NOT NULL DEFAULT 0, + retries INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY(sessionId, eventId) +); + +CREATE INDEX PushRequestSessionAndStatus ON PushRequest (sessionId, status); + +selectAllPendingForSession: +SELECT * FROM PushRequest WHERE status = 0 AND sessionId = ? AND pushDate > ? ORDER BY pushDate ASC; + +insertPushRequest: +INSERT OR REPLACE INTO PushRequest VALUES ?; + +removeAll: +DELETE FROM PushRequest; + +removeOldest: +DELETE FROM PushRequest WHERE rowid NOT IN (SELECT rowid FROM PushRequest ORDER BY pushDate DESC LIMIT ?); diff --git a/libraries/push/impl/src/main/sqldelight/migrations/1.sqm b/libraries/push/impl/src/main/sqldelight/migrations/1.sqm new file mode 100644 index 0000000000..4f9edb2b08 --- /dev/null +++ b/libraries/push/impl/src/main/sqldelight/migrations/1.sqm @@ -0,0 +1,14 @@ +-- Migrate DB from version 1 + +CREATE TABLE PushRequest ( + pushDate INTEGER NOT NULL, + providerInfo TEXT NOT NULL, + eventId TEXT NOT NULL, + roomId TEXT NOT NULL, + sessionId TEXT NOT NULL, + status INTEGER NOT NULL DEFAULT 0, + retries INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY(sessionId, eventId) +); + +CREATE INDEX PushRequestSessionAndStatus ON PushRequest (sessionId, status); diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/history/FakePushHistoryService.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/history/FakePushHistoryService.kt index aac8f8f26d..aa9612717a 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/history/FakePushHistoryService.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/history/FakePushHistoryService.kt @@ -11,7 +11,9 @@ package io.element.android.libraries.push.impl.history 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.impl.db.PushRequest import io.element.android.tests.testutils.lambda.lambdaError +import kotlin.time.Instant class FakePushHistoryService( private val onPushReceivedResult: ( @@ -22,9 +24,13 @@ class FakePushHistoryService( Boolean, Boolean, String? - ) -> Unit = { _, _, _, _, _, _, _ -> lambdaError() } + ) -> Unit = { _, _, _, _, _, _, _ -> lambdaError() }, + private val enqueuePushRequest: (PushRequest) -> Result = { lambdaError() }, + private val replacePushRequests: (List) -> Result = { lambdaError() }, + private val getPendingPushRequests: (SessionId, Instant?) -> Result> = { _, _ -> lambdaError() }, + private val removeOldPushRequests: (SessionId) -> Result = { lambdaError() }, ) : PushHistoryService { - override fun onPushReceived( + override fun onPushResult( providerInfo: String, eventId: EventId?, roomId: RoomId?, @@ -43,4 +49,20 @@ class FakePushHistoryService( comment ) } + + override suspend fun insertOrUpdatePushRequest(pushRequest: PushRequest): Result { + return enqueuePushRequest.invoke(pushRequest) + } + + override suspend fun insertOrUpdatePushRequests(pushRequests: List): Result { + return replacePushRequests.invoke(pushRequests) + } + + override suspend fun getPendingPushRequests(sessionId: SessionId, since: Instant?): Result> { + return getPendingPushRequests.invoke(sessionId, since) + } + + override suspend fun removeOldPushRequests(sessionId: SessionId): Result { + return removeOldPushRequests.invoke(sessionId) + } } diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolverTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolverTest.kt index ba58db2a3c..19ef74d0b9 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolverTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolverTest.kt @@ -47,9 +47,10 @@ 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.aNotificationData 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.db.PushRequest 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.aPushRequest import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent @@ -71,7 +72,7 @@ class DefaultNotifiableEventResolverTest { @Test fun `resolve event no session`() = runTest { val sut = createDefaultNotifiableEventResolver(notificationService = null) - val result = sut.resolveEvents(A_SESSION_ID, listOf(NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase"))) + val result = sut.resolveEvents(A_SESSION_ID, listOf(aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase"))) assertThat(result.isFailure).isTrue() } @@ -80,7 +81,7 @@ class DefaultNotifiableEventResolverTest { val sut = createDefaultNotifiableEventResolver( notificationResult = Result.failure(AN_EXCEPTION) ) - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) assertThat(result.isFailure).isTrue() } @@ -90,7 +91,7 @@ class DefaultNotifiableEventResolverTest { val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success(mapOf(AN_EVENT_ID to Result.failure(AN_EXCEPTION))) ) - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) assertThat(result.getEvent(request)?.isFailure).isTrue() } @@ -109,7 +110,7 @@ class DefaultNotifiableEventResolverTest { ) ) ) - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) val expectedResult = ResolvedPushEvent.Event( aNotifiableMessageEvent(body = "Hello world") @@ -133,7 +134,7 @@ class DefaultNotifiableEventResolverTest { ) ) ) - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) val expectedResult = ResolvedPushEvent.Event( aNotifiableMessageEvent(body = "Hello world", hasMentionOrReply = true) @@ -161,7 +162,7 @@ class DefaultNotifiableEventResolverTest { ) ) ) - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) val expectedResult = ResolvedPushEvent.Event( aNotifiableMessageEvent(body = "Hello world") @@ -189,7 +190,7 @@ class DefaultNotifiableEventResolverTest { ) ) ) - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) val expectedResult = ResolvedPushEvent.Event( aNotifiableMessageEvent(body = "Hello world") @@ -211,7 +212,7 @@ class DefaultNotifiableEventResolverTest { ) ) ) - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) val expectedResult = ResolvedPushEvent.Event( aNotifiableMessageEvent(body = "Audio") @@ -233,7 +234,7 @@ class DefaultNotifiableEventResolverTest { ) ) ) - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) val expectedResult = ResolvedPushEvent.Event( aNotifiableMessageEvent(body = "Video") @@ -255,7 +256,7 @@ class DefaultNotifiableEventResolverTest { ) ) ) - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) val expectedResult = ResolvedPushEvent.Event( aNotifiableMessageEvent(body = "Voice message") @@ -277,7 +278,7 @@ class DefaultNotifiableEventResolverTest { ) ) ) - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) val expectedResult = ResolvedPushEvent.Event( aNotifiableMessageEvent(body = "Image") @@ -299,7 +300,7 @@ class DefaultNotifiableEventResolverTest { ) ) ) - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) val expectedResult = ResolvedPushEvent.Event( aNotifiableMessageEvent(body = "Sticker") @@ -321,7 +322,7 @@ class DefaultNotifiableEventResolverTest { ) ) ) - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) val expectedResult = ResolvedPushEvent.Event( aNotifiableMessageEvent(body = "File") @@ -343,7 +344,7 @@ class DefaultNotifiableEventResolverTest { ) ) ) - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) val expectedResult = ResolvedPushEvent.Event( aNotifiableMessageEvent(body = "Location") @@ -365,7 +366,7 @@ class DefaultNotifiableEventResolverTest { ) ) ) - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) val expectedResult = ResolvedPushEvent.Event( aNotifiableMessageEvent(body = "Notice") @@ -387,7 +388,7 @@ class DefaultNotifiableEventResolverTest { ) ) ) - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) val expectedResult = ResolvedPushEvent.Event( aNotifiableMessageEvent(body = "* Bob is happy") @@ -409,7 +410,7 @@ class DefaultNotifiableEventResolverTest { ) ) ) - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) val expectedResult = ResolvedPushEvent.Event( aNotifiableMessageEvent(body = "Poll: A question") @@ -432,7 +433,7 @@ class DefaultNotifiableEventResolverTest { ) ) ) - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) assertThat(result.getEvent(request)?.getOrNull()).isNull() } @@ -451,7 +452,7 @@ class DefaultNotifiableEventResolverTest { ) ) ) - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) val expectedResult = ResolvedPushEvent.Event( InviteNotifiableEvent( @@ -490,7 +491,7 @@ class DefaultNotifiableEventResolverTest { ) ) ) - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) val expectedResult = ResolvedPushEvent.Event( InviteNotifiableEvent( @@ -527,7 +528,7 @@ class DefaultNotifiableEventResolverTest { ) ) ) - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) val expectedResult = ResolvedPushEvent.Event( InviteNotifiableEvent( @@ -565,7 +566,7 @@ class DefaultNotifiableEventResolverTest { ) ) ) - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) val expectedResult = ResolvedPushEvent.Event( InviteNotifiableEvent( @@ -605,7 +606,7 @@ class DefaultNotifiableEventResolverTest { ) ) ) - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) val expectedResult = ResolvedPushEvent.Event( InviteNotifiableEvent( @@ -642,7 +643,7 @@ class DefaultNotifiableEventResolverTest { ) ) ) - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) assertThat(result.getEvent(request)?.getOrNull()).isNull() } @@ -654,7 +655,7 @@ class DefaultNotifiableEventResolverTest { mapOf(AN_EVENT_ID to Result.success(aNotificationData(content = NotificationContent.MessageLike.RoomEncrypted))) ) ) - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) val expectedResult = ResolvedPushEvent.Event( FallbackNotifiableEvent( @@ -680,7 +681,7 @@ class DefaultNotifiableEventResolverTest { mapOf(AN_EVENT_ID to Result.failure(NotificationResolverException.EventNotFound)) ) ) - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) assertThat(result.getEvent(request)).isEqualTo(Result.failure(NotificationResolverException.EventNotFound)) } @@ -698,7 +699,7 @@ class DefaultNotifiableEventResolverTest { ) ) ) - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) val expectedResult = ResolvedPushEvent.Event( NotifiableMessageEvent( @@ -766,7 +767,7 @@ class DefaultNotifiableEventResolverTest { ) ) callNotificationEventResolver.resolveEventLambda = { _, _, _ -> Result.success(expectedResult.notifiableEvent) } - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult)) } @@ -791,7 +792,7 @@ class DefaultNotifiableEventResolverTest { redactedEventId = AN_EVENT_ID_2, reason = A_REDACTION_REASON, ) - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult)) } @@ -810,7 +811,7 @@ class DefaultNotifiableEventResolverTest { ) ) ) - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) assertThat(result.getEvent(request)?.getOrNull()).isNull() } @@ -857,13 +858,13 @@ class DefaultNotifiableEventResolverTest { mapOf(AN_EVENT_ID to Result.success(aNotificationData(content = content))) ) ) - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) assertThat(result.getEvent(request)?.getOrNull()).isNull() } - private fun Result>>.getEvent( - request: NotificationEventRequest + private fun Result>>.getEvent( + request: PushRequest ): Result? { return getOrNull()?.get(request) } diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationResultProcessorTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationResultProcessorTest.kt new file mode 100644 index 0000000000..5a0d95c017 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationResultProcessorTest.kt @@ -0,0 +1,310 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.notifications + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.call.api.CallType +import io.element.android.features.call.test.FakeElementCallEntryPoint +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.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.exception.NotificationResolverException +import io.element.android.libraries.matrix.api.timeline.item.event.EventType +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.AN_EVENT_ID_2 +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.matrix.test.A_USER_ID +import io.element.android.libraries.push.impl.db.PushRequest +import io.element.android.libraries.push.impl.history.FakePushHistoryService +import io.element.android.libraries.push.impl.notifications.channels.FakeNotificationChannels +import io.element.android.libraries.push.impl.notifications.fixtures.aFallbackNotifiableEvent +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.aPushRequest +import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent +import io.element.android.libraries.push.impl.push.FakeMutableBatteryOptimizationStore +import io.element.android.libraries.push.impl.push.FakeOnNotifiableEventReceived +import io.element.android.libraries.push.impl.push.FakeOnRedactedEventReceived +import io.element.android.libraries.push.impl.push.SyncOnNotifiableEvent +import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStoreFactory +import io.element.android.services.toolbox.test.systemclock.FakeSystemClock +import io.element.android.tests.testutils.lambda.any +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.time.Duration.Companion.milliseconds + +@OptIn(ExperimentalCoroutinesApi::class) +class DefaultNotificationResultProcessorTest { + @Test + fun `when not able to resolve the event, the banner to disable battery optimization will be displayed`() { + `test notification resolver failure`( + notificationResolveResult = { requests: List -> + Result.success( + requests.associateWith { Result.failure(NotificationResolverException.UnknownError("Unable to resolve event")) } + ) + }, + shouldSetOptimizationBatteryBanner = true, + ) + } + + private fun `test notification resolver failure`( + notificationResolveResult: (List) -> Result>>, + shouldSetOptimizationBatteryBanner: Boolean, + ) { + runTest { + val notifiableEventResult = + lambdaRecorder, Result>>> { _, requests -> + notificationResolveResult(requests) + } + val onPushReceivedResult = lambdaRecorder { _, _, _, _, _, _, _ -> } + val pushHistoryService = FakePushHistoryService( + onPushReceivedResult = onPushReceivedResult, + ) + val showBatteryOptimizationBannerResult = lambdaRecorder {} + val processor = createDefaultNotificationResultProcessor( + mutableBatteryOptimizationStore = FakeMutableBatteryOptimizationStore( + showBatteryOptimizationBannerResult = showBatteryOptimizationBannerResult, + ), + pushHistoryService = pushHistoryService, + ) + + runningProcessor(processor) { + emit(mapOf(aPushRequest() to Result.failure(IllegalStateException("boom")))) + } + + notifiableEventResult.assertions() + .isNeverCalled() + onPushReceivedResult.assertions() + .isCalledOnce() + .with(any(), value(AN_EVENT_ID), value(A_ROOM_ID), value(A_USER_ID), value(false), value(true), any()) + showBatteryOptimizationBannerResult.assertions().let { + if (shouldSetOptimizationBatteryBanner) { + it.isCalledOnce() + } else { + it.isNeverCalled() + } + } + } + } + + @Test + fun `when ringing call PushData is received, the incoming call will be handled`() = runTest { + val handleIncomingCallLambda = lambdaRecorder< + CallType.RoomCall, + EventId, + UserId, + String?, + String?, + String?, + String, + String?, + Unit, + > { _, _, _, _, _, _, _, _ -> } + val elementCallEntryPoint = FakeElementCallEntryPoint(handleIncomingCallResult = handleIncomingCallLambda) + val onNotifiableEventsReceived = lambdaRecorder, Unit> {} + val onPushReceivedResult = lambdaRecorder { _, _, _, _, _, _, _ -> } + val pushHistoryService = FakePushHistoryService( + onPushReceivedResult = onPushReceivedResult, + ) + val processor = createDefaultNotificationResultProcessor( + elementCallEntryPoint = elementCallEntryPoint, + onNotifiableEventsReceived = onNotifiableEventsReceived, + pushHistoryService = pushHistoryService, + ) + runningProcessor(processor) { + emit(mapOf(aPushRequest() to Result.success(ResolvedPushEvent.Event(aNotifiableCallEvent())))) + } + + advanceTimeBy(300.milliseconds) + + handleIncomingCallLambda.assertions().isCalledOnce() + onNotifiableEventsReceived.assertions().isNeverCalled() + onPushReceivedResult.assertions().isCalledOnce() + } + + @Test + fun `when notify call PushData is received, the incoming call will be treated as a normal notification`() = runTest { + val onNotifiableEventsReceived = lambdaRecorder, Unit> {} + val handleIncomingCallLambda = lambdaRecorder< + CallType.RoomCall, + EventId, + UserId, + String?, + String?, + String?, + String, + String?, + Unit, + > { _, _, _, _, _, _, _, _ -> } + val elementCallEntryPoint = FakeElementCallEntryPoint(handleIncomingCallResult = handleIncomingCallLambda) + val onPushReceivedResult = lambdaRecorder { _, _, _, _, _, _, _ -> } + val pushHistoryService = FakePushHistoryService( + onPushReceivedResult = onPushReceivedResult, + ) + val processor = createDefaultNotificationResultProcessor( + elementCallEntryPoint = elementCallEntryPoint, + onNotifiableEventsReceived = onNotifiableEventsReceived, + pushHistoryService = pushHistoryService, + ) + + runningProcessor(processor) { + processor.emit(mapOf(aPushRequest() to Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent(type = EventType.RTC_NOTIFICATION))))) + } + + advanceTimeBy(300.milliseconds) + + handleIncomingCallLambda.assertions().isNeverCalled() + onNotifiableEventsReceived.assertions().isCalledOnce() + onPushReceivedResult.assertions().isCalledOnce() + } + + @Test + fun `when notify call PushData is received, the incoming call will be treated as a normal notification even if notification are disabled`() = runTest { + val onNotifiableEventsReceived = lambdaRecorder, Unit> {} + val handleIncomingCallLambda = lambdaRecorder< + CallType.RoomCall, + EventId, + UserId, + String?, + String?, + String?, + String, + String?, + Unit, + > { _, _, _, _, _, _, _, _ -> } + val elementCallEntryPoint = FakeElementCallEntryPoint(handleIncomingCallResult = handleIncomingCallLambda) + val onPushReceivedResult = lambdaRecorder { _, _, _, _, _, _, _ -> } + val pushHistoryService = FakePushHistoryService( + onPushReceivedResult = onPushReceivedResult, + ) + val processor = createDefaultNotificationResultProcessor( + elementCallEntryPoint = elementCallEntryPoint, + onNotifiableEventsReceived = onNotifiableEventsReceived, + pushHistoryService = pushHistoryService, + ) + + runningProcessor(processor) { + processor.emit(mapOf(aPushRequest() to Result.success(ResolvedPushEvent.Event(aNotifiableCallEvent())))) + } + + advanceTimeBy(300.milliseconds) + + handleIncomingCallLambda.assertions().isCalledOnce() + onNotifiableEventsReceived.assertions().isNeverCalled() + onPushReceivedResult.assertions().isCalledOnce() + } + + @Test + fun `when a redaction is received, the onRedactedEventReceived is informed`() = runTest { + val aRedaction = ResolvedPushEvent.Redaction( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + redactedEventId = AN_EVENT_ID_2, + reason = null + ) + val onRedactedEventReceived = lambdaRecorder, Unit> { } + val onPushReceivedResult = lambdaRecorder { _, _, _, _, _, _, _ -> } + val pushHistoryService = FakePushHistoryService( + onPushReceivedResult = onPushReceivedResult, + ) + val processor = createDefaultNotificationResultProcessor( + onRedactedEventReceived = onRedactedEventReceived, + pushHistoryService = pushHistoryService, + ) + + runningProcessor(processor) { + emit(mapOf(aPushRequest() to Result.success(aRedaction))) + } + + advanceTimeBy(300.milliseconds) + + onRedactedEventReceived.assertions().isCalledOnce() + .with(value(listOf(aRedaction))) + onPushReceivedResult.assertions() + .isCalledOnce() + } + + @Test + fun `when receiving a fallback event, we notify the push history service about it not being resolved`() = runTest { + val aNotifiableFallbackEvent = aFallbackNotifiableEvent() + val onNotifiableEventsReceived = lambdaRecorder, Unit> {} + var receivedFallbackEvent = false + val onPushReceivedResult = + lambdaRecorder { _, _, _, _, isResolved, _, comment -> + receivedFallbackEvent = !isResolved && comment == "Unable to resolve event: ${aNotifiableFallbackEvent.cause}" + } + val pushHistoryService = FakePushHistoryService( + onPushReceivedResult = onPushReceivedResult, + ) + + val processor = createDefaultNotificationResultProcessor( + onNotifiableEventsReceived = onNotifiableEventsReceived, + pushHistoryService = pushHistoryService, + ) + + runningProcessor(processor) { + emit(mapOf(aPushRequest() to Result.success(ResolvedPushEvent.Event(aNotifiableFallbackEvent)))) + } + + advanceTimeBy(300.milliseconds) + + onNotifiableEventsReceived.assertions().isCalledOnce() + + assertThat(receivedFallbackEvent).isTrue() + } + + private suspend fun TestScope.runningProcessor(processor: NotificationResultProcessor, block: suspend NotificationResultProcessor.() -> Unit) { + processor.start() + + runCurrent() + + block(processor) + + runCurrent() + + processor.stop() + } + + private fun TestScope.createDefaultNotificationResultProcessor( + systemClock: FakeSystemClock = FakeSystemClock(), + pushHistoryService: FakePushHistoryService = FakePushHistoryService(), + mutableBatteryOptimizationStore: FakeMutableBatteryOptimizationStore = FakeMutableBatteryOptimizationStore(), + fallbackNotificationFactory: FallbackNotificationFactory = FallbackNotificationFactory(systemClock), + userPushStoreFactory: FakeUserPushStoreFactory = FakeUserPushStoreFactory(), + onRedactedEventReceived: (List) -> Unit = {}, + onNotifiableEventsReceived: (List) -> Unit = {}, + featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(), + syncOnNotifiableEvent: SyncOnNotifiableEvent = {}, + elementCallEntryPoint: FakeElementCallEntryPoint = FakeElementCallEntryPoint(), + notificationChannels: FakeNotificationChannels = FakeNotificationChannels(), + coroutineScope: CoroutineScope = backgroundScope, + ) = DefaultNotificationResultProcessor( + pushHistoryService = pushHistoryService, + batteryOptimizationStore = mutableBatteryOptimizationStore, + fallbackNotificationFactory = fallbackNotificationFactory, + userPushStoreFactory = userPushStoreFactory, + onRedactedEventReceived = FakeOnRedactedEventReceived(onRedactedEventReceived), + onNotifiableEventReceived = FakeOnNotifiableEventReceived(onNotifiableEventsReceived), + featureFlagService = featureFlagService, + syncOnNotifiableEvent = syncOnNotifiableEvent, + elementCallEntryPoint = elementCallEntryPoint, + notificationChannels = notificationChannels, + coroutineScope = coroutineScope, + ) +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/FakeNotifiableEventResolver.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/FakeNotifiableEventResolver.kt index 17ba7448ec..a83f582a58 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/FakeNotifiableEventResolver.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/FakeNotifiableEventResolver.kt @@ -9,18 +9,18 @@ package io.element.android.libraries.push.impl.notifications 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.db.PushRequest import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent import io.element.android.tests.testutils.lambda.lambdaError class FakeNotifiableEventResolver( - private val resolveEventsResult: (SessionId, List) -> Result>> = + private val resolveEventsResult: (SessionId, List) -> Result>> = { _, _ -> lambdaError() } ) : NotifiableEventResolver { override suspend fun resolveEvents( sessionId: SessionId, - notificationEventRequests: List - ): Result>> { + notificationEventRequests: List + ): Result>> { return resolveEventsResult(sessionId, notificationEventRequests) } } diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/FakeNotificationResultProcessor.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/FakeNotificationResultProcessor.kt new file mode 100644 index 0000000000..da73cd4560 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/FakeNotificationResultProcessor.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.notifications + +import io.element.android.libraries.push.impl.db.PushRequest +import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeNotificationResultProcessor( + private val emit: (Map>) -> Unit = { lambdaError() }, + private val start: () -> Unit = { lambdaError() }, + private val stop: () -> Unit = { lambdaError() }, +) : NotificationResultProcessor { + override suspend fun emit(results: Map>) { + return emit.invoke(results) + } + + override fun start() { + start.invoke() + } + + override fun stop() { + stop.invoke() + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fixtures/NotificationEventRequestFixture.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fixtures/PushRequestFixture.kt similarity index 58% rename from libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fixtures/NotificationEventRequestFixture.kt rename to libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fixtures/PushRequestFixture.kt index c450287fbd..4d2e475fa1 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fixtures/NotificationEventRequestFixture.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fixtures/PushRequestFixture.kt @@ -1,6 +1,5 @@ /* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2025 New Vector Ltd. + * Copyright (c) 2026 Element Creations Ltd. * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. * Please see LICENSE files in the repository root for full details. @@ -14,16 +13,22 @@ 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 +import io.element.android.libraries.push.impl.db.PushRequest +import io.element.android.libraries.push.impl.push.PushRequestStatus -fun aNotificationEventRequest( +fun aPushRequest( 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: String = "firebase", + status: PushRequestStatus = PushRequestStatus.PENDING, + retries: Int = 0, +) = PushRequest( + pushDate = System.currentTimeMillis(), providerInfo = providerInfo, + eventId = eventId.value, + roomId = roomId.value, + sessionId = sessionId.value, + status = status.value, + retries = retries.toLong(), ) diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlerTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlerTest.kt index 5d3af86eea..733b2b64be 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlerTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlerTest.kt @@ -11,65 +11,38 @@ package io.element.android.libraries.push.impl.push import app.cash.turbine.test -import com.google.common.truth.Truth.assertThat -import io.element.android.features.call.api.CallType -import io.element.android.features.call.test.FakeElementCallEntryPoint -import io.element.android.libraries.androidutils.json.DefaultJsonProvider 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.RoomId import io.element.android.libraries.matrix.api.core.SessionId -import io.element.android.libraries.matrix.api.core.UserId -import io.element.android.libraries.matrix.api.exception.NotificationResolverException -import io.element.android.libraries.matrix.api.notification.RtcNotificationType -import io.element.android.libraries.matrix.api.timeline.item.event.EventType import io.element.android.libraries.matrix.test.AN_EVENT_ID -import io.element.android.libraries.matrix.test.AN_EVENT_ID_2 import io.element.android.libraries.matrix.test.A_ROOM_ID 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_USER_ID 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.db.PushRequest 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.notifications.DefaultNotificationResolverQueue -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.channels.FakeNotificationChannels -import io.element.android.libraries.push.impl.notifications.fixtures.aFallbackNotifiableEvent -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.model.NotifiableEvent -import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent +import io.element.android.libraries.push.impl.notifications.FakeNotificationResultProcessor import io.element.android.libraries.push.impl.test.DefaultTestPush import io.element.android.libraries.push.impl.troubleshoot.DiagnosticPushHandler -import io.element.android.libraries.push.impl.workmanager.SyncNotificationsWorkerDataConverter import io.element.android.libraries.pushproviders.api.PushData -import io.element.android.libraries.pushstore.api.UserPushStore 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.FakeUserPushStoreFactory import io.element.android.libraries.pushstore.test.userpushstore.clientsecret.FakePushClientSecret -import io.element.android.libraries.workmanager.api.WorkManagerRequest +import io.element.android.libraries.workmanager.api.WorkManagerRequestBuilder import io.element.android.libraries.workmanager.test.FakeWorkManagerScheduler import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider import io.element.android.services.toolbox.test.systemclock.FakeSystemClock -import io.element.android.tests.testutils.lambda.any import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaRecorder -import io.element.android.tests.testutils.lambda.matching import io.element.android.tests.testutils.lambda.value import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runTest import org.junit.Test -import java.time.Instant import kotlin.time.Duration.Companion.milliseconds private const val A_PUSHER_INFO = "info" @@ -96,84 +69,36 @@ class DefaultPushHandlerTest { } @Test - fun `when classical PushData is received, the notification drawer is informed`() = runTest { - val aNotifiableMessageEvent = aNotifiableMessageEvent() - val notifiableEventResult = - lambdaRecorder, Result>>> { _, _ -> - 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 onNotifiableEventsReceived = lambdaRecorder, Unit> {} + fun `when classical PushData is received, the work is scheduled`() = runTest { val incrementPushCounterResult = lambdaRecorder {} - val onPushReceivedResult = lambdaRecorder { _, _, _, _, _, _, _ -> } - val pushHistoryService = FakePushHistoryService( - onPushReceivedResult = onPushReceivedResult, - ) val aPushData = PushData( eventId = AN_EVENT_ID, roomId = A_ROOM_ID, unread = 0, clientSecret = A_SECRET, ) + + val enqueuePushRequestResult = lambdaRecorder> { Result.success(Unit) } + val pushHistoryService = FakePushHistoryService( + enqueuePushRequest = enqueuePushRequestResult, + ) + val submitWorkLambda = lambdaRecorder {} + val workManagerScheduler = FakeWorkManagerScheduler(submitLambda = submitWorkLambda) + val defaultPushHandler = createDefaultPushHandler( - onNotifiableEventsReceived = onNotifiableEventsReceived, - notifiableEventsResult = notifiableEventResult, pushClientSecret = FakePushClientSecret( getUserIdFromSecretResult = { A_USER_ID } ), incrementPushCounterResult = incrementPushCounterResult, + workManagerScheduler = workManagerScheduler, pushHistoryService = pushHistoryService, ) defaultPushHandler.handle(aPushData, A_PUSHER_INFO) advanceTimeBy(300.milliseconds) - incrementPushCounterResult.assertions() + submitWorkLambda.assertions() .isCalledOnce() - notifiableEventResult.assertions() - .isCalledOnce() - .with(value(A_USER_ID), any()) - onNotifiableEventsReceived.assertions() - .isCalledOnce() - .with(value(listOf(aNotifiableMessageEvent))) - onPushReceivedResult.assertions() - .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, Result>>> { _, _ -> - 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 {} - 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 {} - 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() @@ -182,13 +107,6 @@ class DefaultPushHandlerTest { @Test fun `when classical PushData is received, but notifications are disabled, nothing happen`() = runTest { - val aNotifiableMessageEvent = aNotifiableMessageEvent() - val notifiableEventResult = - lambdaRecorder, Result>>> { _, _ -> - 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 onNotifiableEventsReceived = lambdaRecorder, Unit> {} val incrementPushCounterResult = lambdaRecorder {} val aPushData = PushData( eventId = AN_EVENT_ID, @@ -197,12 +115,15 @@ class DefaultPushHandlerTest { clientSecret = A_SECRET, ) val onPushReceivedResult = lambdaRecorder { _, _, _, _, _, _, _ -> } + val enqueuePushRequestResult = lambdaRecorder> { Result.success(Unit) } val pushHistoryService = FakePushHistoryService( onPushReceivedResult = onPushReceivedResult, + enqueuePushRequest = enqueuePushRequestResult, ) + val submitWorkLambda = lambdaRecorder {} + val workManagerScheduler = FakeWorkManagerScheduler(submitLambda = submitWorkLambda) + val defaultPushHandler = createDefaultPushHandler( - onNotifiableEventsReceived = onNotifiableEventsReceived, - notifiableEventsResult = notifiableEventResult, pushClientSecret = FakePushClientSecret( getUserIdFromSecretResult = { A_USER_ID } ), @@ -211,31 +132,24 @@ class DefaultPushHandlerTest { }, incrementPushCounterResult = incrementPushCounterResult, pushHistoryService = pushHistoryService, + workManagerScheduler = workManagerScheduler, ) defaultPushHandler.handle(aPushData, A_PUSHER_INFO) advanceTimeBy(300.milliseconds) + submitWorkLambda.assertions() + .isNeverCalled() + enqueuePushRequestResult.assertions() + .isNeverCalled() incrementPushCounterResult.assertions() .isCalledOnce() - notifiableEventResult.assertions() - .isCalledOnce() - onNotifiableEventsReceived.assertions() - .isNeverCalled() onPushReceivedResult.assertions() - .isCalledOnce() + .isNeverCalled() } @Test - fun `when PushData is received, but client secret is not known, nothing happen`() = - runTest { - val aNotifiableMessageEvent = aNotifiableMessageEvent() - val notifiableEventResult = - lambdaRecorder, Result>>> { _, _ -> - 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 onNotifiableEventsReceived = lambdaRecorder, Unit> {} + fun `when PushData is received, but client secret is not known, nothing happen`() = runTest { val incrementPushCounterResult = lambdaRecorder {} val aPushData = PushData( eventId = AN_EVENT_ID, @@ -247,477 +161,85 @@ class DefaultPushHandlerTest { val pushHistoryService = FakePushHistoryService( onPushReceivedResult = onPushReceivedResult, ) + val submitWorkLambda = lambdaRecorder {} + val workManagerScheduler = FakeWorkManagerScheduler(submitLambda = submitWorkLambda) val defaultPushHandler = createDefaultPushHandler( - onNotifiableEventsReceived = onNotifiableEventsReceived, - notifiableEventsResult = notifiableEventResult, pushClientSecret = FakePushClientSecret( getUserIdFromSecretResult = { null } ), incrementPushCounterResult = incrementPushCounterResult, pushHistoryService = pushHistoryService, + workManagerScheduler = workManagerScheduler, ) defaultPushHandler.handle(aPushData, A_PUSHER_INFO) + submitWorkLambda.assertions() + .isNeverCalled() incrementPushCounterResult.assertions() .isCalledOnce() - notifiableEventResult.assertions() - .isNeverCalled() - onNotifiableEventsReceived.assertions() - .isNeverCalled() onPushReceivedResult.assertions() .isCalledOnce() } @Test - fun `when classical PushData is received, but a failure occurs (session not found), nothing happen`() { - `test notification resolver failure`( - notificationResolveResult = { _ -> - Result.failure(NotificationResolverException.UnknownError("Unable to restore session")) - }, - shouldSetOptimizationBatteryBanner = false, + fun `when diagnostic PushData is received, the diagnostic push handler is informed`() = runTest { + val aPushData = PushData( + eventId = DefaultTestPush.TEST_EVENT_ID, + roomId = A_ROOM_ID, + unread = 0, + clientSecret = A_SECRET, ) - } - - @Test - fun `when classical PushData is received, but not able to resolve the event, the banner to disable battery optimization will be displayed`() { - `test notification resolver failure`( - notificationResolveResult = { requests: List -> - Result.success( - requests.associateWith { Result.failure(NotificationResolverException.UnknownError("Unable to resolve event")) } - ) - }, - shouldSetOptimizationBatteryBanner = true, + val diagnosticPushHandler = DiagnosticPushHandler() + val onPushReceivedResult = lambdaRecorder { _, _, _, _, _, _, _ -> } + val pushHistoryService = FakePushHistoryService( + onPushReceivedResult = onPushReceivedResult, ) - } - - private fun `test notification resolver failure`( - notificationResolveResult: (List) -> Result>>, - shouldSetOptimizationBatteryBanner: Boolean, - ) { - runTest { - val notifiableEventResult = - lambdaRecorder, Result>>> { _, requests -> - notificationResolveResult(requests) - } - val onNotifiableEventsReceived = lambdaRecorder, Unit> {} - val incrementPushCounterResult = lambdaRecorder {} - val aPushData = PushData( - eventId = AN_EVENT_ID, - roomId = A_ROOM_ID, - unread = 0, - clientSecret = A_SECRET, - ) - val onPushReceivedResult = lambdaRecorder { _, _, _, _, _, _, _ -> } - val pushHistoryService = FakePushHistoryService( - onPushReceivedResult = onPushReceivedResult, - ) - val showBatteryOptimizationBannerResult = lambdaRecorder {} - val defaultPushHandler = createDefaultPushHandler( - onNotifiableEventsReceived = onNotifiableEventsReceived, - notifiableEventsResult = notifiableEventResult, - buildMeta = aBuildMeta( - // Also test `lowPrivacyLoggingEnabled = false` here - lowPrivacyLoggingEnabled = false - ), - pushClientSecret = FakePushClientSecret( - getUserIdFromSecretResult = { A_USER_ID } - ), - incrementPushCounterResult = incrementPushCounterResult, - mutableBatteryOptimizationStore = FakeMutableBatteryOptimizationStore( - showBatteryOptimizationBannerResult = showBatteryOptimizationBannerResult, - ), - pushHistoryService = pushHistoryService, - ) + val defaultPushHandler = createDefaultPushHandler( + diagnosticPushHandler = diagnosticPushHandler, + incrementPushCounterResult = { }, + pushHistoryService = pushHistoryService, + ) + diagnosticPushHandler.state.test { defaultPushHandler.handle(aPushData, A_PUSHER_INFO) - - advanceTimeBy(300.milliseconds) - - incrementPushCounterResult.assertions() - .isCalledOnce() - notifiableEventResult.assertions() - .isCalledOnce() - .with(value(A_USER_ID), any()) - onPushReceivedResult.assertions() - .isCalledOnce() - .with(any(), value(AN_EVENT_ID), value(A_ROOM_ID), value(A_USER_ID), value(false), value(true), any()) - showBatteryOptimizationBannerResult.assertions().let { - if (shouldSetOptimizationBatteryBanner) { - it.isCalledOnce() - } else { - it.isNeverCalled() - } - } + awaitItem() } - } - - @Test - fun `when ringing call PushData is received, the incoming call will be handled`() = runTest { - val aPushData = PushData( - eventId = AN_EVENT_ID, - roomId = A_ROOM_ID, - unread = 0, - clientSecret = A_SECRET, - ) - val handleIncomingCallLambda = lambdaRecorder< - CallType.RoomCall, - EventId, - UserId, - String?, - String?, - String?, - String, - String?, - Unit, - > { _, _, _, _, _, _, _, _ -> } - val elementCallEntryPoint = FakeElementCallEntryPoint(handleIncomingCallResult = handleIncomingCallLambda) - val onNotifiableEventsReceived = lambdaRecorder, Unit> {} - val onPushReceivedResult = lambdaRecorder { _, _, _, _, _, _, _ -> } - val pushHistoryService = FakePushHistoryService( - onPushReceivedResult = onPushReceivedResult, - ) - val defaultPushHandler = createDefaultPushHandler( - elementCallEntryPoint = elementCallEntryPoint, - notifiableEventsResult = { _, _ -> - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, A_PUSHER_INFO) - Result.success( - mapOf( - request to Result.success( - ResolvedPushEvent.Event( - aNotifiableCallEvent(rtcNotificationType = RtcNotificationType.RING, timestamp = Instant.now().toEpochMilli()) - ) - ) - ) - ) - }, - incrementPushCounterResult = {}, - pushClientSecret = FakePushClientSecret( - getUserIdFromSecretResult = { A_USER_ID } - ), - onNotifiableEventsReceived = onNotifiableEventsReceived, - pushHistoryService = pushHistoryService, - ) - defaultPushHandler.handle(aPushData, A_PUSHER_INFO) - - advanceTimeBy(300.milliseconds) - - handleIncomingCallLambda.assertions().isCalledOnce() - onNotifiableEventsReceived.assertions().isNeverCalled() - onPushReceivedResult.assertions().isCalledOnce() - } - - @Test - fun `when notify call PushData is received, the incoming call will be treated as a normal notification`() = runTest { - val aPushData = PushData( - eventId = AN_EVENT_ID, - roomId = A_ROOM_ID, - unread = 0, - clientSecret = A_SECRET, - ) - val onNotifiableEventsReceived = lambdaRecorder, Unit> {} - val handleIncomingCallLambda = lambdaRecorder< - CallType.RoomCall, - EventId, - UserId, - String?, - String?, - String?, - String, - String?, - Unit, - > { _, _, _, _, _, _, _, _ -> } - val elementCallEntryPoint = FakeElementCallEntryPoint(handleIncomingCallResult = handleIncomingCallLambda) - val onPushReceivedResult = lambdaRecorder { _, _, _, _, _, _, _ -> } - val pushHistoryService = FakePushHistoryService( - onPushReceivedResult = onPushReceivedResult, - ) - val defaultPushHandler = createDefaultPushHandler( - elementCallEntryPoint = elementCallEntryPoint, - onNotifiableEventsReceived = onNotifiableEventsReceived, - notifiableEventsResult = { _, _ -> - 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(type = EventType.RTC_NOTIFICATION))))) - }, - incrementPushCounterResult = {}, - pushClientSecret = FakePushClientSecret( - getUserIdFromSecretResult = { A_USER_ID } - ), - pushHistoryService = pushHistoryService, - ) - defaultPushHandler.handle(aPushData, A_PUSHER_INFO) - - advanceTimeBy(300.milliseconds) - - handleIncomingCallLambda.assertions().isNeverCalled() - onNotifiableEventsReceived.assertions().isCalledOnce() - onPushReceivedResult.assertions().isCalledOnce() - } - - @Test - fun `when notify call PushData is received, the incoming call will be treated as a normal notification even if notification are disabled`() = runTest { - val aPushData = PushData( - eventId = AN_EVENT_ID, - roomId = A_ROOM_ID, - unread = 0, - clientSecret = A_SECRET, - ) - val onNotifiableEventsReceived = lambdaRecorder, Unit> {} - val handleIncomingCallLambda = lambdaRecorder< - CallType.RoomCall, - EventId, - UserId, - String?, - String?, - String?, - String, - String?, - Unit, - > { _, _, _, _, _, _, _, _ -> } - val elementCallEntryPoint = FakeElementCallEntryPoint(handleIncomingCallResult = handleIncomingCallLambda) - val onPushReceivedResult = lambdaRecorder { _, _, _, _, _, _, _ -> } - val pushHistoryService = FakePushHistoryService( - onPushReceivedResult = onPushReceivedResult, - ) - val defaultPushHandler = createDefaultPushHandler( - elementCallEntryPoint = elementCallEntryPoint, - onNotifiableEventsReceived = onNotifiableEventsReceived, - notifiableEventsResult = { _, _ -> - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, A_PUSHER_INFO) - Result.success(mapOf(request to Result.success(ResolvedPushEvent.Event(aNotifiableCallEvent())))) - }, - incrementPushCounterResult = {}, - userPushStore = FakeUserPushStore().apply { - setNotificationEnabledForDevice(false) - }, - pushClientSecret = FakePushClientSecret( - getUserIdFromSecretResult = { A_USER_ID } - ), - pushHistoryService = pushHistoryService, - ) - defaultPushHandler.handle(aPushData, A_PUSHER_INFO) - - advanceTimeBy(300.milliseconds) - - handleIncomingCallLambda.assertions().isCalledOnce() - onNotifiableEventsReceived.assertions().isNeverCalled() - onPushReceivedResult.assertions().isCalledOnce() - } - - @Test - fun `when a redaction is received, the onRedactedEventReceived is informed`() = runTest { - val aPushData = PushData( - eventId = AN_EVENT_ID, - roomId = A_ROOM_ID, - unread = 0, - clientSecret = A_SECRET, - ) - val aRedaction = ResolvedPushEvent.Redaction( - sessionId = A_SESSION_ID, - roomId = A_ROOM_ID, - redactedEventId = AN_EVENT_ID_2, - reason = null - ) - val onRedactedEventReceived = lambdaRecorder, Unit> { } - val incrementPushCounterResult = lambdaRecorder {} - val onPushReceivedResult = lambdaRecorder { _, _, _, _, _, _, _ -> } - val pushHistoryService = FakePushHistoryService( - onPushReceivedResult = onPushReceivedResult, - ) - val defaultPushHandler = createDefaultPushHandler( - onRedactedEventsReceived = onRedactedEventReceived, - incrementPushCounterResult = incrementPushCounterResult, - notifiableEventsResult = { _, _ -> - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, A_PUSHER_INFO) - Result.success(mapOf(request to Result.success(aRedaction))) - }, - pushClientSecret = FakePushClientSecret( - getUserIdFromSecretResult = { A_USER_ID } - ), - pushHistoryService = pushHistoryService, - ) - defaultPushHandler.handle(aPushData, A_PUSHER_INFO) - - advanceTimeBy(300.milliseconds) - - incrementPushCounterResult.assertions() - .isCalledOnce() - onRedactedEventReceived.assertions().isCalledOnce() - .with(value(listOf(aRedaction))) onPushReceivedResult.assertions() .isCalledOnce() } - @Test - fun `when diagnostic PushData is received, the diagnostic push handler is informed`() = - runTest { - val aPushData = PushData( - eventId = DefaultTestPush.TEST_EVENT_ID, - roomId = A_ROOM_ID, - unread = 0, - clientSecret = A_SECRET, - ) - val diagnosticPushHandler = DiagnosticPushHandler() - val onPushReceivedResult = lambdaRecorder { _, _, _, _, _, _, _ -> } - val pushHistoryService = FakePushHistoryService( - onPushReceivedResult = onPushReceivedResult, - ) - val defaultPushHandler = createDefaultPushHandler( - diagnosticPushHandler = diagnosticPushHandler, - incrementPushCounterResult = { }, - pushHistoryService = pushHistoryService, - ) - diagnosticPushHandler.state.test { - defaultPushHandler.handle(aPushData, A_PUSHER_INFO) - awaitItem() - } - onPushReceivedResult.assertions() - .isCalledOnce() - } - - @Test - fun `when receiving several push notifications at the same time, those are batched before being processed`() = runTest { - val aNotifiableMessageEvent = aNotifiableMessageEvent() - val notifiableEventResult = - lambdaRecorder, Result>>> { _, _ -> - 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 onNotifiableEventsReceived = lambdaRecorder, Unit> {} - val incrementPushCounterResult = lambdaRecorder {} - val onPushReceivedResult = lambdaRecorder { _, _, _, _, _, _, _ -> } - val pushHistoryService = FakePushHistoryService( - onPushReceivedResult = onPushReceivedResult, - ) - val aPushData = PushData( - eventId = AN_EVENT_ID, - roomId = A_ROOM_ID, - unread = 0, - clientSecret = A_SECRET, - ) - val anotherPushData = PushData( - eventId = AN_EVENT_ID_2, - roomId = A_ROOM_ID, - unread = 0, - clientSecret = A_SECRET, - ) - val defaultPushHandler = createDefaultPushHandler( - onNotifiableEventsReceived = onNotifiableEventsReceived, - notifiableEventsResult = notifiableEventResult, - pushClientSecret = FakePushClientSecret( - getUserIdFromSecretResult = { A_USER_ID } - ), - incrementPushCounterResult = incrementPushCounterResult, - pushHistoryService = pushHistoryService, - ) - defaultPushHandler.handle(aPushData, A_PUSHER_INFO) - defaultPushHandler.handle(anotherPushData, A_PUSHER_INFO) - - advanceTimeBy(300.milliseconds) - - incrementPushCounterResult.assertions() - .isCalledExactly(2) - notifiableEventResult.assertions() - .isCalledOnce() - .with(value(A_USER_ID), matching> { requests -> - requests.size == 2 && requests.first().eventId == AN_EVENT_ID && requests.last().eventId == AN_EVENT_ID_2 - }) - onNotifiableEventsReceived.assertions() - .isCalledOnce() - onPushReceivedResult.assertions() - .isCalledExactly(2) - } - - @Test - fun `when receiving a fallback event, we notify the push history service about it not being resolved`() = runTest { - val aNotifiableFallbackEvent = aFallbackNotifiableEvent() - val notifiableEventResult = - lambdaRecorder, Result>>> { _, _ -> - val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, A_PUSHER_INFO) - Result.success(mapOf(request to Result.success(ResolvedPushEvent.Event(aNotifiableFallbackEvent)))) - } - val onNotifiableEventsReceived = lambdaRecorder, Unit> {} - val incrementPushCounterResult = lambdaRecorder {} - var receivedFallbackEvent = false - val onPushReceivedResult = - lambdaRecorder { _, _, _, _, isResolved, _, comment -> - receivedFallbackEvent = !isResolved && comment == "Unable to resolve event: ${aNotifiableFallbackEvent.cause}" - } - val pushHistoryService = FakePushHistoryService( - onPushReceivedResult = onPushReceivedResult, - ) - val aPushData = PushData( - eventId = AN_EVENT_ID, - roomId = A_ROOM_ID, - unread = 0, - clientSecret = A_SECRET, - ) - val defaultPushHandler = createDefaultPushHandler( - onNotifiableEventsReceived = onNotifiableEventsReceived, - notifiableEventsResult = notifiableEventResult, - pushClientSecret = FakePushClientSecret( - getUserIdFromSecretResult = { A_USER_ID } - ), - incrementPushCounterResult = incrementPushCounterResult, - pushHistoryService = pushHistoryService, - ) - defaultPushHandler.handle(aPushData, A_PUSHER_INFO) - - advanceTimeBy(300.milliseconds) - - onNotifiableEventsReceived.assertions().isCalledOnce() - - assertThat(receivedFallbackEvent).isTrue() - } - - private fun TestScope.createDefaultPushHandler( - onNotifiableEventsReceived: (List) -> Unit = { lambdaError() }, - onRedactedEventsReceived: (List) -> Unit = { lambdaError() }, - notifiableEventsResult: (SessionId, List) -> Result>> = - { _, _ -> lambdaError() }, + private fun createDefaultPushHandler( incrementPushCounterResult: () -> Unit = { lambdaError() }, - mutableBatteryOptimizationStore: MutableBatteryOptimizationStore = FakeMutableBatteryOptimizationStore(), - userPushStore: UserPushStore = FakeUserPushStore(), + userPushStore: FakeUserPushStore = FakeUserPushStore(), pushClientSecret: PushClientSecret = FakePushClientSecret(), buildMeta: BuildMeta = aBuildMeta(), diagnosticPushHandler: DiagnosticPushHandler = DiagnosticPushHandler(), - elementCallEntryPoint: FakeElementCallEntryPoint = FakeElementCallEntryPoint(), - notificationChannels: FakeNotificationChannels = FakeNotificationChannels(), pushHistoryService: PushHistoryService = FakePushHistoryService(), - syncOnNotifiableEvent: SyncOnNotifiableEvent = SyncOnNotifiableEvent {}, - featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.SyncNotificationsWithWorkManager.key to false)), workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(), analyticsService: FakeAnalyticsService = FakeAnalyticsService(), + systemClock: FakeSystemClock = FakeSystemClock(), + buildVersionSdkIntProvider: FakeBuildVersionSdkIntProvider = FakeBuildVersionSdkIntProvider(33), + resultProcessor: FakeNotificationResultProcessor = FakeNotificationResultProcessor( + emit = { Result.success(Unit) }, + start = {}, + stop = {}, + ), ): DefaultPushHandler { return DefaultPushHandler( - onNotifiableEventReceived = FakeOnNotifiableEventReceived(onNotifiableEventsReceived), - onRedactedEventReceived = FakeOnRedactedEventReceived(onRedactedEventsReceived), incrementPushDataStore = object : IncrementPushDataStore { override suspend fun incrementPushCounter() { incrementPushCounterResult() } }, - mutableBatteryOptimizationStore = mutableBatteryOptimizationStore, userPushStoreFactory = FakeUserPushStoreFactory { userPushStore }, pushClientSecret = pushClientSecret, buildMeta = buildMeta, diagnosticPushHandler = diagnosticPushHandler, - elementCallEntryPoint = elementCallEntryPoint, - notificationChannels = notificationChannels, pushHistoryService = pushHistoryService, // 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, - workerDataConverter = SyncNotificationsWorkerDataConverter(DefaultJsonProvider()), - buildVersionSdkIntProvider = FakeBuildVersionSdkIntProvider(33), - ), - appCoroutineScope = backgroundScope, - fallbackNotificationFactory = FallbackNotificationFactory( - clock = FakeSystemClock(), - ), - syncOnNotifiableEvent = syncOnNotifiableEvent, - featureFlagService = featureFlagService, analyticsService = analyticsService, + systemClock = systemClock, + workManagerScheduler = workManagerScheduler, + buildVersionSdkIntProvider = buildVersionSdkIntProvider, + resultProcessor = resultProcessor, ) } } diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/SyncOnNotifiableEventTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/SyncOnNotifiableEventTest.kt index 6c88e7bf12..0032e95029 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/SyncOnNotifiableEventTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/SyncOnNotifiableEventTest.kt @@ -20,8 +20,7 @@ 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.FakeJoinedRoom import io.element.android.libraries.matrix.test.sync.FakeSyncService -import io.element.android.libraries.push.api.push.SyncOnNotifiableEvent -import io.element.android.libraries.push.impl.notifications.fixtures.aNotificationEventRequest +import io.element.android.libraries.push.impl.notifications.fixtures.aPushRequest import io.element.android.services.appnavstate.test.FakeAppForegroundStateService import io.element.android.tests.testutils.lambda.assert import io.element.android.tests.testutils.lambda.lambdaRecorder @@ -53,7 +52,7 @@ class SyncOnNotifiableEventTest { givenGetRoomResult(A_ROOM_ID, room) } - private val notificationRequest = aNotificationEventRequest() + private val notificationRequest = aPushRequest() @Test fun `when feature flag is disabled, nothing happens`() = runTest { diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/FetchNotificationWorkerTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/FetchNotificationWorkerTest.kt deleted file mode 100644 index 99451027a8..0000000000 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/FetchNotificationWorkerTest.kt +++ /dev/null @@ -1,210 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.libraries.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.androidutils.json.DefaultJsonProvider -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.services.analytics.test.FakeAnalyticsService -import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider -import io.element.android.tests.testutils.lambda.lambdaRecorder -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.advanceTimeBy -import kotlinx.coroutines.test.runTest -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 {} - 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 {}, - analyticsService: FakeAnalyticsService = FakeAnalyticsService(), - ) = FetchNotificationsWorker( - params = createWorkerParams(workDataOf("requests" to input)), - context = InstrumentationRegistry.getInstrumentation().context, - networkMonitor = networkMonitor, - eventResolver = eventResolver, - queue = queue, - workManagerScheduler = workManagerScheduler, - syncOnNotifiableEvent = syncOnNotifiableEvent, - workerDataConverter = SyncNotificationsWorkerDataConverter(DefaultJsonProvider()), - buildVersionSdkIntProvider = FakeBuildVersionSdkIntProvider(33), - analyticsService = analyticsService, - ) - - 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 : ListenableFuture { - 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 -} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/FetchPendingNotificationWorkerTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/FetchPendingNotificationWorkerTest.kt new file mode 100644 index 0000000000..b39c3d3f05 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/FetchPendingNotificationWorkerTest.kt @@ -0,0 +1,278 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.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.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.exception.ClientException +import io.element.android.libraries.push.impl.db.PushRequest +import io.element.android.libraries.push.impl.history.FakePushHistoryService +import io.element.android.libraries.push.impl.notifications.FakeNotifiableEventResolver +import io.element.android.libraries.push.impl.notifications.FakeNotificationResultProcessor +import io.element.android.libraries.push.impl.notifications.fixtures.aPushRequest +import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent +import io.element.android.libraries.push.impl.push.SyncOnNotifiableEvent +import io.element.android.libraries.workmanager.api.WorkManagerRequestBuilder +import io.element.android.libraries.workmanager.api.di.MetroWorkerFactory +import io.element.android.services.analytics.test.FakeAnalyticsService +import io.element.android.services.toolbox.test.systemclock.FakeSystemClock +import io.element.android.tests.testutils.lambda.lambdaRecorder +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +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 +import kotlin.time.Instant + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(AndroidJUnit4::class) +class FetchPendingNotificationWorkerTest { + @Test + fun `test - success`() = runTest { + var synced = false + val syncOnNotifiableEventLambda = SyncOnNotifiableEvent { synced = true } + val emitResultLambda = lambdaRecorder>, Unit> {} + val processor = FakeNotificationResultProcessor(emit = emitResultLambda) + + val getPendingResultsLambda = lambdaRecorder>> { _, _ -> Result.success(listOf(aPushRequest())) } + val replacePushRequestsLambda = lambdaRecorder, Result> { Result.success(Unit) } + val removeOldPushRequestsLambda = lambdaRecorder> { Result.success(Unit) } + val pushHistoryService = FakePushHistoryService( + getPendingPushRequests = getPendingResultsLambda, + replacePushRequests = replacePushRequestsLambda, + removeOldPushRequests = removeOldPushRequestsLambda, + ) + + val worker = createWorker( + input = "@alice:matrix.org", + pushHistoryService = pushHistoryService, + resultProcessor = processor, + syncOnNotifiableEvent = syncOnNotifiableEventLambda, + ) + + val result = worker.doWork() + + // The expected data is fetched and replaced from the service + getPendingResultsLambda.assertions().isCalledOnce() + replacePushRequestsLambda.assertions().isCalledOnce() + removeOldPushRequestsLambda.assertions().isCalledOnce() + + // The process finished successfully + assertThat(result).isEqualTo(ListenableWorker.Result.success()) + + // A result was emitted + emitResultLambda.assertions().isCalledOnce() + + // An opportunistic sync was triggered + assertThat(synced).isTrue() + } + + @Test + fun `test - invalid input fails the work`() = runTest { + val worker = createWorker(input = "!alice:matrix.org") + + 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 emitResultLambda = lambdaRecorder>, Unit> {} + val processor = FakeNotificationResultProcessor(emit = emitResultLambda) + val pushHistoryService = FakePushHistoryService( + getPendingPushRequests = { _, _ -> Result.success(listOf(aPushRequest())) }, + replacePushRequests = { Result.success(Unit) }, + removeOldPushRequests = { Result.success(Unit) }, + ) + val worker = createWorker( + input = "@alice:matrix.org", + networkMonitor = networkMonitor, + resultProcessor = processor, + pushHistoryService = pushHistoryService, + ) + + 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 setup retries the work`() = runTest { + val submitWorkerLambda = lambdaRecorder {} + val emitResultLambda = lambdaRecorder>, Unit> {} + val processor = FakeNotificationResultProcessor(emit = emitResultLambda) + val pushHistoryService = FakePushHistoryService( + getPendingPushRequests = { _, _ -> Result.success(listOf(aPushRequest())) }, + replacePushRequests = { Result.success(Unit) }, + removeOldPushRequests = { Result.success(Unit) }, + ) + + val resolver = FakeNotifiableEventResolver( + resolveEventsResult = { _, _ -> Result.failure(Exception("Failed to resolve events")) } + ) + + val worker = createWorker( + input = "@alice:matrix.org", + eventResolver = resolver, + resultProcessor = processor, + pushHistoryService = pushHistoryService, + ) + + val result = worker.doWork() + + assertThat(result).isEqualTo(ListenableWorker.Result.retry()) + // Never called since we don't need to re-submit + submitWorkerLambda.assertions().isNeverCalled() + } + + @Test + fun `test - failing to resolve events with recoverable error retries the work`() { + val pushRequest = aPushRequest() + runTest { + val submitWorkerLambda = lambdaRecorder {} + val emitResultLambda = lambdaRecorder>, Unit> {} + val processor = FakeNotificationResultProcessor(emit = emitResultLambda) + val pushHistoryService = FakePushHistoryService( + getPendingPushRequests = { _, _ -> Result.success(listOf(pushRequest)) }, + replacePushRequests = { Result.success(Unit) }, + removeOldPushRequests = { Result.success(Unit) }, + ) + + val resolver = FakeNotifiableEventResolver( + resolveEventsResult = { _, _ -> + Result.success(mapOf(pushRequest to Result.failure(ClientException.Generic("error sending request for url", null)))) + } + ) + + val worker = createWorker( + input = "@alice:matrix.org", + eventResolver = resolver, + resultProcessor = processor, + pushHistoryService = pushHistoryService, + ) + + val result = worker.doWork() + + assertThat(result).isEqualTo(ListenableWorker.Result.retry()) + + // Never called since we don't need to re-submit + submitWorkerLambda.assertions().isNeverCalled() + + // We do save the updated events to the push DB + emitResultLambda.assertions().isCalledOnce() + } + } + + @Test + fun `test - failing to resolve events with unrecoverable error saves the new state and ends as success`() { + val pushRequest = aPushRequest() + runTest { + val submitWorkerLambda = lambdaRecorder {} + val emitResultLambda = lambdaRecorder>, Unit> {} + val processor = FakeNotificationResultProcessor(emit = emitResultLambda) + val pushHistoryService = FakePushHistoryService( + getPendingPushRequests = { _, _ -> Result.success(listOf(pushRequest)) }, + replacePushRequests = { Result.success(Unit) }, + removeOldPushRequests = { Result.success(Unit) }, + ) + + val resolver = FakeNotifiableEventResolver( + resolveEventsResult = { _, _ -> + Result.success(mapOf(pushRequest to Result.failure(IllegalStateException("Unrecoverable")))) + } + ) + + val worker = createWorker( + input = "@alice:matrix.org", + eventResolver = resolver, + resultProcessor = processor, + pushHistoryService = pushHistoryService, + ) + + val result = worker.doWork() + + assertThat(result).isEqualTo(ListenableWorker.Result.success()) + + // Never called since we don't need to re-submit + submitWorkerLambda.assertions().isNeverCalled() + + // We do save the updated events to the push DB + emitResultLambda.assertions().isCalledOnce() + } + } + + private fun TestScope.createWorker( + input: String, + networkMonitor: FakeNetworkMonitor = FakeNetworkMonitor(), + eventResolver: FakeNotifiableEventResolver = FakeNotifiableEventResolver(resolveEventsResult = { _, _ -> Result.success(emptyMap()) }), + syncOnNotifiableEvent: SyncOnNotifiableEvent = SyncOnNotifiableEvent {}, + analyticsService: FakeAnalyticsService = FakeAnalyticsService(), + pushHistoryService: FakePushHistoryService = FakePushHistoryService(), + resultProcessor: FakeNotificationResultProcessor = FakeNotificationResultProcessor(), + systemClock: FakeSystemClock = FakeSystemClock(), + ) = FetchPendingNotificationsWorker( + params = createWorkerParams(workDataOf("session_id" to input)), + context = InstrumentationRegistry.getInstrumentation().context, + networkMonitor = networkMonitor, + eventResolver = eventResolver, + syncOnNotifiableEvent = syncOnNotifiableEvent, + analyticsService = analyticsService, + pushHistoryService = pushHistoryService, + resultProcessor = resultProcessor, + systemClock = systemClock, + ) + + 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 : ListenableFuture { + 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 +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/SyncNotificationWorkManagerRequestTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/SyncNotificationWorkManagerRequestTest.kt deleted file mode 100644 index 1f8d646e2b..0000000000 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/SyncNotificationWorkManagerRequestTest.kt +++ /dev/null @@ -1,98 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.libraries.push.impl.workmanager - -import androidx.work.OneTimeWorkRequest -import androidx.work.hasKeyWithValueOfType -import com.google.common.truth.Truth.assertThat -import io.element.android.libraries.androidutils.json.DefaultJsonProvider -import io.element.android.libraries.matrix.api.core.SessionId -import io.element.android.libraries.matrix.test.A_SESSION_ID -import io.element.android.libraries.push.api.push.NotificationEventRequest -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 io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider -import kotlinx.coroutines.test.runTest -import org.junit.Test -import kotlin.collections.first - -class SyncNotificationWorkManagerRequestTest { - @Test - fun `build - success API 33`() = runTest { - val request = createSyncNotificationWorkManagerRequest( - sessionId = A_SESSION_ID, - notificationEventRequests = listOf(aNotificationEventRequest()), - sdkVersion = 33, - ) - - val result = request.build() - assertThat(result.isSuccess).isTrue() - result.getOrNull()!!.first().run { - assertThat(this).isInstanceOf(OneTimeWorkRequest::class.java) - assertThat(workSpec.input.hasKeyWithValueOfType("requests")).isTrue() - // True in API 33+ - assertThat(workSpec.expedited).isTrue() - assertThat(workSpec.traceTag).isEqualTo(workManagerTag(A_SESSION_ID, WorkManagerRequestType.NOTIFICATION_SYNC)) - } - } - - @Test - fun `build - success API 32 and lower`() = runTest { - val request = createSyncNotificationWorkManagerRequest( - sessionId = A_SESSION_ID, - notificationEventRequests = listOf(aNotificationEventRequest()), - sdkVersion = 32, - ) - - val result = request.build() - assertThat(result.isSuccess).isTrue() - result.getOrNull()!!.first().run { - assertThat(this).isInstanceOf(OneTimeWorkRequest::class.java) - assertThat(workSpec.input.hasKeyWithValueOfType("requests")).isTrue() - // False before API 33 - assertThat(workSpec.expedited).isFalse() - assertThat(workSpec.traceTag).isEqualTo(workManagerTag(A_SESSION_ID, WorkManagerRequestType.NOTIFICATION_SYNC)) - } - } - - @Test - fun `build - empty list of requests fails`() = runTest { - val request = createSyncNotificationWorkManagerRequest( - sessionId = A_SESSION_ID, - notificationEventRequests = emptyList() - ) - - val result = request.build() - assertThat(result.isFailure).isTrue() - } - - @Test - fun `build - invalid serialization`() = runTest { - val request = createSyncNotificationWorkManagerRequest( - sessionId = A_SESSION_ID, - notificationEventRequests = listOf(aNotificationEventRequest()), - workerDataConverter = SyncNotificationsWorkerDataConverter({ error("error during serialization") }) - ) - val result = request.build() - assertThat(result.isFailure).isTrue() - } -} - -private fun createSyncNotificationWorkManagerRequest( - sessionId: SessionId, - notificationEventRequests: List, - workerDataConverter: SyncNotificationsWorkerDataConverter = SyncNotificationsWorkerDataConverter(DefaultJsonProvider()), - sdkVersion: Int = 33, -) = SyncNotificationWorkManagerRequest( - sessionId = sessionId, - notificationEventRequests = notificationEventRequests, - workerDataConverter = workerDataConverter, - buildVersionSdkIntProvider = FakeBuildVersionSdkIntProvider(sdkVersion), -) diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/SyncPendingNotificationsRequestBuilderTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/SyncPendingNotificationsRequestBuilderTest.kt new file mode 100644 index 0000000000..c7d54973e3 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/SyncPendingNotificationsRequestBuilderTest.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.push.impl.workmanager + +import androidx.work.OneTimeWorkRequest +import androidx.work.hasKeyWithValueOfType +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.workmanager.api.WorkManagerRequestType +import io.element.android.libraries.workmanager.api.WorkManagerWorkerType +import io.element.android.libraries.workmanager.api.workManagerTag +import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class SyncPendingNotificationsRequestBuilderTest { + @Test + fun `build - success API 33`() = runTest { + val request = createSyncPendingNotificationsRequestBuilder( + sessionId = A_SESSION_ID, + sdkVersion = 33, + ) + + val results = request.build() + assertThat(results.isSuccess).isTrue() + results.getOrNull()!!.first().let { result -> + assertThat(result.type).isInstanceOf(WorkManagerWorkerType.Unique::class.java) + result.request.run { + assertThat(this).isInstanceOf(OneTimeWorkRequest::class.java) + assertThat(workSpec.input.hasKeyWithValueOfType(SyncPendingNotificationsRequestBuilder.SESSION_ID)).isTrue() + // True in API 33+ + assertThat(workSpec.expedited).isTrue() + assertThat(workSpec.traceTag).isEqualTo(workManagerTag(A_SESSION_ID, WorkManagerRequestType.NOTIFICATION_SYNC)) + } + } + } + + @Test + fun `build - success API 32 and lower`() = runTest { + val request = createSyncPendingNotificationsRequestBuilder( + sessionId = A_SESSION_ID, + sdkVersion = 32, + ) + + val results = request.build() + assertThat(results.isSuccess).isTrue() + + results.getOrNull()!!.first().let { result -> + assertThat(result.type).isInstanceOf(WorkManagerWorkerType.Unique::class.java) + result.request.run { + assertThat(this).isInstanceOf(OneTimeWorkRequest::class.java) + assertThat(workSpec.input.hasKeyWithValueOfType(SyncPendingNotificationsRequestBuilder.SESSION_ID)).isTrue() + // False before API 33 + assertThat(workSpec.expedited).isFalse() + assertThat(workSpec.traceTag).isEqualTo(workManagerTag(A_SESSION_ID, WorkManagerRequestType.NOTIFICATION_SYNC)) + } + } + } +} + +private fun createSyncPendingNotificationsRequestBuilder( + sessionId: SessionId, + sdkVersion: Int = 33, +) = SyncPendingNotificationsRequestBuilder( + sessionId = sessionId, + buildVersionSdkIntProvider = FakeBuildVersionSdkIntProvider(sdkVersion), +) diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/WorkerDataConverterTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/WorkerDataConverterTest.kt deleted file mode 100644 index 85b55e0d62..0000000000 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/WorkerDataConverterTest.kt +++ /dev/null @@ -1,141 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.libraries.push.impl.workmanager - -import com.google.common.truth.Truth.assertThat -import io.element.android.libraries.androidutils.json.DefaultJsonProvider -import io.element.android.libraries.matrix.api.core.EventId -import io.element.android.libraries.matrix.test.AN_EVENT_ID -import io.element.android.libraries.matrix.test.AN_EVENT_ID_2 -import io.element.android.libraries.matrix.test.A_ROOM_ID -import io.element.android.libraries.matrix.test.A_ROOM_ID_2 -import io.element.android.libraries.matrix.test.A_ROOM_ID_3 -import io.element.android.libraries.matrix.test.A_SESSION_ID -import io.element.android.libraries.matrix.test.A_SESSION_ID_2 -import io.element.android.libraries.push.api.push.NotificationEventRequest -import org.junit.Test - -class WorkerDataConverterTest { - @Test - fun `ensure identity when serializing - deserializing an empty list`() { - testIdentity(emptyList()) - } - - @Test - fun `ensure identity when serializing - deserializing a list`() { - testIdentity( - listOf( - NotificationEventRequest( - sessionId = A_SESSION_ID, - roomId = A_ROOM_ID, - eventId = AN_EVENT_ID, - providerInfo = "info1", - ), - NotificationEventRequest( - sessionId = A_SESSION_ID_2, - roomId = A_ROOM_ID_2, - eventId = AN_EVENT_ID_2, - providerInfo = "info2", - ), - ) - ) - } - - @Test - fun `serializing lots of data leads to several work data generated - one room - 100 events should be split in 5 chunks`() { - val data = List(100) { - NotificationEventRequest( - sessionId = A_SESSION_ID, - roomId = A_ROOM_ID, - eventId = EventId(AN_EVENT_ID.value + it), - providerInfo = "info$it", - ) - } - val sut = SyncNotificationsWorkerDataConverter(DefaultJsonProvider()) - val serialized = sut.serialize(data) - assertThat(serialized.getOrNull()?.size).isGreaterThan(1) - assertThat(serialized.getOrNull()?.size).isEqualTo(100 / SyncNotificationsWorkerDataConverter.CHUNK_SIZE) - // All the items are present - val deserialized = serialized.getOrNull()?.flatMap { sut.deserialize(it)!! } - assertThat(deserialized).containsExactlyElementsIn(data) - } - - @Test - fun `serializing lots of data leads to several work data generated - one room - 101 events should be split in 6 chunks`() { - val data = List(101) { - NotificationEventRequest( - sessionId = A_SESSION_ID, - roomId = A_ROOM_ID, - eventId = EventId(AN_EVENT_ID.value + it), - providerInfo = "info$it", - ) - } - val sut = SyncNotificationsWorkerDataConverter(DefaultJsonProvider()) - val serialized = sut.serialize(data) - assertThat(serialized.getOrNull()?.size).isGreaterThan(1) - assertThat(serialized.getOrNull()?.size).isEqualTo(100 / SyncNotificationsWorkerDataConverter.CHUNK_SIZE + 1) - // All the items are present - val deserialized = serialized.getOrNull()?.flatMap { sut.deserialize(it)!! } - assertThat(deserialized).containsExactlyElementsIn(data) - } - - @Test - fun `serializing lots of data leads to several work data generated - 3 rooms - 25 events should be split in 2 chunks and room not mixed`() { - val data1 = List(15) { - NotificationEventRequest( - sessionId = A_SESSION_ID, - roomId = A_ROOM_ID, - eventId = EventId(AN_EVENT_ID.value + it), - providerInfo = "info".repeat(100) + it, - ) - } - val data2 = List(3) { - NotificationEventRequest( - sessionId = A_SESSION_ID, - roomId = A_ROOM_ID_2, - eventId = EventId(AN_EVENT_ID.value + it), - providerInfo = "info".repeat(100) + it, - ) - } - val data3 = List(7) { - NotificationEventRequest( - sessionId = A_SESSION_ID, - roomId = A_ROOM_ID_3, - eventId = EventId(AN_EVENT_ID.value + it), - providerInfo = "info".repeat(100) + it, - ) - } - val data = (data1 + data2 + data3).shuffled() - val sut = SyncNotificationsWorkerDataConverter(DefaultJsonProvider()) - val serialized = sut.serialize(data) - assertThat(serialized.getOrNull()?.size).isEqualTo(2) - // All the items are present - val deserialized = serialized.getOrNull()?.flatMap { sut.deserialize(it)!! } - assertThat(deserialized).containsExactlyElementsIn(data) - // Rooms are not mixed between the chunks - val setsOfRooms = serialized.getOrNull()!! - .map { workData -> sut.deserialize(workData)!! } - .map { - it.map { request -> request.roomId }.toSet() - } - // Ensure that all sets are distinct - assertThat(setsOfRooms.size).isEqualTo(2) - // 3 roomId are present - assertThat(setsOfRooms.flatten().toSet()).containsExactly(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3) - // No intersection between sets - assertThat(setsOfRooms[0].intersect(setsOfRooms[1])).isEmpty() - } - - private fun testIdentity(data: List) { - val sut = SyncNotificationsWorkerDataConverter(DefaultJsonProvider()) - val serialized = sut.serialize(data).getOrThrow() - val result = sut.deserialize(serialized.first()) - assertThat(result).isEqualTo(data) - } -} diff --git a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeNotificationResolverQueue.kt b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeNotificationResolverQueue.kt deleted file mode 100644 index d4279ab028..0000000000 --- a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeNotificationResolverQueue.kt +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.libraries.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, -) : NotificationResolverQueue { - override val results = MutableSharedFlow, Map>>>(replay = 1) - - override suspend fun enqueue(request: NotificationEventRequest) { - results.emit(listOf(request) to mapOf(request to processingLambda(request))) - } -} diff --git a/libraries/session-storage/impl/src/main/sqldelight/migrations/0.sqm b/libraries/session-storage/impl/src/main/sqldelight/migrations/0.sqm index 4577105e3d..238e4514f0 100644 --- a/libraries/session-storage/impl/src/main/sqldelight/migrations/0.sqm +++ b/libraries/session-storage/impl/src/main/sqldelight/migrations/0.sqm @@ -1,4 +1,4 @@ --- This file is not striclty necessary, since the first +-- This file is not strictly necessary, since the first -- version of the DB is 1, so we will never migrate from 0 CREATE TABLE SessionData ( diff --git a/libraries/workmanager/api/src/main/kotlin/io/element/android/libraries/workmanager/api/WorkManagerRequest.kt b/libraries/workmanager/api/src/main/kotlin/io/element/android/libraries/workmanager/api/WorkManagerRequest.kt deleted file mode 100644 index af49c3dc87..0000000000 --- a/libraries/workmanager/api/src/main/kotlin/io/element/android/libraries/workmanager/api/WorkManagerRequest.kt +++ /dev/null @@ -1,15 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.libraries.workmanager.api - -import androidx.work.WorkRequest - -interface WorkManagerRequest { - fun build(): Result> -} diff --git a/libraries/workmanager/api/src/main/kotlin/io/element/android/libraries/workmanager/api/WorkManagerRequestBuilder.kt b/libraries/workmanager/api/src/main/kotlin/io/element/android/libraries/workmanager/api/WorkManagerRequestBuilder.kt new file mode 100644 index 0000000000..a82ddf165d --- /dev/null +++ b/libraries/workmanager/api/src/main/kotlin/io/element/android/libraries/workmanager/api/WorkManagerRequestBuilder.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.workmanager.api + +import androidx.work.ExistingWorkPolicy +import androidx.work.WorkRequest + +/** + * A base class that can be customized to [build] work requests to schedule in `WorkManager`. + */ +interface WorkManagerRequestBuilder { + /** + * Builds a work request wrapper using the provided data. + */ + suspend fun build(): Result> +} + +/** + * A wrapper that allows us to avoid using Android APIs directly when scheduling workers. + */ +data class WorkManagerRequestWrapper( + val request: WorkRequest, + val type: WorkManagerWorkerType = WorkManagerWorkerType.Default, +) + +/** + * The type of worker to use when scheduling the task. + */ +sealed interface WorkManagerWorkerType { + /** + * This allows a single worker instance with the [name] id to run at the same time. Its [policy] can be customized. + */ + data class Unique(val name: String, val policy: ExistingWorkPolicy) : WorkManagerWorkerType + + /** + * The default worker type, with no custom rules. + */ + data object Default : WorkManagerWorkerType +} diff --git a/libraries/workmanager/api/src/main/kotlin/io/element/android/libraries/workmanager/api/WorkManagerScheduler.kt b/libraries/workmanager/api/src/main/kotlin/io/element/android/libraries/workmanager/api/WorkManagerScheduler.kt index b538486d35..a616e01573 100644 --- a/libraries/workmanager/api/src/main/kotlin/io/element/android/libraries/workmanager/api/WorkManagerScheduler.kt +++ b/libraries/workmanager/api/src/main/kotlin/io/element/android/libraries/workmanager/api/WorkManagerScheduler.kt @@ -11,9 +11,21 @@ package io.element.android.libraries.workmanager.api import io.element.android.libraries.matrix.api.core.SessionId interface WorkManagerScheduler { - fun submit(workManagerRequest: WorkManagerRequest) + /** + * Submits a new work request built from [workManagerRequestBuilder] to run in `WorkManager`. + */ + suspend fun submit(workManagerRequestBuilder: WorkManagerRequestBuilder) + + /** + * Checks if there are any pending requests scheduled for the provided [sessionId] and [requestType]. + */ fun hasPendingWork(sessionId: SessionId, requestType: WorkManagerRequestType): Boolean - fun cancel(sessionId: SessionId) + + /** + * Cancel pending work requests for the session [SessionId]. + * If [requestType] is provided, it will only cancel requests for that type, otherwise it will cancel all requests. + */ + fun cancel(sessionId: SessionId, requestType: WorkManagerRequestType? = null) } fun workManagerTag(sessionId: SessionId, requestType: WorkManagerRequestType): String { diff --git a/libraries/workmanager/impl/src/main/kotlin/io/element/android/libraries/workmanager/impl/DefaultWorkManagerScheduler.kt b/libraries/workmanager/impl/src/main/kotlin/io/element/android/libraries/workmanager/impl/DefaultWorkManagerScheduler.kt index 4f3806db62..aa645c9a17 100644 --- a/libraries/workmanager/impl/src/main/kotlin/io/element/android/libraries/workmanager/impl/DefaultWorkManagerScheduler.kt +++ b/libraries/workmanager/impl/src/main/kotlin/io/element/android/libraries/workmanager/impl/DefaultWorkManagerScheduler.kt @@ -8,6 +8,7 @@ package io.element.android.libraries.workmanager.impl +import androidx.work.OneTimeWorkRequest import androidx.work.WorkInfo import androidx.work.WorkManager import dev.zacsweers.metro.AppScope @@ -16,9 +17,10 @@ import dev.zacsweers.metro.SingleIn import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.sessionstorage.api.observer.SessionListener import io.element.android.libraries.sessionstorage.api.observer.SessionObserver -import io.element.android.libraries.workmanager.api.WorkManagerRequest +import io.element.android.libraries.workmanager.api.WorkManagerRequestBuilder import io.element.android.libraries.workmanager.api.WorkManagerRequestType import io.element.android.libraries.workmanager.api.WorkManagerScheduler +import io.element.android.libraries.workmanager.api.WorkManagerWorkerType import io.element.android.libraries.workmanager.api.workManagerTag import timber.log.Timber @@ -41,13 +43,22 @@ class DefaultWorkManagerScheduler( }) } - override fun submit(workManagerRequest: WorkManagerRequest) { - workManagerRequest.build().fold( - onSuccess = { workRequests -> - workManager.enqueue(workRequests) + override suspend fun submit(workManagerRequestBuilder: WorkManagerRequestBuilder) { + workManagerRequestBuilder.build().fold( + onSuccess = { wrappers -> + for (wrapper in wrappers) { + when (wrapper.type) { + WorkManagerWorkerType.Default -> workManager.enqueue(wrapper.request) + is WorkManagerWorkerType.Unique -> { + val type = wrapper.type as WorkManagerWorkerType.Unique + val requests = wrapper.request as OneTimeWorkRequest + workManager.enqueueUniqueWork(type.name, type.policy, requests) + } + } + } }, onFailure = { - Timber.e(it, "Failed to build WorkManager request $workManagerRequest") + Timber.e(it, "Failed to build WorkManager request $workManagerRequestBuilder") } ) } @@ -64,10 +75,15 @@ class DefaultWorkManagerScheduler( } } - override fun cancel(sessionId: SessionId) { + override fun cancel(sessionId: SessionId, requestType: WorkManagerRequestType?) { Timber.d("Cancelling work for sessionId: $sessionId") - for (requestType in WorkManagerRequestType.entries) { + + if (requestType != null) { workManager.cancelAllWorkByTag(workManagerTag(sessionId, requestType)) + } else { + for (requestType in WorkManagerRequestType.entries) { + workManager.cancelAllWorkByTag(workManagerTag(sessionId, requestType)) + } } } } diff --git a/libraries/workmanager/impl/src/test/kotlin/io/element/android/libraries/workmanager/impl/DefaultWorkManagerSchedulerTest.kt b/libraries/workmanager/impl/src/test/kotlin/io/element/android/libraries/workmanager/impl/DefaultWorkManagerSchedulerTest.kt index b4f964ed12..761a82e0f4 100644 --- a/libraries/workmanager/impl/src/test/kotlin/io/element/android/libraries/workmanager/impl/DefaultWorkManagerSchedulerTest.kt +++ b/libraries/workmanager/impl/src/test/kotlin/io/element/android/libraries/workmanager/impl/DefaultWorkManagerSchedulerTest.kt @@ -7,12 +7,17 @@ package io.element.android.libraries.workmanager.impl +import android.content.Context +import androidx.work.OneTimeWorkRequest import androidx.work.WorkManager import androidx.work.WorkRequest +import androidx.work.Worker +import androidx.work.WorkerParameters import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.sessionstorage.test.observer.FakeSessionObserver -import io.element.android.libraries.workmanager.api.WorkManagerRequest +import io.element.android.libraries.workmanager.api.WorkManagerRequestBuilder import io.element.android.libraries.workmanager.api.WorkManagerRequestType +import io.element.android.libraries.workmanager.api.WorkManagerRequestWrapper import io.element.android.libraries.workmanager.api.workManagerTag import io.mockk.every import io.mockk.mockk @@ -55,7 +60,7 @@ class DefaultWorkManagerSchedulerTest { sessionObserver = FakeSessionObserver(), ) - scheduler.submit(FakeWorkManagerRequest()) + scheduler.submit(FakeWorkManagerRequestBuilder()) verify { workManager.enqueue(any>()) } } @@ -69,7 +74,7 @@ class DefaultWorkManagerSchedulerTest { sessionObserver = FakeSessionObserver(), ) - scheduler.submit(FakeWorkManagerRequest(result = Result.failure(IllegalStateException("Test error")))) + scheduler.submit(FakeWorkManagerRequestBuilder(result = Result.failure(IllegalStateException("Test error")))) verify(exactly = 0) { workManager.enqueue(any>()) } } @@ -88,7 +93,7 @@ class DefaultWorkManagerSchedulerTest { val mockSessionA = mockk { every { tags } returns setOf(tagToRemove) } - scheduler.submit(FakeWorkManagerRequest(result = Result.success(listOf(mockSessionA)))) + scheduler.submit(FakeWorkManagerRequestBuilder(result = Result.success(listOf(WorkManagerRequestWrapper(mockSessionA))))) scheduler.cancel(sessionId) @@ -96,10 +101,16 @@ class DefaultWorkManagerSchedulerTest { } } -private class FakeWorkManagerRequest( - private val result: Result> = Result.success(listOf()), -) : WorkManagerRequest { - override fun build(): Result> { +private val workRequest = OneTimeWorkRequest.Builder(FakeWorker::class.java).build() + +private class FakeWorkManagerRequestBuilder( + private val result: Result> = Result.success(listOf(WorkManagerRequestWrapper(workRequest))), +) : WorkManagerRequestBuilder { + override suspend fun build(): Result> { return result } } + +internal class FakeWorker(context: Context, params: WorkerParameters) : Worker(context, params) { + override fun doWork(): Result = Result.success() +} diff --git a/libraries/workmanager/test/src/main/kotlin/io/element/android/libraries/workmanager/test/FakeWorkManagerScheduler.kt b/libraries/workmanager/test/src/main/kotlin/io/element/android/libraries/workmanager/test/FakeWorkManagerScheduler.kt index f2caa8c743..aa39b48ed7 100644 --- a/libraries/workmanager/test/src/main/kotlin/io/element/android/libraries/workmanager/test/FakeWorkManagerScheduler.kt +++ b/libraries/workmanager/test/src/main/kotlin/io/element/android/libraries/workmanager/test/FakeWorkManagerScheduler.kt @@ -9,25 +9,25 @@ package io.element.android.libraries.workmanager.test import io.element.android.libraries.matrix.api.core.SessionId -import io.element.android.libraries.workmanager.api.WorkManagerRequest +import io.element.android.libraries.workmanager.api.WorkManagerRequestBuilder import io.element.android.libraries.workmanager.api.WorkManagerRequestType import io.element.android.libraries.workmanager.api.WorkManagerScheduler import io.element.android.tests.testutils.lambda.lambdaError class FakeWorkManagerScheduler( - private val submitLambda: (WorkManagerRequest) -> Unit = { lambdaError() }, + private val submitLambda: (WorkManagerRequestBuilder) -> Unit = { lambdaError() }, private val hasPendingWorkLambda: (SessionId, WorkManagerRequestType) -> Boolean = { _, _ -> false }, - private val cancelLambda: (SessionId) -> Unit = { lambdaError() }, + private val cancelLambda: (SessionId, WorkManagerRequestType?) -> Unit = { _, _ -> lambdaError() }, ) : WorkManagerScheduler { - override fun submit(workManagerRequest: WorkManagerRequest) { - submitLambda(workManagerRequest) + override suspend fun submit(workManagerRequestBuilder: WorkManagerRequestBuilder) { + submitLambda(workManagerRequestBuilder) } override fun hasPendingWork(sessionId: SessionId, requestType: WorkManagerRequestType): Boolean { return hasPendingWorkLambda(sessionId, requestType) } - override fun cancel(sessionId: SessionId) { - cancelLambda(sessionId) + override fun cancel(sessionId: SessionId, requestType: WorkManagerRequestType?) { + cancelLambda(sessionId, requestType) } }