Skip to content

Migration to kotlinx json serialization #5279

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 19 commits into
base: main
Choose a base branch
from

Conversation

TimoPtr
Copy link
Collaborator

@TimoPtr TimoPtr commented May 6, 2025

Summary

To continue updating Retrofit while maintaining our minSDK at 21, we need to migrate away from Jackson for JSON serialization. As proposed in #3518, we can migrate to Kotlinx serialization, which is now mature enough and fully integrated with Retrofit since version 2.10.0.

Kotlinx serialization does not use reflection like Jackson, which requires some adjustments. First, all models must be annotated with @Serializable so that the Gradle plugin can generate the necessary serializers. By default, it does not support the Any type or anything containing it. To address this limitation, I implemented a custom serializer that supports Any, but it has some restrictions—particularly when serializing to JSON, where Any must be a primitive and cannot be an object.

To avoid using SimpleDateFormat with a format that does not fully support ISO8601, I migrated from java.util.Calendar to java.time.LocalDateTime in the models being serialized. This required ensuring that the :common module includes the desugar dependency and has it enabled.

For the Entity model, I removed the context attribute since it was not used anywhere. If needed, it can be reintroduced later. The generic type used in the Entity model made the migration complex, especially for cases where <Any> was not used. To simplify this, I replaced it with Any and adjusted the single case where it was not used. Alternatively, we could create a custom serializer and use a sealed class to handle specific entities, similar to what I did in SocketResponse, but this is outside the scope of this PR.

To facilitate the review process, I structured the changes into distinct commits with no overlap, allowing them to be reviewed individually for clarity. I had to make one PR since everything is coupled here, so you can't build the individual commits it won't compile.

This is a significant and impactful change to the application, which may introduce regressions, particularly in how default parameters are handled. Since reflection is no longer used, the serializer enforces that all fields without default values must have a value, which could lead to exceptions in cases where none were thrown before. We need to be especially vigilant during the beta phase to identify and address any crashes or issues.

Checklist

  • New or updated tests have been added to cover the changes following the testing guidelines.
  • The code follows the project's code style and best_practices.
  • The changes have been thoroughly tested, and edge cases have been considered.
  • Changes are backward compatible whenever feasible. Any breaking changes are documented in the changelog for users and/or in the code for developers depending on the relevance.

Any other notes

Close #3518 and we can close #5114 and #5102 after the merge of this PR.
It remains one JSON serialization in the app that doesn't use Kotlinx serialization, but it is not using Jackson but org.json so we can live with it for a while. I created a small issue for that #5279.
I have made some unit tests that a bit verbose on purpose to showcase how the adjustment is working.

Copy link

github-actions bot commented May 7, 2025

Test Results

0 tests   0 ✅  0s ⏱️
0 suites  0 💤
0 files    0 ❌

Results for commit 5c515a1.

♻️ This comment has been updated with latest results.

@TimoPtr TimoPtr force-pushed the feature/migration_kotlinx_serialization branch 2 times, most recently from 5c515a1 to 2d2e374 Compare May 13, 2025 14:39
@TimoPtr TimoPtr changed the title Migration to kotlinx json serialization in WebSocket scope Migration to kotlinx json serialization May 13, 2025
@TimoPtr TimoPtr force-pushed the feature/migration_kotlinx_serialization branch from cad6361 to 44dfc43 Compare May 14, 2025 17:00
@TimoPtr TimoPtr force-pushed the feature/migration_kotlinx_serialization branch from 9a0d755 to 6a842d6 Compare May 15, 2025 07:32
@TimoPtr TimoPtr marked this pull request as ready for review May 15, 2025 08:08
org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.4.1=ktlintReporter
org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.6.3=_agp_internal_javaPreCompileDebug_kspClasspath,_agp_internal_javaPreCompileRelease_kspClasspath,hiltAnnotationProcessorDebugAndroidTest,hiltAnnotationProcessorDebugUnitTest,hiltAnnotationProcessorReleaseUnitTest,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease,kotlinCompilerPluginClasspathReleaseUnitTest,kspDebugAndroidTestKotlinProcessorClasspath,kspDebugKotlinProcessorClasspath,kspDebugUnitTestKotlinProcessorClasspath,kspPluginClasspath,kspPluginClasspathNonEmbeddable,kspReleaseKotlinProcessorClasspath,kspReleaseUnitTestKotlinProcessorClasspath
org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.7.3=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.8.1=debugAndroidTestCompileClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Side note, while working on this PR and adding the kotlinx-serialization dependency. I was not able to run lint tasks, I had an error on a conflicting version of the new library 1.8.1 and 1.7.3 both were present in the lockfile. I spent a lot of time finding a way to force the new version but it was not working.

