Introduce WorkerDataConverter to avoid hard coded Json key and ensure serializing/deserializing is performed at the same place.

This commit is contained in:
Benoit Marty 2025-10-23 09:32:52 +02:00
parent 4861ae5171
commit 9983871db7
8 changed files with 140 additions and 52 deletions

View file

@ -11,13 +11,13 @@ import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn import dev.zacsweers.metro.SingleIn
import io.element.android.libraries.androidutils.json.JsonProvider
import io.element.android.libraries.di.annotations.AppCoroutineScope import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.push.api.push.NotificationEventRequest import io.element.android.libraries.push.api.push.NotificationEventRequest
import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent
import io.element.android.libraries.push.impl.workmanager.SyncNotificationWorkManagerRequest import io.element.android.libraries.push.impl.workmanager.SyncNotificationWorkManagerRequest
import io.element.android.libraries.push.impl.workmanager.WorkerDataConverter
import io.element.android.libraries.workmanager.api.WorkManagerScheduler import io.element.android.libraries.workmanager.api.WorkManagerScheduler
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -50,7 +50,7 @@ class DefaultNotificationResolverQueue(
private val appCoroutineScope: CoroutineScope, private val appCoroutineScope: CoroutineScope,
private val workManagerScheduler: WorkManagerScheduler, private val workManagerScheduler: WorkManagerScheduler,
private val featureFlagService: FeatureFlagService, private val featureFlagService: FeatureFlagService,
private val json: JsonProvider, private val workerDataConverter: WorkerDataConverter,
) : NotificationResolverQueue { ) : NotificationResolverQueue {
companion object { companion object {
private const val BATCH_WINDOW_MS = 250L private const val BATCH_WINDOW_MS = 250L
@ -101,7 +101,7 @@ class DefaultNotificationResolverQueue(
SyncNotificationWorkManagerRequest( SyncNotificationWorkManagerRequest(
sessionId = sessionId, sessionId = sessionId,
notificationEventRequests = requests, notificationEventRequests = requests,
json = json, workerDataConverter = workerDataConverter,
) )
) )
} }

View file

