Simplify push notification flow by using locally stored values for pending pushes (#6258)
* Create `PushRequest` in push history DB: this will be used to store requests for push notifications, either pending or completed ones. * Rename `WorkManagerRequest` to `WorkManagerRequestBuilder`: make its `build` method return a list of `WorkManagerRequestWrapper`, which can be used to enqueue normal or unique workers. * Rename `PerformDatabaseVacuumRequestBuilder` and adapt it to the new API. * Adjust other components using `WorkManagerRequest`. * Replace `SyncNotificationWorkManagerRequestBuilder` with `SyncPendingNotificationsRequestBuilder` and `FetchNotificationsWorker` with `FetchPendingNotificationsWorker`: this new pair of request builder and worker allow enqueuing requests for a session id and, once the worker runs, retrieve all the pending request data and use it to fetch the associated events. This simplifies quite a bit how this data had to be passed or grouped, since it's no longer necessary to do so * Add new methods to `PushHistoryService` to modify the `PushDatabase`: - insertOrUpdatePushRequest - insertOrUpdatePushRequests - getPendingPushRequests - removeOldPushRequests * Make `PushHandler` just handle incoming pushes: those will be inserted into the pending push request table in DB, then handled by the new worker. Once the process finished, a new `NotificationResultProcessor` will handle the results and what needs to be done with them (call ringing, displaying notifications, etc.) * Add `requestType` optional parameter to `WorkManagerScheduler.cancel` so we can decide to only cancel some kinds of requests. * Add migration to remove existing work manager requests for fetching notifications, since the previous worker class no longer exists.
This commit is contained in:
committed by
GitHub
parent
a8c66381f2
commit
721add707c
@@ -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.AN_EXCEPTION
|
||||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||||
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
|
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
|
||||||
|
import io.element.android.libraries.workmanager.api.WorkManagerRequestType
|
||||||
import io.element.android.libraries.workmanager.test.FakeWorkManagerScheduler
|
import io.element.android.libraries.workmanager.test.FakeWorkManagerScheduler
|
||||||
import io.element.android.tests.testutils.WarmUpRule
|
import io.element.android.tests.testutils.WarmUpRule
|
||||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||||
@@ -149,7 +150,7 @@ class LogoutPresenterTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - logout then confirm`() = runTest {
|
fun `present - logout then confirm`() = runTest {
|
||||||
val cancelWorkManagerJobsLambda = lambdaRecorder<SessionId, Unit> {}
|
val cancelWorkManagerJobsLambda = lambdaRecorder<SessionId, WorkManagerRequestType?, Unit> { _, _ -> }
|
||||||
val workManagerScheduler = FakeWorkManagerScheduler(cancelLambda = cancelWorkManagerJobsLambda)
|
val workManagerScheduler = FakeWorkManagerScheduler(cancelLambda = cancelWorkManagerJobsLambda)
|
||||||
val presenter = createLogoutPresenter(workManagerScheduler = workManagerScheduler)
|
val presenter = createLogoutPresenter(workManagerScheduler = workManagerScheduler)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
@@ -238,7 +239,7 @@ class LogoutPresenterTest {
|
|||||||
internal fun createLogoutPresenter(
|
internal fun createLogoutPresenter(
|
||||||
matrixClient: MatrixClient = FakeMatrixClient(),
|
matrixClient: MatrixClient = FakeMatrixClient(),
|
||||||
encryptionService: EncryptionService = FakeEncryptionService(),
|
encryptionService: EncryptionService = FakeEncryptionService(),
|
||||||
workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(cancelLambda = {}),
|
workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(cancelLambda = { _, _ -> }),
|
||||||
): LogoutPresenter = LogoutPresenter(
|
): LogoutPresenter = LogoutPresenter(
|
||||||
matrixClient = matrixClient,
|
matrixClient = matrixClient,
|
||||||
encryptionService = encryptionService,
|
encryptionService = encryptionService,
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ dependencies {
|
|||||||
implementation(projects.libraries.matrix.api)
|
implementation(projects.libraries.matrix.api)
|
||||||
implementation(projects.libraries.sessionStorage.api)
|
implementation(projects.libraries.sessionStorage.api)
|
||||||
implementation(projects.libraries.uiStrings)
|
implementation(projects.libraries.uiStrings)
|
||||||
|
implementation(projects.libraries.workmanager.api)
|
||||||
|
|
||||||
testCommonDependencies(libs)
|
testCommonDependencies(libs)
|
||||||
testImplementation(projects.libraries.matrix.test)
|
testImplementation(projects.libraries.matrix.test)
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.cancelAndDestroy
|
||||||
import io.element.android.libraries.matrix.impl.util.mxCallbackFlow
|
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.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.sessionstorage.api.SessionStore
|
||||||
import io.element.android.libraries.workmanager.api.WorkManagerRequestType
|
import io.element.android.libraries.workmanager.api.WorkManagerRequestType
|
||||||
import io.element.android.libraries.workmanager.api.WorkManagerScheduler
|
import io.element.android.libraries.workmanager.api.WorkManagerScheduler
|
||||||
@@ -832,8 +832,8 @@ class RustMatrixClient(
|
|||||||
if (workManagerScheduler.hasPendingWork(sessionId, WorkManagerRequestType.DB_VACUUM)) return
|
if (workManagerScheduler.hasPendingWork(sessionId, WorkManagerRequestType.DB_VACUUM)) return
|
||||||
|
|
||||||
Timber.i("Scheduling periodic database vacuuming for session $sessionId")
|
Timber.i("Scheduling periodic database vacuuming for session $sessionId")
|
||||||
val request = PerformDatabaseVacuumWorkManagerRequest(sessionId)
|
val request = PerformDatabaseVacuumRequestBuilder(sessionId)
|
||||||
workManagerScheduler.submit(request)
|
sessionCoroutineScope.launch { workManagerScheduler.submit(request) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,18 +10,18 @@ package io.element.android.libraries.matrix.impl.workmanager
|
|||||||
import androidx.work.Constraints
|
import androidx.work.Constraints
|
||||||
import androidx.work.Data
|
import androidx.work.Data
|
||||||
import androidx.work.PeriodicWorkRequest
|
import androidx.work.PeriodicWorkRequest
|
||||||
import androidx.work.WorkRequest
|
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
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.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.WorkManagerRequestType
|
||||||
|
import io.element.android.libraries.workmanager.api.WorkManagerRequestWrapper
|
||||||
import io.element.android.libraries.workmanager.api.workManagerTag
|
import io.element.android.libraries.workmanager.api.workManagerTag
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
class PerformDatabaseVacuumWorkManagerRequest(
|
class PerformDatabaseVacuumRequestBuilder(
|
||||||
private val sessionId: SessionId,
|
private val sessionId: SessionId,
|
||||||
) : WorkManagerRequest {
|
) : WorkManagerRequestBuilder {
|
||||||
override fun build(): Result<List<WorkRequest>> {
|
override suspend fun build(): Result<List<WorkManagerRequestWrapper>> {
|
||||||
val data = Data.Builder().putString(SESSION_ID_PARAM, sessionId.value).build()
|
val data = Data.Builder().putString(SESSION_ID_PARAM, sessionId.value).build()
|
||||||
val workRequest = PeriodicWorkRequest.Builder(
|
val workRequest = PeriodicWorkRequest.Builder(
|
||||||
workerClass = VacuumDatabaseWorker::class,
|
workerClass = VacuumDatabaseWorker::class,
|
||||||
@@ -41,6 +41,6 @@ class PerformDatabaseVacuumWorkManagerRequest(
|
|||||||
)
|
)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
return Result.success(listOf(workRequest))
|
return Result.success(listOf(WorkManagerRequestWrapper(workRequest)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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.api.SessionStore
|
||||||
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
|
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
|
||||||
import io.element.android.libraries.sessionstorage.test.aSessionData
|
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.libraries.workmanager.test.FakeWorkManagerScheduler
|
||||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||||
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
|
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
|
||||||
@@ -33,7 +33,7 @@ import java.io.File
|
|||||||
class RustMatrixClientFactoryTest {
|
class RustMatrixClientFactoryTest {
|
||||||
@Test
|
@Test
|
||||||
fun test() = runTest {
|
fun test() = runTest {
|
||||||
val scheduleVacuumLambda = lambdaRecorder<WorkManagerRequest, Unit> {}
|
val scheduleVacuumLambda = lambdaRecorder<WorkManagerRequestBuilder, Unit> {}
|
||||||
val workManagerScheduler = FakeWorkManagerScheduler(submitLambda = scheduleVacuumLambda)
|
val workManagerScheduler = FakeWorkManagerScheduler(submitLambda = scheduleVacuumLambda)
|
||||||
val sut = createRustMatrixClientFactory(workManagerScheduler = workManagerScheduler)
|
val sut = createRustMatrixClientFactory(workManagerScheduler = workManagerScheduler)
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
|
||||||
)
|
|
||||||
@@ -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<NotificationEventRequest>)
|
|
||||||
}
|
|
||||||
@@ -97,6 +97,7 @@ sqldelight {
|
|||||||
databases {
|
databases {
|
||||||
create("PushDatabase") {
|
create("PushDatabase") {
|
||||||
schemaOutputDirectory = File("src/main/sqldelight/databases")
|
schemaOutputDirectory = File("src/main/sqldelight/databases")
|
||||||
|
verifyMigrations = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,13 +14,16 @@ import android.os.PowerManager
|
|||||||
import androidx.core.content.getSystemService
|
import androidx.core.content.getSystemService
|
||||||
import dev.zacsweers.metro.AppScope
|
import dev.zacsweers.metro.AppScope
|
||||||
import dev.zacsweers.metro.ContributesBinding
|
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.di.annotations.ApplicationContext
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
import io.element.android.libraries.push.impl.PushDatabase
|
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.PushHistory
|
||||||
|
import io.element.android.libraries.push.impl.db.PushRequest
|
||||||
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
@ContributesBinding(AppScope::class)
|
@ContributesBinding(AppScope::class)
|
||||||
class DefaultPushHistoryService(
|
class DefaultPushHistoryService(
|
||||||
@@ -31,7 +34,37 @@ class DefaultPushHistoryService(
|
|||||||
private val powerManager = context.getSystemService<PowerManager>()
|
private val powerManager = context.getSystemService<PowerManager>()
|
||||||
private val packageName = context.packageName
|
private val packageName = context.packageName
|
||||||
|
|
||||||
override fun onPushReceived(
|
override suspend fun insertOrUpdatePushRequest(pushRequest: PushRequest): Result<Unit> {
|
||||||
|
return runCatchingExceptions { pushDatabase.pushRequestQueries.insertPushRequest(pushRequest).await() }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun insertOrUpdatePushRequests(pushRequests: List<PushRequest>): Result<Unit> {
|
||||||
|
return runCatchingExceptions {
|
||||||
|
pushDatabase.transaction {
|
||||||
|
for (request in pushRequests) {
|
||||||
|
pushDatabase.pushRequestQueries.insertPushRequest(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getPendingPushRequests(sessionId: SessionId, since: Instant?): Result<List<PushRequest>> {
|
||||||
|
return runCatchingExceptions {
|
||||||
|
pushDatabase.transactionWithResult {
|
||||||
|
val sinceTimeMillis = since?.toEpochMilliseconds() ?: 0
|
||||||
|
pushDatabase.pushRequestQueries.selectAllPendingForSession(sessionId.value, sinceTimeMillis).executeAsList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun removeOldPushRequests(sessionId: SessionId): Result<Unit> {
|
||||||
|
return runCatchingExceptions {
|
||||||
|
val keepAmount = 100L
|
||||||
|
pushDatabase.pushRequestQueries.removeOldest(keepAmount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPushResult(
|
||||||
providerInfo: String,
|
providerInfo: String,
|
||||||
eventId: EventId?,
|
eventId: EventId?,
|
||||||
roomId: RoomId?,
|
roomId: RoomId?,
|
||||||
|
|||||||
@@ -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.EventId
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
|
import io.element.android.libraries.push.impl.db.PushRequest
|
||||||
|
import io.element.android.libraries.push.impl.push.PushRequestStatus
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
interface PushHistoryService {
|
interface PushHistoryService {
|
||||||
/**
|
/**
|
||||||
* Create a new push history entry.
|
* Create a new push history entry.
|
||||||
* Do not use directly, prefer using the extension functions.
|
* Do not use directly, prefer using the extension functions.
|
||||||
*/
|
*/
|
||||||
fun onPushReceived(
|
fun onPushResult(
|
||||||
providerInfo: String,
|
providerInfo: String,
|
||||||
eventId: EventId?,
|
eventId: EventId?,
|
||||||
roomId: RoomId?,
|
roomId: RoomId?,
|
||||||
@@ -26,12 +29,33 @@ interface PushHistoryService {
|
|||||||
includeDeviceState: Boolean,
|
includeDeviceState: Boolean,
|
||||||
comment: String?,
|
comment: String?,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds or replaces an existing [PushRequest] in the local database.
|
||||||
|
*/
|
||||||
|
suspend fun insertOrUpdatePushRequest(pushRequest: PushRequest): Result<Unit>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace a list of [PushRequest] in the database.
|
||||||
|
*/
|
||||||
|
suspend fun insertOrUpdatePushRequests(pushRequests: List<PushRequest>): Result<Unit>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<List<PushRequest>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes the oldest push requests for a [SessionId].
|
||||||
|
*/
|
||||||
|
suspend fun removeOldPushRequests(sessionId: SessionId): Result<Unit>
|
||||||
}
|
}
|
||||||
|
|
||||||
fun PushHistoryService.onInvalidPushReceived(
|
fun PushHistoryService.onInvalidPushReceived(
|
||||||
providerInfo: String,
|
providerInfo: String,
|
||||||
data: String,
|
data: String,
|
||||||
) = onPushReceived(
|
) = onPushResult(
|
||||||
providerInfo = providerInfo,
|
providerInfo = providerInfo,
|
||||||
eventId = null,
|
eventId = null,
|
||||||
roomId = null,
|
roomId = null,
|
||||||
@@ -46,7 +70,7 @@ fun PushHistoryService.onUnableToRetrieveSession(
|
|||||||
eventId: EventId,
|
eventId: EventId,
|
||||||
roomId: RoomId,
|
roomId: RoomId,
|
||||||
reason: String,
|
reason: String,
|
||||||
) = onPushReceived(
|
) = onPushResult(
|
||||||
providerInfo = providerInfo,
|
providerInfo = providerInfo,
|
||||||
eventId = eventId,
|
eventId = eventId,
|
||||||
roomId = roomId,
|
roomId = roomId,
|
||||||
@@ -62,7 +86,7 @@ fun PushHistoryService.onUnableToResolveEvent(
|
|||||||
roomId: RoomId,
|
roomId: RoomId,
|
||||||
sessionId: SessionId,
|
sessionId: SessionId,
|
||||||
reason: String,
|
reason: String,
|
||||||
) = onPushReceived(
|
) = onPushResult(
|
||||||
providerInfo = providerInfo,
|
providerInfo = providerInfo,
|
||||||
eventId = eventId,
|
eventId = eventId,
|
||||||
roomId = roomId,
|
roomId = roomId,
|
||||||
@@ -78,7 +102,7 @@ fun PushHistoryService.onSuccess(
|
|||||||
roomId: RoomId,
|
roomId: RoomId,
|
||||||
sessionId: SessionId,
|
sessionId: SessionId,
|
||||||
comment: String?,
|
comment: String?,
|
||||||
) = onPushReceived(
|
) = onPushResult(
|
||||||
providerInfo = providerInfo,
|
providerInfo = providerInfo,
|
||||||
eventId = eventId,
|
eventId = eventId,
|
||||||
roomId = roomId,
|
roomId = roomId,
|
||||||
@@ -95,7 +119,7 @@ fun PushHistoryService.onSuccess(
|
|||||||
|
|
||||||
fun PushHistoryService.onDiagnosticPush(
|
fun PushHistoryService.onDiagnosticPush(
|
||||||
providerInfo: String,
|
providerInfo: String,
|
||||||
) = onPushReceived(
|
) = onPushResult(
|
||||||
providerInfo = providerInfo,
|
providerInfo = providerInfo,
|
||||||
eventId = null,
|
eventId = null,
|
||||||
roomId = null,
|
roomId = null,
|
||||||
|
|||||||
@@ -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.VideoMessageType
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
|
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
|
||||||
import io.element.android.libraries.matrix.ui.messages.toPlainText
|
import io.element.android.libraries.matrix.ui.messages.toPlainText
|
||||||
import io.element.android.libraries.push.api.push.NotificationEventRequest
|
|
||||||
import io.element.android.libraries.push.impl.R
|
import io.element.android.libraries.push.impl.R
|
||||||
|
import io.element.android.libraries.push.impl.db.PushRequest
|
||||||
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
|
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
|
||||||
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
|
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
|
||||||
import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent
|
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.
|
* Result of resolving a batch of push events.
|
||||||
* The outermost [Result] indicates whether the setup to resolve the events was successful.
|
* 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.
|
* If the resolution of a specific event fails, the innermost [Result] will contain an exception.
|
||||||
*/
|
*/
|
||||||
typealias ResolvePushEventsResult = Result<Map<NotificationEventRequest, Result<ResolvedPushEvent>>>
|
typealias ResolvePushEventsResult = Result<Map<PushRequest, Result<ResolvedPushEvent>>>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The notifiable event resolver is able to create a NotifiableEvent (view model for notifications) from an sdk Event.
|
* 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<Map<NotificationEventRequest, Result<
|
|||||||
interface NotifiableEventResolver {
|
interface NotifiableEventResolver {
|
||||||
suspend fun resolveEvents(
|
suspend fun resolveEvents(
|
||||||
sessionId: SessionId,
|
sessionId: SessionId,
|
||||||
notificationEventRequests: List<NotificationEventRequest>
|
notificationEventRequests: List<PushRequest>
|
||||||
): ResolvePushEventsResult
|
): ResolvePushEventsResult
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,15 +96,15 @@ class DefaultNotifiableEventResolver(
|
|||||||
) : NotifiableEventResolver {
|
) : NotifiableEventResolver {
|
||||||
override suspend fun resolveEvents(
|
override suspend fun resolveEvents(
|
||||||
sessionId: SessionId,
|
sessionId: SessionId,
|
||||||
notificationEventRequests: List<NotificationEventRequest>
|
notificationEventRequests: List<PushRequest>
|
||||||
): ResolvePushEventsResult {
|
): ResolvePushEventsResult {
|
||||||
Timber.d("Queueing notifications: $notificationEventRequests")
|
Timber.d("Queueing notifications: $notificationEventRequests")
|
||||||
val client = matrixClientProvider.getOrRestore(sessionId).getOrElse {
|
val client = matrixClientProvider.getOrRestore(sessionId).getOrElse {
|
||||||
return Result.failure(it)
|
return Result.failure(it)
|
||||||
}
|
}
|
||||||
val ids = notificationEventRequests.groupBy { it.roomId }
|
val ids = notificationEventRequests.groupBy { RoomId(it.roomId) }
|
||||||
.mapValues { (_, requests) ->
|
.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
|
// 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(
|
return Result.success(
|
||||||
notificationEventRequests.associate { request ->
|
notificationEventRequests.associate { request ->
|
||||||
val notificationDataResult = notificationDataMap[request.eventId]
|
val notificationDataResult = notificationDataMap[EventId(request.eventId)]
|
||||||
if (notificationDataResult == null) {
|
if (notificationDataResult == null) {
|
||||||
request to Result.failure(NotificationResolverException.UnknownError("No notification data for ${request.roomId} - ${request.eventId}"))
|
request to Result.failure(NotificationResolverException.UnknownError("No notification data for ${request.roomId} - ${request.eventId}"))
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -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<Pair<List<NotificationEventRequest>, Map<NotificationEventRequest, Result<ResolvedPushEvent>>>>
|
|
||||||
suspend fun enqueue(request: NotificationEventRequest)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This class is responsible for periodically batching notification requests and resolving them in a single call,
|
|
||||||
* 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<NotificationEventRequest>(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<Pair<List<NotificationEventRequest>, Map<NotificationEventRequest, Result<ResolvedPushEvent>>>>()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<PushRequest, Result<ResolvedPushEvent>>)
|
||||||
|
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<Map<PushRequest, Result<ResolvedPushEvent>>>(extraBufferCapacity = Int.MAX_VALUE)
|
||||||
|
private var processJob: Job? = null
|
||||||
|
|
||||||
|
override suspend fun emit(results: Map<PushRequest, Result<ResolvedPushEvent>>) {
|
||||||
|
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<PushRequest, Result<ResolvedPushEvent>>) {
|
||||||
|
// 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<NotifiableEvent>()
|
||||||
|
val redactions = mutableListOf<ResolvedPushEvent.Redaction>()
|
||||||
|
|
||||||
|
@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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,42 +11,28 @@ package io.element.android.libraries.push.impl.push
|
|||||||
import dev.zacsweers.metro.AppScope
|
import dev.zacsweers.metro.AppScope
|
||||||
import dev.zacsweers.metro.ContributesBinding
|
import dev.zacsweers.metro.ContributesBinding
|
||||||
import dev.zacsweers.metro.SingleIn
|
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.log.logger.LoggerTag
|
||||||
import io.element.android.libraries.core.meta.BuildMeta
|
import io.element.android.libraries.core.meta.BuildMeta
|
||||||
import io.element.android.libraries.di.annotations.AppCoroutineScope
|
import io.element.android.libraries.push.impl.db.PushRequest
|
||||||
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.history.PushHistoryService
|
import io.element.android.libraries.push.impl.history.PushHistoryService
|
||||||
import io.element.android.libraries.push.impl.history.onDiagnosticPush
|
import io.element.android.libraries.push.impl.history.onDiagnosticPush
|
||||||
import io.element.android.libraries.push.impl.history.onInvalidPushReceived
|
import io.element.android.libraries.push.impl.history.onInvalidPushReceived
|
||||||
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.history.onUnableToRetrieveSession
|
||||||
import io.element.android.libraries.push.impl.notifications.FallbackNotificationFactory
|
import io.element.android.libraries.push.impl.notifications.NotificationResultProcessor
|
||||||
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.test.DefaultTestPush
|
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.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.PushData
|
||||||
import io.element.android.libraries.pushproviders.api.PushHandler
|
import io.element.android.libraries.pushproviders.api.PushHandler
|
||||||
import io.element.android.libraries.pushstore.api.UserPushStoreFactory
|
import io.element.android.libraries.pushstore.api.UserPushStoreFactory
|
||||||
import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret
|
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.AnalyticsLongRunningTransaction
|
||||||
import io.element.android.services.analytics.api.AnalyticsService
|
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.first
|
||||||
import kotlinx.coroutines.flow.launchIn
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
private val loggerTag = LoggerTag("PushHandler", LoggerTag.PushLoggerTag)
|
private val loggerTag = LoggerTag("PushHandler", LoggerTag.PushLoggerTag)
|
||||||
@@ -54,173 +40,20 @@ private val loggerTag = LoggerTag("PushHandler", LoggerTag.PushLoggerTag)
|
|||||||
@SingleIn(AppScope::class)
|
@SingleIn(AppScope::class)
|
||||||
@ContributesBinding(AppScope::class)
|
@ContributesBinding(AppScope::class)
|
||||||
class DefaultPushHandler(
|
class DefaultPushHandler(
|
||||||
private val onNotifiableEventReceived: OnNotifiableEventReceived,
|
|
||||||
private val onRedactedEventReceived: OnRedactedEventReceived,
|
|
||||||
private val incrementPushDataStore: IncrementPushDataStore,
|
private val incrementPushDataStore: IncrementPushDataStore,
|
||||||
private val mutableBatteryOptimizationStore: MutableBatteryOptimizationStore,
|
|
||||||
private val userPushStoreFactory: UserPushStoreFactory,
|
|
||||||
private val pushClientSecret: PushClientSecret,
|
private val pushClientSecret: PushClientSecret,
|
||||||
private val buildMeta: BuildMeta,
|
private val buildMeta: BuildMeta,
|
||||||
private val diagnosticPushHandler: DiagnosticPushHandler,
|
private val diagnosticPushHandler: DiagnosticPushHandler,
|
||||||
private val elementCallEntryPoint: ElementCallEntryPoint,
|
|
||||||
private val notificationChannels: NotificationChannels,
|
|
||||||
private val pushHistoryService: PushHistoryService,
|
private val pushHistoryService: PushHistoryService,
|
||||||
private val resolverQueue: NotificationResolverQueue,
|
private val userPushStoreFactory: UserPushStoreFactory,
|
||||||
@AppCoroutineScope
|
|
||||||
private val appCoroutineScope: CoroutineScope,
|
|
||||||
private val fallbackNotificationFactory: FallbackNotificationFactory,
|
|
||||||
private val syncOnNotifiableEvent: SyncOnNotifiableEvent,
|
|
||||||
private val featureFlagService: FeatureFlagService,
|
|
||||||
private val analyticsService: AnalyticsService,
|
private val analyticsService: AnalyticsService,
|
||||||
|
private val systemClock: SystemClock,
|
||||||
|
private val workManagerScheduler: WorkManagerScheduler,
|
||||||
|
private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider,
|
||||||
|
resultProcessor: NotificationResultProcessor,
|
||||||
) : PushHandler {
|
) : PushHandler {
|
||||||
init {
|
init {
|
||||||
processPushEventResults()
|
resultProcessor.start()
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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<NotifiableEvent>()
|
|
||||||
val redactions = mutableListOf<ResolvedPushEvent.Redaction>()
|
|
||||||
|
|
||||||
@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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -233,9 +66,7 @@ class DefaultPushHandler(
|
|||||||
// Start measuring how long it takes to display a notification from when the push is received
|
// 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}")
|
Timber.d("Calculating push-to-notification for event ${pushData.eventId}")
|
||||||
val parent = analyticsService.startLongRunningTransaction(AnalyticsLongRunningTransaction.PushToNotification(pushData.eventId.value))
|
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}")
|
Timber.tag(loggerTag.value).d("## handling pushData: ${pushData.roomId}/${pushData.eventId}")
|
||||||
if (buildMeta.lowPrivacyLoggingEnabled) {
|
if (buildMeta.lowPrivacyLoggingEnabled) {
|
||||||
@@ -282,34 +113,56 @@ class DefaultPushHandler(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
appCoroutineScope.launch {
|
val areNotificationsEnabled = userPushStoreFactory.getOrCreate(userId).getNotificationEnabledForDevice().first()
|
||||||
val notificationEventRequest = NotificationEventRequest(
|
if (!areNotificationsEnabled) {
|
||||||
sessionId = userId,
|
Timber.w("Push notification received when push notifications are disabled.")
|
||||||
roomId = pushData.roomId,
|
return
|
||||||
eventId = pushData.eventId,
|
}
|
||||||
providerInfo = providerInfo,
|
|
||||||
|
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) {
|
} catch (e: Exception) {
|
||||||
Timber.tag(loggerTag.value).e(e, "## handleInternal() failed")
|
Timber.tag(loggerTag.value).e(e, "## handleInternal() failed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
private suspend fun handleRingingCallEvent(notifiableEvent: NotifiableRingingCallEvent) {
|
|
||||||
Timber.i("## handleInternal() : Incoming call.")
|
/**
|
||||||
elementCallEntryPoint.handleIncomingCall(
|
* Represents the status of a [PushRequest].
|
||||||
callType = CallType.RoomCall(notifiableEvent.sessionId, notifiableEvent.roomId),
|
*/
|
||||||
eventId = notifiableEvent.eventId,
|
enum class PushRequestStatus(val value: Long) {
|
||||||
senderId = notifiableEvent.senderId,
|
/**
|
||||||
roomName = notifiableEvent.roomName,
|
* Either it was enqueued, and we never tried to fetch it, or it failed with a recoverable error.
|
||||||
senderName = notifiableEvent.senderDisambiguatedDisplayName,
|
*/
|
||||||
avatarUrl = notifiableEvent.roomAvatarUrl,
|
PENDING(0),
|
||||||
timestamp = notifiableEvent.timestamp,
|
|
||||||
expirationTimestamp = notifiableEvent.expirationTimestamp,
|
/**
|
||||||
notificationChannelId = notificationChannels.getChannelForIncomingCall(ring = true),
|
* The event for the [PushRequest] was fetched successfully.
|
||||||
textContent = notifiableEvent.description,
|
*/
|
||||||
)
|
SUCCESS(1),
|
||||||
}
|
|
||||||
|
/**
|
||||||
|
* Fetching the event for the [PushRequest] failed with an unrecoverable error, and it won't be retried.
|
||||||
|
*/
|
||||||
|
FAILED(2),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.FeatureFlagService
|
||||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||||
import io.element.android.libraries.matrix.api.MatrixClientProvider
|
import io.element.android.libraries.matrix.api.MatrixClientProvider
|
||||||
import io.element.android.libraries.push.api.push.NotificationEventRequest
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.push.api.push.SyncOnNotifiableEvent
|
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 io.element.android.services.appnavstate.api.AppForegroundStateService
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -29,7 +30,7 @@ class DefaultSyncOnNotifiableEvent(
|
|||||||
private val appForegroundStateService: AppForegroundStateService,
|
private val appForegroundStateService: AppForegroundStateService,
|
||||||
private val dispatchers: CoroutineDispatchers,
|
private val dispatchers: CoroutineDispatchers,
|
||||||
) : SyncOnNotifiableEvent {
|
) : SyncOnNotifiableEvent {
|
||||||
override suspend operator fun invoke(requests: List<NotificationEventRequest>) = withContext(dispatchers.io) {
|
override suspend operator fun invoke(requests: List<PushRequest>) = withContext(dispatchers.io) {
|
||||||
if (!featureFlagService.isFeatureEnabled(FeatureFlags.SyncOnPush)) {
|
if (!featureFlagService.isFeatureEnabled(FeatureFlags.SyncOnPush)) {
|
||||||
return@withContext
|
return@withContext
|
||||||
}
|
}
|
||||||
@@ -41,8 +42,8 @@ class DefaultSyncOnNotifiableEvent(
|
|||||||
Timber.d("Starting opportunistic room list sync | In foreground: ${appForegroundStateService.isInForeground.value}")
|
Timber.d("Starting opportunistic room list sync | In foreground: ${appForegroundStateService.isInForeground.value}")
|
||||||
|
|
||||||
for ((sessionId, events) in eventsBySession) {
|
for ((sessionId, events) in eventsBySession) {
|
||||||
val client = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: continue
|
val client = matrixClientProvider.getOrRestore(SessionId(sessionId)).getOrNull() ?: continue
|
||||||
val roomIds = events.map { it.roomId }.distinct()
|
val roomIds = events.map { RoomId(it.roomId) }.distinct()
|
||||||
|
|
||||||
client.roomListService.subscribeToVisibleRooms(roomIds)
|
client.roomListService.subscribeToVisibleRooms(roomIds)
|
||||||
|
|
||||||
|
|||||||
@@ -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<PushRequest>)
|
||||||
|
}
|
||||||
@@ -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<SessionId, Throwable>()
|
|
||||||
|
|
||||||
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<NotificationEventRequest>, 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<SessionId, List<NotificationEventRequest>>,
|
|
||||||
) {
|
|
||||||
for ((sessionId, notificationRequests) in groupedRequests) {
|
|
||||||
runCatchingExceptions {
|
|
||||||
syncOnNotifiableEvent(notificationRequests)
|
|
||||||
}.onFailure {
|
|
||||||
Timber.e(it, "Failed to sync on notifiable events for session $sessionId")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ContributesIntoMap(AppScope::class, binding = binding<MetroWorkerFactory.WorkerInstanceFactory<*>>())
|
|
||||||
@WorkerKey(FetchNotificationsWorker::class)
|
|
||||||
@AssistedFactory
|
|
||||||
interface Factory : MetroWorkerFactory.WorkerInstanceFactory<FetchNotificationsWorker>
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun <T : AnalyticsTransaction> Collection<T>.finish() = forEach { it.finish() }
|
|
||||||
@@ -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<PushRequest>()
|
||||||
|
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<SessionId, List<PushRequest>>,
|
||||||
|
) {
|
||||||
|
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<PushRequest>): 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<PushRequest>, 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<PushRequest>,
|
||||||
|
pendingAnalyticTransactions: Map<String, AnalyticsTransaction>,
|
||||||
|
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<MetroWorkerFactory.WorkerInstanceFactory<*>>())
|
||||||
|
@WorkerKey(FetchPendingNotificationsWorker::class)
|
||||||
|
@AssistedFactory
|
||||||
|
interface Factory : MetroWorkerFactory.WorkerInstanceFactory<FetchPendingNotificationsWorker>
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <T : AnalyticsTransaction> Collection<T>.finish() = forEach { it.finish() }
|
||||||
@@ -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<NotificationEventRequest>,
|
|
||||||
private val workerDataConverter: SyncNotificationsWorkerDataConverter,
|
|
||||||
private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider,
|
|
||||||
) : WorkManagerRequest {
|
|
||||||
override fun build(): Result<List<WorkRequest>> {
|
|
||||||
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<FetchNotificationsWorker>()
|
|
||||||
.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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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<NotificationEventRequest>): Result<List<Data>> {
|
|
||||||
// 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<NotificationEventRequest>()
|
|
||||||
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<NotificationEventRequest>): Result<Data> {
|
|
||||||
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<NotificationEventRequest>? {
|
|
||||||
val rawRequestsJson = data.getString(REQUESTS_KEY) ?: return null
|
|
||||||
return runCatchingExceptions {
|
|
||||||
json().decodeFromString<List<SyncNotificationWorkManagerRequest.Data>>(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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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<List<WorkManagerRequestWrapper>> {
|
||||||
|
val type = WorkManagerWorkerType.Unique(
|
||||||
|
name = workManagerTag(sessionId = sessionId, requestType = WorkManagerRequestType.NOTIFICATION_SYNC),
|
||||||
|
policy = ExistingWorkPolicy.APPEND_OR_REPLACE,
|
||||||
|
)
|
||||||
|
val request = OneTimeWorkRequestBuilder<FetchPendingNotificationsWorker>()
|
||||||
|
.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)))
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
libraries/push/impl/src/main/sqldelight/databases/1.db
Normal file
BIN
libraries/push/impl/src/main/sqldelight/databases/1.db
Normal file
Binary file not shown.
BIN
libraries/push/impl/src/main/sqldelight/databases/2.db
Normal file
BIN
libraries/push/impl/src/main/sqldelight/databases/2.db
Normal file
Binary file not shown.
@@ -17,6 +17,5 @@ INSERT INTO PushHistory VALUES ?;
|
|||||||
removeAll:
|
removeAll:
|
||||||
DELETE FROM PushHistory;
|
DELETE FROM PushHistory;
|
||||||
|
|
||||||
-- add query to keep only the last x entries
|
|
||||||
removeOldest:
|
removeOldest:
|
||||||
DELETE FROM PushHistory WHERE rowid NOT IN (SELECT rowid FROM PushHistory ORDER BY pushDate DESC LIMIT ?);
|
DELETE FROM PushHistory WHERE rowid NOT IN (SELECT rowid FROM PushHistory ORDER BY pushDate DESC LIMIT ?);
|
||||||
|
|||||||
@@ -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 ?);
|
||||||
14
libraries/push/impl/src/main/sqldelight/migrations/1.sqm
Normal file
14
libraries/push/impl/src/main/sqldelight/migrations/1.sqm
Normal file
@@ -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);
|
||||||
@@ -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.EventId
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
|
import io.element.android.libraries.push.impl.db.PushRequest
|
||||||
import io.element.android.tests.testutils.lambda.lambdaError
|
import io.element.android.tests.testutils.lambda.lambdaError
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
class FakePushHistoryService(
|
class FakePushHistoryService(
|
||||||
private val onPushReceivedResult: (
|
private val onPushReceivedResult: (
|
||||||
@@ -22,9 +24,13 @@ class FakePushHistoryService(
|
|||||||
Boolean,
|
Boolean,
|
||||||
Boolean,
|
Boolean,
|
||||||
String?
|
String?
|
||||||
) -> Unit = { _, _, _, _, _, _, _ -> lambdaError() }
|
) -> Unit = { _, _, _, _, _, _, _ -> lambdaError() },
|
||||||
|
private val enqueuePushRequest: (PushRequest) -> Result<Unit> = { lambdaError() },
|
||||||
|
private val replacePushRequests: (List<PushRequest>) -> Result<Unit> = { lambdaError() },
|
||||||
|
private val getPendingPushRequests: (SessionId, Instant?) -> Result<List<PushRequest>> = { _, _ -> lambdaError() },
|
||||||
|
private val removeOldPushRequests: (SessionId) -> Result<Unit> = { lambdaError() },
|
||||||
) : PushHistoryService {
|
) : PushHistoryService {
|
||||||
override fun onPushReceived(
|
override fun onPushResult(
|
||||||
providerInfo: String,
|
providerInfo: String,
|
||||||
eventId: EventId?,
|
eventId: EventId?,
|
||||||
roomId: RoomId?,
|
roomId: RoomId?,
|
||||||
@@ -43,4 +49,20 @@ class FakePushHistoryService(
|
|||||||
comment
|
comment
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun insertOrUpdatePushRequest(pushRequest: PushRequest): Result<Unit> {
|
||||||
|
return enqueuePushRequest.invoke(pushRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun insertOrUpdatePushRequests(pushRequests: List<PushRequest>): Result<Unit> {
|
||||||
|
return replacePushRequests.invoke(pushRequests)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getPendingPushRequests(sessionId: SessionId, since: Instant?): Result<List<PushRequest>> {
|
||||||
|
return getPendingPushRequests.invoke(sessionId, since)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun removeOldPushRequests(sessionId: SessionId): Result<Unit> {
|
||||||
|
return removeOldPushRequests.invoke(sessionId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.FakeNotificationService
|
||||||
import io.element.android.libraries.matrix.test.notification.aNotificationData
|
import io.element.android.libraries.matrix.test.notification.aNotificationData
|
||||||
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
|
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
|
||||||
import io.element.android.libraries.push.api.push.NotificationEventRequest
|
import io.element.android.libraries.push.impl.db.PushRequest
|
||||||
import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationMediaRepo
|
import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationMediaRepo
|
||||||
import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent
|
import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent
|
||||||
|
import io.element.android.libraries.push.impl.notifications.fixtures.aPushRequest
|
||||||
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
|
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.InviteNotifiableEvent
|
||||||
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
|
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
|
||||||
@@ -71,7 +72,7 @@ class DefaultNotifiableEventResolverTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `resolve event no session`() = runTest {
|
fun `resolve event no session`() = runTest {
|
||||||
val sut = createDefaultNotifiableEventResolver(notificationService = null)
|
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()
|
assertThat(result.isFailure).isTrue()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,7 +81,7 @@ class DefaultNotifiableEventResolverTest {
|
|||||||
val sut = createDefaultNotifiableEventResolver(
|
val sut = createDefaultNotifiableEventResolver(
|
||||||
notificationResult = Result.failure(AN_EXCEPTION)
|
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))
|
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
|
||||||
assertThat(result.isFailure).isTrue()
|
assertThat(result.isFailure).isTrue()
|
||||||
}
|
}
|
||||||
@@ -90,7 +91,7 @@ class DefaultNotifiableEventResolverTest {
|
|||||||
val sut = createDefaultNotifiableEventResolver(
|
val sut = createDefaultNotifiableEventResolver(
|
||||||
notificationResult = Result.success(mapOf(AN_EVENT_ID to Result.failure(AN_EXCEPTION)))
|
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))
|
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
|
||||||
assertThat(result.getEvent(request)?.isFailure).isTrue()
|
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 result = sut.resolveEvents(A_SESSION_ID, listOf(request))
|
||||||
val expectedResult = ResolvedPushEvent.Event(
|
val expectedResult = ResolvedPushEvent.Event(
|
||||||
aNotifiableMessageEvent(body = "Hello world")
|
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 result = sut.resolveEvents(A_SESSION_ID, listOf(request))
|
||||||
val expectedResult = ResolvedPushEvent.Event(
|
val expectedResult = ResolvedPushEvent.Event(
|
||||||
aNotifiableMessageEvent(body = "Hello world", hasMentionOrReply = true)
|
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 result = sut.resolveEvents(A_SESSION_ID, listOf(request))
|
||||||
val expectedResult = ResolvedPushEvent.Event(
|
val expectedResult = ResolvedPushEvent.Event(
|
||||||
aNotifiableMessageEvent(body = "Hello world")
|
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 result = sut.resolveEvents(A_SESSION_ID, listOf(request))
|
||||||
val expectedResult = ResolvedPushEvent.Event(
|
val expectedResult = ResolvedPushEvent.Event(
|
||||||
aNotifiableMessageEvent(body = "Hello world")
|
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 result = sut.resolveEvents(A_SESSION_ID, listOf(request))
|
||||||
val expectedResult = ResolvedPushEvent.Event(
|
val expectedResult = ResolvedPushEvent.Event(
|
||||||
aNotifiableMessageEvent(body = "Audio")
|
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 result = sut.resolveEvents(A_SESSION_ID, listOf(request))
|
||||||
val expectedResult = ResolvedPushEvent.Event(
|
val expectedResult = ResolvedPushEvent.Event(
|
||||||
aNotifiableMessageEvent(body = "Video")
|
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 result = sut.resolveEvents(A_SESSION_ID, listOf(request))
|
||||||
val expectedResult = ResolvedPushEvent.Event(
|
val expectedResult = ResolvedPushEvent.Event(
|
||||||
aNotifiableMessageEvent(body = "Voice message")
|
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 result = sut.resolveEvents(A_SESSION_ID, listOf(request))
|
||||||
val expectedResult = ResolvedPushEvent.Event(
|
val expectedResult = ResolvedPushEvent.Event(
|
||||||
aNotifiableMessageEvent(body = "Image")
|
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 result = sut.resolveEvents(A_SESSION_ID, listOf(request))
|
||||||
val expectedResult = ResolvedPushEvent.Event(
|
val expectedResult = ResolvedPushEvent.Event(
|
||||||
aNotifiableMessageEvent(body = "Sticker")
|
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 result = sut.resolveEvents(A_SESSION_ID, listOf(request))
|
||||||
val expectedResult = ResolvedPushEvent.Event(
|
val expectedResult = ResolvedPushEvent.Event(
|
||||||
aNotifiableMessageEvent(body = "File")
|
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 result = sut.resolveEvents(A_SESSION_ID, listOf(request))
|
||||||
val expectedResult = ResolvedPushEvent.Event(
|
val expectedResult = ResolvedPushEvent.Event(
|
||||||
aNotifiableMessageEvent(body = "Location")
|
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 result = sut.resolveEvents(A_SESSION_ID, listOf(request))
|
||||||
val expectedResult = ResolvedPushEvent.Event(
|
val expectedResult = ResolvedPushEvent.Event(
|
||||||
aNotifiableMessageEvent(body = "Notice")
|
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 result = sut.resolveEvents(A_SESSION_ID, listOf(request))
|
||||||
val expectedResult = ResolvedPushEvent.Event(
|
val expectedResult = ResolvedPushEvent.Event(
|
||||||
aNotifiableMessageEvent(body = "* Bob is happy")
|
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 result = sut.resolveEvents(A_SESSION_ID, listOf(request))
|
||||||
val expectedResult = ResolvedPushEvent.Event(
|
val expectedResult = ResolvedPushEvent.Event(
|
||||||
aNotifiableMessageEvent(body = "Poll: A question")
|
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))
|
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
|
||||||
assertThat(result.getEvent(request)?.getOrNull()).isNull()
|
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 result = sut.resolveEvents(A_SESSION_ID, listOf(request))
|
||||||
val expectedResult = ResolvedPushEvent.Event(
|
val expectedResult = ResolvedPushEvent.Event(
|
||||||
InviteNotifiableEvent(
|
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 result = sut.resolveEvents(A_SESSION_ID, listOf(request))
|
||||||
val expectedResult = ResolvedPushEvent.Event(
|
val expectedResult = ResolvedPushEvent.Event(
|
||||||
InviteNotifiableEvent(
|
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 result = sut.resolveEvents(A_SESSION_ID, listOf(request))
|
||||||
val expectedResult = ResolvedPushEvent.Event(
|
val expectedResult = ResolvedPushEvent.Event(
|
||||||
InviteNotifiableEvent(
|
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 result = sut.resolveEvents(A_SESSION_ID, listOf(request))
|
||||||
val expectedResult = ResolvedPushEvent.Event(
|
val expectedResult = ResolvedPushEvent.Event(
|
||||||
InviteNotifiableEvent(
|
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 result = sut.resolveEvents(A_SESSION_ID, listOf(request))
|
||||||
val expectedResult = ResolvedPushEvent.Event(
|
val expectedResult = ResolvedPushEvent.Event(
|
||||||
InviteNotifiableEvent(
|
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))
|
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
|
||||||
assertThat(result.getEvent(request)?.getOrNull()).isNull()
|
assertThat(result.getEvent(request)?.getOrNull()).isNull()
|
||||||
}
|
}
|
||||||
@@ -654,7 +655,7 @@ class DefaultNotifiableEventResolverTest {
|
|||||||
mapOf(AN_EVENT_ID to Result.success(aNotificationData(content = NotificationContent.MessageLike.RoomEncrypted)))
|
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 result = sut.resolveEvents(A_SESSION_ID, listOf(request))
|
||||||
val expectedResult = ResolvedPushEvent.Event(
|
val expectedResult = ResolvedPushEvent.Event(
|
||||||
FallbackNotifiableEvent(
|
FallbackNotifiableEvent(
|
||||||
@@ -680,7 +681,7 @@ class DefaultNotifiableEventResolverTest {
|
|||||||
mapOf(AN_EVENT_ID to Result.failure(NotificationResolverException.EventNotFound))
|
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))
|
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
|
||||||
assertThat(result.getEvent(request)).isEqualTo(Result.failure<ResolvedPushEvent?>(NotificationResolverException.EventNotFound))
|
assertThat(result.getEvent(request)).isEqualTo(Result.failure<ResolvedPushEvent?>(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 result = sut.resolveEvents(A_SESSION_ID, listOf(request))
|
||||||
val expectedResult = ResolvedPushEvent.Event(
|
val expectedResult = ResolvedPushEvent.Event(
|
||||||
NotifiableMessageEvent(
|
NotifiableMessageEvent(
|
||||||
@@ -766,7 +767,7 @@ class DefaultNotifiableEventResolverTest {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
callNotificationEventResolver.resolveEventLambda = { _, _, _ -> Result.success(expectedResult.notifiableEvent) }
|
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))
|
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
|
||||||
assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult))
|
assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult))
|
||||||
}
|
}
|
||||||
@@ -791,7 +792,7 @@ class DefaultNotifiableEventResolverTest {
|
|||||||
redactedEventId = AN_EVENT_ID_2,
|
redactedEventId = AN_EVENT_ID_2,
|
||||||
reason = A_REDACTION_REASON,
|
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))
|
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
|
||||||
assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult))
|
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))
|
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
|
||||||
assertThat(result.getEvent(request)?.getOrNull()).isNull()
|
assertThat(result.getEvent(request)?.getOrNull()).isNull()
|
||||||
}
|
}
|
||||||
@@ -857,13 +858,13 @@ class DefaultNotifiableEventResolverTest {
|
|||||||
mapOf(AN_EVENT_ID to Result.success(aNotificationData(content = content)))
|
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))
|
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
|
||||||
assertThat(result.getEvent(request)?.getOrNull()).isNull()
|
assertThat(result.getEvent(request)?.getOrNull()).isNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Result<Map<NotificationEventRequest, Result<ResolvedPushEvent>>>.getEvent(
|
private fun Result<Map<PushRequest, Result<ResolvedPushEvent>>>.getEvent(
|
||||||
request: NotificationEventRequest
|
request: PushRequest
|
||||||
): Result<ResolvedPushEvent>? {
|
): Result<ResolvedPushEvent>? {
|
||||||
return getOrNull()?.get(request)
|
return getOrNull()?.get(request)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<PushRequest> ->
|
||||||
|
Result.success(
|
||||||
|
requests.associateWith { Result.failure(NotificationResolverException.UnknownError("Unable to resolve event")) }
|
||||||
|
)
|
||||||
|
},
|
||||||
|
shouldSetOptimizationBatteryBanner = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun `test notification resolver failure`(
|
||||||
|
notificationResolveResult: (List<PushRequest>) -> Result<Map<PushRequest, Result<ResolvedPushEvent>>>,
|
||||||
|
shouldSetOptimizationBatteryBanner: Boolean,
|
||||||
|
) {
|
||||||
|
runTest {
|
||||||
|
val notifiableEventResult =
|
||||||
|
lambdaRecorder<SessionId, List<PushRequest>, Result<Map<PushRequest, Result<ResolvedPushEvent>>>> { _, requests ->
|
||||||
|
notificationResolveResult(requests)
|
||||||
|
}
|
||||||
|
val onPushReceivedResult = lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, Boolean, String?, Unit> { _, _, _, _, _, _, _ -> }
|
||||||
|
val pushHistoryService = FakePushHistoryService(
|
||||||
|
onPushReceivedResult = onPushReceivedResult,
|
||||||
|
)
|
||||||
|
val showBatteryOptimizationBannerResult = lambdaRecorder<Unit> {}
|
||||||
|
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<List<NotifiableEvent>, Unit> {}
|
||||||
|
val onPushReceivedResult = lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, Boolean, String?, Unit> { _, _, _, _, _, _, _ -> }
|
||||||
|
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<List<NotifiableEvent>, Unit> {}
|
||||||
|
val handleIncomingCallLambda = lambdaRecorder<
|
||||||
|
CallType.RoomCall,
|
||||||
|
EventId,
|
||||||
|
UserId,
|
||||||
|
String?,
|
||||||
|
String?,
|
||||||
|
String?,
|
||||||
|
String,
|
||||||
|
String?,
|
||||||
|
Unit,
|
||||||
|
> { _, _, _, _, _, _, _, _ -> }
|
||||||
|
val elementCallEntryPoint = FakeElementCallEntryPoint(handleIncomingCallResult = handleIncomingCallLambda)
|
||||||
|
val onPushReceivedResult = lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, Boolean, String?, Unit> { _, _, _, _, _, _, _ -> }
|
||||||
|
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<List<NotifiableEvent>, Unit> {}
|
||||||
|
val handleIncomingCallLambda = lambdaRecorder<
|
||||||
|
CallType.RoomCall,
|
||||||
|
EventId,
|
||||||
|
UserId,
|
||||||
|
String?,
|
||||||
|
String?,
|
||||||
|
String?,
|
||||||
|
String,
|
||||||
|
String?,
|
||||||
|
Unit,
|
||||||
|
> { _, _, _, _, _, _, _, _ -> }
|
||||||
|
val elementCallEntryPoint = FakeElementCallEntryPoint(handleIncomingCallResult = handleIncomingCallLambda)
|
||||||
|
val onPushReceivedResult = lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, Boolean, String?, Unit> { _, _, _, _, _, _, _ -> }
|
||||||
|
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<List<ResolvedPushEvent.Redaction>, Unit> { }
|
||||||
|
val onPushReceivedResult = lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, Boolean, String?, Unit> { _, _, _, _, _, _, _ -> }
|
||||||
|
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<List<NotifiableEvent>, Unit> {}
|
||||||
|
var receivedFallbackEvent = false
|
||||||
|
val onPushReceivedResult =
|
||||||
|
lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, Boolean, String?, Unit> { _, _, _, _, 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<ResolvedPushEvent.Redaction>) -> Unit = {},
|
||||||
|
onNotifiableEventsReceived: (List<NotifiableEvent>) -> 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -9,18 +9,18 @@
|
|||||||
package io.element.android.libraries.push.impl.notifications
|
package io.element.android.libraries.push.impl.notifications
|
||||||
|
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
import io.element.android.libraries.push.api.push.NotificationEventRequest
|
import io.element.android.libraries.push.impl.db.PushRequest
|
||||||
import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent
|
import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent
|
||||||
import io.element.android.tests.testutils.lambda.lambdaError
|
import io.element.android.tests.testutils.lambda.lambdaError
|
||||||
|
|
||||||
class FakeNotifiableEventResolver(
|
class FakeNotifiableEventResolver(
|
||||||
private val resolveEventsResult: (SessionId, List<NotificationEventRequest>) -> Result<Map<NotificationEventRequest, Result<ResolvedPushEvent>>> =
|
private val resolveEventsResult: (SessionId, List<PushRequest>) -> Result<Map<PushRequest, Result<ResolvedPushEvent>>> =
|
||||||
{ _, _ -> lambdaError() }
|
{ _, _ -> lambdaError() }
|
||||||
) : NotifiableEventResolver {
|
) : NotifiableEventResolver {
|
||||||
override suspend fun resolveEvents(
|
override suspend fun resolveEvents(
|
||||||
sessionId: SessionId,
|
sessionId: SessionId,
|
||||||
notificationEventRequests: List<NotificationEventRequest>
|
notificationEventRequests: List<PushRequest>
|
||||||
): Result<Map<NotificationEventRequest, Result<ResolvedPushEvent>>> {
|
): Result<Map<PushRequest, Result<ResolvedPushEvent>>> {
|
||||||
return resolveEventsResult(sessionId, notificationEventRequests)
|
return resolveEventsResult(sessionId, notificationEventRequests)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<PushRequest, Result<ResolvedPushEvent>>) -> Unit = { lambdaError() },
|
||||||
|
private val start: () -> Unit = { lambdaError() },
|
||||||
|
private val stop: () -> Unit = { lambdaError() },
|
||||||
|
) : NotificationResultProcessor {
|
||||||
|
override suspend fun emit(results: Map<PushRequest, Result<ResolvedPushEvent>>) {
|
||||||
|
return emit.invoke(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun start() {
|
||||||
|
start.invoke()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun stop() {
|
||||||
|
stop.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright (c) 2025 Element Creations Ltd.
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
* Copyright 2025 New Vector Ltd.
|
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
* Please see LICENSE files in the repository root for full details.
|
* 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.AN_EVENT_ID
|
||||||
import io.element.android.libraries.matrix.test.A_ROOM_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.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,
|
sessionId: SessionId = A_SESSION_ID,
|
||||||
roomId: RoomId = A_ROOM_ID,
|
roomId: RoomId = A_ROOM_ID,
|
||||||
eventId: EventId = AN_EVENT_ID,
|
eventId: EventId = AN_EVENT_ID,
|
||||||
providerInfo: String = "providerInfo",
|
providerInfo: String = "firebase",
|
||||||
) = NotificationEventRequest(
|
status: PushRequestStatus = PushRequestStatus.PENDING,
|
||||||
sessionId = sessionId,
|
retries: Int = 0,
|
||||||
roomId = roomId,
|
) = PushRequest(
|
||||||
eventId = eventId,
|
pushDate = System.currentTimeMillis(),
|
||||||
providerInfo = providerInfo,
|
providerInfo = providerInfo,
|
||||||
|
eventId = eventId.value,
|
||||||
|
roomId = roomId.value,
|
||||||
|
sessionId = sessionId.value,
|
||||||
|
status = status.value,
|
||||||
|
retries = retries.toLong(),
|
||||||
)
|
)
|
||||||
@@ -11,65 +11,38 @@
|
|||||||
package io.element.android.libraries.push.impl.push
|
package io.element.android.libraries.push.impl.push
|
||||||
|
|
||||||
import app.cash.turbine.test
|
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.core.meta.BuildMeta
|
||||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
|
||||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
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
|
||||||
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
|
||||||
import io.element.android.libraries.matrix.test.A_SECRET
|
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.A_USER_ID
|
||||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||||
import io.element.android.libraries.push.api.push.NotificationEventRequest
|
import io.element.android.libraries.push.impl.db.PushRequest
|
||||||
import io.element.android.libraries.push.api.push.SyncOnNotifiableEvent
|
|
||||||
import io.element.android.libraries.push.impl.history.FakePushHistoryService
|
import io.element.android.libraries.push.impl.history.FakePushHistoryService
|
||||||
import io.element.android.libraries.push.impl.history.PushHistoryService
|
import io.element.android.libraries.push.impl.history.PushHistoryService
|
||||||
import io.element.android.libraries.push.impl.notifications.DefaultNotificationResolverQueue
|
import io.element.android.libraries.push.impl.notifications.FakeNotificationResultProcessor
|
||||||
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.test.DefaultTestPush
|
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.troubleshoot.DiagnosticPushHandler
|
||||||
import io.element.android.libraries.push.impl.workmanager.SyncNotificationsWorkerDataConverter
|
|
||||||
import io.element.android.libraries.pushproviders.api.PushData
|
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.api.clientsecret.PushClientSecret
|
||||||
import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStore
|
import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStore
|
||||||
import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStoreFactory
|
import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStoreFactory
|
||||||
import io.element.android.libraries.pushstore.test.userpushstore.clientsecret.FakePushClientSecret
|
import io.element.android.libraries.pushstore.test.userpushstore.clientsecret.FakePushClientSecret
|
||||||
import io.element.android.libraries.workmanager.api.WorkManagerRequest
|
import io.element.android.libraries.workmanager.api.WorkManagerRequestBuilder
|
||||||
import io.element.android.libraries.workmanager.test.FakeWorkManagerScheduler
|
import io.element.android.libraries.workmanager.test.FakeWorkManagerScheduler
|
||||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||||
import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider
|
import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider
|
||||||
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
|
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
|
||||||
import io.element.android.tests.testutils.lambda.any
|
|
||||||
import io.element.android.tests.testutils.lambda.lambdaError
|
import io.element.android.tests.testutils.lambda.lambdaError
|
||||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
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 io.element.android.tests.testutils.lambda.value
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.test.TestScope
|
|
||||||
import kotlinx.coroutines.test.advanceTimeBy
|
import kotlinx.coroutines.test.advanceTimeBy
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import java.time.Instant
|
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
private const val A_PUSHER_INFO = "info"
|
private const val A_PUSHER_INFO = "info"
|
||||||
@@ -96,84 +69,36 @@ class DefaultPushHandlerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `when classical PushData is received, the notification drawer is informed`() = runTest {
|
fun `when classical PushData is received, the work is scheduled`() = runTest {
|
||||||
val aNotifiableMessageEvent = aNotifiableMessageEvent()
|
|
||||||
val notifiableEventResult =
|
|
||||||
lambdaRecorder<SessionId, List<NotificationEventRequest>, Result<Map<NotificationEventRequest, Result<ResolvedPushEvent>>>> { _, _ ->
|
|
||||||
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, A_PUSHER_INFO)
|
|
||||||
Result.success(mapOf(request to Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent))))
|
|
||||||
}
|
|
||||||
val onNotifiableEventsReceived = lambdaRecorder<List<NotifiableEvent>, Unit> {}
|
|
||||||
val incrementPushCounterResult = lambdaRecorder<Unit> {}
|
val incrementPushCounterResult = lambdaRecorder<Unit> {}
|
||||||
val onPushReceivedResult = lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, Boolean, String?, Unit> { _, _, _, _, _, _, _ -> }
|
|
||||||
val pushHistoryService = FakePushHistoryService(
|
|
||||||
onPushReceivedResult = onPushReceivedResult,
|
|
||||||
)
|
|
||||||
val aPushData = PushData(
|
val aPushData = PushData(
|
||||||
eventId = AN_EVENT_ID,
|
eventId = AN_EVENT_ID,
|
||||||
roomId = A_ROOM_ID,
|
roomId = A_ROOM_ID,
|
||||||
unread = 0,
|
unread = 0,
|
||||||
clientSecret = A_SECRET,
|
clientSecret = A_SECRET,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val enqueuePushRequestResult = lambdaRecorder<PushRequest, Result<Unit>> { Result.success(Unit) }
|
||||||
|
val pushHistoryService = FakePushHistoryService(
|
||||||
|
enqueuePushRequest = enqueuePushRequestResult,
|
||||||
|
)
|
||||||
|
val submitWorkLambda = lambdaRecorder<WorkManagerRequestBuilder, Unit> {}
|
||||||
|
val workManagerScheduler = FakeWorkManagerScheduler(submitLambda = submitWorkLambda)
|
||||||
|
|
||||||
val defaultPushHandler = createDefaultPushHandler(
|
val defaultPushHandler = createDefaultPushHandler(
|
||||||
onNotifiableEventsReceived = onNotifiableEventsReceived,
|
|
||||||
notifiableEventsResult = notifiableEventResult,
|
|
||||||
pushClientSecret = FakePushClientSecret(
|
pushClientSecret = FakePushClientSecret(
|
||||||
getUserIdFromSecretResult = { A_USER_ID }
|
getUserIdFromSecretResult = { A_USER_ID }
|
||||||
),
|
),
|
||||||
incrementPushCounterResult = incrementPushCounterResult,
|
incrementPushCounterResult = incrementPushCounterResult,
|
||||||
|
workManagerScheduler = workManagerScheduler,
|
||||||
pushHistoryService = pushHistoryService,
|
pushHistoryService = pushHistoryService,
|
||||||
)
|
)
|
||||||
defaultPushHandler.handle(aPushData, A_PUSHER_INFO)
|
defaultPushHandler.handle(aPushData, A_PUSHER_INFO)
|
||||||
|
|
||||||
advanceTimeBy(300.milliseconds)
|
advanceTimeBy(300.milliseconds)
|
||||||
|
|
||||||
incrementPushCounterResult.assertions()
|
submitWorkLambda.assertions()
|
||||||
.isCalledOnce()
|
.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<SessionId, List<NotificationEventRequest>, Result<Map<NotificationEventRequest, Result<ResolvedPushEvent>>>> { _, _ ->
|
|
||||||
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, A_PUSHER_INFO)
|
|
||||||
Result.success(mapOf(request to Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent))))
|
|
||||||
}
|
|
||||||
val incrementPushCounterResult = lambdaRecorder<Unit> {}
|
|
||||||
val aPushData = PushData(
|
|
||||||
eventId = AN_EVENT_ID,
|
|
||||||
roomId = A_ROOM_ID,
|
|
||||||
unread = 0,
|
|
||||||
clientSecret = A_SECRET,
|
|
||||||
)
|
|
||||||
|
|
||||||
val featureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.SyncNotificationsWithWorkManager.key to true))
|
|
||||||
val submitWorkLambda = lambdaRecorder<WorkManagerRequest, Unit> {}
|
|
||||||
val workManagerScheduler = FakeWorkManagerScheduler(submitLambda = submitWorkLambda)
|
|
||||||
|
|
||||||
val defaultPushHandler = createDefaultPushHandler(
|
|
||||||
notifiableEventsResult = notifiableEventResult,
|
|
||||||
pushClientSecret = FakePushClientSecret(
|
|
||||||
getUserIdFromSecretResult = { A_USER_ID }
|
|
||||||
),
|
|
||||||
incrementPushCounterResult = incrementPushCounterResult,
|
|
||||||
featureFlagService = featureFlagService,
|
|
||||||
workManagerScheduler = workManagerScheduler,
|
|
||||||
)
|
|
||||||
defaultPushHandler.handle(aPushData, A_PUSHER_INFO)
|
|
||||||
|
|
||||||
advanceTimeBy(300.milliseconds)
|
|
||||||
|
|
||||||
submitWorkLambda.assertions().isCalledOnce()
|
|
||||||
|
|
||||||
incrementPushCounterResult.assertions()
|
incrementPushCounterResult.assertions()
|
||||||
.isCalledOnce()
|
.isCalledOnce()
|
||||||
@@ -182,13 +107,6 @@ class DefaultPushHandlerTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `when classical PushData is received, but notifications are disabled, nothing happen`() =
|
fun `when classical PushData is received, but notifications are disabled, nothing happen`() =
|
||||||
runTest {
|
runTest {
|
||||||
val aNotifiableMessageEvent = aNotifiableMessageEvent()
|
|
||||||
val notifiableEventResult =
|
|
||||||
lambdaRecorder<SessionId, List<NotificationEventRequest>, Result<Map<NotificationEventRequest, Result<ResolvedPushEvent>>>> { _, _ ->
|
|
||||||
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, A_PUSHER_INFO)
|
|
||||||
Result.success(mapOf(request to Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent))))
|
|
||||||
}
|
|
||||||
val onNotifiableEventsReceived = lambdaRecorder<List<NotifiableEvent>, Unit> {}
|
|
||||||
val incrementPushCounterResult = lambdaRecorder<Unit> {}
|
val incrementPushCounterResult = lambdaRecorder<Unit> {}
|
||||||
val aPushData = PushData(
|
val aPushData = PushData(
|
||||||
eventId = AN_EVENT_ID,
|
eventId = AN_EVENT_ID,
|
||||||
@@ -197,12 +115,15 @@ class DefaultPushHandlerTest {
|
|||||||
clientSecret = A_SECRET,
|
clientSecret = A_SECRET,
|
||||||
)
|
)
|
||||||
val onPushReceivedResult = lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, Boolean, String?, Unit> { _, _, _, _, _, _, _ -> }
|
val onPushReceivedResult = lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, Boolean, String?, Unit> { _, _, _, _, _, _, _ -> }
|
||||||
|
val enqueuePushRequestResult = lambdaRecorder<PushRequest, Result<Unit>> { Result.success(Unit) }
|
||||||
val pushHistoryService = FakePushHistoryService(
|
val pushHistoryService = FakePushHistoryService(
|
||||||
onPushReceivedResult = onPushReceivedResult,
|
onPushReceivedResult = onPushReceivedResult,
|
||||||
|
enqueuePushRequest = enqueuePushRequestResult,
|
||||||
)
|
)
|
||||||
|
val submitWorkLambda = lambdaRecorder<WorkManagerRequestBuilder, Unit> {}
|
||||||
|
val workManagerScheduler = FakeWorkManagerScheduler(submitLambda = submitWorkLambda)
|
||||||
|
|
||||||
val defaultPushHandler = createDefaultPushHandler(
|
val defaultPushHandler = createDefaultPushHandler(
|
||||||
onNotifiableEventsReceived = onNotifiableEventsReceived,
|
|
||||||
notifiableEventsResult = notifiableEventResult,
|
|
||||||
pushClientSecret = FakePushClientSecret(
|
pushClientSecret = FakePushClientSecret(
|
||||||
getUserIdFromSecretResult = { A_USER_ID }
|
getUserIdFromSecretResult = { A_USER_ID }
|
||||||
),
|
),
|
||||||
@@ -211,31 +132,24 @@ class DefaultPushHandlerTest {
|
|||||||
},
|
},
|
||||||
incrementPushCounterResult = incrementPushCounterResult,
|
incrementPushCounterResult = incrementPushCounterResult,
|
||||||
pushHistoryService = pushHistoryService,
|
pushHistoryService = pushHistoryService,
|
||||||
|
workManagerScheduler = workManagerScheduler,
|
||||||
)
|
)
|
||||||
defaultPushHandler.handle(aPushData, A_PUSHER_INFO)
|
defaultPushHandler.handle(aPushData, A_PUSHER_INFO)
|
||||||
|
|
||||||
advanceTimeBy(300.milliseconds)
|
advanceTimeBy(300.milliseconds)
|
||||||
|
|
||||||
|
submitWorkLambda.assertions()
|
||||||
|
.isNeverCalled()
|
||||||
|
enqueuePushRequestResult.assertions()
|
||||||
|
.isNeverCalled()
|
||||||
incrementPushCounterResult.assertions()
|
incrementPushCounterResult.assertions()
|
||||||
.isCalledOnce()
|
.isCalledOnce()
|
||||||
notifiableEventResult.assertions()
|
|
||||||
.isCalledOnce()
|
|
||||||
onNotifiableEventsReceived.assertions()
|
|
||||||
.isNeverCalled()
|
|
||||||
onPushReceivedResult.assertions()
|
onPushReceivedResult.assertions()
|
||||||
.isCalledOnce()
|
.isNeverCalled()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `when PushData is received, but client secret is not known, nothing happen`() =
|
fun `when PushData is received, but client secret is not known, nothing happen`() = runTest {
|
||||||
runTest {
|
|
||||||
val aNotifiableMessageEvent = aNotifiableMessageEvent()
|
|
||||||
val notifiableEventResult =
|
|
||||||
lambdaRecorder<SessionId, List<NotificationEventRequest>, Result<Map<NotificationEventRequest, Result<ResolvedPushEvent>>>> { _, _ ->
|
|
||||||
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, A_PUSHER_INFO)
|
|
||||||
Result.success(mapOf(request to Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent))))
|
|
||||||
}
|
|
||||||
val onNotifiableEventsReceived = lambdaRecorder<List<NotifiableEvent>, Unit> {}
|
|
||||||
val incrementPushCounterResult = lambdaRecorder<Unit> {}
|
val incrementPushCounterResult = lambdaRecorder<Unit> {}
|
||||||
val aPushData = PushData(
|
val aPushData = PushData(
|
||||||
eventId = AN_EVENT_ID,
|
eventId = AN_EVENT_ID,
|
||||||
@@ -247,477 +161,85 @@ class DefaultPushHandlerTest {
|
|||||||
val pushHistoryService = FakePushHistoryService(
|
val pushHistoryService = FakePushHistoryService(
|
||||||
onPushReceivedResult = onPushReceivedResult,
|
onPushReceivedResult = onPushReceivedResult,
|
||||||
)
|
)
|
||||||
|
val submitWorkLambda = lambdaRecorder<WorkManagerRequestBuilder, Unit> {}
|
||||||
|
val workManagerScheduler = FakeWorkManagerScheduler(submitLambda = submitWorkLambda)
|
||||||
val defaultPushHandler = createDefaultPushHandler(
|
val defaultPushHandler = createDefaultPushHandler(
|
||||||
onNotifiableEventsReceived = onNotifiableEventsReceived,
|
|
||||||
notifiableEventsResult = notifiableEventResult,
|
|
||||||
pushClientSecret = FakePushClientSecret(
|
pushClientSecret = FakePushClientSecret(
|
||||||
getUserIdFromSecretResult = { null }
|
getUserIdFromSecretResult = { null }
|
||||||
),
|
),
|
||||||
incrementPushCounterResult = incrementPushCounterResult,
|
incrementPushCounterResult = incrementPushCounterResult,
|
||||||
pushHistoryService = pushHistoryService,
|
pushHistoryService = pushHistoryService,
|
||||||
|
workManagerScheduler = workManagerScheduler,
|
||||||
)
|
)
|
||||||
defaultPushHandler.handle(aPushData, A_PUSHER_INFO)
|
defaultPushHandler.handle(aPushData, A_PUSHER_INFO)
|
||||||
|
submitWorkLambda.assertions()
|
||||||
|
.isNeverCalled()
|
||||||
incrementPushCounterResult.assertions()
|
incrementPushCounterResult.assertions()
|
||||||
.isCalledOnce()
|
.isCalledOnce()
|
||||||
notifiableEventResult.assertions()
|
|
||||||
.isNeverCalled()
|
|
||||||
onNotifiableEventsReceived.assertions()
|
|
||||||
.isNeverCalled()
|
|
||||||
onPushReceivedResult.assertions()
|
onPushReceivedResult.assertions()
|
||||||
.isCalledOnce()
|
.isCalledOnce()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `when classical PushData is received, but a failure occurs (session not found), nothing happen`() {
|
fun `when diagnostic PushData is received, the diagnostic push handler is informed`() = runTest {
|
||||||
`test notification resolver failure`(
|
val aPushData = PushData(
|
||||||
notificationResolveResult = { _ ->
|
eventId = DefaultTestPush.TEST_EVENT_ID,
|
||||||
Result.failure(NotificationResolverException.UnknownError("Unable to restore session"))
|
roomId = A_ROOM_ID,
|
||||||
},
|
unread = 0,
|
||||||
shouldSetOptimizationBatteryBanner = false,
|
clientSecret = A_SECRET,
|
||||||
)
|
)
|
||||||
}
|
val diagnosticPushHandler = DiagnosticPushHandler()
|
||||||
|
val onPushReceivedResult = lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, Boolean, String?, Unit> { _, _, _, _, _, _, _ -> }
|
||||||
@Test
|
val pushHistoryService = FakePushHistoryService(
|
||||||
fun `when classical PushData is received, but not able to resolve the event, the banner to disable battery optimization will be displayed`() {
|
onPushReceivedResult = onPushReceivedResult,
|
||||||
`test notification resolver failure`(
|
|
||||||
notificationResolveResult = { requests: List<NotificationEventRequest> ->
|
|
||||||
Result.success(
|
|
||||||
requests.associateWith { Result.failure(NotificationResolverException.UnknownError("Unable to resolve event")) }
|
|
||||||
)
|
|
||||||
},
|
|
||||||
shouldSetOptimizationBatteryBanner = true,
|
|
||||||
)
|
)
|
||||||
}
|
val defaultPushHandler = createDefaultPushHandler(
|
||||||
|
diagnosticPushHandler = diagnosticPushHandler,
|
||||||
private fun `test notification resolver failure`(
|
incrementPushCounterResult = { },
|
||||||
notificationResolveResult: (List<NotificationEventRequest>) -> Result<Map<NotificationEventRequest, Result<ResolvedPushEvent>>>,
|
pushHistoryService = pushHistoryService,
|
||||||
shouldSetOptimizationBatteryBanner: Boolean,
|
)
|
||||||
) {
|
diagnosticPushHandler.state.test {
|
||||||
runTest {
|
|
||||||
val notifiableEventResult =
|
|
||||||
lambdaRecorder<SessionId, List<NotificationEventRequest>, Result<Map<NotificationEventRequest, Result<ResolvedPushEvent>>>> { _, requests ->
|
|
||||||
notificationResolveResult(requests)
|
|
||||||
}
|
|
||||||
val onNotifiableEventsReceived = lambdaRecorder<List<NotifiableEvent>, Unit> {}
|
|
||||||
val incrementPushCounterResult = lambdaRecorder<Unit> {}
|
|
||||||
val aPushData = PushData(
|
|
||||||
eventId = AN_EVENT_ID,
|
|
||||||
roomId = A_ROOM_ID,
|
|
||||||
unread = 0,
|
|
||||||
clientSecret = A_SECRET,
|
|
||||||
)
|
|
||||||
val onPushReceivedResult = lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, Boolean, String?, Unit> { _, _, _, _, _, _, _ -> }
|
|
||||||
val pushHistoryService = FakePushHistoryService(
|
|
||||||
onPushReceivedResult = onPushReceivedResult,
|
|
||||||
)
|
|
||||||
val showBatteryOptimizationBannerResult = lambdaRecorder<Unit> {}
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
defaultPushHandler.handle(aPushData, A_PUSHER_INFO)
|
defaultPushHandler.handle(aPushData, A_PUSHER_INFO)
|
||||||
|
awaitItem()
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@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<List<NotifiableEvent>, Unit> {}
|
|
||||||
val onPushReceivedResult = lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, Boolean, String?, Unit> { _, _, _, _, _, _, _ -> }
|
|
||||||
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<List<NotifiableEvent>, Unit> {}
|
|
||||||
val handleIncomingCallLambda = lambdaRecorder<
|
|
||||||
CallType.RoomCall,
|
|
||||||
EventId,
|
|
||||||
UserId,
|
|
||||||
String?,
|
|
||||||
String?,
|
|
||||||
String?,
|
|
||||||
String,
|
|
||||||
String?,
|
|
||||||
Unit,
|
|
||||||
> { _, _, _, _, _, _, _, _ -> }
|
|
||||||
val elementCallEntryPoint = FakeElementCallEntryPoint(handleIncomingCallResult = handleIncomingCallLambda)
|
|
||||||
val onPushReceivedResult = lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, Boolean, String?, Unit> { _, _, _, _, _, _, _ -> }
|
|
||||||
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<List<NotifiableEvent>, Unit> {}
|
|
||||||
val handleIncomingCallLambda = lambdaRecorder<
|
|
||||||
CallType.RoomCall,
|
|
||||||
EventId,
|
|
||||||
UserId,
|
|
||||||
String?,
|
|
||||||
String?,
|
|
||||||
String?,
|
|
||||||
String,
|
|
||||||
String?,
|
|
||||||
Unit,
|
|
||||||
> { _, _, _, _, _, _, _, _ -> }
|
|
||||||
val elementCallEntryPoint = FakeElementCallEntryPoint(handleIncomingCallResult = handleIncomingCallLambda)
|
|
||||||
val onPushReceivedResult = lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, Boolean, String?, Unit> { _, _, _, _, _, _, _ -> }
|
|
||||||
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<List<ResolvedPushEvent.Redaction>, Unit> { }
|
|
||||||
val incrementPushCounterResult = lambdaRecorder<Unit> {}
|
|
||||||
val onPushReceivedResult = lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, Boolean, String?, Unit> { _, _, _, _, _, _, _ -> }
|
|
||||||
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()
|
onPushReceivedResult.assertions()
|
||||||
.isCalledOnce()
|
.isCalledOnce()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
private fun createDefaultPushHandler(
|
||||||
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<String, EventId?, RoomId?, SessionId?, Boolean, Boolean, String?, Unit> { _, _, _, _, _, _, _ -> }
|
|
||||||
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<SessionId, List<NotificationEventRequest>, Result<Map<NotificationEventRequest, Result<ResolvedPushEvent>>>> { _, _ ->
|
|
||||||
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, A_PUSHER_INFO)
|
|
||||||
Result.success(mapOf(request to Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent))))
|
|
||||||
}
|
|
||||||
val onNotifiableEventsReceived = lambdaRecorder<List<NotifiableEvent>, Unit> {}
|
|
||||||
val incrementPushCounterResult = lambdaRecorder<Unit> {}
|
|
||||||
val onPushReceivedResult = lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, Boolean, String?, Unit> { _, _, _, _, _, _, _ -> }
|
|
||||||
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<List<NotificationEventRequest>> { 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<SessionId, List<NotificationEventRequest>, Result<Map<NotificationEventRequest, Result<ResolvedPushEvent>>>> { _, _ ->
|
|
||||||
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, A_PUSHER_INFO)
|
|
||||||
Result.success(mapOf(request to Result.success(ResolvedPushEvent.Event(aNotifiableFallbackEvent))))
|
|
||||||
}
|
|
||||||
val onNotifiableEventsReceived = lambdaRecorder<List<NotifiableEvent>, Unit> {}
|
|
||||||
val incrementPushCounterResult = lambdaRecorder<Unit> {}
|
|
||||||
var receivedFallbackEvent = false
|
|
||||||
val onPushReceivedResult =
|
|
||||||
lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, Boolean, String?, Unit> { _, _, _, _, 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<NotifiableEvent>) -> Unit = { lambdaError() },
|
|
||||||
onRedactedEventsReceived: (List<ResolvedPushEvent.Redaction>) -> Unit = { lambdaError() },
|
|
||||||
notifiableEventsResult: (SessionId, List<NotificationEventRequest>) -> Result<Map<NotificationEventRequest, Result<ResolvedPushEvent>>> =
|
|
||||||
{ _, _ -> lambdaError() },
|
|
||||||
incrementPushCounterResult: () -> Unit = { lambdaError() },
|
incrementPushCounterResult: () -> Unit = { lambdaError() },
|
||||||
mutableBatteryOptimizationStore: MutableBatteryOptimizationStore = FakeMutableBatteryOptimizationStore(),
|
userPushStore: FakeUserPushStore = FakeUserPushStore(),
|
||||||
userPushStore: UserPushStore = FakeUserPushStore(),
|
|
||||||
pushClientSecret: PushClientSecret = FakePushClientSecret(),
|
pushClientSecret: PushClientSecret = FakePushClientSecret(),
|
||||||
buildMeta: BuildMeta = aBuildMeta(),
|
buildMeta: BuildMeta = aBuildMeta(),
|
||||||
diagnosticPushHandler: DiagnosticPushHandler = DiagnosticPushHandler(),
|
diagnosticPushHandler: DiagnosticPushHandler = DiagnosticPushHandler(),
|
||||||
elementCallEntryPoint: FakeElementCallEntryPoint = FakeElementCallEntryPoint(),
|
|
||||||
notificationChannels: FakeNotificationChannels = FakeNotificationChannels(),
|
|
||||||
pushHistoryService: PushHistoryService = FakePushHistoryService(),
|
pushHistoryService: PushHistoryService = FakePushHistoryService(),
|
||||||
syncOnNotifiableEvent: SyncOnNotifiableEvent = SyncOnNotifiableEvent {},
|
|
||||||
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.SyncNotificationsWithWorkManager.key to false)),
|
|
||||||
workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(),
|
workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(),
|
||||||
analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
|
analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
|
||||||
|
systemClock: FakeSystemClock = FakeSystemClock(),
|
||||||
|
buildVersionSdkIntProvider: FakeBuildVersionSdkIntProvider = FakeBuildVersionSdkIntProvider(33),
|
||||||
|
resultProcessor: FakeNotificationResultProcessor = FakeNotificationResultProcessor(
|
||||||
|
emit = { Result.success(Unit) },
|
||||||
|
start = {},
|
||||||
|
stop = {},
|
||||||
|
),
|
||||||
): DefaultPushHandler {
|
): DefaultPushHandler {
|
||||||
return DefaultPushHandler(
|
return DefaultPushHandler(
|
||||||
onNotifiableEventReceived = FakeOnNotifiableEventReceived(onNotifiableEventsReceived),
|
|
||||||
onRedactedEventReceived = FakeOnRedactedEventReceived(onRedactedEventsReceived),
|
|
||||||
incrementPushDataStore = object : IncrementPushDataStore {
|
incrementPushDataStore = object : IncrementPushDataStore {
|
||||||
override suspend fun incrementPushCounter() {
|
override suspend fun incrementPushCounter() {
|
||||||
incrementPushCounterResult()
|
incrementPushCounterResult()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mutableBatteryOptimizationStore = mutableBatteryOptimizationStore,
|
|
||||||
userPushStoreFactory = FakeUserPushStoreFactory { userPushStore },
|
userPushStoreFactory = FakeUserPushStoreFactory { userPushStore },
|
||||||
pushClientSecret = pushClientSecret,
|
pushClientSecret = pushClientSecret,
|
||||||
buildMeta = buildMeta,
|
buildMeta = buildMeta,
|
||||||
diagnosticPushHandler = diagnosticPushHandler,
|
diagnosticPushHandler = diagnosticPushHandler,
|
||||||
elementCallEntryPoint = elementCallEntryPoint,
|
|
||||||
notificationChannels = notificationChannels,
|
|
||||||
pushHistoryService = pushHistoryService,
|
pushHistoryService = pushHistoryService,
|
||||||
// We don't use a fake here so we can perform tests that are a bit more end to end
|
// 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,
|
analyticsService = analyticsService,
|
||||||
|
systemClock = systemClock,
|
||||||
|
workManagerScheduler = workManagerScheduler,
|
||||||
|
buildVersionSdkIntProvider = buildVersionSdkIntProvider,
|
||||||
|
resultProcessor = resultProcessor,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.FakeBaseRoom
|
||||||
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
|
||||||
import io.element.android.libraries.matrix.test.sync.FakeSyncService
|
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.aPushRequest
|
||||||
import io.element.android.libraries.push.impl.notifications.fixtures.aNotificationEventRequest
|
|
||||||
import io.element.android.services.appnavstate.test.FakeAppForegroundStateService
|
import io.element.android.services.appnavstate.test.FakeAppForegroundStateService
|
||||||
import io.element.android.tests.testutils.lambda.assert
|
import io.element.android.tests.testutils.lambda.assert
|
||||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||||
@@ -53,7 +52,7 @@ class SyncOnNotifiableEventTest {
|
|||||||
givenGetRoomResult(A_ROOM_ID, room)
|
givenGetRoomResult(A_ROOM_ID, room)
|
||||||
}
|
}
|
||||||
|
|
||||||
private val notificationRequest = aNotificationEventRequest()
|
private val notificationRequest = aPushRequest()
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `when feature flag is disabled, nothing happens`() = runTest {
|
fun `when feature flag is disabled, nothing happens`() = runTest {
|
||||||
|
|||||||
@@ -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<WorkManagerRequest, Unit> {}
|
|
||||||
val scheduler = FakeWorkManagerScheduler(submitLambda = submitWorkerLambda)
|
|
||||||
|
|
||||||
val resolver = FakeNotifiableEventResolver(
|
|
||||||
resolveEventsResult = { _, _ -> Result.failure(Exception("Failed to resolve events")) }
|
|
||||||
)
|
|
||||||
|
|
||||||
val worker = createWorker(
|
|
||||||
input = """
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"session_id": "@alice:matrix.org",
|
|
||||||
"room_id": "!roomid:matrix.org",
|
|
||||||
"event_id": "$1436ebk:matrix.org",
|
|
||||||
"provider_info": "some_info"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
""".trimIndent(),
|
|
||||||
eventResolver = resolver,
|
|
||||||
workManagerScheduler = scheduler,
|
|
||||||
)
|
|
||||||
|
|
||||||
val result = worker.doWork()
|
|
||||||
|
|
||||||
// The process was considered successful, but a retry was scheduled due to the failure to resolve events
|
|
||||||
assertThat(result).isEqualTo(ListenableWorker.Result.success())
|
|
||||||
submitWorkerLambda.assertions().isCalledOnce()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun TestScope.createWorker(
|
|
||||||
input: String,
|
|
||||||
networkMonitor: FakeNetworkMonitor = FakeNetworkMonitor(),
|
|
||||||
eventResolver: FakeNotifiableEventResolver = FakeNotifiableEventResolver(resolveEventsResult = { _, _ -> Result.success(emptyMap()) }),
|
|
||||||
queue: NotificationResolverQueue = FakeNotificationResolverQueue(
|
|
||||||
processingLambda = { Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent())) }
|
|
||||||
),
|
|
||||||
workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(),
|
|
||||||
syncOnNotifiableEvent: SyncOnNotifiableEvent = SyncOnNotifiableEvent {},
|
|
||||||
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<T> : ListenableFuture<T> {
|
|
||||||
override fun addListener(listener: Runnable, executor: Executor) = Unit
|
|
||||||
override fun cancel(mayInterruptIfRunning: Boolean): Boolean = true
|
|
||||||
override fun get(): T? = null
|
|
||||||
override fun get(timeout: Long, unit: TimeUnit?): T? = null
|
|
||||||
override fun isCancelled(): Boolean = false
|
|
||||||
override fun isDone(): Boolean = false
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,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<Map<PushRequest, Result<ResolvedPushEvent>>, Unit> {}
|
||||||
|
val processor = FakeNotificationResultProcessor(emit = emitResultLambda)
|
||||||
|
|
||||||
|
val getPendingResultsLambda = lambdaRecorder<SessionId, Instant?, Result<List<PushRequest>>> { _, _ -> Result.success(listOf(aPushRequest())) }
|
||||||
|
val replacePushRequestsLambda = lambdaRecorder<List<PushRequest>, Result<Unit>> { Result.success(Unit) }
|
||||||
|
val removeOldPushRequestsLambda = lambdaRecorder<SessionId, Result<Unit>> { 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<Map<PushRequest, Result<ResolvedPushEvent>>, 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<WorkManagerRequestBuilder, Unit> {}
|
||||||
|
val emitResultLambda = lambdaRecorder<Map<PushRequest, Result<ResolvedPushEvent>>, 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<WorkManagerRequestBuilder, Unit> {}
|
||||||
|
val emitResultLambda = lambdaRecorder<Map<PushRequest, Result<ResolvedPushEvent>>, 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<WorkManagerRequestBuilder, Unit> {}
|
||||||
|
val emitResultLambda = lambdaRecorder<Map<PushRequest, Result<ResolvedPushEvent>>, 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<T> : ListenableFuture<T> {
|
||||||
|
override fun addListener(listener: Runnable, executor: Executor) = Unit
|
||||||
|
override fun cancel(mayInterruptIfRunning: Boolean): Boolean = true
|
||||||
|
override fun get(): T? = null
|
||||||
|
override fun get(timeout: Long, unit: TimeUnit?): T? = null
|
||||||
|
override fun isCancelled(): Boolean = false
|
||||||
|
override fun isDone(): Boolean = false
|
||||||
|
}
|
||||||
@@ -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<String>("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<String>("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<NotificationEventRequest>,
|
|
||||||
workerDataConverter: SyncNotificationsWorkerDataConverter = SyncNotificationsWorkerDataConverter(DefaultJsonProvider()),
|
|
||||||
sdkVersion: Int = 33,
|
|
||||||
) = SyncNotificationWorkManagerRequest(
|
|
||||||
sessionId = sessionId,
|
|
||||||
notificationEventRequests = notificationEventRequests,
|
|
||||||
workerDataConverter = workerDataConverter,
|
|
||||||
buildVersionSdkIntProvider = FakeBuildVersionSdkIntProvider(sdkVersion),
|
|
||||||
)
|
|
||||||
@@ -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<String>(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<String>(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),
|
||||||
|
)
|
||||||
@@ -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<NotificationEventRequest>) {
|
|
||||||
val sut = SyncNotificationsWorkerDataConverter(DefaultJsonProvider())
|
|
||||||
val serialized = sut.serialize(data).getOrThrow()
|
|
||||||
val result = sut.deserialize(serialized.first())
|
|
||||||
assertThat(result).isEqualTo(data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<ResolvedPushEvent>,
|
|
||||||
) : NotificationResolverQueue {
|
|
||||||
override val results = MutableSharedFlow<Pair<List<NotificationEventRequest>, Map<NotificationEventRequest, Result<ResolvedPushEvent>>>>(replay = 1)
|
|
||||||
|
|
||||||
override suspend fun enqueue(request: NotificationEventRequest) {
|
|
||||||
results.emit(listOf(request) to mapOf(request to processingLambda(request)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
-- version of the DB is 1, so we will never migrate from 0
|
||||||
|
|
||||||
CREATE TABLE SessionData (
|
CREATE TABLE SessionData (
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (c) 2025 Element Creations Ltd.
|
|
||||||
* Copyright 2025 New Vector Ltd.
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
|
||||||
* Please see LICENSE files in the repository root for full details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package io.element.android.libraries.workmanager.api
|
|
||||||
|
|
||||||
import androidx.work.WorkRequest
|
|
||||||
|
|
||||||
interface WorkManagerRequest {
|
|
||||||
fun build(): Result<List<WorkRequest>>
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Element Creations Ltd.
|
||||||
|
* Copyright 2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.workmanager.api
|
||||||
|
|
||||||
|
import androidx.work.ExistingWorkPolicy
|
||||||
|
import androidx.work.WorkRequest
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A base class that can be customized to [build] work requests to schedule in `WorkManager`.
|
||||||
|
*/
|
||||||
|
interface WorkManagerRequestBuilder {
|
||||||
|
/**
|
||||||
|
* Builds a work request wrapper using the provided data.
|
||||||
|
*/
|
||||||
|
suspend fun build(): Result<List<WorkManagerRequestWrapper>>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A wrapper that allows us to avoid using Android APIs directly when scheduling workers.
|
||||||
|
*/
|
||||||
|
data class WorkManagerRequestWrapper(
|
||||||
|
val request: WorkRequest,
|
||||||
|
val type: WorkManagerWorkerType = WorkManagerWorkerType.Default,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The type of worker to use when scheduling the task.
|
||||||
|
*/
|
||||||
|
sealed interface WorkManagerWorkerType {
|
||||||
|
/**
|
||||||
|
* This allows a single worker instance with the [name] id to run at the same time. Its [policy] can be customized.
|
||||||
|
*/
|
||||||
|
data class Unique(val name: String, val policy: ExistingWorkPolicy) : WorkManagerWorkerType
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The default worker type, with no custom rules.
|
||||||
|
*/
|
||||||
|
data object Default : WorkManagerWorkerType
|
||||||
|
}
|
||||||
@@ -11,9 +11,21 @@ package io.element.android.libraries.workmanager.api
|
|||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
|
|
||||||
interface WorkManagerScheduler {
|
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 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 {
|
fun workManagerTag(sessionId: SessionId, requestType: WorkManagerRequestType): String {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
package io.element.android.libraries.workmanager.impl
|
package io.element.android.libraries.workmanager.impl
|
||||||
|
|
||||||
|
import androidx.work.OneTimeWorkRequest
|
||||||
import androidx.work.WorkInfo
|
import androidx.work.WorkInfo
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
import dev.zacsweers.metro.AppScope
|
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.matrix.api.core.SessionId
|
||||||
import io.element.android.libraries.sessionstorage.api.observer.SessionListener
|
import io.element.android.libraries.sessionstorage.api.observer.SessionListener
|
||||||
import io.element.android.libraries.sessionstorage.api.observer.SessionObserver
|
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.WorkManagerRequestType
|
||||||
import io.element.android.libraries.workmanager.api.WorkManagerScheduler
|
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 io.element.android.libraries.workmanager.api.workManagerTag
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
@@ -41,13 +43,22 @@ class DefaultWorkManagerScheduler(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun submit(workManagerRequest: WorkManagerRequest) {
|
override suspend fun submit(workManagerRequestBuilder: WorkManagerRequestBuilder) {
|
||||||
workManagerRequest.build().fold(
|
workManagerRequestBuilder.build().fold(
|
||||||
onSuccess = { workRequests ->
|
onSuccess = { wrappers ->
|
||||||
workManager.enqueue(workRequests)
|
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 = {
|
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")
|
Timber.d("Cancelling work for sessionId: $sessionId")
|
||||||
for (requestType in WorkManagerRequestType.entries) {
|
|
||||||
|
if (requestType != null) {
|
||||||
workManager.cancelAllWorkByTag(workManagerTag(sessionId, requestType))
|
workManager.cancelAllWorkByTag(workManagerTag(sessionId, requestType))
|
||||||
|
} else {
|
||||||
|
for (requestType in WorkManagerRequestType.entries) {
|
||||||
|
workManager.cancelAllWorkByTag(workManagerTag(sessionId, requestType))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,12 +7,17 @@
|
|||||||
|
|
||||||
package io.element.android.libraries.workmanager.impl
|
package io.element.android.libraries.workmanager.impl
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.work.OneTimeWorkRequest
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
import androidx.work.WorkRequest
|
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.matrix.api.core.SessionId
|
||||||
import io.element.android.libraries.sessionstorage.test.observer.FakeSessionObserver
|
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.WorkManagerRequestType
|
||||||
|
import io.element.android.libraries.workmanager.api.WorkManagerRequestWrapper
|
||||||
import io.element.android.libraries.workmanager.api.workManagerTag
|
import io.element.android.libraries.workmanager.api.workManagerTag
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
@@ -55,7 +60,7 @@ class DefaultWorkManagerSchedulerTest {
|
|||||||
sessionObserver = FakeSessionObserver(),
|
sessionObserver = FakeSessionObserver(),
|
||||||
)
|
)
|
||||||
|
|
||||||
scheduler.submit(FakeWorkManagerRequest())
|
scheduler.submit(FakeWorkManagerRequestBuilder())
|
||||||
|
|
||||||
verify { workManager.enqueue(any<List<WorkRequest>>()) }
|
verify { workManager.enqueue(any<List<WorkRequest>>()) }
|
||||||
}
|
}
|
||||||
@@ -69,7 +74,7 @@ class DefaultWorkManagerSchedulerTest {
|
|||||||
sessionObserver = FakeSessionObserver(),
|
sessionObserver = FakeSessionObserver(),
|
||||||
)
|
)
|
||||||
|
|
||||||
scheduler.submit(FakeWorkManagerRequest(result = Result.failure(IllegalStateException("Test error"))))
|
scheduler.submit(FakeWorkManagerRequestBuilder(result = Result.failure(IllegalStateException("Test error"))))
|
||||||
|
|
||||||
verify(exactly = 0) { workManager.enqueue(any<List<WorkRequest>>()) }
|
verify(exactly = 0) { workManager.enqueue(any<List<WorkRequest>>()) }
|
||||||
}
|
}
|
||||||
@@ -88,7 +93,7 @@ class DefaultWorkManagerSchedulerTest {
|
|||||||
val mockSessionA = mockk<WorkRequest> {
|
val mockSessionA = mockk<WorkRequest> {
|
||||||
every { tags } returns setOf(tagToRemove)
|
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)
|
scheduler.cancel(sessionId)
|
||||||
|
|
||||||
@@ -96,10 +101,16 @@ class DefaultWorkManagerSchedulerTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class FakeWorkManagerRequest(
|
private val workRequest = OneTimeWorkRequest.Builder(FakeWorker::class.java).build()
|
||||||
private val result: Result<List<WorkRequest>> = Result.success(listOf()),
|
|
||||||
) : WorkManagerRequest {
|
private class FakeWorkManagerRequestBuilder(
|
||||||
override fun build(): Result<List<WorkRequest>> {
|
private val result: Result<List<WorkManagerRequestWrapper>> = Result.success(listOf(WorkManagerRequestWrapper(workRequest))),
|
||||||
|
) : WorkManagerRequestBuilder {
|
||||||
|
override suspend fun build(): Result<List<WorkManagerRequestWrapper>> {
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal class FakeWorker(context: Context, params: WorkerParameters) : Worker(context, params) {
|
||||||
|
override fun doWork(): Result = Result.success()
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,25 +9,25 @@
|
|||||||
package io.element.android.libraries.workmanager.test
|
package io.element.android.libraries.workmanager.test
|
||||||
|
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
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.WorkManagerRequestType
|
||||||
import io.element.android.libraries.workmanager.api.WorkManagerScheduler
|
import io.element.android.libraries.workmanager.api.WorkManagerScheduler
|
||||||
import io.element.android.tests.testutils.lambda.lambdaError
|
import io.element.android.tests.testutils.lambda.lambdaError
|
||||||
|
|
||||||
class FakeWorkManagerScheduler(
|
class FakeWorkManagerScheduler(
|
||||||
private val submitLambda: (WorkManagerRequest) -> Unit = { lambdaError() },
|
private val submitLambda: (WorkManagerRequestBuilder) -> Unit = { lambdaError() },
|
||||||
private val hasPendingWorkLambda: (SessionId, WorkManagerRequestType) -> Boolean = { _, _ -> false },
|
private val hasPendingWorkLambda: (SessionId, WorkManagerRequestType) -> Boolean = { _, _ -> false },
|
||||||
private val cancelLambda: (SessionId) -> Unit = { lambdaError() },
|
private val cancelLambda: (SessionId, WorkManagerRequestType?) -> Unit = { _, _ -> lambdaError() },
|
||||||
) : WorkManagerScheduler {
|
) : WorkManagerScheduler {
|
||||||
override fun submit(workManagerRequest: WorkManagerRequest) {
|
override suspend fun submit(workManagerRequestBuilder: WorkManagerRequestBuilder) {
|
||||||
submitLambda(workManagerRequest)
|
submitLambda(workManagerRequestBuilder)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun hasPendingWork(sessionId: SessionId, requestType: WorkManagerRequestType): Boolean {
|
override fun hasPendingWork(sessionId: SessionId, requestType: WorkManagerRequestType): Boolean {
|
||||||
return hasPendingWorkLambda(sessionId, requestType)
|
return hasPendingWorkLambda(sessionId, requestType)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun cancel(sessionId: SessionId) {
|
override fun cancel(sessionId: SessionId, requestType: WorkManagerRequestType?) {
|
||||||
cancelLambda(sessionId)
|
cancelLambda(sessionId, requestType)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user