Skip to content

Commit

Permalink
fix: Prevent crash when Friendica returns a null voted_on property (#…
Browse files Browse the repository at this point in the history
…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
  • Loading branch information
nikclayton authored Feb 19, 2024
1 parent 23e3cf1 commit 73c947e
Show file tree
Hide file tree
Showing 14 changed files with 307 additions and 169 deletions.
10 changes: 6 additions & 4 deletions app/src/test/java/app/pachli/StatusComparisonTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
10 changes: 6 additions & 4 deletions app/src/test/java/app/pachli/components/timeline/StatusMocker.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
4 changes: 2 additions & 2 deletions app/src/test/java/app/pachli/di/FakeNetworkModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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")
}
}
}
}
Loading

0 comments on commit 73c947e

Please sign in to comment.