Introduce JsonProvider.

It will ensure that classes are using the correct Json instances in the unit tests.
This commit is contained in:
Benoit Marty 2025-10-17 17:31:15 +02:00 committed by Benoit Marty
parent fa8ddba1f5
commit df48ed5a2d
21 changed files with 103 additions and 52 deletions

View file

@ -35,7 +35,6 @@ import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.plus
import kotlinx.serialization.json.Json
import java.io.File
@BindingContainer
@ -121,10 +120,4 @@ object AppModule {
fun providesEmojibaseProvider(@ApplicationContext context: Context): EmojibaseProvider {
return DefaultEmojibaseProvider(context)
}
@Provides
@SingleIn(AppScope::class)
fun providesJson(): Json = Json {
ignoreUnknownKeys = true
}
}

View file

@ -9,18 +9,18 @@ package io.element.android.features.call.impl.utils
import dev.zacsweers.metro.Inject
import io.element.android.features.call.impl.data.WidgetMessage
import io.element.android.libraries.androidutils.json.JsonProvider
import io.element.android.libraries.core.extensions.runCatchingExceptions
import kotlinx.serialization.json.Json
@Inject
class WidgetMessageSerializer(
private val json: Json,
private val json: JsonProvider,
) {
fun deserialize(message: String): Result<WidgetMessage> {
return runCatchingExceptions { json.decodeFromString(WidgetMessage.serializer(), message) }
return runCatchingExceptions { json().decodeFromString(WidgetMessage.serializer(), message) }
}
fun serialize(message: WidgetMessage): String {
return json.encodeToString(WidgetMessage.serializer(), message)
return json().encodeToString(WidgetMessage.serializer(), message)
}
}

View file

@ -20,6 +20,7 @@ import io.element.android.features.call.impl.utils.WidgetMessageSerializer
import io.element.android.features.call.utils.FakeActiveCallManager
import io.element.android.features.call.utils.FakeCallWidgetProvider
import io.element.android.features.call.utils.FakeWidgetMessageInterceptor
import io.element.android.libraries.androidutils.json.DefaultJsonProvider
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.sync.SyncState
@ -47,7 +48,6 @@ import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import org.junit.Rule
import org.junit.Test
import kotlin.time.Duration.Companion.seconds
@ -412,7 +412,7 @@ class CallScreenPresenterTest {
languageTagProvider = FakeLanguageTagProvider("en-US"),
appForegroundStateService = appForegroundStateService,
appCoroutineScope = backgroundScope,
widgetMessageSerializer = WidgetMessageSerializer(Json { ignoreUnknownKeys = true }),
widgetMessageSerializer = WidgetMessageSerializer(DefaultJsonProvider()),
)
}
}

View file

