From d6fb0bc469b3e7604572afebc397951c0dbff015 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Irfan=20=C3=96m=C3=BCr?= Date: Thu, 23 Oct 2025 22:07:52 +0300 Subject: [PATCH 1/6] Add JsonObjectOrNullAdapterFactory Add a new Gson `TypeAdapterFactory` for handling fields that should be a JSON object but might come as an empty array (`[]`), null, or a primitive from the API. This factory ensures that if the incoming JSON token for a field is not `BEGIN_OBJECT`, it's parsed as `null` instead of causing a crash. This makes the JSON parsing more robust against inconsistent API responses. --- .../rest/JsonObjectOrNullAdapterFactory.kt | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/JsonObjectOrNullAdapterFactory.kt diff --git a/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/JsonObjectOrNullAdapterFactory.kt b/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/JsonObjectOrNullAdapterFactory.kt new file mode 100644 index 000000000000..64365dd8bc95 --- /dev/null +++ b/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/JsonObjectOrNullAdapterFactory.kt @@ -0,0 +1,35 @@ +package org.wordpress.android.fluxc.network.rest + +import com.google.gson.Gson +import com.google.gson.TypeAdapter +import com.google.gson.TypeAdapterFactory +import com.google.gson.reflect.TypeToken +import com.google.gson.stream.JsonReader +import com.google.gson.stream.JsonToken +import com.google.gson.stream.JsonWriter + +/** + * Parses fields that should be a JSON object but might come as an empty array (`[]`), null, or a primitive from the + * API. This factory ensures that if the incoming JSON token for a field is not `BEGIN_OBJECT`, it's parsed as `null` + * instead of causing a crash. + */ +class JsonObjectOrNullAdapterFactory : TypeAdapterFactory { + override fun create(gson: Gson, type: TypeToken): TypeAdapter? { + if (!JsonObjectOrNull::class.java.isAssignableFrom(type.rawType)) return null + + val delegate: TypeAdapter = gson.getDelegateAdapter(this, type) + return object : TypeAdapter() { + override fun write(out: JsonWriter, value: T?) { + delegate.write(out, value) + } + + override fun read(`in`: JsonReader): T? = if (`in`.peek() == JsonToken.BEGIN_OBJECT) { + delegate.read(`in`) + } else { + // BEGIN_ARRAY, NULL, primitive vs. + `in`.skipValue() + null + } + }.nullSafe() + } +} From e45009915bdb6d803f7c3c3ff091045326c408cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Irfan=20=C3=96m=C3=BCr?= Date: Thu, 23 Oct 2025 22:09:11 +0300 Subject: [PATCH 2/6] Use TypeAdapter for `JsonObjectOrNull` deserialization Replaces the `JsonObjectOrEmptyArrayDeserializer` with a new `JsonObjectOrNullAdapterFactory`. --- .../android/fluxc/module/ReleaseNetworkModule.java | 6 ++---- .../fluxc/network/discovery/RootWPAPIRestResponse.kt | 4 ++-- .../wordpress/android/fluxc/network/rest/GsonRequest.java | 3 +-- .../{JsonObjectOrEmptyArray.java => JsonObjectOrNull.java} | 2 +- .../fluxc/network/rest/wpapi/media/MediaWPRESTResponse.kt | 6 +++--- 5 files changed, 9 insertions(+), 12 deletions(-) rename libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/{JsonObjectOrEmptyArray.java => JsonObjectOrNull.java} (51%) diff --git a/libs/fluxc/src/main/java/org/wordpress/android/fluxc/module/ReleaseNetworkModule.java b/libs/fluxc/src/main/java/org/wordpress/android/fluxc/module/ReleaseNetworkModule.java index 111a69b5ba73..dfe96d6656e2 100644 --- a/libs/fluxc/src/main/java/org/wordpress/android/fluxc/module/ReleaseNetworkModule.java +++ b/libs/fluxc/src/main/java/org/wordpress/android/fluxc/module/ReleaseNetworkModule.java @@ -13,8 +13,7 @@ import org.wordpress.android.fluxc.network.OkHttpStack; import org.wordpress.android.fluxc.network.OpenJdkCookieManager; import org.wordpress.android.fluxc.network.RetryOnRedirectBasicNetwork; -import org.wordpress.android.fluxc.network.rest.JsonObjectOrEmptyArray; -import org.wordpress.android.fluxc.network.rest.JsonObjectOrEmptyArrayDeserializer; +import org.wordpress.android.fluxc.network.rest.JsonObjectOrNullAdapterFactory; import org.wordpress.android.fluxc.network.rest.JsonObjectOrFalse; import org.wordpress.android.fluxc.network.rest.JsonObjectOrFalseDeserializer; @@ -132,8 +131,7 @@ public Gson provideGson() { GsonBuilder gsonBuilder = new GsonBuilder(); gsonBuilder.setLenient(); gsonBuilder.registerTypeHierarchyAdapter(JsonObjectOrFalse.class, new JsonObjectOrFalseDeserializer()); - gsonBuilder.registerTypeHierarchyAdapter(JsonObjectOrEmptyArray.class, - new JsonObjectOrEmptyArrayDeserializer()); + gsonBuilder.registerTypeAdapterFactory(new JsonObjectOrNullAdapterFactory()); return gsonBuilder.create(); } } diff --git a/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/discovery/RootWPAPIRestResponse.kt b/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/discovery/RootWPAPIRestResponse.kt index 6e6148882fcc..638c17415b58 100644 --- a/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/discovery/RootWPAPIRestResponse.kt +++ b/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/discovery/RootWPAPIRestResponse.kt @@ -2,7 +2,7 @@ package org.wordpress.android.fluxc.network.discovery import com.google.gson.annotations.SerializedName import org.wordpress.android.fluxc.network.Response -import org.wordpress.android.fluxc.network.rest.JsonObjectOrEmptyArray +import org.wordpress.android.fluxc.network.rest.JsonObjectOrNull class RootWPAPIRestResponse( val name: String? = null, @@ -14,7 +14,7 @@ class RootWPAPIRestResponse( ) : Response { class Authentication( @SerializedName("application-passwords") val applicationPasswords: ApplicationPasswords? = null - ) : JsonObjectOrEmptyArray() { + ) : JsonObjectOrNull() { class ApplicationPasswords( val endpoints: Endpoints? ) { diff --git a/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/GsonRequest.java b/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/GsonRequest.java index 07185a398ba2..47d7c4f3e5f6 100644 --- a/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/GsonRequest.java +++ b/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/GsonRequest.java @@ -176,8 +176,7 @@ public static GsonBuilder getDefaultGsonBuilder() { GsonBuilder gsonBuilder = new GsonBuilder(); gsonBuilder.setStrictness(Strictness.LENIENT); gsonBuilder.registerTypeHierarchyAdapter(JsonObjectOrFalse.class, new JsonObjectOrFalseDeserializer()); - gsonBuilder.registerTypeHierarchyAdapter(JsonObjectOrEmptyArray.class, - new JsonObjectOrEmptyArrayDeserializer()); + gsonBuilder.registerTypeAdapterFactory(new JsonObjectOrNullAdapterFactory()); gsonBuilder.registerTypeHierarchyAdapter(List.class, new ListOrObjectDeserializer()); gsonBuilder.registerTypeHierarchyAdapter(Object[].class, new ArrayOrObjectDeserializer()); return gsonBuilder; diff --git a/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/JsonObjectOrEmptyArray.java b/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/JsonObjectOrNull.java similarity index 51% rename from libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/JsonObjectOrEmptyArray.java rename to libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/JsonObjectOrNull.java index 0ebd4167ea61..a0581152676e 100644 --- a/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/JsonObjectOrEmptyArray.java +++ b/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/JsonObjectOrNull.java @@ -1,3 +1,3 @@ package org.wordpress.android.fluxc.network.rest; -public abstract class JsonObjectOrEmptyArray {} +public abstract class JsonObjectOrNull {} diff --git a/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/media/MediaWPRESTResponse.kt b/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/media/MediaWPRESTResponse.kt index 27bab3427f0b..44ffa9c8f2e4 100644 --- a/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/media/MediaWPRESTResponse.kt +++ b/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/media/MediaWPRESTResponse.kt @@ -6,7 +6,7 @@ import com.google.gson.annotations.SerializedName import org.apache.commons.text.StringEscapeUtils import org.wordpress.android.fluxc.model.MediaModel import org.wordpress.android.fluxc.model.MediaModel.MediaUploadState -import org.wordpress.android.fluxc.network.rest.JsonObjectOrEmptyArray +import org.wordpress.android.fluxc.network.rest.JsonObjectOrNull import org.wordpress.android.fluxc.network.rest.wpcom.media.MediaWPComRestResponse import org.wordpress.android.util.DateTimeUtils import java.text.SimpleDateFormat @@ -40,13 +40,13 @@ data class MediaWPRESTResponse( val height: Int, val file: String?, val sizes: Sizes? - ) : JsonObjectOrEmptyArray() + ) : JsonObjectOrNull() data class Sizes( val medium: ImageSize?, val thumbnail: ImageSize?, val full: ImageSize? - ) : JsonObjectOrEmptyArray() + ) : JsonObjectOrNull() data class ImageSize( val path: String?, From 2729afcfcf41c79afa0481310f2617e2ab3e9a37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Irfan=20=C3=96m=C3=BCr?= Date: Thu, 23 Oct 2025 22:09:28 +0300 Subject: [PATCH 3/6] Remove JsonObjectOrEmptyArrayDeserializer --- .../JsonObjectOrEmptyArrayDeserializer.java | 26 ------------------- 1 file changed, 26 deletions(-) delete mode 100644 libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/JsonObjectOrEmptyArrayDeserializer.java diff --git a/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/JsonObjectOrEmptyArrayDeserializer.java b/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/JsonObjectOrEmptyArrayDeserializer.java deleted file mode 100644 index 060f42385897..000000000000 --- a/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/JsonObjectOrEmptyArrayDeserializer.java +++ /dev/null @@ -1,26 +0,0 @@ -package org.wordpress.android.fluxc.network.rest; - -import com.google.gson.Gson; -import com.google.gson.JsonDeserializationContext; -import com.google.gson.JsonDeserializer; -import com.google.gson.JsonElement; -import com.google.gson.JsonParseException; - -import java.lang.reflect.Type; - -/** - * Deserializes a response that is either an arbitrary JSON object, or an empty JSON array. - * For example, if we want to use CustomServerResponse.class to represent the result of an API call that returns either - * an object or [], this will deserialize the JSON object into CustomServerResponse.class, or return a null - * MyServerResponse if the server response was []. - */ -public class JsonObjectOrEmptyArrayDeserializer implements JsonDeserializer { - @Override - public JsonObjectOrEmptyArray deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) - throws JsonParseException { - if (json.isJsonObject()) { - return new Gson().fromJson(json, typeOfT); - } - return null; - } -} From 180671cfa5b2d204e70fd2fa1e1b38e5aece5d96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Irfan=20=C3=96m=C3=BCr?= Date: Thu, 23 Oct 2025 22:09:56 +0300 Subject: [PATCH 4/6] Make `mediaDetails` nullable in `MediaWPRESTResponse` The `mediaDetails` field in the `MediaWPRESTResponse` is now nullable to prevent crashes when the API response for media items is missing this information. --- .../network/rest/wpapi/media/MediaWPRESTResponse.kt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/media/MediaWPRESTResponse.kt b/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/media/MediaWPRESTResponse.kt index 44ffa9c8f2e4..8e10d5c02388 100644 --- a/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/media/MediaWPRESTResponse.kt +++ b/libs/fluxc/src/main/java/org/wordpress/android/fluxc/network/rest/wpapi/media/MediaWPRESTResponse.kt @@ -28,7 +28,7 @@ data class MediaWPRESTResponse( @SerializedName("alt_text") val altText: String, @SerializedName("media_type") val mediaType: String, @SerializedName("mime_type") val mimeType: String, - @SerializedName("media_details") val mediaDetails: MediaDetails, + @SerializedName("media_details") val mediaDetails: MediaDetails?, @SerializedName("source_url") val sourceURL: String? ) { data class Attribute( @@ -70,16 +70,16 @@ fun MediaWPRESTResponse.toMediaModel(localSiteId: Int) = MediaModel( SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.ROOT).parse(dateGmt) ), sourceURL.orEmpty(), - mediaDetails.sizes?.thumbnail?.sourceURL, - mediaDetails.file, - mediaDetails.file?.substringAfterLast('.', ""), + mediaDetails?.sizes?.thumbnail?.sourceURL, + mediaDetails?.file, + mediaDetails?.file?.substringAfterLast('.', ""), mimeType, StringEscapeUtils.unescapeHtml4(title.rendered), StringEscapeUtils.unescapeHtml4(caption.rendered), StringEscapeUtils.unescapeHtml4(description.rendered), StringEscapeUtils.unescapeHtml4(altText), - mediaDetails.width, - mediaDetails.height, + mediaDetails?.width ?: 0, + mediaDetails?.height ?: 0, 0, null, false, From 87a104d1266eaa38fd5eb450eeba5cef7486ae06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Irfan=20=C3=96m=C3=BCr?= Date: Thu, 23 Oct 2025 22:29:59 +0300 Subject: [PATCH 5/6] Update RELEASE-NOTES.txt --- RELEASE-NOTES.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index c072baac7a82..7e525464a7b9 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -11,6 +11,7 @@ - [Internal] Remove unused dependencies and optimize dependency scopes [https://github.com/woocommerce/woocommerce-android/pull/14710] - [*] Fix a rare crash in order refund flow [https://github.com/woocommerce/woocommerce-android/pull/14742] - [*] Fix customer name display when filtering orders from order details [https://github.com/woocommerce/woocommerce-android/pull/14761] +- [*] Fixed a rare media upload error [https://github.com/woocommerce/woocommerce-android/pull/14813] 23.4 ----- From 35123ad7f371a36aa42bd84fb919f8712038c583 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Irfan=20=C3=96m=C3=BCr?= Date: Thu, 23 Oct 2025 23:18:17 +0300 Subject: [PATCH 6/6] Add tests for `JsonObjectOrNullAdapterFactory` --- .../JsonObjectOrNullAdapterFactoryTest.kt | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 libs/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/JsonObjectOrNullAdapterFactoryTest.kt diff --git a/libs/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/JsonObjectOrNullAdapterFactoryTest.kt b/libs/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/JsonObjectOrNullAdapterFactoryTest.kt new file mode 100644 index 000000000000..cff6993222f1 --- /dev/null +++ b/libs/fluxc/src/test/java/org/wordpress/android/fluxc/network/rest/JsonObjectOrNullAdapterFactoryTest.kt @@ -0,0 +1,57 @@ +package org.wordpress.android.fluxc.network.rest + +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class JsonObjectOrNullAdapterFactoryTest { + data class SampleModel(val id: Int, val name: String?) : JsonObjectOrNull() + + private fun gson(): Gson = GsonBuilder() + .registerTypeAdapterFactory(JsonObjectOrNullAdapterFactory()) + .create() + + @Test + fun `when json is a valid object, then it is deserialized`() { + val json = """{"id": 7, "name": "woo"}""" + val result: SampleModel = gson().fromJson(json, SampleModel::class.java) + assertEquals(SampleModel(7, "woo"), result) + } + + @Test + fun `when json is an empty array, then returns null`() { + val json = "[]" + val result: SampleModel? = gson().fromJson(json, SampleModel::class.java) + assertNull(result) + } + + @Test + fun `when json is null, then returns null`() { + val json = "null" + val result: SampleModel? = gson().fromJson(json, SampleModel::class.java) + assertNull(result) + } + + @Test + fun `when json is a primitive string, then returns null`() { + val json = "\"primitive\"" + val result: SampleModel? = gson().fromJson(json, SampleModel::class.java) + assertNull(result) + } + + @Test + fun `when json is a primitive number, then returns null`() { + val json = "123" + val result: SampleModel? = gson().fromJson(json, SampleModel::class.java) + assertNull(result) + } + + @Test + fun `when json is a primitive boolean, then returns null`() { + val json = "true" + val result: SampleModel? = gson().fromJson(json, SampleModel::class.java) + assertNull(result) + } +}