In the end I found out that I had to remove the lockfile and regenerate it. I suppose that the lock task in some specify cases keep some old artifacts in the file, probably when updating Gradle or a plugin.

Copy link
Member

@jpelgrom jpelgrom left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for picking this up and great to see that there is a solution to Any (I couldn't figure it out when I looked at it) 🙏

I've only read the code so far, not done any testing

Co-authored-by: Joris Pelgröm <[email protected]>
@bgoncal bgoncal requested a review from Copilot May 20, 2025 12:53
Copy link

@Copilot Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

Migrate JSON serialization from Jackson to Kotlinx and adjust models and control providers accordingly.

  • Replace Jackson ObjectMapper calls with kotlinJsonMapper for JSON de/serialization
  • Remove Jackson dependencies and annotate models for Kotlinx serialization
  • Update control-related Entity<Map<String, Any>> usages to raw Entity, and switch from Calendar to LocalDateTime

Reviewed Changes

Copilot reviewed 161 out of 161 changed files in this pull request and generated no comments.

Show a summary per file
File Description
app/src/main/kotlin/.../ManageControlsViewModel.kt Changed entitiesList type from List<Entity<*>> to raw List<Entity>
app/src/main/kotlin/.../notifications/MessagingManager.kt Replaced Jackson parsing with kotlinJsonMapper.decodeFromString
app/src/main/kotlin/.../controls/HaControlsProviderService.kt Updated generics on Entity and swapped to LocalDateTime
app/src/full/.../settings/wear/SettingsWearViewModel.kt Swapped Jackson to kotlinJsonMapper and updated exception handling
app/src/full/.../sensors/LocationSensorManager.kt Adjusted Entity<ZoneAttributes> to raw Entity and updated attribute casts
Comments suppressed due to low confidence (3)

app/src/main/kotlin/io/homeassistant/companion/android/controls/HaControl.kt:30

  • The interface now takes a raw Entity parameter; consider using Entity<Any> or Entity<*> to retain generic type information and suppress raw-type warnings.
fun createControl(..., entity: Entity, ...)

app/src/main/kotlin/io/homeassistant/companion/android/settings/controls/ManageControlsViewModel.kt:47

  • Using the raw type Entity loses compile-time type safety. Consider specifying a generic parameter like Entity<Any> or Entity<*> to preserve intent and avoid unchecked usages.
val entitiesList = mutableStateMapOf<Int, List<Entity>>()

app/src/main/kotlin/io/homeassistant/companion/android/qs/TileExtensions.kt:150

  • Switched to raw Entity here; specifying a generic such as Entity<Any> or Entity<*> will make the code clearer and safer against unchecked casts.
val state: Entity? =

@jpelgrom
Copy link
Member

Running this on my device, it does not like getting zones.

2025-05-20 23:02:07.010 19752-19799 LocationSensorManager   io....stant.companion.android.debug  E  Error receiving zones from Home Assistant
                                                                                                    io.homeassistant.companion.android.common.data.integration.IntegrationException: java.lang.IllegalArgumentException: Unable to create converter for io.homeassistant.companion.android.common.data.integration.impl.entities.EntityResponse[]
                                                                                                        for method IntegrationService.getZones
                                                                                                    	at io.homeassistant.companion.android.common.data.integration.impl.IntegrationRepositoryImpl.getZones(IntegrationRepositoryImpl.kt:396)
                                                                                                    	at io.homeassistant.companion.android.sensors.LocationSensorManager.getZones(LocationSensorManager.kt:965)
                                                                                                    	at io.homeassistant.companion.android.sensors.LocationSensorManager.access$getZones(LocationSensorManager.kt:59)
                                                                                                    	at io.homeassistant.companion.android.sensors.LocationSensorManager$createGeofencingRequest$2$1.invokeSuspend(LocationSensorManager.kt:985)
                                                                                                    	at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
                                                                                                    	at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:100)
                                                                                                    	at kotlinx.coroutines.internal.LimitedDispatcher$Worker.run(LimitedDispatcher.kt:124)
                                                                                                    	at kotlinx.coroutines.scheduling.TaskImpl.run(Tasks.kt:89)
                                                                                                    	at kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely(CoroutineScheduler.kt:586)
                                                                                                    	at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.executeTask(CoroutineScheduler.kt:820)
                                                                                                    	at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.runWorker(CoroutineScheduler.kt:717)
                                                                                                    	at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:704)
                                                                                                    Caused by: java.lang.IllegalArgumentException: Unable to create converter for io.homeassistant.companion.android.common.data.integration.impl.entities.EntityResponse[]
                                                                                                        for method IntegrationService.getZones
                                                                                                    	at retrofit2.Utils.methodError(Utils.java:56)
                                                                                                    	at retrofit2.HttpServiceMethod.createResponseConverter(HttpServiceMethod.java:139)
                                                                                                    	at retrofit2.HttpServiceMethod.parseAnnotations(HttpServiceMethod.java:97)
                                                                                                    	at retrofit2.ServiceMethod.parseAnnotations(ServiceMethod.java:39)
                                                                                                    	at retrofit2.Retrofit.loadServiceMethod(Retrofit.java:235)
                                                                                                    	at retrofit2.Retrofit$1.invoke(Retrofit.java:177)
                                                                                                    	at java.lang.reflect.Proxy.invoke(Proxy.java:1006)
                                                                                                    	at $Proxy5.getZones(Unknown Source)
                                                                                                    	at io.homeassistant.companion.android.common.data.integration.impl.IntegrationRepositoryImpl.getZones(IntegrationRepositoryImpl.kt:384)
                                                                                                    	... 11 more
                                                                                                    Caused by: java.lang.IllegalStateException: unsupported type in GenericArray: class java.lang.Class
                                                                                                    	at kotlinx.serialization.SerializersKt__SerializersJvmKt.genericArraySerializer$SerializersKt__SerializersJvmKt(SerializersJvm.kt:189)
                                                                                                    	at kotlinx.serialization.SerializersKt__SerializersJvmKt.serializerByJavaTypeImpl$SerializersKt__SerializersJvmKt(SerializersJvm.kt:106)
                                                                                                    	at kotlinx.serialization.SerializersKt__SerializersJvmKt.serializer(SerializersJvm.kt:76)
                                                                                                    	at kotlinx.serialization.SerializersKt.serializer(Unknown Source:1)
                                                                                                    	at retrofit2.converter.kotlinx.serialization.Serializer.serializer(Serializer.kt:21)
                                                                                                    	at retrofit2.converter.kotlinx.serialization.Factory.responseBodyConverter(Factory.kt:26)
                                                                                                    	at retrofit2.Retrofit.nextResponseBodyConverter(Retrofit.java:418)
                                                                                                    	at retrofit2.Retrofit.responseBodyConverter(Retrofit.java:401)
                                                                                                    	at retrofit2.HttpServiceMethod.createResponseConverter(HttpServiceMethod.java:137)
                                                                                                    	... 18 more