@ -11,8 +11,8 @@ import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.libraries.androidutils.json.JsonProvider
import io.element.android.libraries.matrix.api.auth.external.ExternalSession
import kotlinx.serialization.json.Json
interface MessageParser {
/**
@ -26,10 +26,10 @@ interface MessageParser {
@Inject
class DefaultMessageParser(
private val accountProviderDataSource: AccountProviderDataSource,
private val json: Json,
private val json: JsonProvider,
) : MessageParser {
override fun parse(message: String): ExternalSession {
val response = json.decodeFromString(MobileRegistrationResponse.serializer(), message)
val response = json().decodeFromString(MobileRegistrationResponse.serializer(), message)
val userId = response.userId ?: error("No user ID in response")
val homeServer = response.homeServer ?: accountProviderDataSource.flow.value.url
val accessToken = response.accessToken ?: error("No access token in response")

View file

@ -11,9 +11,9 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.appconfig.AuthenticationConfig
import io.element.android.features.enterprise.test.FakeEnterpriseService
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.libraries.androidutils.json.DefaultJsonProvider
import io.element.android.libraries.matrix.api.auth.external.ExternalSession
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import org.junit.Assert.assertThrows
import org.junit.Test
@ -70,7 +70,7 @@ class DefaultMessageParserTest {
private fun createDefaultMessageParser(): DefaultMessageParser {
return DefaultMessageParser(
accountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()),
json = Json { ignoreUnknownKeys = true },
json = DefaultJsonProvider(),
)
}
}

View file

@ -32,6 +32,7 @@ dependencies {
implementation(libs.androidx.recyclerview)
implementation(libs.androidx.exifinterface)
implementation(libs.androidx.datastore.preferences)
implementation(libs.serialization.json)
api(libs.androidx.browser)
testCommonDependencies(libs)

View file

@ -0,0 +1,28 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.androidutils.json
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.Provider
import dev.zacsweers.metro.SingleIn
import kotlinx.serialization.json.Json
/**
* Provides a Json instance configured to ignore unknown keys.
*/
interface JsonProvider : Provider<Json>
@ContributesBinding(AppScope::class)
@SingleIn(AppScope::class)
@Inject
class DefaultJsonProvider : JsonProvider {
private val json: Json by lazy { Json { ignoreUnknownKeys = true } }
override fun invoke() = json
}

View file

@ -23,6 +23,7 @@ android {
setupDependencyInjection()
dependencies {
implementation(projects.libraries.androidutils)
implementation(projects.libraries.core)
implementation(projects.libraries.di)
implementation(projects.libraries.matrix.api)

View file

@ -9,8 +9,8 @@ package io.element.android.libraries.network
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.Provider
import io.element.android.libraries.androidutils.json.JsonProvider
import io.element.android.libraries.core.uri.ensureTrailingSlash
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import retrofit2.Retrofit
@ -19,11 +19,11 @@ import retrofit2.converter.kotlinx.serialization.asConverterFactory
@Inject
class RetrofitFactory(
private val okHttpClient: Provider<OkHttpClient>,
private val json: Provider<Json>,
private val json: Provider<JsonProvider>,
) {
fun create(baseUrl: String): Retrofit = Retrofit.Builder()
.baseUrl(baseUrl.ensureTrailingSlash())
.addConverterFactory(json().asConverterFactory("application/json".toMediaType()))
.addConverterFactory(json()().asConverterFactory("application/json".toMediaType()))
.callFactory { request -> okHttpClient().newCall(request) }
.build()
}

View file

@ -11,6 +11,7 @@ import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
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.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
@ -49,10 +50,12 @@ class DefaultNotificationResolverQueue(
private val appCoroutineScope: CoroutineScope,
private val workManagerScheduler: WorkManagerScheduler,
private val featureFlagService: FeatureFlagService,
private val json: JsonProvider,
) : NotificationResolverQueue {
companion object {
private const val BATCH_WINDOW_MS = 250L
}
private val requestQueue = Channel<NotificationEventRequest>(capacity = 100)
private var currentProcessingJob: Job? = null
@ -94,7 +97,13 @@ class DefaultNotificationResolverQueue(
if (featureFlagService.isFeatureEnabled(FeatureFlags.SyncNotificationsWithWorkManager)) {
for ((sessionId, requests) in groupedRequestsById) {
workManagerScheduler.submit(SyncNotificationWorkManagerRequest(sessionId, requests))
workManagerScheduler.submit(
SyncNotificationWorkManagerRequest(
sessionId = sessionId,
notificationEventRequests = requests,
json = json,
)
)
}
} else {
val sessionIds = groupedRequestsById.keys

View file

@ -18,6 +18,7 @@ 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.androidutils.json.JsonProvider
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.di.annotations.ApplicationContext
@ -33,7 +34,6 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.json.Json
import timber.log.Timber
import kotlin.time.Duration.Companion.seconds
@ -47,13 +47,13 @@ class FetchNotificationsWorker(
private val workManagerScheduler: WorkManagerScheduler,
private val syncOnNotifiableEvent: SyncOnNotifiableEvent,
private val coroutineDispatchers: CoroutineDispatchers,
private val json: Json,
private val json: JsonProvider,
) : CoroutineWorker(context, workerParams) {
override suspend fun doWork(): Result = withContext(coroutineDispatchers.io) {
Timber.d("FetchNotificationsWorker started")
val rawRequestsJson = inputData.getString("requests") ?: return@withContext Result.failure()
val requests = runCatchingExceptions {
json.decodeFromString<List<SyncNotificationWorkManagerRequest.Data>>(rawRequestsJson).map { it.toRequest() }
json().decodeFromString<List<SyncNotificationWorkManagerRequest.Data>>(rawRequestsJson).map { it.toRequest() }
}.getOrElse {
Timber.e(it, "Failed to deserialize notification requests")
return@withContext Result.failure()
@ -93,7 +93,13 @@ class FetchNotificationsWorker(
for (failedSessionId in failedSyncForSessions) {
val requestsToRetry = groupedRequests[failedSessionId] ?: continue
Timber.d("Re-scheduling ${requestsToRetry.size} failed notification requests for session $failedSessionId")
workManagerScheduler.submit(SyncNotificationWorkManagerRequest(failedSessionId, requestsToRetry))
workManagerScheduler.submit(
SyncNotificationWorkManagerRequest(
sessionId = failedSessionId,
notificationEventRequests = requestsToRetry,
json = json,
)
)
}
}

View file

@ -11,6 +11,7 @@ import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.OutOfQuotaPolicy
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
@ -21,22 +22,20 @@ import io.element.android.libraries.workmanager.api.WorkManagerRequestType
import io.element.android.libraries.workmanager.api.workManagerTag
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import timber.log.Timber
import java.security.InvalidParameterException
class SyncNotificationWorkManagerRequest(
private val sessionId: SessionId,
private val notificationEventRequests: List<NotificationEventRequest>,
private val json: JsonProvider,
) : WorkManagerRequest {
private val json = Json { ignoreUnknownKeys = true }
override fun build(): Result<WorkRequest> {
if (notificationEventRequests.isEmpty()) {
return Result.failure(InvalidParameterException("notificationEventRequests cannot be empty"))
}
val json = runCatchingExceptions { json.encodeToString(notificationEventRequests.map { it.toData() }) }
val json = runCatchingExceptions { json().encodeToString(notificationEventRequests.map { it.toData() }) }
.getOrElse {
Timber.e(it, "Failed to serialize notification requests")
return Result.failure(it)
@ -50,7 +49,7 @@ class SyncNotificationWorkManagerRequest(
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.setTraceTag(workManagerTag(sessionId, WorkManagerRequestType.NOTIFICATION_SYNC))
// TODO investigate using this instead of the resolver queue
// .setInputMerger()
// .setInputMerger()
.build()
)
}

View file

@ -13,6 +13,7 @@ import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.api.CallType
import io.element.android.features.call.test.FakeElementCallEntryPoint
import io.element.android.libraries.androidutils.json.DefaultJsonProvider
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
@ -714,6 +715,7 @@ class DefaultPushHandlerTest {
appCoroutineScope = backgroundScope,
workManagerScheduler = workManagerScheduler,
featureFlagService = featureFlagService,
json = DefaultJsonProvider(),
),
appCoroutineScope = backgroundScope,
fallbackNotificationFactory = FallbackNotificationFactory(

View file

@ -18,6 +18,7 @@ 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
@ -33,7 +34,6 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import org.junit.Test
import org.junit.runner.RunWith
import java.util.UUID
@ -175,7 +175,7 @@ class FetchNotificationWorkerTest {
workManagerScheduler = workManagerScheduler,
syncOnNotifiableEvent = syncOnNotifiableEvent,
coroutineDispatchers = testCoroutineDispatchers(),
json = Json {},
json = DefaultJsonProvider(),
)
private fun TestScope.createWorkerParams(

View file

@ -10,7 +10,10 @@ 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
@ -20,7 +23,7 @@ import org.junit.Test
class SyncNotificationWorkManagerRequestTest {
@Test
fun `build - success`() = runTest {
val request = SyncNotificationWorkManagerRequest(
val request = createSyncNotificationWorkManagerRequest(
sessionId = A_SESSION_ID,
notificationEventRequests = listOf(aNotificationEventRequest())
)
@ -37,7 +40,7 @@ class SyncNotificationWorkManagerRequestTest {
@Test
fun `build - empty list of requests fails`() = runTest {
val request = SyncNotificationWorkManagerRequest(
val request = createSyncNotificationWorkManagerRequest(
sessionId = A_SESSION_ID,
notificationEventRequests = emptyList()
)
@ -48,3 +51,12 @@ class SyncNotificationWorkManagerRequestTest {
// TODO add test for invalid serialization (how?)
}
private fun createSyncNotificationWorkManagerRequest(
sessionId: SessionId,
notificationEventRequests: List<NotificationEventRequest>,
) = SyncNotificationWorkManagerRequest(
sessionId = sessionId,
notificationEventRequests = notificationEventRequests,
json = DefaultJsonProvider(),
)

View file

@ -39,6 +39,7 @@ data class PushDataUnifiedPushNotification(
@SerialName("event_id") val eventId: String? = null,
@SerialName("room_id") val roomId: String? = null,
@SerialName("counts") val counts: PushDataUnifiedPushCounts? = null,
@SerialName("prio") val prio: String? = null,
)
@Serializable

View file

@ -8,15 +8,15 @@
package io.element.android.libraries.pushproviders.unifiedpush
import dev.zacsweers.metro.Inject
import io.element.android.libraries.androidutils.json.JsonProvider
import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.pushproviders.api.PushData
import kotlinx.serialization.json.Json
@Inject
class UnifiedPushParser(
private val json: Json,
private val json: JsonProvider,
) {
fun parse(message: ByteArray, clientSecret: String): PushData? {
return tryOrNull { json.decodeFromString<PushDataUnifiedPush>(String(message)) }?.toPushData(clientSecret)
return tryOrNull { json().decodeFromString<PushDataUnifiedPush>(String(message)) }?.toPushData(clientSecret)
}
}

View file

@ -8,11 +8,11 @@
package io.element.android.libraries.pushproviders.unifiedpush
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.A_ROOM_ID
import io.element.android.libraries.pushproviders.api.PushData
import io.element.android.tests.testutils.assertThrowsInDebug
import kotlinx.serialization.json.Json
import org.junit.Test
class UnifiedPushParserTest {
@ -83,8 +83,6 @@ private fun String.mutate(oldValue: String, newValue: String): ByteArray {
return replace(oldValue, newValue).toByteArray()
}
fun createUnifiedPushParser(
json: Json = Json { ignoreUnknownKeys = true },
) = UnifiedPushParser(
json = json,
fun createUnifiedPushParser() = UnifiedPushParser(
json = DefaultJsonProvider(),
)

View file

@ -27,8 +27,9 @@ dependencies {
implementation(platform(libs.network.retrofit.bom))
implementation(libs.network.retrofit)
implementation(libs.serialization.json)
implementation(projects.libraries.architecture)
implementation(projects.libraries.core)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.network)

View file

@ -9,20 +9,20 @@ package io.element.android.libraries.wellknown.impl
import dev.zacsweers.metro.ContributesBinding
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.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.wellknown.api.ElementWellKnown
import io.element.android.libraries.wellknown.api.SessionWellknownRetriever
import io.element.android.libraries.wellknown.api.WellKnown
import kotlinx.serialization.json.Json
import timber.log.Timber
@ContributesBinding(SessionScope::class)
@Inject
class DefaultSessionWellknownRetriever(
private val matrixClient: MatrixClient,
private val json: Json,
private val json: JsonProvider,
) : SessionWellknownRetriever {
private val domain by lazy { matrixClient.userIdServerName() }
@ -32,7 +32,7 @@ class DefaultSessionWellknownRetriever(
.getUrl(url)
.mapCatchingExceptions {
val data = String(it)
json.decodeFromString(InternalWellKnown.serializer(), data)
json().decodeFromString(InternalWellKnown.serializer(), data)
}
.onFailure { Timber.e(it, "Failed to retrieve .well-known from $domain") }
.map { it.map() }
@ -45,7 +45,7 @@ class DefaultSessionWellknownRetriever(
.getUrl(url)
.mapCatchingExceptions {
val data = String(it)
json.decodeFromString(InternalElementWellKnown.serializer(), data)
json().decodeFromString(InternalElementWellKnown.serializer(), data)
}
.onFailure { Timber.e(it, "Failed to retrieve Element .well-known from $domain") }
.map { it.map() }

View file

@ -8,6 +8,7 @@
package io.element.android.libraries.wellknown.impl
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.androidutils.json.DefaultJsonProvider
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.wellknown.api.ElementWellKnown
@ -16,7 +17,6 @@ import io.element.android.libraries.wellknown.api.WellKnownBaseConfig
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import org.junit.Test
class DefaultSessionWellknownRetrieverTest {
@ -107,7 +107,7 @@ class DefaultSessionWellknownRetrieverTest {
"other": true
}""".trimIndent().toByteArray()
)
}
},
)
assertThat(sut.getWellKnown()).isEqualTo(
WellKnown(
@ -201,7 +201,7 @@ class DefaultSessionWellknownRetrieverTest {
"other": true
}""".trimIndent().toByteArray()
)
}
},
)
assertThat(sut.getElementWellKnown()).isEqualTo(
ElementWellKnown(
@ -244,6 +244,6 @@ class DefaultSessionWellknownRetrieverTest {
userIdServerNameLambda = { "user.domain.org" },
getUrlLambda = getUrlLambda,
),
json = Json { ignoreUnknownKeys = true },
json = DefaultJsonProvider(),
)
}