The case of deserialization of a Kotlin data class

Today we faced a tricky issue when trying to deserialize a data class using Gson.

We got a report from QA of a feature “not working” for the release builds of our library. This feature is quite new, so we started the investigation by comparing it with other similar features. Tracking down the issue, it looked like the network code was not working, unable to deserialize a given object.

We use the same pattern for all of the network calls, by using Retrofit and Gson. The JSON responses are deserialized to very simple Java classes, like this one:

public class Content {
    public String locale;
    public String content;

    public String getLanguage() {
        return locale;
    }

    public void setLanguage(String language) {
        locale = language;
    }

    public String getText() {
        return content;
    }

    public void setText(String text) {
        content = text;
    }
}

But then, we realized that the new feature was developed in Kotlin, and used data classes to get the response data. Like so:

class AddressDeliveryRequest(
        val addressId: String,
        val cartId: String,
        val email: String)

So, at first sight may be the same, but… something was off.

Proguard (in fact, now R8) is used to optimize the release builds. There’s an specific rule for the package that includes all this classes:

-keepclassmembers public class com.myapp.network.data.** { public *; }

By examining this, I realized that in the Java classes, the backing fields were not private, as is the common case when you try to control the access to is by using a getter and a setter.

So, I decided to look at the generated code for the Kotlin data class to confirm my suspicion. So, from the Kotlin data class, I selected Tools -> Kotlin -> Show Kotlin Bytecode and then, in the pane that open press Decompile to see the equivalent Java code.

public final class AddressDeliveryRequest {
   @NotNull
   private final String addressId;
   @NotNull
   private final String cartId;
   @NotNull
   private final String email;

   @NotNull
   public final String getAddressId() {
      return this.addressId;
   }

   @NotNull
   public final String getCartId() {
      return this.cartId;
   }

   @NotNull
   public final String getEmail() {
      return this.email;
   }

   public AddressDeliveryRequest(@NotNull String addressId, @NotNull String cartId, @NotNull String email) {
      Intrinsics.checkParameterIsNotNull(addressId, "addressId");
      Intrinsics.checkParameterIsNotNull(cartId, "cartId");
      Intrinsics.checkParameterIsNotNull(email, "email");
      super();
      this.addressId = addressId;
      this.cartId = cartId;
      this.email = email;
   }
}

So, the fields that are expected by Gson were being removed by R8 as it didn’t match the rule that specifies that all the public fields should be kept.

So, in order to implement a quick fix, and pending a further investigation to see if we can come with a nicer solution, we changed the data class to this:

class AddressDeliveryRequest(
        @JvmField val addressId: String,
        @JvmField val cartId: String,
        @JvmField val email: String) {

    fun getAddressId() = addressId
    
    fun getCardId() = cartId
    
    fun getEmail() = email
    
}

With this, the generated code matched what was expected by R8 and Gson:

public final class AddressDeliveryRequest {
   @JvmField
   @NotNull
   public final String addressId;
   @JvmField
   @NotNull
   public final String cartId;
   @JvmField
   @NotNull
   public final String email;

   @NotNull
   public final String getAddressId() {
      return this.addressId;
   }

   @NotNull
   public final String getCardId() {
      return this.cartId;
   }

   @NotNull
   public final String getEmail() {
      return this.email;
   }

   public AddressDeliveryRequest(@NotNull String addressId, @NotNull String cartId, @NotNull String email) {
      Intrinsics.checkParameterIsNotNull(addressId, "addressId");
      Intrinsics.checkParameterIsNotNull(cartId, "cartId");
      Intrinsics.checkParameterIsNotNull(email, "email");
      super();
      this.addressId = addressId;
      this.cartId = cartId;
      this.email = email;
   }
}

Notice that there’s an open bug in Gson for already quite a long time requesting getters and setters support.