@TimoPtr
Copy link
Collaborator Author

TimoPtr commented May 21, 2025

Running this on my device, it does not like getting zones.

2025-05-20 23:02:07.010 19752-19799 LocationSensorManager   io....stant.companion.android.debug  E  Error receiving zones from Home Assistant
                                                                                                    io.homeassistant.companion.android.common.data.integration.IntegrationException: java.lang.IllegalArgumentException: Unable to create converter for io.homeassistant.companion.android.common.data.integration.impl.entities.EntityResponse[]
                                                                                                        for method IntegrationService.getZones
                                                                                                    	at io.homeassistant.companion.android.common.data.integration.impl.IntegrationRepositoryImpl.getZones(IntegrationRepositoryImpl.kt:396)
                                                                                                    	at io.homeassistant.companion.android.sensors.LocationSensorManager.getZones(LocationSensorManager.kt:965)
                                                                                                    	at io.homeassistant.companion.android.sensors.LocationSensorManager.access$getZones(LocationSensorManager.kt:59)
                                                                                                    	at io.homeassistant.companion.android.sensors.LocationSensorManager$createGeofencingRequest$2$1.invokeSuspend(LocationSensorManager.kt:985)
                                                                                                    	at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
                                                                                                    	at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:100)
                                                                                                    	at kotlinx.coroutines.internal.LimitedDispatcher$Worker.run(LimitedDispatcher.kt:124)
                                                                                                    	at kotlinx.coroutines.scheduling.TaskImpl.run(Tasks.kt:89)
                                                                                                    	at kotlinx.coroutines.scheduling.CoroutineScheduler.runSafely(CoroutineScheduler.kt:586)
                                                                                                    	at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.executeTask(CoroutineScheduler.kt:820)
                                                                                                    	at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.runWorker(CoroutineScheduler.kt:717)
                                                                                                    	at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:704)
                                                                                                    Caused by: java.lang.IllegalArgumentException: Unable to create converter for io.homeassistant.companion.android.common.data.integration.impl.entities.EntityResponse[]
                                                                                                        for method IntegrationService.getZones
                                                                                                    	at retrofit2.Utils.methodError(Utils.java:56)
                                                                                                    	at retrofit2.HttpServiceMethod.createResponseConverter(HttpServiceMethod.java:139)
                                                                                                    	at retrofit2.HttpServiceMethod.parseAnnotations(HttpServiceMethod.java:97)
                                                                                                    	at retrofit2.ServiceMethod.parseAnnotations(ServiceMethod.java:39)
                                                                                                    	at retrofit2.Retrofit.loadServiceMethod(Retrofit.java:235)
                                                                                                    	at retrofit2.Retrofit$1.invoke(Retrofit.java:177)
                                                                                                    	at java.lang.reflect.Proxy.invoke(Proxy.java:1006)
                                                                                                    	at $Proxy5.getZones(Unknown Source)
                                                                                                    	at io.homeassistant.companion.android.common.data.integration.impl.IntegrationRepositoryImpl.getZones(IntegrationRepositoryImpl.kt:384)
                                                                                                    	... 11 more
                                                                                                    Caused by: java.lang.IllegalStateException: unsupported type in GenericArray: class java.lang.Class
                                                                                                    	at kotlinx.serialization.SerializersKt__SerializersJvmKt.genericArraySerializer$SerializersKt__SerializersJvmKt(SerializersJvm.kt:189)
                                                                                                    	at kotlinx.serialization.SerializersKt__SerializersJvmKt.serializerByJavaTypeImpl$SerializersKt__SerializersJvmKt(SerializersJvm.kt:106)
                                                                                                    	at kotlinx.serialization.SerializersKt__SerializersJvmKt.serializer(SerializersJvm.kt:76)
                                                                                                    	at kotlinx.serialization.SerializersKt.serializer(Unknown Source:1)
                                                                                                    	at retrofit2.converter.kotlinx.serialization.Serializer.serializer(Serializer.kt:21)
                                                                                                    	at retrofit2.converter.kotlinx.serialization.Factory.responseBodyConverter(Factory.kt:26)
                                                                                                    	at retrofit2.Retrofit.nextResponseBodyConverter(Retrofit.java:418)
                                                                                                    	at retrofit2.Retrofit.responseBodyConverter(Retrofit.java:401)
                                                                                                    	at retrofit2.HttpServiceMethod.createResponseConverter(HttpServiceMethod.java:137)
                                                                                                    	... 18 more

