fix: Prevent crash when Friendica returns a null `voted_on` property (#456)

Friendica can return a null `voted_on` property, in violation of the API
spec.

Introduce a `BooleanIfNull` annotation that will convert the `null` to
`false` if encountered.

While I'm here update the other adapters as classes on their relevant
annotations instead of standalone classes to keep the code consistent.

Fixes #455
This commit is contained in:
Nik Clayton 2024-02-19 19:04:29 +01:00 committed by GitHub
parent 23e3cf1035
commit 73c947edfa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 307 additions and 169 deletions

View File

@ -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

View File

@ -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

View File

@ -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(

View File

@ -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

View File

@ -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

View File

@ -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 <http://www.gnu.org/licenses>.
*/
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<out Annotation>,
moshi: Moshi,
): JsonAdapter<*>? {
val delegateAnnotations = Types.nextAnnotations(
annotations,
BooleanIfNull::class.java,
) ?: return null
val delegate = moshi.nextAdapter<Any>(
this,
type,
delegateAnnotations,
)
val annotation = annotations.first { it is BooleanIfNull } as BooleanIfNull
return Adapter(delegate, annotation.value)
}
private class Adapter(private val delegate: JsonAdapter<Any>, val default: Boolean) : JsonAdapter<Any>() {
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)
}
}
}

View File

@ -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 <http://www.gnu.org/licenses>.
*/
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<out Annotation>,
moshi: Moshi,
): JsonAdapter<*>? {
val delegateAnnotations = Types.nextAnnotations(
annotations,
DefaultIfNull::class.java,
) ?: return null
val delegate = moshi.nextAdapter<Any>(
this,
type,
delegateAnnotations,
)
return DefaultIfNullAdapter(delegate)
}
private class DefaultIfNullAdapter(private val delegate: JsonAdapter<Any>) : JsonAdapter<Any>() {
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)
}
}
}
}

View File

@ -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 <http://www.gnu.org/licenses>.
*/
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<Any>) : JsonAdapter<Any>() {
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<out Annotation>,
moshi: Moshi,
): JsonAdapter<*>? {
val delegateAnnotations = Types.nextAnnotations(
annotations,
DefaultIfNull::class.java,
) ?: return null
val delegate = moshi.nextAdapter<Any>(
this,
type,
delegateAnnotations,
)
return DefaultIfNullAdapter(delegate)
}
}
}
}

View File

@ -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 <http://www.gnu.org/licenses>.
*/
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<out Annotation>,
moshi: Moshi,
): JsonAdapter<*>? {
val delegateAnnotations = Types.nextAnnotations(
annotations,
Guarded::class.java,
) ?: return null
val delegate = moshi.nextAdapter<Any>(
this,
type,
delegateAnnotations,
)
return GuardedAdapter(delegate)
}
private class GuardedAdapter(private val delegate: JsonAdapter<*>) : JsonAdapter<Any>() {
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")
}
}
}
}

View File

@ -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 <http://www.gnu.org/licenses>.
*/
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<Any>() {
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<out Annotation>,
moshi: Moshi,
): JsonAdapter<*>? {
val delegateAnnotations = Types.nextAnnotations(
annotations,
Guarded::class.java,
) ?: return null
val delegate = moshi.nextAdapter<Any>(
this,
type,
delegateAnnotations,
)
return GuardedAdapter(delegate)
}
}
}
}

View File

@ -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<PollOption>,
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<Int>?,
) {

View File

@ -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<Data>().fromJson(jsonInput)).isEqualTo(Data(x = true))
}
@Test
fun `false x is false`() {
val jsonInput = """
{
"x": false
}
""".trimIndent()
assertThat(moshi.adapter<Data>().fromJson(jsonInput)).isEqualTo(Data(x = false))
}
@Test
fun `null x is false`() {
val jsonInput = """
{
"x": null
}
""".trimIndent()
assertThat(moshi.adapter<Data>().fromJson(jsonInput)).isEqualTo(Data(x = false))
}
@Test
fun `null x is true`() {
val jsonInput = """
{
"x": null
}
""".trimIndent()
assertThat(moshi.adapter<Data2>().fromJson(jsonInput)).isEqualTo(Data2(x = true))
}
}

View File

@ -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)

View File

@ -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