@ -18,7 +18,6 @@ import dev.zacsweers.metro.ContributesIntoMap
import dev.zacsweers.metro.binding import dev.zacsweers.metro.binding
import io.element.android.features.networkmonitor.api.NetworkMonitor import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.libraries.androidutils.json.JsonProvider
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.di.annotations.ApplicationContext import io.element.android.libraries.di.annotations.ApplicationContext
@ -47,20 +46,11 @@ class FetchNotificationsWorker(
private val workManagerScheduler: WorkManagerScheduler, private val workManagerScheduler: WorkManagerScheduler,
private val syncOnNotifiableEvent: SyncOnNotifiableEvent, private val syncOnNotifiableEvent: SyncOnNotifiableEvent,
private val coroutineDispatchers: CoroutineDispatchers, private val coroutineDispatchers: CoroutineDispatchers,
private val json: JsonProvider, private val workerDataConverter: WorkerDataConverter,
) : CoroutineWorker(context, workerParams) { ) : CoroutineWorker(context, workerParams) {
override suspend fun doWork(): Result = withContext(coroutineDispatchers.io) { override suspend fun doWork(): Result = withContext(coroutineDispatchers.io) {
Timber.d("FetchNotificationsWorker started") Timber.d("FetchNotificationsWorker started")
val rawRequestsJson = inputData.getString("requests") ?: return@withContext Result.failure() val requests = workerDataConverter.deserialize(inputData) ?: return@withContext Result.failure()
val requests = runCatchingExceptions {
json().decodeFromString<List<SyncNotificationWorkManagerRequest.Data>>(rawRequestsJson).map { it.toRequest() }
}.getOrElse {
Timber.e(it, "Failed to deserialize notification requests")
return@withContext Result.failure()
}
Timber.d("Deserialized ${requests.size} requests")
// Wait for network to be available, but not more than 10 seconds // Wait for network to be available, but not more than 10 seconds
val hasNetwork = withTimeoutOrNull(10.seconds) { val hasNetwork = withTimeoutOrNull(10.seconds) {
networkMonitor.connectivity.first { it == NetworkStatus.Connected } networkMonitor.connectivity.first { it == NetworkStatus.Connected }
@ -97,7 +87,7 @@ class FetchNotificationsWorker(
SyncNotificationWorkManagerRequest( SyncNotificationWorkManagerRequest(
sessionId = failedSessionId, sessionId = failedSessionId,
notificationEventRequests = requestsToRetry, notificationEventRequests = requestsToRetry,
json = json, workerDataConverter = workerDataConverter,
) )
) )
} }

View file

@ -10,11 +10,6 @@ package io.element.android.libraries.push.impl.workmanager
import androidx.work.OneTimeWorkRequestBuilder import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.OutOfQuotaPolicy import androidx.work.OutOfQuotaPolicy
import androidx.work.WorkRequest import androidx.work.WorkRequest
import androidx.work.workDataOf
import io.element.android.libraries.androidutils.json.JsonProvider
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.matrix.api.core.SessionId
import io.element.android.libraries.push.api.push.NotificationEventRequest import io.element.android.libraries.push.api.push.NotificationEventRequest
import io.element.android.libraries.workmanager.api.WorkManagerRequest import io.element.android.libraries.workmanager.api.WorkManagerRequest
@ -28,24 +23,19 @@ import java.security.InvalidParameterException
class SyncNotificationWorkManagerRequest( class SyncNotificationWorkManagerRequest(
private val sessionId: SessionId, private val sessionId: SessionId,
private val notificationEventRequests: List<NotificationEventRequest>, private val notificationEventRequests: List<NotificationEventRequest>,
private val json: JsonProvider, private val workerDataConverter: WorkerDataConverter,
) : WorkManagerRequest { ) : WorkManagerRequest {
override fun build(): Result<WorkRequest> { override fun build(): Result<WorkRequest> {
if (notificationEventRequests.isEmpty()) { if (notificationEventRequests.isEmpty()) {
return Result.failure(InvalidParameterException("notificationEventRequests cannot be empty")) return Result.failure(InvalidParameterException("notificationEventRequests cannot be empty"))
} }
val data = workerDataConverter.serialize(notificationEventRequests).getOrElse {
val json = runCatchingExceptions { json().encodeToString(notificationEventRequests.map { it.toData() }) } return Result.failure(it)
.getOrElse { }
Timber.e(it, "Failed to serialize notification requests")
return Result.failure(it)
}
Timber.d("Scheduling ${notificationEventRequests.size} notification requests with WorkManager for $sessionId") Timber.d("Scheduling ${notificationEventRequests.size} notification requests with WorkManager for $sessionId")
return Result.success( return Result.success(
OneTimeWorkRequestBuilder<FetchNotificationsWorker>() OneTimeWorkRequestBuilder<FetchNotificationsWorker>()
.setInputData(workDataOf("requests" to json)) .setInputData(data)
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.setTraceTag(workManagerTag(sessionId, WorkManagerRequestType.NOTIFICATION_SYNC)) .setTraceTag(workManagerTag(sessionId, WorkManagerRequestType.NOTIFICATION_SYNC))
// TODO investigate using this instead of the resolver queue // TODO investigate using this instead of the resolver queue
@ -64,23 +54,5 @@ class SyncNotificationWorkManagerRequest(
val eventId: String, val eventId: String,
@SerialName("provider_info") @SerialName("provider_info")
val providerInfo: String, val providerInfo: String,
) {
fun toRequest(): NotificationEventRequest {
return NotificationEventRequest(
sessionId = SessionId(sessionId),
roomId = RoomId(roomId),
eventId = EventId(eventId),
providerInfo = providerInfo,
)
}
}
}
private fun NotificationEventRequest.toData(): SyncNotificationWorkManagerRequest.Data {
return SyncNotificationWorkManagerRequest.Data(
sessionId = sessionId.value,
roomId = roomId.value,
eventId = eventId.value,
providerInfo = providerInfo,
) )
} }

View file

@ -0,0 +1,72 @@
/*
* 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.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 WorkerDataConverter(
private val json: JsonProvider,
) {
fun serialize(notificationEventRequests: List<NotificationEventRequest>): Result<Data> {
return runCatchingExceptions { json().encodeToString(notificationEventRequests.map { it.toData() }) }
.onFailure {
Timber.e(it, "Failed to serialize notification requests")
}
.map { str ->
workDataOf(REQUESTS_KEY to str)
}
}
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"
}
}
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,
)
}

View file

@ -46,6 +46,7 @@ import io.element.android.libraries.push.impl.notifications.model.NotifiableEven
import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent
import io.element.android.libraries.push.impl.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.WorkerDataConverter
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.UserPushStore
import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret
@ -715,7 +716,7 @@ class DefaultPushHandlerTest {
appCoroutineScope = backgroundScope, appCoroutineScope = backgroundScope,
workManagerScheduler = workManagerScheduler, workManagerScheduler = workManagerScheduler,
featureFlagService = featureFlagService, featureFlagService = featureFlagService,
json = DefaultJsonProvider(), workerDataConverter = WorkerDataConverter(DefaultJsonProvider()),
), ),
appCoroutineScope = backgroundScope, appCoroutineScope = backgroundScope,
fallbackNotificationFactory = FallbackNotificationFactory( fallbackNotificationFactory = FallbackNotificationFactory(

View file

@ -175,7 +175,7 @@ class FetchNotificationWorkerTest {
workManagerScheduler = workManagerScheduler, workManagerScheduler = workManagerScheduler,
syncOnNotifiableEvent = syncOnNotifiableEvent, syncOnNotifiableEvent = syncOnNotifiableEvent,
coroutineDispatchers = testCoroutineDispatchers(), coroutineDispatchers = testCoroutineDispatchers(),
json = DefaultJsonProvider(), workerDataConverter = WorkerDataConverter(DefaultJsonProvider()),
) )
private fun TestScope.createWorkerParams( private fun TestScope.createWorkerParams(

View file

@ -58,5 +58,5 @@ private fun createSyncNotificationWorkManagerRequest(
) = SyncNotificationWorkManagerRequest( ) = SyncNotificationWorkManagerRequest(
sessionId = sessionId, sessionId = sessionId,
notificationEventRequests = notificationEventRequests, notificationEventRequests = notificationEventRequests,
json = DefaultJsonProvider(), workerDataConverter = WorkerDataConverter(DefaultJsonProvider()),
) )

View file

@ -0,0 +1,53 @@
/*
* 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.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_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",
),
)
)
}
private fun testIdentity(data: List<NotificationEventRequest>) {
val sut = WorkerDataConverter(DefaultJsonProvider())
val serialized = sut.serialize(data).getOrThrow()
val result = sut.deserialize(serialized)
assertThat(result).isEqualTo(data)
}
}