Good catch thanks. It's because it use Array instead of a List. I will try to see if there are other usages of Array that could cause issues.

@TimoPtr TimoPtr requested a review from jpelgrom May 23, 2025 06:17
@jpelgrom
Copy link
Member

jpelgrom commented May 23, 2025

Another exception while editing settings for a Wear device (easiest to trigger if you add a template tile on the watch and try to edit it from the phone, the phone app crashes the moment you start typing text)

2025-05-23 23:13:30.088 17813-17813 AndroidRuntime          io....stant.companion.android.debug  E  FATAL EXCEPTION: main
                                                                                                    Process: io.homeassistant.companion.android.debug, PID: 17813
                                                                                                    kotlinx.serialization.SerializationException: Serializer for class 'SnapshotStateMap' is not found.
                                                                                                    Please ensure that class is marked as '@Serializable' and that the serialization compiler plugin is applied.
                                                                                                    
                                                                                                    	at kotlinx.serialization.internal.Platform_commonKt.serializerNotRegistered(Platform.common.kt:90)
                                                                                                    	at kotlinx.serialization.SerializersKt__SerializersKt.noCompiledSerializer(Serializers.kt:398)
                                                                                                    	at kotlinx.serialization.SerializersKt.noCompiledSerializer(Unknown Source:1)
                                                                                                    	at io.homeassistant.companion.android.settings.wear.SettingsWearViewModel.sendTemplateTileInfo(SettingsWearViewModel.kt:428)
                                                                                                    	at io.homeassistant.companion.android.settings.wear.views.SettingsWearHomeViewKt$LoadSettingsHomeView$1$1$1$5.invoke$lambda$6$lambda$1$lambda$0(SettingsWearHomeView.kt:85)
                                                                                                    	at io.homeassistant.companion.android.settings.wear.views.SettingsWearHomeViewKt$LoadSettingsHomeView$1$1$1$5.$r8$lambda$eVnn60L2JVyINLUWuLMa8m7Meq8(Unknown Source:0)
                                                                                                    	at io.homeassistant.companion.android.settings.wear.views.SettingsWearHomeViewKt$LoadSettingsHomeView$1$1$1$5$$ExternalSyntheticLambda0.invoke(D8$$SyntheticClass:0)
                                                                                                    	at androidx.compose.foundation.text.BasicTextFieldKt$BasicTextField$8$1.invoke(BasicTextField.kt:741)
                                                                                                    	at androidx.compose.foundation.text.BasicTextFieldKt$BasicTextField$8$1.invoke(BasicTextField.kt:734)
                                                                                                    	at androidx.compose.foundation.text.LegacyTextFieldState$onValueChange$1.invoke(CoreTextField.kt:876)
                                                                                                    	at androidx.compose.foundation.text.LegacyTextFieldState$onValueChange$1.invoke(CoreTextField.kt:862)
                                                                                                    	at androidx.compose.foundation.text.TextFieldDelegate$Companion.onEditCommand$foundation_release(TextFieldDelegate.kt:302)
                                                                                                    	at androidx.compose.foundation.text.TextFieldDelegate$Companion$restartInput$1.invoke(TextFieldDelegate.kt:351)
                                                                                                    	at androidx.compose.foundation.text.TextFieldDelegate$Companion$restartInput$1.invoke(TextFieldDelegate.kt:348)
                                                                                                    	at androidx.compose.foundation.text.input.internal.LegacyTextInputMethodRequest$createInputConnection$1.onEditCommands(LegacyPlatformTextInputServiceAdapter.android.kt:274)
                                                                                                    	at androidx.compose.foundation.text.input.internal.RecordingInputConnection.endBatchEditInternal(RecordingInputConnection.android.kt:194)
                                                                                                    	at androidx.compose.foundation.text.input.internal.RecordingInputConnection.addEditCommandWithBatch(RecordingInputConnection.android.kt:166)
                                                                                                    	at androidx.compose.foundation.text.input.internal.RecordingInputConnection.commitText(RecordingInputConnection.android.kt:217)
                                                                                                    	at androidx.compose.ui.text.input.NullableInputConnectionWrapperApi21.commitText(NullableInputConnectionWrapper.android.kt:133)
                                                                                                    	at android.view.inputmethod.RemoteInputConnectionImpl.lambda$commitText$17(RemoteInputConnectionImpl.java:612)
                                                                                                    	at android.view.inputmethod.RemoteInputConnectionImpl.$r8$lambda$jNtA8MXobPnaECkNr8D9WTYrxk0(Unknown Source:0)
                                                                                                    	at android.view.inputmethod.RemoteInputConnectionImpl$$ExternalSyntheticLambda46.run(D8$$SyntheticClass:0)
                                                                                                    	at android.os.Handler.handleCallback(Handler.java:991)
                                                                                                    	at android.os.Handler.dispatchMessage(Handler.java:102)
                                                                                                    	at android.os.Looper.loopOnce(Looper.java:232)
                                                                                                    	at android.os.Looper.loop(Looper.java:317)
                                                                                                    	at android.app.ActivityThread.main(ActivityThread.java:8934)
                                                                                                    	at java.lang.reflect.Method.invoke(Native Method)
                                                                                                    	at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:591)
                                                                                                    	at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:911)

SettingsWearViewModel.sendTemplateTileInfo(SettingsWearViewModel.kt:428) links to

dataMap.putString(WearDataMessages.CONFIG_TEMPLATE_TILES, kotlinJsonMapper.encodeToString(templateTiles))
as the call site

@jpelgrom
Copy link
Member

You mentioned adding a lint check to catch issues like above on Discord. Do you plan to add that in this PR, or later?

@TimoPtr
Copy link
Collaborator Author

TimoPtr commented May 26, 2025

You mentioned adding a lint check to catch issues like above on Discord. Do you plan to add that in this PR, or later?

I'm polishing it with some tests, and I'm opening a PR that we can merge on main without this PR and I will merge main into this PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Jackson 2.14+ requires higher minimum SDK level
2 participants