diff --git a/app/src/test/java/app/pachli/StatusComparisonTest.kt b/app/src/test/java/app/pachli/StatusComparisonTest.kt index b7bdad5e9..063f6faa7 100644 --- a/app/src/test/java/app/pachli/StatusComparisonTest.kt +++ b/app/src/test/java/app/pachli/StatusComparisonTest.kt @@ -2,8 +2,9 @@ package app.pachli import androidx.test.ext.junit.runners.AndroidJUnit4 import app.pachli.core.database.model.TranslationState -import app.pachli.core.network.json.DefaultIfNullAdapter.Companion.DefaultIfNullAdapterFactory -import app.pachli.core.network.json.GuardedAdapter.Companion.GuardedAdapterFactory +import app.pachli.core.network.json.BooleanIfNull +import app.pachli.core.network.json.DefaultIfNull +import app.pachli.core.network.json.Guarded import app.pachli.core.network.model.Status import app.pachli.viewdata.StatusViewData import com.squareup.moshi.Moshi @@ -19,8 +20,9 @@ import org.junit.runner.RunWith class StatusComparisonTest { private val moshi = Moshi.Builder() .add(Date::class.java, Rfc3339DateJsonAdapter()) - .add(GuardedAdapterFactory()) - .add(DefaultIfNullAdapterFactory()) + .add(Guarded.Factory()) + .add(DefaultIfNull.Factory()) + .add(BooleanIfNull.Factory()) .build() @Test diff --git a/app/src/test/java/app/pachli/components/timeline/CachedTimelineRemoteMediatorTest.kt b/app/src/test/java/app/pachli/components/timeline/CachedTimelineRemoteMediatorTest.kt index 80f20ab4f..6be1dc08e 100644 --- a/app/src/test/java/app/pachli/components/timeline/CachedTimelineRemoteMediatorTest.kt +++ b/app/src/test/java/app/pachli/components/timeline/CachedTimelineRemoteMediatorTest.kt @@ -20,8 +20,9 @@ import app.pachli.core.database.model.AccountEntity import app.pachli.core.database.model.RemoteKeyEntity import app.pachli.core.database.model.RemoteKeyKind import app.pachli.core.database.model.TimelineStatusWithAccount -import app.pachli.core.network.json.DefaultIfNullAdapter.Companion.DefaultIfNullAdapterFactory -import app.pachli.core.network.json.GuardedAdapter.Companion.GuardedAdapterFactory +import app.pachli.core.network.json.BooleanIfNull +import app.pachli.core.network.json.DefaultIfNull +import app.pachli.core.network.json.Guarded import com.google.common.truth.Truth.assertThat import com.squareup.moshi.Moshi import com.squareup.moshi.adapters.Rfc3339DateJsonAdapter @@ -66,8 +67,9 @@ class CachedTimelineRemoteMediatorTest { private val moshi: Moshi = Moshi.Builder() .add(Date::class.java, Rfc3339DateJsonAdapter()) - .add(GuardedAdapterFactory()) - .add(DefaultIfNullAdapterFactory()) + .add(Guarded.Factory()) + .add(DefaultIfNull.Factory()) + .add(BooleanIfNull.Factory()) .build() @Before diff --git a/app/src/test/java/app/pachli/components/timeline/StatusMocker.kt b/app/src/test/java/app/pachli/components/timeline/StatusMocker.kt index a6e75d4ef..5711dc117 100644 --- a/app/src/test/java/app/pachli/components/timeline/StatusMocker.kt +++ b/app/src/test/java/app/pachli/components/timeline/StatusMocker.kt @@ -5,8 +5,9 @@ import app.pachli.core.database.model.TimelineAccountEntity import app.pachli.core.database.model.TimelineStatusEntity import app.pachli.core.database.model.TimelineStatusWithAccount import app.pachli.core.database.model.TranslationState -import app.pachli.core.network.json.DefaultIfNullAdapter.Companion.DefaultIfNullAdapterFactory -import app.pachli.core.network.json.GuardedAdapter.Companion.GuardedAdapterFactory +import app.pachli.core.network.json.BooleanIfNull +import app.pachli.core.network.json.DefaultIfNull +import app.pachli.core.network.json.Guarded import app.pachli.core.network.model.Status import app.pachli.core.network.model.TimelineAccount import app.pachli.viewdata.StatusViewData @@ -101,8 +102,9 @@ fun mockStatusEntityWithAccount( val mockedStatus = mockStatus(id) val moshi = Moshi.Builder() .add(Date::class.java, Rfc3339DateJsonAdapter()) - .add(GuardedAdapterFactory()) - .add(DefaultIfNullAdapterFactory()) + .add(Guarded.Factory()) + .add(DefaultIfNull.Factory()) + .add(BooleanIfNull.Factory()) .build() return TimelineStatusWithAccount( diff --git a/app/src/test/java/app/pachli/di/FakeNetworkModule.kt b/app/src/test/java/app/pachli/di/FakeNetworkModule.kt index 8a247944c..02a37de5b 100644 --- a/app/src/test/java/app/pachli/di/FakeNetworkModule.kt +++ b/app/src/test/java/app/pachli/di/FakeNetworkModule.kt @@ -19,7 +19,7 @@ package app.pachli.di import app.pachli.components.compose.MediaUploader import app.pachli.core.network.di.NetworkModule -import app.pachli.core.network.json.GuardedAdapter +import app.pachli.core.network.json.Guarded import com.squareup.moshi.Moshi import com.squareup.moshi.adapters.Rfc3339DateJsonAdapter import dagger.Module @@ -41,7 +41,7 @@ object FakeNetworkModule { @Singleton fun providesMoshi(): Moshi = Moshi.Builder() .add(Date::class.java, Rfc3339DateJsonAdapter()) - .add(GuardedAdapter.Companion.GuardedAdapterFactory()) + .add(Guarded.Factory()) .build() @Provides diff --git a/core/network/src/main/kotlin/app/pachli/core/network/di/NetworkModule.kt b/core/network/src/main/kotlin/app/pachli/core/network/di/NetworkModule.kt index 9503230f8..5939a2acb 100644 --- a/core/network/src/main/kotlin/app/pachli/core/network/di/NetworkModule.kt +++ b/core/network/src/main/kotlin/app/pachli/core/network/di/NetworkModule.kt @@ -22,8 +22,9 @@ import android.os.Build import app.pachli.core.common.util.versionName import app.pachli.core.mastodon.model.MediaUploadApi import app.pachli.core.network.BuildConfig -import app.pachli.core.network.json.DefaultIfNullAdapter.Companion.DefaultIfNullAdapterFactory -import app.pachli.core.network.json.GuardedAdapter.Companion.GuardedAdapterFactory +import app.pachli.core.network.json.BooleanIfNull +import app.pachli.core.network.json.DefaultIfNull +import app.pachli.core.network.json.Guarded import app.pachli.core.network.retrofit.InstanceSwitchAuthInterceptor import app.pachli.core.network.retrofit.MastodonApi import app.pachli.core.preferences.PrefKeys.HTTP_PROXY_ENABLED @@ -63,8 +64,9 @@ object NetworkModule { @Singleton fun providesMoshi(): Moshi = Moshi.Builder() .add(Date::class.java, Rfc3339DateJsonAdapter()) - .add(GuardedAdapterFactory()) - .add(DefaultIfNullAdapterFactory()) + .add(Guarded.Factory()) + .add(DefaultIfNull.Factory()) + .add(BooleanIfNull.Factory()) .build() @Provides diff --git a/core/network/src/main/kotlin/app/pachli/core/network/json/BooleanIfNull.kt b/core/network/src/main/kotlin/app/pachli/core/network/json/BooleanIfNull.kt new file mode 100644 index 000000000..e7457601f --- /dev/null +++ b/core/network/src/main/kotlin/app/pachli/core/network/json/BooleanIfNull.kt @@ -0,0 +1,78 @@ +/* + * Copyright 2024 Pachli Association + * + * This file is a part of Pachli. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Pachli; if not, + * see . + */ + +package app.pachli.core.network.json + +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.JsonQualifier +import com.squareup.moshi.JsonReader +import com.squareup.moshi.JsonWriter +import com.squareup.moshi.Moshi +import com.squareup.moshi.Types +import java.lang.reflect.Type + +/** + * A [JsonQualifier] for use with [Boolean] properties to indicate that their + * value be set to the given [value] if the JSON property is `null`. + * + * Absent properties use the property's default value as normal. + * + * Usage: + * ``` + * val moshi = Moshi.Builder() + * .add(BooleanIfNull.Factory()) + * .build() + * + * @JsonClass(generateAdapter = true) + * data class Foo( + * @BooleanIfNull(false) val data: Boolean + * ) + * ``` + */ +@Retention(AnnotationRetention.RUNTIME) +@JsonQualifier +annotation class BooleanIfNull(val value: Boolean) { + class Factory : JsonAdapter.Factory { + override fun create( + type: Type, + annotations: MutableSet, + moshi: Moshi, + ): JsonAdapter<*>? { + val delegateAnnotations = Types.nextAnnotations( + annotations, + BooleanIfNull::class.java, + ) ?: return null + val delegate = moshi.nextAdapter( + this, + type, + delegateAnnotations, + ) + + val annotation = annotations.first { it is BooleanIfNull } as BooleanIfNull + return Adapter(delegate, annotation.value) + } + + private class Adapter(private val delegate: JsonAdapter, val default: Boolean) : JsonAdapter() { + override fun fromJson(reader: JsonReader): Any { + val value = reader.readJsonValue() + return value as? Boolean ?: default + } + + override fun toJson(writer: JsonWriter, value: Any?) = delegate.toJson(writer, value) + } + } +} diff --git a/core/network/src/main/kotlin/app/pachli/core/network/json/DefaultIfNull.kt b/core/network/src/main/kotlin/app/pachli/core/network/json/DefaultIfNull.kt new file mode 100644 index 000000000..e94d67f6b --- /dev/null +++ b/core/network/src/main/kotlin/app/pachli/core/network/json/DefaultIfNull.kt @@ -0,0 +1,64 @@ +/* + * Copyright 2024 Pachli Association + * + * This file is a part of Pachli. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Pachli; if not, + * see . + */ + +package app.pachli.core.network.json + +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.JsonQualifier +import com.squareup.moshi.JsonReader +import com.squareup.moshi.JsonWriter +import com.squareup.moshi.Moshi +import com.squareup.moshi.Types +import java.lang.reflect.Type + +@Retention(AnnotationRetention.RUNTIME) +@JsonQualifier +annotation class DefaultIfNull { + class Factory : JsonAdapter.Factory { + override fun create( + type: Type, + annotations: MutableSet, + moshi: Moshi, + ): JsonAdapter<*>? { + val delegateAnnotations = Types.nextAnnotations( + annotations, + DefaultIfNull::class.java, + ) ?: return null + val delegate = moshi.nextAdapter( + this, + type, + delegateAnnotations, + ) + return DefaultIfNullAdapter(delegate) + } + + private class DefaultIfNullAdapter(private val delegate: JsonAdapter) : JsonAdapter() { + override fun fromJson(reader: JsonReader): Any? { + val value = reader.readJsonValue() + if (value is Map<*, *>) { + val withoutNulls = value.filterValues { it != null } + return delegate.fromJsonValue(withoutNulls) + } + return delegate.fromJsonValue(value) + } + + override fun toJson(writer: JsonWriter, value: Any?) { + return delegate.toJson(writer, value) + } + } + } +} diff --git a/core/network/src/main/kotlin/app/pachli/core/network/json/DefaultIfNullAdapter.kt b/core/network/src/main/kotlin/app/pachli/core/network/json/DefaultIfNullAdapter.kt deleted file mode 100644 index a5fa44eb1..000000000 --- a/core/network/src/main/kotlin/app/pachli/core/network/json/DefaultIfNullAdapter.kt +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright 2024 Pachli Association - * - * This file is a part of Pachli. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Pachli; if not, - * see . - */ - -package app.pachli.core.network.json - -import com.squareup.moshi.JsonAdapter -import com.squareup.moshi.JsonQualifier -import com.squareup.moshi.JsonReader -import com.squareup.moshi.JsonWriter -import com.squareup.moshi.Moshi -import com.squareup.moshi.Types -import java.lang.reflect.Type - -@Retention(AnnotationRetention.RUNTIME) -@JsonQualifier -annotation class DefaultIfNull - -class DefaultIfNullAdapter(private val delegate: JsonAdapter) : JsonAdapter() { - override fun fromJson(reader: JsonReader): Any? { - val value = reader.readJsonValue() - if (value is Map<*, *>) { - val withoutNulls = value.filterValues { it != null } - return delegate.fromJsonValue(withoutNulls) - } - return delegate.fromJsonValue(value) - } - - override fun toJson(writer: JsonWriter, value: Any?) { - return delegate.toJson(writer, value) - } - - companion object { - class DefaultIfNullAdapterFactory : Factory { - override fun create( - type: Type, - annotations: MutableSet, - moshi: Moshi, - ): JsonAdapter<*>? { - val delegateAnnotations = Types.nextAnnotations( - annotations, - DefaultIfNull::class.java, - ) ?: return null - val delegate = moshi.nextAdapter( - this, - type, - delegateAnnotations, - ) - return DefaultIfNullAdapter(delegate) - } - } - } -} diff --git a/core/network/src/main/kotlin/app/pachli/core/network/json/Guarded.kt b/core/network/src/main/kotlin/app/pachli/core/network/json/Guarded.kt new file mode 100644 index 000000000..f8c74826b --- /dev/null +++ b/core/network/src/main/kotlin/app/pachli/core/network/json/Guarded.kt @@ -0,0 +1,73 @@ +/* + * Copyright 2024 Pachli Association + * + * This file is a part of Pachli. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Pachli; if not, + * see . + */ + +package app.pachli.core.network.json + +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.JsonDataException +import com.squareup.moshi.JsonQualifier +import com.squareup.moshi.JsonReader +import com.squareup.moshi.JsonWriter +import com.squareup.moshi.Moshi +import com.squareup.moshi.Types +import java.lang.reflect.Type + +/** + * Deserialize this field as the given type, or null if the field value is not + * this type. + */ +@Retention(AnnotationRetention.RUNTIME) +@JsonQualifier +annotation class Guarded { + class Factory : JsonAdapter.Factory { + override fun create( + type: Type, + annotations: MutableSet, + moshi: Moshi, + ): JsonAdapter<*>? { + val delegateAnnotations = Types.nextAnnotations( + annotations, + Guarded::class.java, + ) ?: return null + val delegate = moshi.nextAdapter( + this, + type, + delegateAnnotations, + ) + return GuardedAdapter(delegate) + } + + private class GuardedAdapter(private val delegate: JsonAdapter<*>) : JsonAdapter() { + override fun fromJson(reader: JsonReader): Any? { + val peeked = reader.peekJson() + val result = try { + delegate.fromJson(peeked) + } catch (_: JsonDataException) { + null + } finally { + peeked.close() + } + reader.skipValue() + return result + } + + override fun toJson(writer: JsonWriter, value: Any?) { + throw UnsupportedOperationException("@Guarded is only used to desererialize objects") + } + } + } +} diff --git a/core/network/src/main/kotlin/app/pachli/core/network/json/GuardedAdapter.kt b/core/network/src/main/kotlin/app/pachli/core/network/json/GuardedAdapter.kt deleted file mode 100644 index 3958036b1..000000000 --- a/core/network/src/main/kotlin/app/pachli/core/network/json/GuardedAdapter.kt +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Copyright 2024 Pachli Association - * - * This file is a part of Pachli. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Pachli; if not, - * see . - */ - -package app.pachli.core.network.json - -import com.squareup.moshi.JsonAdapter -import com.squareup.moshi.JsonDataException -import com.squareup.moshi.JsonQualifier -import com.squareup.moshi.JsonReader -import com.squareup.moshi.JsonWriter -import com.squareup.moshi.Moshi -import com.squareup.moshi.Types -import java.lang.reflect.Type - -/** - * Deserialize this field as the given type, or null if the field value is not - * this type. - */ -@Retention(AnnotationRetention.RUNTIME) -@JsonQualifier -annotation class Guarded - -/** - * Parse the given field as either the delegate type, or null if it does not parse - * as that type. - */ -class GuardedAdapter(private val delegate: JsonAdapter<*>) : JsonAdapter() { - override fun fromJson(reader: JsonReader): Any? { - val peeked = reader.peekJson() - val result = try { - delegate.fromJson(peeked) - } catch (_: JsonDataException) { - null - } finally { - peeked.close() - } - reader.skipValue() - return result - } - - override fun toJson(writer: JsonWriter, value: Any?) { - throw UnsupportedOperationException("@Guarded is only used to desererialize objects") - } - - companion object { - class GuardedAdapterFactory : Factory { - override fun create( - type: Type, - annotations: MutableSet, - moshi: Moshi, - ): JsonAdapter<*>? { - val delegateAnnotations = Types.nextAnnotations( - annotations, - Guarded::class.java, - ) ?: return null - val delegate = moshi.nextAdapter( - this, - type, - delegateAnnotations, - ) - return GuardedAdapter(delegate) - } - } - } -} diff --git a/core/network/src/main/kotlin/app/pachli/core/network/model/Poll.kt b/core/network/src/main/kotlin/app/pachli/core/network/model/Poll.kt index b9c9240bc..2c405ff43 100644 --- a/core/network/src/main/kotlin/app/pachli/core/network/model/Poll.kt +++ b/core/network/src/main/kotlin/app/pachli/core/network/model/Poll.kt @@ -1,5 +1,6 @@ package app.pachli.core.network.model +import app.pachli.core.network.json.BooleanIfNull import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import java.util.Date @@ -11,10 +12,11 @@ data class Poll( val expired: Boolean, val multiple: Boolean, @Json(name = "votes_count") val votesCount: Int, - // nullable for compatibility with Pleroma @Json(name = "voters_count") val votersCount: Int?, val options: List, - val voted: Boolean, + // Friendica can incorrectly return null for `voted`. Default to false. + // https://github.com/friendica/friendica/issues/13922 + @BooleanIfNull(false) val voted: Boolean, @Json(name = "own_votes") val ownVotes: List?, ) { diff --git a/core/network/src/test/kotlin/app/pachli/core/network/json/BooleanIfNullTest.kt b/core/network/src/test/kotlin/app/pachli/core/network/json/BooleanIfNullTest.kt new file mode 100644 index 000000000..703300f2b --- /dev/null +++ b/core/network/src/test/kotlin/app/pachli/core/network/json/BooleanIfNullTest.kt @@ -0,0 +1,60 @@ +package app.pachli.core.network.json + +import com.google.common.truth.Truth.assertThat +import com.squareup.moshi.JsonClass +import com.squareup.moshi.Moshi +import com.squareup.moshi.adapter +import org.junit.Test + +@OptIn(ExperimentalStdlibApi::class) +class BooleanIfNullTest { + private val moshi = Moshi.Builder() + .add(BooleanIfNull.Factory()) + .build() + + @JsonClass(generateAdapter = true) + data class Data(@BooleanIfNull(false) val x: Boolean) + + @JsonClass(generateAdapter = true) + data class Data2(@BooleanIfNull(true) val x: Boolean) + + @Test + fun `true x is true`() { + val jsonInput = """ + { + "x": true + } + """.trimIndent() + assertThat(moshi.adapter().fromJson(jsonInput)).isEqualTo(Data(x = true)) + } + + @Test + fun `false x is false`() { + val jsonInput = """ + { + "x": false + } + """.trimIndent() + assertThat(moshi.adapter().fromJson(jsonInput)).isEqualTo(Data(x = false)) + } + + @Test + fun `null x is false`() { + val jsonInput = """ + { + "x": null + } + """.trimIndent() + assertThat(moshi.adapter().fromJson(jsonInput)).isEqualTo(Data(x = false)) + } + + @Test + fun `null x is true`() { + val jsonInput = """ + { + "x": null + } + """.trimIndent() + assertThat(moshi.adapter().fromJson(jsonInput)).isEqualTo(Data2(x = true)) + } +} diff --git a/core/network/src/test/kotlin/app/pachli/core/network/json/DefaultIfNullTest.kt b/core/network/src/test/kotlin/app/pachli/core/network/json/DefaultIfNullTest.kt index d6d23cdb3..a8c17af4d 100644 --- a/core/network/src/test/kotlin/app/pachli/core/network/json/DefaultIfNullTest.kt +++ b/core/network/src/test/kotlin/app/pachli/core/network/json/DefaultIfNullTest.kt @@ -1,6 +1,5 @@ package app.pachli.core.network.json -import app.pachli.core.network.json.DefaultIfNullAdapter.Companion.DefaultIfNullAdapterFactory import com.google.common.truth.Truth.assertThat import com.squareup.moshi.JsonClass import com.squareup.moshi.Moshi @@ -11,7 +10,7 @@ import org.junit.Test class DefaultIfNullTest { private val moshi = Moshi.Builder() - .add(DefaultIfNullAdapterFactory()) + .add(DefaultIfNull.Factory()) .build() @JsonClass(generateAdapter = true) diff --git a/core/network/src/test/kotlin/app/pachli/core/network/json/GuardedAdapterTest.kt b/core/network/src/test/kotlin/app/pachli/core/network/json/GuardedAdapterTest.kt index 385385d9e..5c542b58f 100644 --- a/core/network/src/test/kotlin/app/pachli/core/network/json/GuardedAdapterTest.kt +++ b/core/network/src/test/kotlin/app/pachli/core/network/json/GuardedAdapterTest.kt @@ -1,6 +1,5 @@ package app.pachli.core.network.json -import app.pachli.core.network.json.GuardedAdapter.Companion.GuardedAdapterFactory import app.pachli.core.network.model.Relationship import com.google.common.truth.Truth.assertThat import com.squareup.moshi.Moshi @@ -11,7 +10,7 @@ import org.junit.Test class GuardedAdapterTest { private val moshi = Moshi.Builder() - .add(GuardedAdapterFactory()) + .add(Guarded.Factory()) .build() @Test