From a3d45ca9ec0c29b8a04acc07a1065981d761913d Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Fri, 9 Feb 2024 12:41:13 +0100 Subject: [PATCH] refactor: Convert from Gson to Moshi (#428) Moshi is faster to decode JSON at runtime, is actively maintained, has a smaller memory and method footprint, and a slightly smaller APK size. Moshi also correctly creates default constructor arguments instead of leaving them null, which was a source of `NullPointerExceptions` when using Gson. The conversion broadly consisted of: - Adding `@JsonClass(generateAdapter = true)` to data classes that marshall to/from JSON. - Replacing `@SerializedName(value = ...)` with `@Json(name = ...)`. - Replacing Gson instances with Moshi in Retrofit, Hilt, and tests. - Using Moshi adapters to marshall to/from JSON instead of Gson `toJson` / `fromJson`. - Deleting `Rfc3339DateJsonAdapter` and related code, and using the equivalent adapter bundled with Moshi. - Rewriting `GuardedBooleanAdapter` as a more generic `GuardedAdapter`. - Deleting unused ProGuard rules; Moshi generates adapters using code generation, not runtime reflection. The conversion surfaced some bugs which have been fixed. - Not all audio attachments have attachment size metadata. Don't show the attachment preview if the metadata is missing. - Some `throwable` were not being logged correctly. - The wrong type was being used when parsing the response when sending a scheduled status. - Exceptions other than `HttpException` or `IoException` would also cause a status to be resent. If there's a JSON error parsing a response the status would be repeatedly sent. - In tests strings containing error responses were not valid JSON. - Workaround Mastodon a bug and ensure `filter.keywords` is populated, https://github.com/mastodon/mastodon/issues/29142 --- app/build.gradle.kts | 4 +- app/proguard-rules.pro | 13 - .../kotlin/app/pachli/di/UpdateCheckModule.kt | 6 +- .../app/pachli/updatecheck/FdroidService.kt | 3 + .../kotlin/app/pachli/di/UpdateCheckModule.kt | 5 +- .../app/pachli/updatecheck/GithubService.kt | 9 +- .../main/java/app/pachli/ViewMediaActivity.kt | 5 +- .../adapter/ReportNotificationViewHolder.kt | 2 +- .../pachli/adapter/StatusBaseViewHolder.kt | 2 + .../java/app/pachli/appstore/CacheUpdater.kt | 9 +- .../main/java/app/pachli/appstore/Events.kt | 2 +- .../NotificationsPagingSource.kt | 10 +- .../notifications/NotificationsRepository.kt | 6 +- .../scheduled/ScheduledStatusViewModel.kt | 2 +- .../search/adapter/SearchPagingSource.kt | 6 +- .../search/fragments/SearchFragment.kt | 1 - .../timeline/CachedTimelineRepository.kt | 6 +- .../viewmodel/CachedTimelineRemoteMediator.kt | 10 +- .../viewmodel/CachedTimelineViewModel.kt | 6 +- .../viewthread/ViewThreadViewModel.kt | 8 +- .../app/pachli/service/SendStatusService.kt | 39 ++- .../java/app/pachli/util/AttachmentHelper.kt | 9 +- .../app/pachli/viewdata/StatusViewData.kt | 6 +- .../java/app/pachli/StatusComparisonTest.kt | 14 +- .../NotificationsPagingSourceTest.kt | 26 +- .../CachedTimelineRemoteMediatorTest.kt | 24 +- .../CachedTimelineViewModelTestBase.kt | 7 +- .../components/timeline/StatusMocker.kt | 13 +- .../viewthread/ViewThreadViewModelTest.kt | 6 +- .../java/app/pachli/di/FakeNetworkModule.kt | 13 +- core/database/build.gradle.kts | 6 +- .../app/pachli/core/database/Converters.kt | 57 +-- .../core/database/model/ConversationEntity.kt | 2 + .../pachli/core/database/model/DraftEntity.kt | 17 +- .../database/model/TimelineStatusEntity.kt | 81 ++--- .../core/database/di/FakeDatabaseModule.kt | 6 +- .../app/pachli/core/navigation/Navigation.kt | 6 +- core/network/build.gradle.kts | 5 +- .../pachli/core/network/di/NetworkModule.kt | 19 +- .../core/network/json/GuardedAdapter.kt | 79 +++++ .../network/json/GuardedBooleanAdapter.kt | 34 -- .../pachli/core/network/json/Iso8601Utils.kt | 327 ------------------ .../network/json/Rfc3339DateJsonAdapter.kt | 56 --- .../pachli/core/network/model/AccessToken.kt | 6 +- .../app/pachli/core/network/model/Account.kt | 24 +- .../pachli/core/network/model/Announcement.kt | 17 +- .../core/network/model/AppCredentials.kt | 8 +- .../pachli/core/network/model/Attachment.kt | 57 ++- .../app/pachli/core/network/model/Card.kt | 16 +- .../pachli/core/network/model/Conversation.kt | 6 +- .../core/network/model/DeletedStatus.kt | 12 +- .../app/pachli/core/network/model/Emoji.kt | 8 +- .../app/pachli/core/network/model/Error.kt | 7 +- .../app/pachli/core/network/model/Filter.kt | 15 +- .../core/network/model/FilterKeyword.kt | 6 +- .../pachli/core/network/model/FilterResult.kt | 8 +- .../app/pachli/core/network/model/FilterV1.kt | 8 +- .../app/pachli/core/network/model/HashTag.kt | 3 + .../pachli/core/network/model/InstanceV1.kt | 60 ++-- .../pachli/core/network/model/InstanceV2.kt | 69 ++-- .../app/pachli/core/network/model/Marker.kt | 8 +- .../pachli/core/network/model/MastoList.kt | 3 + .../core/network/model/MediaUploadResult.kt | 3 + .../pachli/core/network/model/NewStatus.kt | 18 +- .../pachli/core/network/model/Notification.kt | 32 +- .../model/NotificationSubscribeResult.kt | 6 +- .../app/pachli/core/network/model/Poll.kt | 15 +- .../pachli/core/network/model/Relationship.kt | 27 +- .../app/pachli/core/network/model/Report.kt | 10 +- .../core/network/model/ScheduledStatus.kt | 8 +- .../pachli/core/network/model/SearchResult.kt | 3 + .../app/pachli/core/network/model/Status.kt | 36 +- .../core/network/model/StatusContext.kt | 3 + .../pachli/core/network/model/StatusEdit.kt | 10 +- .../pachli/core/network/model/StatusParams.kt | 8 +- .../pachli/core/network/model/StatusSource.kt | 6 +- .../core/network/model/TimelineAccount.kt | 10 +- .../pachli/core/network/model/Translation.kt | 13 +- .../pachli/core/network/model/TrendingTag.kt | 3 + .../pachli/core/network/model/TrendsLink.kt | 25 +- .../core/network/model/nodeinfo/NodeInfo.kt | 5 + .../core/network/retrofit/MastodonApi.kt | 8 + .../app/pachli/core/network/ServerTest.kt | 17 +- ...anAdapterTest.kt => GuardedAdapterTest.kt} | 22 +- gradle/libs.versions.toml | 10 +- tools/mkserverversions/build.gradle.kts | 5 +- .../app/pachli/mkserverversions/Main.kt | 8 +- 87 files changed, 731 insertions(+), 888 deletions(-) create mode 100644 core/network/src/main/kotlin/app/pachli/core/network/json/GuardedAdapter.kt delete mode 100644 core/network/src/main/kotlin/app/pachli/core/network/json/GuardedBooleanAdapter.kt delete mode 100644 core/network/src/main/kotlin/app/pachli/core/network/json/Iso8601Utils.kt delete mode 100644 core/network/src/main/kotlin/app/pachli/core/network/json/Rfc3339DateJsonAdapter.kt rename core/network/src/test/kotlin/app/pachli/core/network/json/{GuardedBooleanAdapterTest.kt => GuardedAdapterTest.kt} (85%) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2fe017edd..bbbe61b89 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -143,7 +143,9 @@ dependencies { implementation(libs.android.material) - implementation(libs.gson) + implementation(libs.moshi) + implementation(libs.moshi.adapters) + ksp(libs.moshi.codegen) implementation(libs.bundles.retrofit) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 7047a3b26..39b5f9edf 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -57,19 +57,6 @@ # error reports -keepnames class * -# https://github.com/google/gson/blob/master/examples/android-proguard-example/proguard.cfg - -# Prevent proguard from stripping interface information from TypeAdapter, TypeAdapterFactory, -# JsonSerializer, JsonDeserializer instances (so they can be used in @JsonAdapter) --keep class * extends com.google.gson.TypeAdapter --keep class * implements com.google.gson.TypeAdapterFactory --keep class * implements com.google.gson.JsonSerializer --keep class * implements com.google.gson.JsonDeserializer - -# Retain generic signatures of TypeToken and its subclasses with R8 version 3.0 and higher. --keep,allowobfuscation,allowshrinking class com.google.gson.reflect.TypeToken --keep,allowobfuscation,allowshrinking class * extends com.google.gson.reflect.TypeToken - # Retain generic signatures of classes used in MastodonApi so Retrofit works -keep,allowobfuscation,allowshrinking class io.reactivex.rxjava3.core.Single -keep,allowobfuscation,allowshrinking class retrofit2.Response diff --git a/app/src/fdroid/kotlin/app/pachli/di/UpdateCheckModule.kt b/app/src/fdroid/kotlin/app/pachli/di/UpdateCheckModule.kt index 6a48f09fa..1988bf583 100644 --- a/app/src/fdroid/kotlin/app/pachli/di/UpdateCheckModule.kt +++ b/app/src/fdroid/kotlin/app/pachli/di/UpdateCheckModule.kt @@ -19,7 +19,6 @@ package app.pachli.di import app.pachli.updatecheck.FdroidService import at.connyduck.calladapter.networkresult.NetworkResultCallAdapterFactory -import com.google.gson.Gson import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -27,7 +26,7 @@ import dagger.hilt.components.SingletonComponent import javax.inject.Singleton import okhttp3.OkHttpClient import retrofit2.Retrofit -import retrofit2.converter.gson.GsonConverterFactory +import retrofit2.converter.moshi.MoshiConverterFactory import retrofit2.create @InstallIn(SingletonComponent::class) @@ -37,11 +36,10 @@ object UpdateCheckModule { @Singleton fun providesFdroidService( httpClient: OkHttpClient, - gson: Gson, ): FdroidService = Retrofit.Builder() .baseUrl("https://f-droid.org") .client(httpClient) - .addConverterFactory(GsonConverterFactory.create(gson)) + .addConverterFactory(MoshiConverterFactory.create()) .addCallAdapterFactory(NetworkResultCallAdapterFactory.create()) .build() .create() diff --git a/app/src/fdroid/kotlin/app/pachli/updatecheck/FdroidService.kt b/app/src/fdroid/kotlin/app/pachli/updatecheck/FdroidService.kt index da8b78876..5a4110c08 100644 --- a/app/src/fdroid/kotlin/app/pachli/updatecheck/FdroidService.kt +++ b/app/src/fdroid/kotlin/app/pachli/updatecheck/FdroidService.kt @@ -19,16 +19,19 @@ package app.pachli.updatecheck import androidx.annotation.Keep import at.connyduck.calladapter.networkresult.NetworkResult +import com.squareup.moshi.JsonClass import retrofit2.http.GET import retrofit2.http.Path @Keep +@JsonClass(generateAdapter = true) data class FdroidPackageVersion( val versionName: String, val versionCode: Int, ) @Keep +@JsonClass(generateAdapter = true) data class FdroidPackage( val packageName: String, val suggestedVersionCode: Int, diff --git a/app/src/github/kotlin/app/pachli/di/UpdateCheckModule.kt b/app/src/github/kotlin/app/pachli/di/UpdateCheckModule.kt index f95256f4c..327d841b2 100644 --- a/app/src/github/kotlin/app/pachli/di/UpdateCheckModule.kt +++ b/app/src/github/kotlin/app/pachli/di/UpdateCheckModule.kt @@ -19,7 +19,6 @@ package app.pachli.di import app.pachli.updatecheck.GitHubService import at.connyduck.calladapter.networkresult.NetworkResultCallAdapterFactory -import com.google.gson.Gson import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -27,7 +26,6 @@ import dagger.hilt.components.SingletonComponent import javax.inject.Singleton import okhttp3.OkHttpClient import retrofit2.Retrofit -import retrofit2.converter.gson.GsonConverterFactory import retrofit2.create @InstallIn(SingletonComponent::class) @@ -37,11 +35,10 @@ object UpdateCheckModule { @Singleton fun providesGitHubService( httpClient: OkHttpClient, - gson: Gson, ): GitHubService = Retrofit.Builder() .baseUrl("https://api.github.com") .client(httpClient) - .addConverterFactory(GsonConverterFactory.create(gson)) + .addConverterFactory(MoshiConverterFactory.create()) .addCallAdapterFactory(NetworkResultCallAdapterFactory.create()) .build() .create() diff --git a/app/src/github/kotlin/app/pachli/updatecheck/GithubService.kt b/app/src/github/kotlin/app/pachli/updatecheck/GithubService.kt index 9c94a1c3a..6643e5064 100644 --- a/app/src/github/kotlin/app/pachli/updatecheck/GithubService.kt +++ b/app/src/github/kotlin/app/pachli/updatecheck/GithubService.kt @@ -19,23 +19,26 @@ package app.pachli.updatecheck import androidx.annotation.Keep import at.connyduck.calladapter.networkresult.NetworkResult -import com.google.gson.annotations.SerializedName +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass import retrofit2.http.GET import retrofit2.http.Path @Keep +@JsonClass(generateAdapter = true) data class GitHubReleaseAsset( /** File name for the asset, e.g., "113.apk" */ val name: String, /** MIME content type for the asset, e.g., "application/vnd.android.package-archive" */ - @SerializedName("content_type") val contentType: String, + @Json(name = "content_type") val contentType: String, ) @Keep +@JsonClass(generateAdapter = true) data class GitHubRelease( /** URL for the release's web page */ - @SerializedName("html_url") val htmlUrl: String, + @Json(name = "html_url") val htmlUrl: String, val assets: List, ) diff --git a/app/src/main/java/app/pachli/ViewMediaActivity.kt b/app/src/main/java/app/pachli/ViewMediaActivity.kt index 504500b1e..7635d47cf 100644 --- a/app/src/main/java/app/pachli/ViewMediaActivity.kt +++ b/app/src/main/java/app/pachli/ViewMediaActivity.kt @@ -37,10 +37,7 @@ import android.view.MenuItem import android.view.View import android.webkit.MimeTypeMap import android.widget.Toast -import androidx.core.app.ActivityCompat.invalidateOptionsMenu -import androidx.core.app.ActivityCompat.requestPermissions import androidx.core.app.ShareCompat -import androidx.core.content.ContextCompat.getSystemService import androidx.core.content.FileProvider import androidx.fragment.app.FragmentActivity import androidx.lifecycle.Lifecycle @@ -89,7 +86,7 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener var isToolbarVisible = true private set - private var attachments: ArrayList? = null + private var attachments: List? = null private val toolbarVisibilityListeners = mutableListOf() private var imageUrl: String? = null diff --git a/app/src/main/java/app/pachli/adapter/ReportNotificationViewHolder.kt b/app/src/main/java/app/pachli/adapter/ReportNotificationViewHolder.kt index 7fd2f90e9..aef9f1543 100644 --- a/app/src/main/java/app/pachli/adapter/ReportNotificationViewHolder.kt +++ b/app/src/main/java/app/pachli/adapter/ReportNotificationViewHolder.kt @@ -91,7 +91,7 @@ class ReportNotificationViewHolder( binding.notificationSummary.text = itemView.context.getString( R.string.notification_summary_report_format, getRelativeTimeSpanString(itemView.context, report.createdAt.time, Date().time), - report.status_ids?.size ?: 0, + report.statusIds?.size ?: 0, ) binding.notificationCategory.text = getTranslatedCategory(itemView.context, report.category) diff --git a/app/src/main/java/app/pachli/adapter/StatusBaseViewHolder.kt b/app/src/main/java/app/pachli/adapter/StatusBaseViewHolder.kt index 0fb660ad1..c34b41529 100644 --- a/app/src/main/java/app/pachli/adapter/StatusBaseViewHolder.kt +++ b/app/src/main/java/app/pachli/adapter/StatusBaseViewHolder.kt @@ -901,6 +901,8 @@ abstract class StatusBaseViewHolder protected constructor(i protected fun hasPreviewableAttachment(attachments: List): Boolean { for (attachment in attachments) { if (attachment.type == Attachment.Type.UNKNOWN) return false + + if (attachment.meta?.original?.width == null && attachment.meta?.small?.width == null) return false } return true } diff --git a/app/src/main/java/app/pachli/appstore/CacheUpdater.kt b/app/src/main/java/app/pachli/appstore/CacheUpdater.kt index d463b2195..7e11393ff 100644 --- a/app/src/main/java/app/pachli/appstore/CacheUpdater.kt +++ b/app/src/main/java/app/pachli/appstore/CacheUpdater.kt @@ -2,7 +2,9 @@ package app.pachli.appstore import app.pachli.core.accounts.AccountManager import app.pachli.core.database.dao.TimelineDao -import com.google.gson.Gson +import app.pachli.core.network.model.Poll +import com.squareup.moshi.Moshi +import com.squareup.moshi.adapter import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -10,11 +12,12 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch +@OptIn(ExperimentalStdlibApi::class) class CacheUpdater @Inject constructor( eventHub: EventHub, accountManager: AccountManager, timelineDao: TimelineDao, - gson: Gson, + moshi: Moshi, ) { private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) @@ -34,7 +37,7 @@ class CacheUpdater @Inject constructor( is StatusDeletedEvent -> timelineDao.delete(accountId, event.statusId) is PollVoteEvent -> { - val pollString = gson.toJson(event.poll) + val pollString = moshi.adapter().toJson(event.poll) timelineDao.setVoted(accountId, event.statusId, pollString) } is PinEvent -> diff --git a/app/src/main/java/app/pachli/appstore/Events.kt b/app/src/main/java/app/pachli/appstore/Events.kt index 2fe8b25c5..7458368a2 100644 --- a/app/src/main/java/app/pachli/appstore/Events.kt +++ b/app/src/main/java/app/pachli/appstore/Events.kt @@ -18,7 +18,7 @@ data class StatusDeletedEvent(val statusId: String) : Event /** A status the user wrote was successfully sent */ // TODO: Rename, calling it "Composed" does not imply anything about the sent state data class StatusComposedEvent(val status: Status) : Event -data class StatusScheduledEvent(val status: Status) : Event +data object StatusScheduledEvent : Event data class StatusEditedEvent(val originalId: String, val status: Status) : Event data class ProfileEditedEvent(val newProfileData: Account) : Event data class FilterChangedEvent(val filterKind: Filter.Kind) : Event diff --git a/app/src/main/java/app/pachli/components/notifications/NotificationsPagingSource.kt b/app/src/main/java/app/pachli/components/notifications/NotificationsPagingSource.kt index a4bd16ae1..3380b195d 100644 --- a/app/src/main/java/app/pachli/components/notifications/NotificationsPagingSource.kt +++ b/app/src/main/java/app/pachli/components/notifications/NotificationsPagingSource.kt @@ -24,7 +24,8 @@ import app.pachli.core.network.model.Error import app.pachli.core.network.model.Links import app.pachli.core.network.model.Notification import app.pachli.core.network.retrofit.MastodonApi -import com.google.gson.Gson +import com.squareup.moshi.Moshi +import com.squareup.moshi.adapter import javax.inject.Inject import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope @@ -37,9 +38,10 @@ private val INVALID = LoadResult.Invalid() /** [PagingSource] for Mastodon Notifications, identified by the Notification ID */ class NotificationsPagingSource @Inject constructor( private val mastodonApi: MastodonApi, - private val gson: Gson, + private val moshi: Moshi, private val notificationFilter: Set, ) : PagingSource() { + @OptIn(ExperimentalStdlibApi::class) override suspend fun load(params: LoadParams): LoadResult { Timber.d("load() with ${params.javaClass.simpleName} for key: ${params.key}") @@ -67,12 +69,12 @@ class NotificationsPagingSource @Inject constructor( if (errorBody.isBlank()) return@let "no reason given" val error = try { - gson.fromJson(errorBody, Error::class.java) + moshi.adapter().fromJson(errorBody)!! } catch (e: Exception) { return@let "$errorBody ($e)" } - when (val desc = error.error_description) { + when (val desc = error.errorDescription) { null -> error.error else -> "${error.error}: $desc" } diff --git a/app/src/main/java/app/pachli/components/notifications/NotificationsRepository.kt b/app/src/main/java/app/pachli/components/notifications/NotificationsRepository.kt index 337fbadd1..2276c497b 100644 --- a/app/src/main/java/app/pachli/components/notifications/NotificationsRepository.kt +++ b/app/src/main/java/app/pachli/components/notifications/NotificationsRepository.kt @@ -25,7 +25,7 @@ import androidx.paging.PagingSource import app.pachli.core.common.di.ApplicationScope import app.pachli.core.network.model.Notification import app.pachli.core.network.retrofit.MastodonApi -import com.google.gson.Gson +import com.squareup.moshi.Moshi import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.async @@ -36,7 +36,7 @@ import timber.log.Timber class NotificationsRepository @Inject constructor( private val mastodonApi: MastodonApi, - private val gson: Gson, + private val moshi: Moshi, @ApplicationScope private val externalScope: CoroutineScope, ) { private var factory: InvalidatingPagingSourceFactory? = null @@ -53,7 +53,7 @@ class NotificationsRepository @Inject constructor( Timber.d("getNotificationsStream(), filtering: $filter") factory = InvalidatingPagingSourceFactory { - NotificationsPagingSource(mastodonApi, gson, filter) + NotificationsPagingSource(mastodonApi, moshi, filter) } return Pager( diff --git a/app/src/main/java/app/pachli/components/scheduled/ScheduledStatusViewModel.kt b/app/src/main/java/app/pachli/components/scheduled/ScheduledStatusViewModel.kt index da958e86a..e6ec46e41 100644 --- a/app/src/main/java/app/pachli/components/scheduled/ScheduledStatusViewModel.kt +++ b/app/src/main/java/app/pachli/components/scheduled/ScheduledStatusViewModel.kt @@ -51,7 +51,7 @@ class ScheduledStatusViewModel @Inject constructor( pagingSourceFactory.remove(status) }, { throwable -> - Timber.w("Error deleting scheduled status", throwable) + Timber.w("Error deleting scheduled status: $throwable") }, ) } diff --git a/app/src/main/java/app/pachli/components/search/adapter/SearchPagingSource.kt b/app/src/main/java/app/pachli/components/search/adapter/SearchPagingSource.kt index abe3312c7..90406b9c1 100644 --- a/app/src/main/java/app/pachli/components/search/adapter/SearchPagingSource.kt +++ b/app/src/main/java/app/pachli/components/search/adapter/SearchPagingSource.kt @@ -22,6 +22,7 @@ import app.pachli.components.search.SearchType import app.pachli.core.network.model.SearchResult import app.pachli.core.network.retrofit.MastodonApi import at.connyduck.calladapter.networkresult.getOrElse +import timber.log.Timber class SearchPagingSource( private val mastodonApi: MastodonApi, @@ -61,7 +62,10 @@ class SearchPagingSource( limit = params.loadSize, offset = currentKey, following = false, - ).getOrElse { return LoadResult.Error(it) } + ).getOrElse { + Timber.w(it) + return LoadResult.Error(it) + } val res = parser(data) diff --git a/app/src/main/java/app/pachli/components/search/fragments/SearchFragment.kt b/app/src/main/java/app/pachli/components/search/fragments/SearchFragment.kt index d027afbaf..a66163b35 100644 --- a/app/src/main/java/app/pachli/components/search/fragments/SearchFragment.kt +++ b/app/src/main/java/app/pachli/components/search/fragments/SearchFragment.kt @@ -79,7 +79,6 @@ abstract class SearchFragment : } adapter.addLoadStateListener { loadState -> - if (loadState.refresh is LoadState.Error) { showError() } diff --git a/app/src/main/java/app/pachli/components/timeline/CachedTimelineRepository.kt b/app/src/main/java/app/pachli/components/timeline/CachedTimelineRepository.kt index bdb218177..5b1bd45be 100644 --- a/app/src/main/java/app/pachli/components/timeline/CachedTimelineRepository.kt +++ b/app/src/main/java/app/pachli/components/timeline/CachedTimelineRepository.kt @@ -40,7 +40,7 @@ import app.pachli.util.EmptyPagingSource import app.pachli.viewdata.StatusViewData import at.connyduck.calladapter.networkresult.NetworkResult import at.connyduck.calladapter.networkresult.fold -import com.google.gson.Gson +import com.squareup.moshi.Moshi import javax.inject.Inject import javax.inject.Singleton import kotlinx.coroutines.CoroutineScope @@ -66,7 +66,7 @@ class CachedTimelineRepository @Inject constructor( val timelineDao: TimelineDao, private val remoteKeyDao: RemoteKeyDao, private val translatedStatusDao: TranslatedStatusDao, - private val gson: Gson, + private val moshi: Moshi, @ApplicationScope private val externalScope: CoroutineScope, ) { private var factory: InvalidatingPagingSourceFactory? = null @@ -118,7 +118,7 @@ class CachedTimelineRepository @Inject constructor( transactionProvider, timelineDao, remoteKeyDao, - gson, + moshi, ), pagingSourceFactory = factory!!, ).flow diff --git a/app/src/main/java/app/pachli/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt b/app/src/main/java/app/pachli/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt index 5c2548f65..6e7c2317c 100644 --- a/app/src/main/java/app/pachli/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt +++ b/app/src/main/java/app/pachli/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt @@ -36,7 +36,7 @@ import app.pachli.core.database.model.TimelineStatusWithAccount import app.pachli.core.network.model.Links import app.pachli.core.network.model.Status import app.pachli.core.network.retrofit.MastodonApi -import com.google.gson.Gson +import com.squareup.moshi.Moshi import java.io.IOException import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope @@ -54,7 +54,7 @@ class CachedTimelineRemoteMediator( private val transactionProvider: TransactionProvider, private val timelineDao: TimelineDao, private val remoteKeyDao: RemoteKeyDao, - private val gson: Gson, + private val moshi: Moshi, ) : RemoteMediator() { private val activeAccount = accountManager.activeAccount!! @@ -254,9 +254,9 @@ class CachedTimelineRemoteMediator( @Transaction private suspend fun insertStatuses(statuses: List) { for (status in statuses) { - timelineDao.insertAccount(TimelineAccountEntity.from(status.account, activeAccount.id, gson)) + timelineDao.insertAccount(TimelineAccountEntity.from(status.account, activeAccount.id, moshi)) status.reblog?.account?.let { - val account = TimelineAccountEntity.from(it, activeAccount.id, gson) + val account = TimelineAccountEntity.from(it, activeAccount.id, moshi) timelineDao.insertAccount(account) } @@ -264,7 +264,7 @@ class CachedTimelineRemoteMediator( TimelineStatusEntity.from( status, timelineUserId = activeAccount.id, - gson = gson, + moshi = moshi, ), ) } diff --git a/app/src/main/java/app/pachli/components/timeline/viewmodel/CachedTimelineViewModel.kt b/app/src/main/java/app/pachli/components/timeline/viewmodel/CachedTimelineViewModel.kt index ddc9166c4..76084782a 100644 --- a/app/src/main/java/app/pachli/components/timeline/viewmodel/CachedTimelineViewModel.kt +++ b/app/src/main/java/app/pachli/components/timeline/viewmodel/CachedTimelineViewModel.kt @@ -37,7 +37,7 @@ import app.pachli.core.preferences.SharedPreferencesRepository import app.pachli.usecase.TimelineCases import app.pachli.util.StatusDisplayOptionsRepository import app.pachli.viewdata.StatusViewData -import com.google.gson.Gson +import com.squareup.moshi.Moshi import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -61,7 +61,7 @@ class CachedTimelineViewModel @Inject constructor( accountManager: AccountManager, statusDisplayOptionsRepository: StatusDisplayOptionsRepository, sharedPreferencesRepository: SharedPreferencesRepository, - private val gson: Gson, + private val moshi: Moshi, ) : TimelineViewModel( savedStateHandle, timelineCases, @@ -93,7 +93,7 @@ class CachedTimelineViewModel @Inject constructor( .map { StatusViewData.from( it, - gson, + moshi, isExpanded = activeAccount.alwaysOpenSpoiler, isShowingContent = activeAccount.alwaysShowSensitiveMedia, ) diff --git a/app/src/main/java/app/pachli/components/viewthread/ViewThreadViewModel.kt b/app/src/main/java/app/pachli/components/viewthread/ViewThreadViewModel.kt index 761ddd715..a4031456d 100644 --- a/app/src/main/java/app/pachli/components/viewthread/ViewThreadViewModel.kt +++ b/app/src/main/java/app/pachli/components/viewthread/ViewThreadViewModel.kt @@ -48,7 +48,7 @@ import app.pachli.viewdata.StatusViewData import at.connyduck.calladapter.networkresult.fold import at.connyduck.calladapter.networkresult.getOrElse import at.connyduck.calladapter.networkresult.getOrThrow -import com.google.gson.Gson +import com.squareup.moshi.Moshi import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.Job @@ -69,7 +69,7 @@ class ViewThreadViewModel @Inject constructor( eventHub: EventHub, accountManager: AccountManager, private val timelineDao: TimelineDao, - private val gson: Gson, + private val moshi: Moshi, private val repository: CachedTimelineRepository, statusDisplayOptionsRepository: StatusDisplayOptionsRepository, private val filtersRepository: FiltersRepository, @@ -133,7 +133,7 @@ class ViewThreadViewModel @Inject constructor( var detailedStatus = if (timelineStatusWithAccount != null) { Timber.d("Loaded status from local timeline") - val status = timelineStatusWithAccount.toStatus(gson) + val status = timelineStatusWithAccount.toStatus(moshi) // Return the correct status, depending on which one matched. If you do not do // this the status IDs will be different between the status that's displayed with @@ -152,7 +152,7 @@ class ViewThreadViewModel @Inject constructor( } else { StatusViewData.from( timelineStatusWithAccount, - gson, + moshi, isExpanded = alwaysOpenSpoiler, isShowingContent = (alwaysShowSensitiveMedia || !status.actionableStatus.sensitive), isDetailed = true, diff --git a/app/src/main/java/app/pachli/service/SendStatusService.kt b/app/src/main/java/app/pachli/service/SendStatusService.kt index 4d7615920..4801d2efc 100644 --- a/app/src/main/java/app/pachli/service/SendStatusService.kt +++ b/app/src/main/java/app/pachli/service/SendStatusService.kt @@ -39,6 +39,7 @@ import app.pachli.core.network.retrofit.MastodonApi import app.pachli.util.unsafeLazy import at.connyduck.calladapter.networkresult.fold import dagger.hilt.android.AndroidEntryPoint +import java.io.IOException import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.TimeUnit import javax.inject.Inject @@ -222,12 +223,21 @@ class SendStatusService : Service() { ) val sendResult = if (isNew) { - mastodonApi.createStatus( - "Bearer " + account.accessToken, - account.domain, - statusToSend.idempotencyKey, - newStatus, - ) + if (newStatus.scheduledAt == null) { + mastodonApi.createStatus( + "Bearer " + account.accessToken, + account.domain, + statusToSend.idempotencyKey, + newStatus, + ) + } else { + mastodonApi.createScheduledStatus( + "Bearer " + account.accessToken, + account.domain, + statusToSend.idempotencyKey, + newStatus, + ) + } } else { mastodonApi.editStatus( statusToSend.statusId!!, @@ -250,16 +260,16 @@ class SendStatusService : Service() { val scheduled = !statusToSend.scheduledAt.isNullOrEmpty() if (scheduled) { - eventHub.dispatch(StatusScheduledEvent(sentStatus)) + eventHub.dispatch(StatusScheduledEvent) } else if (!isNew) { - eventHub.dispatch(StatusEditedEvent(statusToSend.statusId!!, sentStatus)) + eventHub.dispatch(StatusEditedEvent(statusToSend.statusId!!, sentStatus as Status)) } else { - eventHub.dispatch(StatusComposedEvent(sentStatus)) + eventHub.dispatch(StatusComposedEvent(sentStatus as Status)) } notificationManager.cancel(statusId) }, { throwable -> - Timber.w("failed sending status", throwable) + Timber.w("failed sending status: $throwable") failOrRetry(throwable, statusId) }) stopSelfWhenDone() @@ -267,12 +277,13 @@ class SendStatusService : Service() { } private suspend fun failOrRetry(throwable: Throwable, statusId: Int) { - if (throwable is HttpException) { + when (throwable) { // the server refused to accept, save status & show error message - failSending(statusId) - } else { + is HttpException -> failSending(statusId) // a network problem occurred, let's retry sending the status - retrySending(statusId) + is IOException -> retrySending(statusId) + // Some other problem, fail + else -> failSending(statusId) } } diff --git a/app/src/main/java/app/pachli/util/AttachmentHelper.kt b/app/src/main/java/app/pachli/util/AttachmentHelper.kt index feed3375e..7bea532a8 100644 --- a/app/src/main/java/app/pachli/util/AttachmentHelper.kt +++ b/app/src/main/java/app/pachli/util/AttachmentHelper.kt @@ -27,8 +27,11 @@ private fun formatDuration(durationInSeconds: Double): String { fun List.aspectRatios(): List { return map { attachment -> // clamp ratio between 2:1 & 1:2, defaulting to 16:9 - val size = (attachment.meta?.small ?: attachment.meta?.original) ?: return@map 1.7778 - val aspect = if (size.aspect > 0) size.aspect else size.width.toDouble() / size.height - aspect.coerceIn(0.5, 2.0) + val (width, height, aspect) = (attachment.meta?.small ?: attachment.meta?.original) ?: return@map 1.7778 + width ?: return@map 1.778 + height ?: return@map 1.778 + aspect ?: return@map 1.778 + val adjustedAspect = if (aspect > 0) aspect else width.toDouble() / height + adjustedAspect.coerceIn(0.5, 2.0) } } diff --git a/app/src/main/java/app/pachli/viewdata/StatusViewData.kt b/app/src/main/java/app/pachli/viewdata/StatusViewData.kt index e2d974c35..06318a8bd 100644 --- a/app/src/main/java/app/pachli/viewdata/StatusViewData.kt +++ b/app/src/main/java/app/pachli/viewdata/StatusViewData.kt @@ -28,7 +28,7 @@ import app.pachli.core.network.model.Status import app.pachli.core.network.parseAsMastodonHtml import app.pachli.core.network.replaceCrashingCharacters import app.pachli.util.shouldTrimStatus -import com.google.gson.Gson +import com.squareup.moshi.Moshi /** * Interface for the data shown when viewing a status, or something that wraps @@ -273,13 +273,13 @@ data class StatusViewData( fun from( timelineStatusWithAccount: TimelineStatusWithAccount, - gson: Gson, + moshi: Moshi, isExpanded: Boolean, isShowingContent: Boolean, isDetailed: Boolean = false, translationState: TranslationState = TranslationState.SHOW_ORIGINAL, ): StatusViewData { - val status = timelineStatusWithAccount.toStatus(gson) + val status = timelineStatusWithAccount.toStatus(moshi) return StatusViewData( status = status, translation = timelineStatusWithAccount.translatedStatus, diff --git a/app/src/test/java/app/pachli/StatusComparisonTest.kt b/app/src/test/java/app/pachli/StatusComparisonTest.kt index 3fb6b7680..1796b7f3d 100644 --- a/app/src/test/java/app/pachli/StatusComparisonTest.kt +++ b/app/src/test/java/app/pachli/StatusComparisonTest.kt @@ -2,9 +2,13 @@ package app.pachli import androidx.test.ext.junit.runners.AndroidJUnit4 import app.pachli.core.database.model.TranslationState +import app.pachli.core.network.json.GuardedAdapter.Companion.GuardedAdapterFactory import app.pachli.core.network.model.Status import app.pachli.viewdata.StatusViewData -import com.google.gson.Gson +import com.squareup.moshi.Moshi +import com.squareup.moshi.adapter +import com.squareup.moshi.adapters.Rfc3339DateJsonAdapter +import java.util.Date import org.junit.Assert.assertEquals import org.junit.Assert.assertNotEquals import org.junit.Test @@ -12,6 +16,9 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class StatusComparisonTest { + private val moshi = Moshi.Builder() + .add(Date::class.java, Rfc3339DateJsonAdapter()) + .add(GuardedAdapterFactory()).build() @Test fun `two equal statuses - should be equal`() { @@ -36,8 +43,6 @@ class StatusComparisonTest { assertNotEquals(createStatus(note = "Test"), createStatus(note = "Test 123456")) } - private val gson = Gson() - @Test fun `two equal status view data - should be equal`() { val viewdata1 = StatusViewData( @@ -95,6 +100,7 @@ class StatusComparisonTest { assertNotEquals(viewdata1, viewdata2) } + @OptIn(ExperimentalStdlibApi::class) private fun createStatus( id: String = "123456", content: String = """ @@ -206,6 +212,6 @@ class StatusComparisonTest { "poll": null } """.trimIndent() - return gson.fromJson(statusJson, Status::class.java) + return moshi.adapter().fromJson(statusJson)!! } } diff --git a/app/src/test/java/app/pachli/components/notifications/NotificationsPagingSourceTest.kt b/app/src/test/java/app/pachli/components/notifications/NotificationsPagingSourceTest.kt index 04395ef94..53a1a65b8 100644 --- a/app/src/test/java/app/pachli/components/notifications/NotificationsPagingSourceTest.kt +++ b/app/src/test/java/app/pachli/components/notifications/NotificationsPagingSourceTest.kt @@ -21,7 +21,7 @@ import androidx.paging.PagingSource import androidx.test.ext.junit.runners.AndroidJUnit4 import app.pachli.core.network.model.Notification import app.pachli.core.network.retrofit.MastodonApi -import com.google.gson.Gson +import com.squareup.moshi.Moshi import kotlinx.coroutines.test.runTest import okhttp3.ResponseBody.Companion.toResponseBody import org.junit.Assert.assertEquals @@ -39,15 +39,15 @@ class NotificationsPagingSourceTest { @Test fun `load() returns error message on HTTP error`() = runTest { // Given - val jsonError = "{error: 'This is an error'}".toResponseBody() + val jsonError = """{"error": "This is an error"}""".toResponseBody() val mockApi: MastodonApi = mock { onBlocking { notifications(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } doReturn Response.error(429, jsonError) onBlocking { notification(any()) } doReturn Response.error(429, jsonError) } val filter = emptySet() - val gson = Gson() - val pagingSource = NotificationsPagingSource(mockApi, gson, filter) + val moshi = Moshi.Builder().build() + val pagingSource = NotificationsPagingSource(mockApi, moshi, filter) val loadingParams = PagingSource.LoadParams.Refresh("0", 5, false) // When @@ -65,15 +65,15 @@ class NotificationsPagingSourceTest { @Test fun `load() returns extended error message on HTTP error`() = runTest { // Given - val jsonError = "{error: 'This is an error', error_description: 'Description of the error'}".toResponseBody() + val jsonError = """{"error": "This is an error", "error_description": "Description of the error"}""".toResponseBody() val mockApi: MastodonApi = mock { onBlocking { notifications(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } doReturn Response.error(429, jsonError) onBlocking { notification(any()) } doReturn Response.error(429, jsonError) } val filter = emptySet() - val gson = Gson() - val pagingSource = NotificationsPagingSource(mockApi, gson, filter) + val moshi = Moshi.Builder().build() + val pagingSource = NotificationsPagingSource(mockApi, moshi, filter) val loadingParams = PagingSource.LoadParams.Refresh("0", 5, false) // When @@ -91,15 +91,15 @@ class NotificationsPagingSourceTest { @Test fun `load() returns default error message on empty HTTP error`() = runTest { // Given - val jsonError = "{}".toResponseBody() + val jsonError = "".toResponseBody() val mockApi: MastodonApi = mock { onBlocking { notifications(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } doReturn Response.error(429, jsonError) onBlocking { notification(any()) } doReturn Response.error(429, jsonError) } val filter = emptySet() - val gson = Gson() - val pagingSource = NotificationsPagingSource(mockApi, gson, filter) + val moshi = Moshi.Builder().build() + val pagingSource = NotificationsPagingSource(mockApi, moshi, filter) val loadingParams = PagingSource.LoadParams.Refresh("0", 5, false) // When @@ -124,8 +124,8 @@ class NotificationsPagingSourceTest { } val filter = emptySet() - val gson = Gson() - val pagingSource = NotificationsPagingSource(mockApi, gson, filter) + val moshi = Moshi.Builder().build() + val pagingSource = NotificationsPagingSource(mockApi, moshi, filter) val loadingParams = PagingSource.LoadParams.Refresh("0", 5, false) // When @@ -134,7 +134,7 @@ class NotificationsPagingSourceTest { // Then assertTrue(loadResult is PagingSource.LoadResult.Error) assertEquals( - "HTTP 429: {'malformedjson} (com.google.gson.JsonSyntaxException: com.google.gson.stream.MalformedJsonException: Unterminated string at line 1 column 17 path \$.)", + "HTTP 429: {'malformedjson} (com.squareup.moshi.JsonEncodingException: Use JsonReader.setLenient(true) to accept malformed JSON at path \$.)", (loadResult as PagingSource.LoadResult.Error).throwable.message, ) } 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 186400903..117ec0c53 100644 --- a/app/src/test/java/app/pachli/components/timeline/CachedTimelineRemoteMediatorTest.kt +++ b/app/src/test/java/app/pachli/components/timeline/CachedTimelineRemoteMediatorTest.kt @@ -20,9 +20,12 @@ 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.GuardedAdapter.Companion.GuardedAdapterFactory import com.google.common.truth.Truth.assertThat -import com.google.gson.Gson +import com.squareup.moshi.Moshi +import com.squareup.moshi.adapters.Rfc3339DateJsonAdapter import java.io.IOException +import java.util.Date import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest @@ -60,12 +63,17 @@ class CachedTimelineRemoteMediatorTest { private lateinit var pagingSourceFactory: InvalidatingPagingSourceFactory + private val moshi: Moshi = Moshi.Builder() + .add(Date::class.java, Rfc3339DateJsonAdapter()) + .add(GuardedAdapterFactory()) + .build() + @Before @ExperimentalCoroutinesApi fun setup() { val context = InstrumentationRegistry.getInstrumentation().targetContext db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java) - .addTypeConverter(Converters(Gson())) + .addTypeConverter(Converters(moshi)) .build() transactionProvider = TransactionProvider(db) @@ -91,7 +99,7 @@ class CachedTimelineRemoteMediatorTest { transactionProvider = transactionProvider, timelineDao = db.timelineDao(), remoteKeyDao = db.remoteKeyDao(), - gson = Gson(), + moshi = moshi, ) val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state()) } @@ -114,7 +122,7 @@ class CachedTimelineRemoteMediatorTest { transactionProvider = transactionProvider, timelineDao = db.timelineDao(), remoteKeyDao = db.remoteKeyDao(), - gson = Gson(), + moshi = moshi, ) val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state()) } @@ -134,7 +142,7 @@ class CachedTimelineRemoteMediatorTest { transactionProvider = transactionProvider, timelineDao = db.timelineDao(), remoteKeyDao = db.remoteKeyDao(), - gson = Gson(), + moshi = moshi, ) val state = state( @@ -174,7 +182,7 @@ class CachedTimelineRemoteMediatorTest { transactionProvider = transactionProvider, timelineDao = db.timelineDao(), remoteKeyDao = db.remoteKeyDao(), - gson = Gson(), + moshi = moshi, ) val state = state( @@ -228,7 +236,7 @@ class CachedTimelineRemoteMediatorTest { transactionProvider = transactionProvider, timelineDao = db.timelineDao(), remoteKeyDao = db.remoteKeyDao(), - gson = Gson(), + moshi = moshi, ) val state = state( @@ -289,7 +297,7 @@ class CachedTimelineRemoteMediatorTest { transactionProvider = transactionProvider, timelineDao = db.timelineDao(), remoteKeyDao = db.remoteKeyDao(), - gson = Gson(), + moshi = moshi, ) val state = state( diff --git a/app/src/test/java/app/pachli/components/timeline/CachedTimelineViewModelTestBase.kt b/app/src/test/java/app/pachli/components/timeline/CachedTimelineViewModelTestBase.kt index a76c9b238..80f8a5129 100644 --- a/app/src/test/java/app/pachli/components/timeline/CachedTimelineViewModelTestBase.kt +++ b/app/src/test/java/app/pachli/components/timeline/CachedTimelineViewModelTestBase.kt @@ -35,7 +35,7 @@ import app.pachli.core.testing.rules.MainCoroutineRule import app.pachli.usecase.TimelineCases import app.pachli.util.StatusDisplayOptionsRepository import at.connyduck.calladapter.networkresult.NetworkResult -import com.google.gson.Gson +import com.squareup.moshi.Moshi import dagger.hilt.android.testing.CustomTestApplication import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest @@ -92,6 +92,9 @@ abstract class CachedTimelineViewModelTestBase { @Inject lateinit var statusDisplayOptionsRepository: StatusDisplayOptionsRepository + @Inject + lateinit var moshi: Moshi + protected lateinit var timelineCases: TimelineCases protected lateinit var viewModel: TimelineViewModel @@ -160,7 +163,7 @@ abstract class CachedTimelineViewModelTestBase { accountManager, statusDisplayOptionsRepository, sharedPreferencesRepository, - Gson(), + moshi, ) } } 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 ccc1833c6..de45d949a 100644 --- a/app/src/test/java/app/pachli/components/timeline/StatusMocker.kt +++ b/app/src/test/java/app/pachli/components/timeline/StatusMocker.kt @@ -5,10 +5,12 @@ 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.GuardedAdapter.Companion.GuardedAdapterFactory import app.pachli.core.network.model.Status import app.pachli.core.network.model.TimelineAccount import app.pachli.viewdata.StatusViewData -import com.google.gson.Gson +import com.squareup.moshi.Moshi +import com.squareup.moshi.adapters.Rfc3339DateJsonAdapter import java.util.Date private val fixedDate = Date(1638889052000) @@ -96,18 +98,21 @@ fun mockStatusEntityWithAccount( expanded: Boolean = false, ): TimelineStatusWithAccount { val mockedStatus = mockStatus(id) - val gson = Gson() + val moshi = Moshi.Builder() + .add(Date::class.java, Rfc3339DateJsonAdapter()) + .add(GuardedAdapterFactory()) + .build() return TimelineStatusWithAccount( status = TimelineStatusEntity.from( mockedStatus, timelineUserId = userId, - gson = gson, + moshi = moshi, ), account = TimelineAccountEntity.from( mockedStatus.account, accountId = userId, - gson = gson, + moshi = moshi, ), viewData = StatusViewDataEntity( serverId = id, diff --git a/app/src/test/java/app/pachli/components/viewthread/ViewThreadViewModelTest.kt b/app/src/test/java/app/pachli/components/viewthread/ViewThreadViewModelTest.kt index 2d89258a7..657b68c40 100644 --- a/app/src/test/java/app/pachli/components/viewthread/ViewThreadViewModelTest.kt +++ b/app/src/test/java/app/pachli/components/viewthread/ViewThreadViewModelTest.kt @@ -26,7 +26,7 @@ import app.pachli.core.preferences.SharedPreferencesRepository import app.pachli.usecase.TimelineCases import app.pachli.util.StatusDisplayOptionsRepository import at.connyduck.calladapter.networkresult.NetworkResult -import com.google.gson.Gson +import com.squareup.moshi.Moshi import dagger.hilt.android.testing.BindValue import dagger.hilt.android.testing.CustomTestApplication import dagger.hilt.android.testing.HiltAndroidRule @@ -111,7 +111,7 @@ class ViewThreadViewModelTest { lateinit var timelineDao: TimelineDao @Inject - lateinit var gson: Gson + lateinit var moshi: Moshi @BindValue @JvmField val filtersRepository: FiltersRepository = mock() @@ -188,7 +188,7 @@ class ViewThreadViewModelTest { eventHub, accountManager, timelineDao, - gson, + moshi, cachedTimelineRepository, statusDisplayOptionsRepository, filtersRepository, diff --git a/app/src/test/java/app/pachli/di/FakeNetworkModule.kt b/app/src/test/java/app/pachli/di/FakeNetworkModule.kt index 103035645..8a247944c 100644 --- a/app/src/test/java/app/pachli/di/FakeNetworkModule.kt +++ b/app/src/test/java/app/pachli/di/FakeNetworkModule.kt @@ -19,9 +19,9 @@ package app.pachli.di import app.pachli.components.compose.MediaUploader import app.pachli.core.network.di.NetworkModule -import app.pachli.core.network.json.Rfc3339DateJsonAdapter -import com.google.gson.Gson -import com.google.gson.GsonBuilder +import app.pachli.core.network.json.GuardedAdapter +import com.squareup.moshi.Moshi +import com.squareup.moshi.adapters.Rfc3339DateJsonAdapter import dagger.Module import dagger.Provides import dagger.hilt.components.SingletonComponent @@ -39,9 +39,10 @@ import org.mockito.kotlin.mock object FakeNetworkModule { @Provides @Singleton - fun providesGson(): Gson = GsonBuilder() - .registerTypeAdapter(Date::class.java, Rfc3339DateJsonAdapter()) - .create() + fun providesMoshi(): Moshi = Moshi.Builder() + .add(Date::class.java, Rfc3339DateJsonAdapter()) + .add(GuardedAdapter.Companion.GuardedAdapterFactory()) + .build() @Provides @Singleton diff --git a/core/database/build.gradle.kts b/core/database/build.gradle.kts index d0c33575c..5a71b7114 100644 --- a/core/database/build.gradle.kts +++ b/core/database/build.gradle.kts @@ -35,6 +35,8 @@ dependencies { implementation(projects.core.network) implementation(projects.core.preferences) - // Because of the use of @SerializedName in DraftEntity - implementation(libs.gson) + // Because of the use of @Json in DraftEntity + implementation(libs.moshi) + implementation(libs.moshi.adapters) + ksp(libs.moshi.codegen) } diff --git a/core/database/src/main/kotlin/app/pachli/core/database/Converters.kt b/core/database/src/main/kotlin/app/pachli/core/database/Converters.kt index 7cfdd0bd8..28ead74ef 100644 --- a/core/database/src/main/kotlin/app/pachli/core/database/Converters.kt +++ b/core/database/src/main/kotlin/app/pachli/core/database/Converters.kt @@ -30,28 +30,29 @@ import app.pachli.core.network.model.Poll import app.pachli.core.network.model.Status import app.pachli.core.network.model.TranslatedAttachment import app.pachli.core.network.model.TranslatedPoll -import com.google.gson.Gson -import com.google.gson.reflect.TypeToken +import com.squareup.moshi.Moshi +import com.squareup.moshi.adapter import java.net.URLDecoder import java.net.URLEncoder import java.util.Date import javax.inject.Inject import javax.inject.Singleton +@OptIn(ExperimentalStdlibApi::class) @ProvidedTypeConverter @Singleton class Converters @Inject constructor( - private val gson: Gson, + private val moshi: Moshi, ) { @TypeConverter - fun jsonToEmojiList(emojiListJson: String?): List? { - return gson.fromJson(emojiListJson, object : TypeToken>() {}.type) + fun jsonToEmojiList(json: String?): List? { + return json?.let { moshi.adapter>().fromJson(it) } } @TypeConverter fun emojiListToJson(emojiList: List?): String { - return gson.toJson(emojiList) + return moshi.adapter?>().toJson(emojiList) } @TypeConverter @@ -81,52 +82,52 @@ class Converters @Inject constructor( @TypeConverter fun accountToJson(account: ConversationAccountEntity?): String { - return gson.toJson(account) + return moshi.adapter().toJson(account) } @TypeConverter fun jsonToAccount(accountJson: String?): ConversationAccountEntity? { - return gson.fromJson(accountJson, ConversationAccountEntity::class.java) + return accountJson?.let { moshi.adapter().fromJson(it) } } @TypeConverter fun accountListToJson(accountList: List?): String { - return gson.toJson(accountList) + return moshi.adapter>().toJson(accountList) } @TypeConverter fun jsonToAccountList(accountListJson: String?): List? { - return gson.fromJson(accountListJson, object : TypeToken>() {}.type) + return accountListJson?.let { moshi.adapter?>().fromJson(it) } } @TypeConverter fun attachmentListToJson(attachmentList: List?): String { - return gson.toJson(attachmentList) + return moshi.adapter?>().toJson(attachmentList) } @TypeConverter fun jsonToAttachmentList(attachmentListJson: String?): List? { - return gson.fromJson(attachmentListJson, object : TypeToken>() {}.type) + return attachmentListJson?.let { moshi.adapter?>().fromJson(it) } } @TypeConverter fun mentionListToJson(mentionArray: List?): String? { - return gson.toJson(mentionArray) + return moshi.adapter?>().toJson(mentionArray) } @TypeConverter fun jsonToMentionArray(mentionListJson: String?): List? { - return gson.fromJson(mentionListJson, object : TypeToken>() {}.type) + return mentionListJson?.let { moshi.adapter?>().fromJson(it) } } @TypeConverter fun tagListToJson(tagArray: List?): String? { - return gson.toJson(tagArray) + return moshi.adapter?>().toJson(tagArray) } @TypeConverter fun jsonToTagArray(tagListJson: String?): List? { - return gson.fromJson(tagListJson, object : TypeToken>() {}.type) + return tagListJson?.let { moshi.adapter?>().fromJson(it) } } @TypeConverter @@ -141,61 +142,61 @@ class Converters @Inject constructor( @TypeConverter fun pollToJson(poll: Poll?): String? { - return gson.toJson(poll) + return moshi.adapter().toJson(poll) } @TypeConverter fun jsonToPoll(pollJson: String?): Poll? { - return gson.fromJson(pollJson, Poll::class.java) + return pollJson?.let { moshi.adapter().fromJson(it) } } @TypeConverter fun newPollToJson(newPoll: NewPoll?): String? { - return gson.toJson(newPoll) + return moshi.adapter().toJson(newPoll) } @TypeConverter fun jsonToNewPoll(newPollJson: String?): NewPoll? { - return gson.fromJson(newPollJson, NewPoll::class.java) + return newPollJson?.let { moshi.adapter().fromJson(it) } } @TypeConverter fun draftAttachmentListToJson(draftAttachments: List?): String? { - return gson.toJson(draftAttachments) + return moshi.adapter?>().toJson(draftAttachments) } @TypeConverter fun jsonToDraftAttachmentList(draftAttachmentListJson: String?): List? { - return gson.fromJson(draftAttachmentListJson, object : TypeToken>() {}.type) + return draftAttachmentListJson?.let { moshi.adapter?>().fromJson(it) } } @TypeConverter fun filterResultListToJson(filterResults: List?): String? { - return gson.toJson(filterResults) + return moshi.adapter?>().toJson(filterResults) } @TypeConverter fun jsonToFilterResultList(filterResultListJson: String?): List? { - return gson.fromJson(filterResultListJson, object : TypeToken>() {}.type) + return filterResultListJson?.let { moshi.adapter>().fromJson(it) } } @TypeConverter fun translatedPolltoJson(translatedPoll: TranslatedPoll?): String? { - return gson.toJson(translatedPoll) + return moshi.adapter().toJson(translatedPoll) } @TypeConverter fun jsonToTranslatedPoll(translatedPollJson: String?): TranslatedPoll? { - return gson.fromJson(translatedPollJson, TranslatedPoll::class.java) + return translatedPollJson?.let { moshi.adapter().fromJson(it) } } @TypeConverter fun translatedAttachmentToJson(translatedAttachment: List?): String { - return gson.toJson(translatedAttachment) + return moshi.adapter?>().toJson(translatedAttachment) } @TypeConverter fun jsonToTranslatedAttachment(translatedAttachmentJson: String): List? { - return gson.fromJson(translatedAttachmentJson, object : TypeToken>() {}.type) + return moshi.adapter?>().fromJson(translatedAttachmentJson) } } diff --git a/core/database/src/main/kotlin/app/pachli/core/database/model/ConversationEntity.kt b/core/database/src/main/kotlin/app/pachli/core/database/model/ConversationEntity.kt index 3251eff5d..e1813edda 100644 --- a/core/database/src/main/kotlin/app/pachli/core/database/model/ConversationEntity.kt +++ b/core/database/src/main/kotlin/app/pachli/core/database/model/ConversationEntity.kt @@ -28,6 +28,7 @@ import app.pachli.core.network.model.HashTag import app.pachli.core.network.model.Poll import app.pachli.core.network.model.Status import app.pachli.core.network.model.TimelineAccount +import com.squareup.moshi.JsonClass import java.util.Date @Entity(primaryKeys = ["id", "accountId"]) @@ -65,6 +66,7 @@ data class ConversationEntity( } } +@JsonClass(generateAdapter = true) data class ConversationAccountEntity( val id: String, val localUsername: String, diff --git a/core/database/src/main/kotlin/app/pachli/core/database/model/DraftEntity.kt b/core/database/src/main/kotlin/app/pachli/core/database/model/DraftEntity.kt index 24cab9adf..57a1c79e0 100644 --- a/core/database/src/main/kotlin/app/pachli/core/database/model/DraftEntity.kt +++ b/core/database/src/main/kotlin/app/pachli/core/database/model/DraftEntity.kt @@ -27,7 +27,8 @@ import app.pachli.core.database.Converters import app.pachli.core.network.model.Attachment import app.pachli.core.network.model.NewPoll import app.pachli.core.network.model.Status -import com.google.gson.annotations.SerializedName +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass import kotlinx.parcelize.Parcelize @Entity @@ -49,17 +50,13 @@ data class DraftEntity( val statusId: String?, ) -/** - * The alternate names are here because we accidentally published versions were DraftAttachment was minified - * Tusky 15: uriString = e, description = f, type = g - * Tusky 16 beta: uriString = i, description = j, type = k - */ @Parcelize +@JsonClass(generateAdapter = true) data class DraftAttachment( - @SerializedName(value = "uriString", alternate = ["e", "i"]) val uriString: String, - @SerializedName(value = "description", alternate = ["f", "j"]) val description: String?, - @SerializedName(value = "focus") val focus: Attachment.Focus?, - @SerializedName(value = "type", alternate = ["g", "k"]) val type: Type, + @Json(name = "uriString") val uriString: String, + @Json(name = "description") val description: String?, + @Json(name = "focus") val focus: Attachment.Focus?, + @Json(name = "type") val type: Type, ) : Parcelable { val uri: Uri get() = uriString.toUri() diff --git a/core/database/src/main/kotlin/app/pachli/core/database/model/TimelineStatusEntity.kt b/core/database/src/main/kotlin/app/pachli/core/database/model/TimelineStatusEntity.kt index a33087ef4..7b2e045c7 100644 --- a/core/database/src/main/kotlin/app/pachli/core/database/model/TimelineStatusEntity.kt +++ b/core/database/src/main/kotlin/app/pachli/core/database/model/TimelineStatusEntity.kt @@ -32,9 +32,8 @@ import app.pachli.core.network.model.HashTag import app.pachli.core.network.model.Poll import app.pachli.core.network.model.Status import app.pachli.core.network.model.TimelineAccount -import com.google.gson.Gson -import com.google.gson.reflect.TypeToken -import java.lang.reflect.Type +import com.squareup.moshi.Moshi +import com.squareup.moshi.adapter import java.util.Date /** @@ -100,7 +99,8 @@ data class TimelineStatusEntity( val filtered: List?, ) { companion object { - fun from(status: Status, timelineUserId: Long, gson: Gson) = TimelineStatusEntity( + @OptIn(ExperimentalStdlibApi::class) + fun from(status: Status, timelineUserId: Long, moshi: Moshi) = TimelineStatusEntity( serverId = status.id, url = status.actionableStatus.url, timelineUserId = timelineUserId, @@ -110,7 +110,7 @@ data class TimelineStatusEntity( content = status.actionableStatus.content, createdAt = status.actionableStatus.createdAt.time, editedAt = status.actionableStatus.editedAt?.time, - emojis = status.actionableStatus.emojis.let(gson::toJson), + emojis = moshi.adapter>().toJson(status.actionableStatus.emojis), reblogsCount = status.actionableStatus.reblogsCount, favouritesCount = status.actionableStatus.favouritesCount, reblogged = status.actionableStatus.reblogged, @@ -119,16 +119,16 @@ data class TimelineStatusEntity( sensitive = status.actionableStatus.sensitive, spoilerText = status.actionableStatus.spoilerText, visibility = status.actionableStatus.visibility, - attachments = status.actionableStatus.attachments.let(gson::toJson), - mentions = status.actionableStatus.mentions.let(gson::toJson), - tags = status.actionableStatus.tags.let(gson::toJson), - application = status.actionableStatus.application.let(gson::toJson), + attachments = moshi.adapter>().toJson(status.actionableStatus.attachments), + mentions = moshi.adapter>().toJson(status.actionableStatus.mentions), + tags = moshi.adapter>().toJson(status.actionableStatus.tags), + application = moshi.adapter().toJson(status.actionableStatus.application), reblogServerId = status.reblog?.id, reblogAccountId = status.reblog?.let { status.account.id }, - poll = status.actionableStatus.poll.let(gson::toJson), + poll = moshi.adapter().toJson(status.actionableStatus.poll), muted = status.actionableStatus.muted, pinned = status.actionableStatus.pinned == true, - card = status.actionableStatus.card?.let(gson::toJson), + card = moshi.adapter().toJson(status.actionableStatus.card), repliesCount = status.actionableStatus.repliesCount, language = status.actionableStatus.language, filtered = status.actionableStatus.filtered, @@ -150,7 +150,8 @@ data class TimelineAccountEntity( val emojis: String, val bot: Boolean, ) { - fun toTimelineAccount(gson: Gson): TimelineAccount { + @OptIn(ExperimentalStdlibApi::class) + fun toTimelineAccount(moshi: Moshi): TimelineAccount { return TimelineAccount( id = serverId, localUsername = localUsername, @@ -160,12 +161,13 @@ data class TimelineAccountEntity( url = url, avatar = avatar, bot = bot, - emojis = gson.fromJson(emojis, emojisListType), + emojis = moshi.adapter>().fromJson(emojis), ) } companion object { - fun from(timelineAccount: TimelineAccount, accountId: Long, gson: Gson) = TimelineAccountEntity( + @OptIn(ExperimentalStdlibApi::class) + fun from(timelineAccount: TimelineAccount, accountId: Long, moshi: Moshi) = TimelineAccountEntity( serverId = timelineAccount.id, timelineUserId = accountId, localUsername = timelineAccount.localUsername, @@ -173,7 +175,7 @@ data class TimelineAccountEntity( displayName = timelineAccount.name, url = timelineAccount.url, avatar = timelineAccount.avatar, - emojis = gson.toJson(timelineAccount.emojis), + emojis = moshi.adapter>().toJson(timelineAccount.emojis), bot = timelineAccount.bot, ) } @@ -214,11 +216,6 @@ data class StatusViewDataEntity( val translationState: TranslationState, ) -val attachmentArrayListType: Type = object : TypeToken>() {}.type -val emojisListType: Type = object : TypeToken>() {}.type -val mentionListType: Type = object : TypeToken>() {}.type -val tagListType: Type = object : TypeToken>() {}.type - data class TimelineStatusWithAccount( @Embedded val status: TimelineStatusEntity, @@ -232,32 +229,28 @@ data class TimelineStatusWithAccount( @Embedded(prefix = "t_") val translatedStatus: TranslatedStatusEntity? = null, ) { - fun toStatus(gson: Gson): Status { - val attachments: ArrayList = gson.fromJson( - status.attachments, - attachmentArrayListType, - ) ?: arrayListOf() - val mentions: List = gson.fromJson( - status.mentions, - mentionListType, - ) ?: emptyList() - val tags: List? = gson.fromJson( - status.tags, - tagListType, - ) - val application = gson.fromJson(status.application, Status.Application::class.java) - val emojis: List = gson.fromJson( - status.emojis, - emojisListType, - ) ?: emptyList() - val poll: Poll? = gson.fromJson(status.poll, Poll::class.java) - val card: Card? = gson.fromJson(status.card, Card::class.java) + @OptIn(ExperimentalStdlibApi::class) + fun toStatus(moshi: Moshi): Status { + val attachments: List = status.attachments?.let { + moshi.adapter?>().fromJson(it) + } ?: emptyList() + val mentions: List = status.mentions?.let { + moshi.adapter?>().fromJson(it) + } ?: emptyList() + val tags: List? = status.tags?.let { + moshi.adapter?>().fromJson(it) + } + val application = status.application?.let { moshi.adapter().fromJson(it) } + val emojis: List = status.emojis?.let { moshi.adapter?>().fromJson(it) } + ?: emptyList() + val poll: Poll? = status.poll?.let { moshi.adapter().fromJson(it) } + val card: Card? = status.card?.let { moshi.adapter().fromJson(it) } val reblog = status.reblogServerId?.let { id -> Status( id = id, url = status.url, - account = account.toTimelineAccount(gson), + account = account.toTimelineAccount(moshi), inReplyToId = status.inReplyToId, inReplyToAccountId = status.inReplyToAccountId, reblog = null, @@ -291,7 +284,7 @@ data class TimelineStatusWithAccount( id = status.serverId, // no url for reblogs url = null, - account = reblogAccount!!.toTimelineAccount(gson), + account = reblogAccount!!.toTimelineAccount(moshi), inReplyToId = null, inReplyToAccountId = null, reblog = reblog, @@ -308,7 +301,7 @@ data class TimelineStatusWithAccount( sensitive = false, spoilerText = "", visibility = status.visibility, - attachments = ArrayList(), + attachments = listOf(), mentions = listOf(), tags = listOf(), application = null, @@ -324,7 +317,7 @@ data class TimelineStatusWithAccount( Status( id = status.serverId, url = status.url, - account = account.toTimelineAccount(gson), + account = account.toTimelineAccount(moshi), inReplyToId = status.inReplyToId, inReplyToAccountId = status.inReplyToAccountId, reblog = null, diff --git a/core/database/src/test/kotlin/app/pachli/core/database/di/FakeDatabaseModule.kt b/core/database/src/test/kotlin/app/pachli/core/database/di/FakeDatabaseModule.kt index 81d5b1d18..f14f2b8c7 100644 --- a/core/database/src/test/kotlin/app/pachli/core/database/di/FakeDatabaseModule.kt +++ b/core/database/src/test/kotlin/app/pachli/core/database/di/FakeDatabaseModule.kt @@ -21,7 +21,7 @@ import androidx.room.Room import androidx.test.platform.app.InstrumentationRegistry import app.pachli.core.database.AppDatabase import app.pachli.core.database.Converters -import com.google.gson.Gson +import com.squareup.moshi.Moshi import dagger.Module import dagger.Provides import dagger.hilt.components.SingletonComponent @@ -36,10 +36,10 @@ import javax.inject.Singleton object FakeDatabaseModule { @Provides @Singleton - fun providesDatabase(gson: Gson): AppDatabase { + fun providesDatabase(moshi: Moshi): AppDatabase { val context = InstrumentationRegistry.getInstrumentation().targetContext return Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java) - .addTypeConverter(Converters(gson)) + .addTypeConverter(Converters(moshi)) .allowMainThreadQueries() .build() } diff --git a/core/navigation/src/main/kotlin/app/pachli/core/navigation/Navigation.kt b/core/navigation/src/main/kotlin/app/pachli/core/navigation/Navigation.kt index acd5c9e92..00945753e 100644 --- a/core/navigation/src/main/kotlin/app/pachli/core/navigation/Navigation.kt +++ b/core/navigation/src/main/kotlin/app/pachli/core/navigation/Navigation.kt @@ -148,7 +148,7 @@ class ComposeActivityIntent(context: Context) : Intent() { /** Editing a status started as an existing draft */ EDIT_DRAFT, - /** Editing an an existing scheduled status */ + /** Editing an existing scheduled status */ EDIT_SCHEDULED, } @@ -210,7 +210,7 @@ class EditFilterActivityIntent(context: Context, filter: Filter? = null) : Inten /** * @param context * @param loginMode See [LoginMode] - * @see [app.pachli.components.login.LoginActivity] + * @see [app.pachli.feature.login.LoginActivity] */ class LoginActivityIntent(context: Context, loginMode: LoginMode = LoginMode.DEFAULT) : Intent() { /** How to log in */ @@ -472,7 +472,7 @@ class ViewMediaActivityIntent private constructor(context: Context) : Intent() { private const val EXTRA_SINGLE_IMAGE_URL = "singleImage" /** @return the list of [AttachmentViewData] passed in this intent, or null */ - fun getAttachments(intent: Intent): ArrayList? = IntentCompat.getParcelableArrayListExtra(intent, EXTRA_ATTACHMENTS, AttachmentViewData::class.java) + fun getAttachments(intent: Intent): List? = IntentCompat.getParcelableArrayListExtra(intent, EXTRA_ATTACHMENTS, AttachmentViewData::class.java) /** @return the index of the attachment to show, or 0 */ fun getAttachmentIndex(intent: Intent) = intent.getIntExtra(EXTRA_ATTACHMENT_INDEX, 0) diff --git a/core/network/build.gradle.kts b/core/network/build.gradle.kts index 02ff370a4..1a2767c33 100644 --- a/core/network/build.gradle.kts +++ b/core/network/build.gradle.kts @@ -33,7 +33,10 @@ dependencies { implementation(projects.core.common) implementation(projects.core.preferences) - implementation(libs.gson) + implementation(libs.moshi) + implementation(libs.moshi.adapters) + ksp(libs.moshi.codegen) + implementation(libs.bundles.retrofit) implementation(libs.bundles.okhttp) api(libs.networkresult.calladapter) 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 26bdfea28..bff3c160e 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,7 +22,7 @@ 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.Rfc3339DateJsonAdapter +import app.pachli.core.network.json.GuardedAdapter.Companion.GuardedAdapterFactory import app.pachli.core.network.retrofit.InstanceSwitchAuthInterceptor import app.pachli.core.network.retrofit.MastodonApi import app.pachli.core.preferences.PrefKeys.HTTP_PROXY_ENABLED @@ -32,8 +32,8 @@ import app.pachli.core.preferences.ProxyConfiguration import app.pachli.core.preferences.SharedPreferencesRepository import app.pachli.core.preferences.getNonNullString import at.connyduck.calladapter.networkresult.NetworkResultCallAdapterFactory -import com.google.gson.Gson -import com.google.gson.GsonBuilder +import com.squareup.moshi.Moshi +import com.squareup.moshi.adapters.Rfc3339DateJsonAdapter import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -50,7 +50,7 @@ import okhttp3.OkHttp import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Retrofit -import retrofit2.converter.gson.GsonConverterFactory +import retrofit2.converter.moshi.MoshiConverterFactory import retrofit2.create import timber.log.Timber @@ -60,9 +60,10 @@ object NetworkModule { @Provides @Singleton - fun providesGson(): Gson = GsonBuilder() - .registerTypeAdapter(Date::class.java, Rfc3339DateJsonAdapter()) - .create() + fun providesMoshi(): Moshi = Moshi.Builder() + .add(Date::class.java, Rfc3339DateJsonAdapter()) + .add(GuardedAdapterFactory()) + .build() @Provides @Singleton @@ -116,11 +117,11 @@ object NetworkModule { @Singleton fun providesRetrofit( httpClient: OkHttpClient, - gson: Gson, + moshi: Moshi, ): Retrofit { return Retrofit.Builder().baseUrl("https://" + MastodonApi.PLACEHOLDER_DOMAIN) .client(httpClient) - .addConverterFactory(GsonConverterFactory.create(gson)) + .addConverterFactory(MoshiConverterFactory.create(moshi)) .addCallAdapterFactory(NetworkResultCallAdapterFactory.create()) .build() } 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 new file mode 100644 index 000000000..3958036b1 --- /dev/null +++ b/core/network/src/main/kotlin/app/pachli/core/network/json/GuardedAdapter.kt @@ -0,0 +1,79 @@ +/* + * 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/json/GuardedBooleanAdapter.kt b/core/network/src/main/kotlin/app/pachli/core/network/json/GuardedBooleanAdapter.kt deleted file mode 100644 index dab1191d1..000000000 --- a/core/network/src/main/kotlin/app/pachli/core/network/json/GuardedBooleanAdapter.kt +++ /dev/null @@ -1,34 +0,0 @@ -/* Copyright 2022 Tusky Contributors - * - * 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.google.gson.JsonDeserializationContext -import com.google.gson.JsonDeserializer -import com.google.gson.JsonElement -import com.google.gson.JsonParseException -import java.lang.reflect.Type - -class GuardedBooleanAdapter : JsonDeserializer { - @Throws(JsonParseException::class) - override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): Boolean? { - return if (json.isJsonObject) { - null - } else { - json.asBoolean - } - } -} diff --git a/core/network/src/main/kotlin/app/pachli/core/network/json/Iso8601Utils.kt b/core/network/src/main/kotlin/app/pachli/core/network/json/Iso8601Utils.kt deleted file mode 100644 index b8b8e1091..000000000 --- a/core/network/src/main/kotlin/app/pachli/core/network/json/Iso8601Utils.kt +++ /dev/null @@ -1,327 +0,0 @@ -/* - * Copyright 2023 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 - -/* - * Copyright (C) 2011 FasterXML, LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import com.google.gson.JsonParseException -import java.util.Calendar -import java.util.Date -import java.util.GregorianCalendar -import java.util.Locale -import java.util.TimeZone -import kotlin.math.min -import kotlin.math.pow - -/* - * Jackson’s date formatter, pruned to Moshi's needs. Forked from this file: - * https://github.com/FasterXML/jackson-databind/blob/67ebf7305f492285a8f9f4de31545f5f16fc7c3a/src/main/java/com/fasterxml/jackson/databind/util/ISO8601Utils.java - * - * Utilities methods for manipulating dates in iso8601 format. This is much much faster and GC - * friendly than using SimpleDateFormat so highly suitable if you (un)serialize lots of date - * objects. - * - * Supported parse format: - * `[yyyy-MM-dd|yyyyMMdd][T(hh:mm[:ss[.sss]]|hhmm[ss[.sss]])]?[Z|[+-]hh[:]mm]]` - * - * @see [this specification](http://www.w3.org/TR/NOTE-datetime) - */ - -/** ID to represent the 'GMT' string */ -private const val GMT_ID = "GMT" - -/** The GMT timezone, prefetched to avoid more lookups. */ -private val TIMEZONE_Z: TimeZone = TimeZone.getTimeZone(GMT_ID) - -/** Returns `date` formatted as yyyy-MM-ddThh:mm:ss.sssZ */ -internal fun Date.formatIsoDate(): String { - val calendar: Calendar = GregorianCalendar(TIMEZONE_Z, Locale.US) - calendar.time = this - - // estimate capacity of buffer as close as we can (yeah, that's pedantic ;) - val capacity = "yyyy-MM-ddThh:mm:ss.sssZ".length - val formatted = StringBuilder(capacity) - padInt(formatted, calendar[Calendar.YEAR], "yyyy".length) - formatted.append('-') - padInt(formatted, calendar[Calendar.MONTH] + 1, "MM".length) - formatted.append('-') - padInt(formatted, calendar[Calendar.DAY_OF_MONTH], "dd".length) - formatted.append('T') - padInt(formatted, calendar[Calendar.HOUR_OF_DAY], "hh".length) - formatted.append(':') - padInt(formatted, calendar[Calendar.MINUTE], "mm".length) - formatted.append(':') - padInt(formatted, calendar[Calendar.SECOND], "ss".length) - formatted.append('.') - padInt(formatted, calendar[Calendar.MILLISECOND], "sss".length) - formatted.append('Z') - return formatted.toString() -} - -/** - * Parse a date from ISO-8601 formatted string. It expects a format - * `[yyyy-MM-dd|yyyyMMdd][T(hh:mm[:ss[.sss]]|hhmm[ss[.sss]])]?[Z|[+-]hh:mm]]` - * - * @receiver ISO string to parse in the appropriate format. - * @return the parsed date - */ -internal fun String.parseIsoDate(): Date { - return try { - var offset = 0 - - // extract year - val year = parseInt( - this, - offset, - 4.let { - offset += it - offset - }, - ) - if (checkOffset(this, offset, '-')) { - offset += 1 - } - - // extract month - val month = parseInt( - this, - offset, - 2.let { - offset += it - offset - }, - ) - if (checkOffset(this, offset, '-')) { - offset += 1 - } - - // extract day - val day = parseInt( - this, - offset, - 2.let { - offset += it - offset - }, - ) - // default time value - var hour = 0 - var minutes = 0 - var seconds = 0 - // always use 0 otherwise returned date will include millis of current time - var milliseconds = 0 - - // if the value has no time component (and no time zone), we are done - val hasT = checkOffset(this, offset, 'T') - if (!hasT && this.length <= offset) { - return GregorianCalendar(year, month - 1, day).time - } - if (hasT) { - // extract hours, minutes, seconds and milliseconds - hour = parseInt( - this, - 1.let { - offset += it - offset - }, - 2.let { - offset += it - offset - }, - ) - if (checkOffset(this, offset, ':')) { - offset += 1 - } - minutes = parseInt( - this, offset, - 2.let { - offset += it - offset - }, - ) - if (checkOffset(this, offset, ':')) { - offset += 1 - } - // second and milliseconds can be optional - if (this.length > offset) { - val c = this[offset] - if (c != 'Z' && c != '+' && c != '-') { - seconds = parseInt( - this, offset, - 2.let { - offset += it - offset - }, - ) - if (seconds in 60..62) seconds = 59 // truncate up to 3 leap seconds - // milliseconds can be optional in the format - if (checkOffset(this, offset, '.')) { - offset += 1 - val endOffset = indexOfNonDigit(this, offset + 1) // assume at least one digit - val parseEndOffset = min(endOffset, offset + 3) // parse up to 3 digits - val fraction = parseInt(this, offset, parseEndOffset) - milliseconds = - (10.0.pow((3 - (parseEndOffset - offset)).toDouble()) * fraction).toInt() - offset = endOffset - } - } - } - } - - // extract timezone - require(this.length > offset) { "No time zone indicator" } - val timezone: TimeZone - val timezoneIndicator = this[offset] - if (timezoneIndicator == 'Z') { - timezone = TIMEZONE_Z - } else if (timezoneIndicator == '+' || timezoneIndicator == '-') { - val timezoneOffset = this.substring(offset) - // 18-Jun-2015, tatu: Minor simplification, skip offset of "+0000"/"+00:00" - if ("+0000" == timezoneOffset || "+00:00" == timezoneOffset) { - timezone = TIMEZONE_Z - } else { - // 18-Jun-2015, tatu: Looks like offsets only work from GMT, not UTC... - // not sure why, but it is what it is. - val timezoneId = GMT_ID + timezoneOffset - timezone = TimeZone.getTimeZone(timezoneId) - val act = timezone.id - if (act != timezoneId) { - /* - * 22-Jan-2015, tatu: Looks like canonical version has colons, but we may be given - * one without. If so, don't sweat. - * Yes, very inefficient. Hopefully not hit often. - * If it becomes a perf problem, add 'loose' comparison instead. - */ - val cleaned = act.replace(":", "") - if (cleaned != timezoneId) { - throw IndexOutOfBoundsException( - "Mismatching time zone indicator: $timezoneId given, resolves to ${timezone.id}", - ) - } - } - } - } else { - throw IndexOutOfBoundsException( - "Invalid time zone indicator '$timezoneIndicator'", - ) - } - val calendar: Calendar = GregorianCalendar(timezone) - calendar.isLenient = false - calendar[Calendar.YEAR] = year - calendar[Calendar.MONTH] = month - 1 - calendar[Calendar.DAY_OF_MONTH] = day - calendar[Calendar.HOUR_OF_DAY] = hour - calendar[Calendar.MINUTE] = minutes - calendar[Calendar.SECOND] = seconds - calendar[Calendar.MILLISECOND] = milliseconds - calendar.time - // If we get a ParseException it'll already have the right message/offset. - // Other exception types can convert here. - } catch (e: IndexOutOfBoundsException) { - throw JsonParseException("Not an RFC 3339 date: $this", e) - } catch (e: IllegalArgumentException) { - throw JsonParseException("Not an RFC 3339 date: $this", e) - } -} - -/** - * Check if the expected character exist at the given offset in the value. - * - * @param value the string to check at the specified offset - * @param offset the offset to look for the expected character - * @param expected the expected character - * @return true if the expected character exist at the given offset - */ -private fun checkOffset(value: String, offset: Int, expected: Char): Boolean { - return offset < value.length && value[offset] == expected -} - -/** - * Parse an integer located between 2 given offsets in a string - * - * @param value the string to parse - * @param beginIndex the start index for the integer in the string - * @param endIndex the end index for the integer in the string - * @return the int - * @throws NumberFormatException if the value is not a number - */ -private fun parseInt(value: String, beginIndex: Int, endIndex: Int): Int { - if (beginIndex < 0 || endIndex > value.length || beginIndex > endIndex) { - throw NumberFormatException(value) - } - // use same logic as in Integer.parseInt() but less generic we're not supporting negative values - var i = beginIndex - var result = 0 - var digit: Int - if (i < endIndex) { - digit = Character.digit(value[i++], 10) - if (digit < 0) { - throw NumberFormatException("Invalid number: " + value.substring(beginIndex, endIndex)) - } - result = -digit - } - while (i < endIndex) { - digit = Character.digit(value[i++], 10) - if (digit < 0) { - throw NumberFormatException("Invalid number: " + value.substring(beginIndex, endIndex)) - } - result *= 10 - result -= digit - } - return -result -} - -/** - * Zero pad a number to a specified length - * - * @param buffer buffer to use for padding - * @param value the integer value to pad if necessary. - * @param length the length of the string we should zero pad - */ -private fun padInt(buffer: StringBuilder, value: Int, length: Int) { - val strValue = value.toString() - for (i in length - strValue.length downTo 1) { - buffer.append('0') - } - buffer.append(strValue) -} - -/** - * Returns the index of the first character in the string that is not a digit, starting at offset. - */ -private fun indexOfNonDigit(string: String, offset: Int): Int { - for (i in offset until string.length) { - val c = string[i] - if (c < '0' || c > '9') return i - } - return string.length -} diff --git a/core/network/src/main/kotlin/app/pachli/core/network/json/Rfc3339DateJsonAdapter.kt b/core/network/src/main/kotlin/app/pachli/core/network/json/Rfc3339DateJsonAdapter.kt deleted file mode 100644 index 1a26de3ee..000000000 --- a/core/network/src/main/kotlin/app/pachli/core/network/json/Rfc3339DateJsonAdapter.kt +++ /dev/null @@ -1,56 +0,0 @@ -// https://github.com/google/gson/blob/master/extras/src/main/java/com/google/gson/typeadapters/UtcDateTypeAdapter.java -/* - * Copyright (C) 2011 Google Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package app.pachli.core.network.json - -import com.google.gson.JsonParseException -import com.google.gson.TypeAdapter -import com.google.gson.stream.JsonReader -import com.google.gson.stream.JsonToken -import com.google.gson.stream.JsonWriter -import java.io.IOException -import java.util.Date -import timber.log.Timber - -class Rfc3339DateJsonAdapter : TypeAdapter() { - - @Throws(IOException::class) - override fun write(writer: JsonWriter, date: Date?) { - if (date == null) { - writer.nullValue() - } else { - writer.value(date.formatIsoDate()) - } - } - - @Throws(IOException::class) - override fun read(reader: JsonReader): Date? { - return when (reader.peek()) { - JsonToken.NULL -> { - reader.nextNull() - null - } - else -> { - try { - reader.nextString().parseIsoDate() - } catch (jpe: JsonParseException) { - Timber.w(jpe) - null - } - } - } - } -} diff --git a/core/network/src/main/kotlin/app/pachli/core/network/model/AccessToken.kt b/core/network/src/main/kotlin/app/pachli/core/network/model/AccessToken.kt index 3e3ab48af..3f46436e1 100644 --- a/core/network/src/main/kotlin/app/pachli/core/network/model/AccessToken.kt +++ b/core/network/src/main/kotlin/app/pachli/core/network/model/AccessToken.kt @@ -17,8 +17,10 @@ package app.pachli.core.network.model -import com.google.gson.annotations.SerializedName +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +@JsonClass(generateAdapter = true) data class AccessToken( - @SerializedName("access_token") val accessToken: String, + @Json(name = "access_token") val accessToken: String, ) diff --git a/core/network/src/main/kotlin/app/pachli/core/network/model/Account.kt b/core/network/src/main/kotlin/app/pachli/core/network/model/Account.kt index d51cf9436..4b3d05d67 100644 --- a/core/network/src/main/kotlin/app/pachli/core/network/model/Account.kt +++ b/core/network/src/main/kotlin/app/pachli/core/network/model/Account.kt @@ -16,25 +16,27 @@ package app.pachli.core.network.model -import com.google.gson.annotations.SerializedName +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass import java.util.Date +@JsonClass(generateAdapter = true) data class Account( val id: String, - @SerializedName("username") val localUsername: String, - @SerializedName("acct") val username: String, + @Json(name = "username") val localUsername: String, + @Json(name = "acct") val username: String, // should never be null per API definition, but some servers break the contract - @SerializedName("display_name") val displayName: String?, + @Json(name = "display_name") val displayName: String?, // should never be null per API definition, but some servers break the contract - @SerializedName("created_at") val createdAt: Date?, + @Json(name = "created_at") val createdAt: Date?, val note: String, val url: String, val avatar: String, val header: String, val locked: Boolean = false, - @SerializedName("followers_count") val followersCount: Int = 0, - @SerializedName("following_count") val followingCount: Int = 0, - @SerializedName("statuses_count") val statusesCount: Int = 0, + @Json(name = "followers_count") val followersCount: Int = 0, + @Json(name = "following_count") val followingCount: Int = 0, + @Json(name = "statuses_count") val statusesCount: Int = 0, val source: AccountSource? = null, val bot: Boolean = false, // nullable for backward compatibility @@ -55,6 +57,7 @@ data class Account( fun isRemote(): Boolean = this.username != this.localUsername } +@JsonClass(generateAdapter = true) data class AccountSource( val privacy: Status.Visibility?, val sensitive: Boolean?, @@ -63,18 +66,21 @@ data class AccountSource( val language: String?, ) +@JsonClass(generateAdapter = true) data class Field( val name: String, val value: String, - @SerializedName("verified_at") val verifiedAt: Date?, + @Json(name = "verified_at") val verifiedAt: Date?, ) +@JsonClass(generateAdapter = true) data class StringField( val name: String, val value: String, ) /** [Mastodon Entities: Role](https://docs.joinmastodon.org/entities/Role) */ +@JsonClass(generateAdapter = true) data class Role( /** Displayable name of the role */ val name: String, diff --git a/core/network/src/main/kotlin/app/pachli/core/network/model/Announcement.kt b/core/network/src/main/kotlin/app/pachli/core/network/model/Announcement.kt index b762c2497..c521f4cfc 100644 --- a/core/network/src/main/kotlin/app/pachli/core/network/model/Announcement.kt +++ b/core/network/src/main/kotlin/app/pachli/core/network/model/Announcement.kt @@ -16,17 +16,19 @@ package app.pachli.core.network.model -import com.google.gson.annotations.SerializedName +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass import java.util.Date +@JsonClass(generateAdapter = true) data class Announcement( val id: String, val content: String, - @SerializedName("starts_at") val startsAt: Date?, - @SerializedName("ends_at") val endsAt: Date?, - @SerializedName("all_day") val allDay: Boolean, - @SerializedName("published_at") val publishedAt: Date, - @SerializedName("updated_at") val updatedAt: Date, + @Json(name = "starts_at") val startsAt: Date?, + @Json(name = "ends_at") val endsAt: Date?, + @Json(name = "all_day") val allDay: Boolean, + @Json(name = "published_at") val publishedAt: Date, + @Json(name = "updated_at") val updatedAt: Date, val read: Boolean, val mentions: List, val statuses: List, @@ -47,11 +49,12 @@ data class Announcement( return id.hashCode() } + @JsonClass(generateAdapter = true) data class Reaction( val name: String, val count: Int, val me: Boolean, val url: String?, - @SerializedName("static_url") val staticUrl: String?, + @Json(name = "static_url") val staticUrl: String?, ) } diff --git a/core/network/src/main/kotlin/app/pachli/core/network/model/AppCredentials.kt b/core/network/src/main/kotlin/app/pachli/core/network/model/AppCredentials.kt index fcf1e7c3c..d202befd4 100644 --- a/core/network/src/main/kotlin/app/pachli/core/network/model/AppCredentials.kt +++ b/core/network/src/main/kotlin/app/pachli/core/network/model/AppCredentials.kt @@ -16,9 +16,11 @@ package app.pachli.core.network.model -import com.google.gson.annotations.SerializedName +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +@JsonClass(generateAdapter = true) data class AppCredentials( - @SerializedName("client_id") val clientId: String, - @SerializedName("client_secret") val clientSecret: String, + @Json(name = "client_id") val clientId: String, + @Json(name = "client_secret") val clientSecret: String, ) diff --git a/core/network/src/main/kotlin/app/pachli/core/network/model/Attachment.kt b/core/network/src/main/kotlin/app/pachli/core/network/model/Attachment.kt index 2d4472019..5c0ae2109 100644 --- a/core/network/src/main/kotlin/app/pachli/core/network/model/Attachment.kt +++ b/core/network/src/main/kotlin/app/pachli/core/network/model/Attachment.kt @@ -18,61 +18,45 @@ package app.pachli.core.network.model import android.os.Parcelable -import com.google.gson.JsonDeserializationContext -import com.google.gson.JsonDeserializer -import com.google.gson.JsonElement -import com.google.gson.JsonParseException -import com.google.gson.annotations.JsonAdapter -import com.google.gson.annotations.SerializedName +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass import kotlinx.parcelize.Parcelize @Parcelize +@JsonClass(generateAdapter = true) data class Attachment( val id: String, val url: String, // can be null for e.g. audio attachments - @SerializedName("preview_url") val previewUrl: String?, + @Json(name = "preview_url") val previewUrl: String?, val meta: MetaData?, val type: Type, val description: String?, val blurhash: String?, ) : Parcelable { - @JsonAdapter(MediaTypeDeserializer::class) enum class Type { - @SerializedName("image") + @Json(name = "image") IMAGE, - @SerializedName("gifv") + @Json(name = "gifv") GIFV, - @SerializedName("video") + @Json(name = "video") VIDEO, - @SerializedName("audio") + @Json(name = "audio") AUDIO, - @SerializedName("unknown") + @Json(name = "unknown") UNKNOWN, } - class MediaTypeDeserializer : JsonDeserializer { - @Throws(JsonParseException::class) - override fun deserialize(json: JsonElement, classOfT: java.lang.reflect.Type, context: JsonDeserializationContext): Type { - return when (json.toString()) { - "\"image\"" -> Type.IMAGE - "\"gifv\"" -> Type.GIFV - "\"video\"" -> Type.VIDEO - "\"audio\"" -> Type.AUDIO - else -> Type.UNKNOWN - } - } - } - /** * The meta data of an [Attachment]. */ @Parcelize + @JsonClass(generateAdapter = true) data class MetaData( val focus: Focus?, val duration: Float?, @@ -87,6 +71,7 @@ data class Attachment( * https://github.com/jonom/jquery-focuspoint#1-calculate-your-images-focus-point */ @Parcelize + @JsonClass(generateAdapter = true) data class Focus( val x: Float, val y: Float, @@ -98,9 +83,21 @@ data class Attachment( * The size of an image, used to specify the width/height. */ @Parcelize + @JsonClass(generateAdapter = true) data class Size( - val width: Int, - val height: Int, - val aspect: Double, - ) : Parcelable + val width: Int?, + val height: Int?, + // Not always present, see https://github.com/mastodon/mastodon/issues/29125 + @Json(name = "aspect") + val _aspect: Double?, + ) : Parcelable { + val aspect: Double + get() { + if (_aspect != null) return _aspect + width ?: return 1.778 + height ?: return 1.778 + + return (width / height).toDouble() + } + } } diff --git a/core/network/src/main/kotlin/app/pachli/core/network/model/Card.kt b/core/network/src/main/kotlin/app/pachli/core/network/model/Card.kt index 0bb9b02d4..1657e295b 100644 --- a/core/network/src/main/kotlin/app/pachli/core/network/model/Card.kt +++ b/core/network/src/main/kotlin/app/pachli/core/network/model/Card.kt @@ -17,22 +17,24 @@ package app.pachli.core.network.model -import com.google.gson.annotations.SerializedName +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +@JsonClass(generateAdapter = true) data class Card( override val url: String, override val title: String, override val description: String, - @SerializedName("type") override val kind: PreviewCardKind, - @SerializedName("author_name") override val authorName: String, - @SerializedName("author_url") override val authorUrl: String, - @SerializedName("provider_name") override val providerName: String, - @SerializedName("provider_url") override val providerUrl: String, + @Json(name = "type") override val kind: PreviewCardKind, + @Json(name = "author_name") override val authorName: String, + @Json(name = "author_url") override val authorUrl: String, + @Json(name = "provider_name") override val providerName: String, + @Json(name = "provider_url") override val providerUrl: String, override val html: String, override val width: Int, override val height: Int, override val image: String? = null, - @SerializedName("embed_url") override val embedUrl: String, + @Json(name = "embed_url") override val embedUrl: String, override val blurhash: String? = null, ) : PreviewCard { diff --git a/core/network/src/main/kotlin/app/pachli/core/network/model/Conversation.kt b/core/network/src/main/kotlin/app/pachli/core/network/model/Conversation.kt index 41320818a..b4b3571c2 100644 --- a/core/network/src/main/kotlin/app/pachli/core/network/model/Conversation.kt +++ b/core/network/src/main/kotlin/app/pachli/core/network/model/Conversation.kt @@ -16,12 +16,14 @@ package app.pachli.core.network.model -import com.google.gson.annotations.SerializedName +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +@JsonClass(generateAdapter = true) data class Conversation( val id: String, val accounts: List, // should never be null, but apparently its possible https://github.com/tuskyapp/Tusky/issues/1038 - @SerializedName("last_status") val lastStatus: Status?, + @Json(name = "last_status") val lastStatus: Status?, val unread: Boolean, ) diff --git a/core/network/src/main/kotlin/app/pachli/core/network/model/DeletedStatus.kt b/core/network/src/main/kotlin/app/pachli/core/network/model/DeletedStatus.kt index 917f90e9b..a09ed6ae0 100644 --- a/core/network/src/main/kotlin/app/pachli/core/network/model/DeletedStatus.kt +++ b/core/network/src/main/kotlin/app/pachli/core/network/model/DeletedStatus.kt @@ -16,18 +16,20 @@ package app.pachli.core.network.model -import com.google.gson.annotations.SerializedName +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass import java.util.Date +@JsonClass(generateAdapter = true) data class DeletedStatus( val text: String?, - @SerializedName("in_reply_to_id") val inReplyToId: String?, - @SerializedName("spoiler_text") val spoilerText: String, + @Json(name = "in_reply_to_id") val inReplyToId: String?, + @Json(name = "spoiler_text") val spoilerText: String, val visibility: Status.Visibility, val sensitive: Boolean, - @SerializedName("media_attachments") val attachments: List?, + @Json(name = "media_attachments") val attachments: List?, val poll: Poll?, - @SerializedName("created_at") val createdAt: Date, + @Json(name = "created_at") val createdAt: Date, val language: String?, ) { fun isEmpty(): Boolean { diff --git a/core/network/src/main/kotlin/app/pachli/core/network/model/Emoji.kt b/core/network/src/main/kotlin/app/pachli/core/network/model/Emoji.kt index cd8e4a3dc..7a2e608f3 100644 --- a/core/network/src/main/kotlin/app/pachli/core/network/model/Emoji.kt +++ b/core/network/src/main/kotlin/app/pachli/core/network/model/Emoji.kt @@ -17,13 +17,15 @@ package app.pachli.core.network.model import android.os.Parcelable -import com.google.gson.annotations.SerializedName +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass import kotlinx.parcelize.Parcelize @Parcelize +@JsonClass(generateAdapter = true) data class Emoji( val shortcode: String, val url: String, - @SerializedName("static_url") val staticUrl: String, - @SerializedName("visible_in_picker") val visibleInPicker: Boolean?, + @Json(name = "static_url") val staticUrl: String, + @Json(name = "visible_in_picker") val visibleInPicker: Boolean?, ) : Parcelable diff --git a/core/network/src/main/kotlin/app/pachli/core/network/model/Error.kt b/core/network/src/main/kotlin/app/pachli/core/network/model/Error.kt index 8c666197e..496fff996 100644 --- a/core/network/src/main/kotlin/app/pachli/core/network/model/Error.kt +++ b/core/network/src/main/kotlin/app/pachli/core/network/model/Error.kt @@ -17,8 +17,13 @@ package app.pachli.core.network.model +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + /** @see [Error](https://docs.joinmastodon.org/entities/Error/) */ +@JsonClass(generateAdapter = true) data class Error( val error: String, - val error_description: String?, + @Json(name = "error_description") + val errorDescription: String?, ) diff --git a/core/network/src/main/kotlin/app/pachli/core/network/model/Filter.kt b/core/network/src/main/kotlin/app/pachli/core/network/model/Filter.kt index 5a510351a..e1efd1954 100644 --- a/core/network/src/main/kotlin/app/pachli/core/network/model/Filter.kt +++ b/core/network/src/main/kotlin/app/pachli/core/network/model/Filter.kt @@ -1,18 +1,25 @@ package app.pachli.core.network.model import android.os.Parcelable -import com.google.gson.annotations.SerializedName +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass import java.util.Date import kotlinx.parcelize.Parcelize @Parcelize +@JsonClass(generateAdapter = true) data class Filter( val id: String, val title: String, val context: List, - @SerializedName("expires_at") val expiresAt: Date?, - @SerializedName("filter_action") private val filterAction: String, - val keywords: List, + @Json(name = "expires_at") val expiresAt: Date?, + @Json(name = "filter_action") val filterAction: String, + // This should not normally be empty. However, Mastodon does not include + // this in a status' `filtered.filter` property (it's not null or empty, + // it's missing) which breaks deserialisation. Patch this by ensuring it's + // always initialised to an empty list. + // TODO: https://github.com/mastodon/mastodon/issues/29142 + val keywords: List = emptyList(), // val statuses: List, ) : Parcelable { enum class Action(val action: String) { diff --git a/core/network/src/main/kotlin/app/pachli/core/network/model/FilterKeyword.kt b/core/network/src/main/kotlin/app/pachli/core/network/model/FilterKeyword.kt index 9bf6bfbca..1071e0b32 100644 --- a/core/network/src/main/kotlin/app/pachli/core/network/model/FilterKeyword.kt +++ b/core/network/src/main/kotlin/app/pachli/core/network/model/FilterKeyword.kt @@ -1,12 +1,14 @@ package app.pachli.core.network.model import android.os.Parcelable -import com.google.gson.annotations.SerializedName +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass import kotlinx.parcelize.Parcelize @Parcelize +@JsonClass(generateAdapter = true) data class FilterKeyword( val id: String, val keyword: String, - @SerializedName("whole_word") val wholeWord: Boolean, + @Json(name = "whole_word") val wholeWord: Boolean, ) : Parcelable diff --git a/core/network/src/main/kotlin/app/pachli/core/network/model/FilterResult.kt b/core/network/src/main/kotlin/app/pachli/core/network/model/FilterResult.kt index 4f27165b1..d6045f9a7 100644 --- a/core/network/src/main/kotlin/app/pachli/core/network/model/FilterResult.kt +++ b/core/network/src/main/kotlin/app/pachli/core/network/model/FilterResult.kt @@ -1,9 +1,11 @@ package app.pachli.core.network.model -import com.google.gson.annotations.SerializedName +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +@JsonClass(generateAdapter = true) data class FilterResult( val filter: Filter, - @SerializedName("keyword_matches") val keywordMatches: List?, - @SerializedName("status_matches") val statusMatches: List?, + @Json(name = "keyword_matches") val keywordMatches: List?, + @Json(name = "status_matches") val statusMatches: List?, ) diff --git a/core/network/src/main/kotlin/app/pachli/core/network/model/FilterV1.kt b/core/network/src/main/kotlin/app/pachli/core/network/model/FilterV1.kt index d60707f27..6227b8d99 100644 --- a/core/network/src/main/kotlin/app/pachli/core/network/model/FilterV1.kt +++ b/core/network/src/main/kotlin/app/pachli/core/network/model/FilterV1.kt @@ -16,16 +16,18 @@ package app.pachli.core.network.model -import com.google.gson.annotations.SerializedName +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass import java.util.Date +@JsonClass(generateAdapter = true) data class FilterV1( val id: String, val phrase: String, val context: List, - @SerializedName("expires_at") val expiresAt: Date?, + @Json(name = "expires_at") val expiresAt: Date?, val irreversible: Boolean, - @SerializedName("whole_word") val wholeWord: Boolean, + @Json(name = "whole_word") val wholeWord: Boolean, ) { companion object { const val HOME = "home" diff --git a/core/network/src/main/kotlin/app/pachli/core/network/model/HashTag.kt b/core/network/src/main/kotlin/app/pachli/core/network/model/HashTag.kt index 4a248837a..ff46ed9ae 100644 --- a/core/network/src/main/kotlin/app/pachli/core/network/model/HashTag.kt +++ b/core/network/src/main/kotlin/app/pachli/core/network/model/HashTag.kt @@ -1,3 +1,6 @@ package app.pachli.core.network.model +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) data class HashTag(val name: String, val url: String, val following: Boolean? = null) diff --git a/core/network/src/main/kotlin/app/pachli/core/network/model/InstanceV1.kt b/core/network/src/main/kotlin/app/pachli/core/network/model/InstanceV1.kt index faeb891f9..aec4afe6d 100644 --- a/core/network/src/main/kotlin/app/pachli/core/network/model/InstanceV1.kt +++ b/core/network/src/main/kotlin/app/pachli/core/network/model/InstanceV1.kt @@ -16,9 +16,11 @@ package app.pachli.core.network.model -import com.google.gson.annotations.SerializedName +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass /** https://docs.joinmastodon.org/entities/V1_Instance/ */ +@JsonClass(generateAdapter = true) data class InstanceV1( val uri: String, // val title: String, @@ -29,13 +31,13 @@ data class InstanceV1( // val stats: Map?, // val thumbnail: String?, // val languages: List, - // @SerializedName("contact_account") val contactAccount: Account, - @SerializedName("max_toot_chars") val maxTootChars: Int?, - @SerializedName("poll_limits") val pollConfiguration: PollConfiguration?, + // @Json(name = "contact_account") val contactAccount: Account, + @Json(name = "max_toot_chars") val maxTootChars: Int?, + @Json(name = "poll_limits") val pollConfiguration: PollConfiguration?, val configuration: InstanceConfiguration?, - @SerializedName("max_media_attachments") val maxMediaAttachments: Int?, + @Json(name = "max_media_attachments") val maxMediaAttachments: Int?, val pleroma: PleromaConfiguration?, - @SerializedName("upload_limit") val uploadLimit: Int?, + @Json(name = "upload_limit") val uploadLimit: Int?, val rules: List?, ) { override fun hashCode(): Int { @@ -51,49 +53,57 @@ data class InstanceV1( } } +@JsonClass(generateAdapter = true) data class PollConfiguration( - @SerializedName("max_options") val maxOptions: Int?, - @SerializedName("max_option_chars") val maxOptionChars: Int?, - @SerializedName("max_characters_per_option") val maxCharactersPerOption: Int?, - @SerializedName("min_expiration") val minExpiration: Int?, - @SerializedName("max_expiration") val maxExpiration: Int?, + @Json(name = "max_options") val maxOptions: Int?, + @Json(name = "max_option_chars") val maxOptionChars: Int?, + @Json(name = "max_characters_per_option") val maxCharactersPerOption: Int?, + @Json(name = "min_expiration") val minExpiration: Int?, + @Json(name = "max_expiration") val maxExpiration: Int?, ) +@JsonClass(generateAdapter = true) data class InstanceConfiguration( val statuses: StatusConfiguration?, - @SerializedName("media_attachments") val mediaAttachments: MediaAttachmentConfiguration?, + @Json(name = "media_attachments") val mediaAttachments: MediaAttachmentConfiguration?, val polls: PollConfiguration?, ) +@JsonClass(generateAdapter = true) data class StatusConfiguration( - @SerializedName("max_characters") val maxCharacters: Int?, - @SerializedName("max_media_attachments") val maxMediaAttachments: Int?, - @SerializedName("characters_reserved_per_url") val charactersReservedPerUrl: Int?, + @Json(name = "max_characters") val maxCharacters: Int?, + @Json(name = "max_media_attachments") val maxMediaAttachments: Int?, + @Json(name = "characters_reserved_per_url") val charactersReservedPerUrl: Int?, ) +@JsonClass(generateAdapter = true) data class MediaAttachmentConfiguration( - @SerializedName("supported_mime_types") val supportedMimeTypes: List?, - @SerializedName("image_size_limit") val imageSizeLimit: Int?, - @SerializedName("image_matrix_limit") val imageMatrixLimit: Int?, - @SerializedName("video_size_limit") val videoSizeLimit: Int?, - @SerializedName("video_frame_rate_limit") val videoFrameRateLimit: Int?, - @SerializedName("video_matrix_limit") val videoMatrixLimit: Int?, + @Json(name = "supported_mime_types") val supportedMimeTypes: List?, + @Json(name = "image_size_limit") val imageSizeLimit: Int?, + @Json(name = "image_matrix_limit") val imageMatrixLimit: Int?, + @Json(name = "video_size_limit") val videoSizeLimit: Int?, + @Json(name = "video_frame_rate_limit") val videoFrameRateLimit: Int?, + @Json(name = "video_matrix_limit") val videoMatrixLimit: Int?, ) +@JsonClass(generateAdapter = true) data class PleromaConfiguration( val metadata: PleromaMetadata?, ) +@JsonClass(generateAdapter = true) data class PleromaMetadata( - @SerializedName("fields_limits") val fieldLimits: PleromaFieldLimits, + @Json(name = "fields_limits") val fieldLimits: PleromaFieldLimits, ) +@JsonClass(generateAdapter = true) data class PleromaFieldLimits( - @SerializedName("max_fields") val maxFields: Int?, - @SerializedName("name_length") val nameLength: Int?, - @SerializedName("value_length") val valueLength: Int?, + @Json(name = "max_fields") val maxFields: Int?, + @Json(name = "name_length") val nameLength: Int?, + @Json(name = "value_length") val valueLength: Int?, ) +@JsonClass(generateAdapter = true) data class InstanceRules( val id: String, val text: String, diff --git a/core/network/src/main/kotlin/app/pachli/core/network/model/InstanceV2.kt b/core/network/src/main/kotlin/app/pachli/core/network/model/InstanceV2.kt index 448c4096d..208b67f9e 100644 --- a/core/network/src/main/kotlin/app/pachli/core/network/model/InstanceV2.kt +++ b/core/network/src/main/kotlin/app/pachli/core/network/model/InstanceV2.kt @@ -17,9 +17,11 @@ package app.pachli.core.network.model -import com.google.gson.annotations.SerializedName +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass /** https://docs.joinmastodon.org/entities/Instance/ */ +@JsonClass(generateAdapter = true) data class InstanceV2( /** The domain name of the instance */ val domain: String, @@ -34,7 +36,7 @@ data class InstanceV2( * The URL for the source code of the software running on this instance, in keeping with AGPL * license requirements. */ - @SerializedName("source_url") val sourceUrl: String, + @Json(name = "source_url") val sourceUrl: String, /** A short, plain-text description defined by the admin. */ val description: String, @@ -61,16 +63,19 @@ data class InstanceV2( val rules: List, ) +@JsonClass(generateAdapter = true) data class Usage( /** Usage data related to users on this instance. */ val users: Users, ) +@JsonClass(generateAdapter = true) data class Users( /** The number of active users in the past 4 weeks. */ val activeMonth: Int = 0, ) +@JsonClass(generateAdapter = true) data class Thumbnail( /** The URL for the thumbnail image. */ val url: String, @@ -85,14 +90,16 @@ data class Thumbnail( val versions: ThumbnailVersions?, ) +@JsonClass(generateAdapter = true) data class ThumbnailVersions( /** The URL for the thumbnail image at 1x resolution. */ - @SerializedName("@1x") val oneX: String?, + @Json(name = "@1x") val oneX: String?, /** The URL for the thumbnail image at 2x resolution. */ - @SerializedName("@2x") val twoX: String?, + @Json(name = "@2x") val twoX: String?, ) +@JsonClass(generateAdapter = true) data class Configuration( /** URLs of interest for clients apps. */ val urls: InstanceV2Urls, @@ -104,7 +111,7 @@ data class Configuration( val statuses: InstanceV2Statuses, /** Hints for which attachments will be accepted. */ - @SerializedName("media_attachments") val mediaAttachments: MediaAttachments, + @Json(name = "media_attachments") val mediaAttachments: MediaAttachments, /** Limits related to polls. */ val polls: InstanceV2Polls, @@ -113,77 +120,94 @@ data class Configuration( val translation: InstanceV2Translation, ) +@JsonClass(generateAdapter = true) data class InstanceV2Urls( - /** The Websockets URL for connecting to the streaming API. */ - @SerializedName("streaming_api") val streamingApi: String, + /** + * The Websockets URL for connecting to the streaming API. This is the + * documented property name + */ + @Json(name = "streaming_api") val streamingApi: String? = null, + + /** + * The Websockets URL for connecting to the streaming API. This is the + * undocumented property name, see https://github.com/mastodon/mastodon/pull/29124 + */ + @Json(name = "streaming") val streaming: String? = null, ) +@JsonClass(generateAdapter = true) data class InstanceV2Accounts( /** The maximum number of featured tags allowed for each account. */ - @SerializedName("max_featured_tags") val maxFeaturedTags: Int, + @Json(name = "max_featured_tags") val maxFeaturedTags: Int, ) +@JsonClass(generateAdapter = true) data class InstanceV2Statuses( /** The maximum number of allowed characters per status. */ - @SerializedName("max_characters") val maxCharacters: Int, + @Json(name = "max_characters") val maxCharacters: Int, /** The maximum number of media attachments that can be added to a status. */ - @SerializedName("max_media_attachments") val maxMediaAttachments: Int, + @Json(name = "max_media_attachments") val maxMediaAttachments: Int, /** Each URL in a status will be assumed to be exactly this many characters. */ - @SerializedName("characters_reserved_per_url") val charactersReservedPerUrl: Int, + @Json(name = "characters_reserved_per_url") val charactersReservedPerUrl: Int, ) +@JsonClass(generateAdapter = true) data class MediaAttachments( /** Contains MIME types that can be uploaded. */ - @SerializedName("supported_mime_types") val supportedMimeTypes: List, + @Json(name = "supported_mime_types") val supportedMimeTypes: List, /** The maximum size of any uploaded image, in bytes. */ - @SerializedName("image_size_limit") val imageSizeLimit: Int, + @Json(name = "image_size_limit") val imageSizeLimit: Int, /** The maximum number of pixels (width times height) for image uploads. */ - @SerializedName("image_matrix_limit") val imageMatrixLimit: Int, + @Json(name = "image_matrix_limit") val imageMatrixLimit: Int, /** The maximum size of any uploaded video, in bytes. */ - @SerializedName("video_size_limit") val videoSizeLimit: Int, + @Json(name = "video_size_limit") val videoSizeLimit: Int, /** The maximum frame rate for any uploaded video. */ - @SerializedName("video_frame_rate_limit") val videoFrameRateLimit: Int, + @Json(name = "video_frame_rate_limit") val videoFrameRateLimit: Int, /** The maximum number of pixels (width times height) for video uploads. */ - @SerializedName("video_matrix_limit") val videoMatrixLimit: Int, + @Json(name = "video_matrix_limit") val videoMatrixLimit: Int, ) +@JsonClass(generateAdapter = true) data class InstanceV2Polls( /** Each poll is allowed to have up to this many options. */ - @SerializedName("max_options") val maxOptions: Int, + @Json(name = "max_options") val maxOptions: Int, /** Each poll option is allowed to have this many characters. */ - @SerializedName("max_characters_per_option") val maxCharactersPerOption: Int, + @Json(name = "max_characters_per_option") val maxCharactersPerOption: Int, /** The shortest allowed poll duration, in seconds. */ - @SerializedName("min_expiration") val minExpiration: Int, + @Json(name = "min_expiration") val minExpiration: Int, /** The longest allowed poll duration, in seconds. */ - @SerializedName("max_expiration") val maxExpiration: Int, + @Json(name = "max_expiration") val maxExpiration: Int, ) +@JsonClass(generateAdapter = true) data class InstanceV2Translation( /** Whether the Translations API is available on this instance. */ val enabled: Boolean, ) +@JsonClass(generateAdapter = true) data class Registrations( /** Whether registrations are enabled. */ val enabled: Boolean, /** Whether registrations require moderator approval. */ - @SerializedName("approval_required") val approvalRequired: Boolean, + @Json(name = "approval_required") val approvalRequired: Boolean, /** A custom message to be shown when registrations are closed. */ val message: String?, ) +@JsonClass(generateAdapter = true) data class Contact( /** An email address that can be messaged regarding inquiries or issues. */ val email: String, @@ -193,6 +217,7 @@ data class Contact( ) /** https://docs.joinmastodon.org/entities/Rule/ */ +@JsonClass(generateAdapter = true) data class Rule( /** An identifier for the rule. */ val id: String, diff --git a/core/network/src/main/kotlin/app/pachli/core/network/model/Marker.kt b/core/network/src/main/kotlin/app/pachli/core/network/model/Marker.kt index 4d09595d5..df3804c2d 100644 --- a/core/network/src/main/kotlin/app/pachli/core/network/model/Marker.kt +++ b/core/network/src/main/kotlin/app/pachli/core/network/model/Marker.kt @@ -1,15 +1,17 @@ package app.pachli.core.network.model -import com.google.gson.annotations.SerializedName +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass import java.util.Date /** * API type for saving the scroll position of a timeline. */ +@JsonClass(generateAdapter = true) data class Marker( - @SerializedName("last_read_id") + @Json(name = "last_read_id") val lastReadId: String, val version: Int, - @SerializedName("updated_at") + @Json(name = "updated_at") val updatedAt: Date, ) diff --git a/core/network/src/main/kotlin/app/pachli/core/network/model/MastoList.kt b/core/network/src/main/kotlin/app/pachli/core/network/model/MastoList.kt index f0ab30a49..847d8204d 100644 --- a/core/network/src/main/kotlin/app/pachli/core/network/model/MastoList.kt +++ b/core/network/src/main/kotlin/app/pachli/core/network/model/MastoList.kt @@ -16,6 +16,9 @@ package app.pachli.core.network.model +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) data class MastoList( val id: String, val title: String, diff --git a/core/network/src/main/kotlin/app/pachli/core/network/model/MediaUploadResult.kt b/core/network/src/main/kotlin/app/pachli/core/network/model/MediaUploadResult.kt index 53de61327..d125d0561 100644 --- a/core/network/src/main/kotlin/app/pachli/core/network/model/MediaUploadResult.kt +++ b/core/network/src/main/kotlin/app/pachli/core/network/model/MediaUploadResult.kt @@ -1,9 +1,12 @@ package app.pachli.core.network.model +import com.squareup.moshi.JsonClass + /** * The same as Attachment, except the url is null - see https://docs.joinmastodon.org/methods/statuses/media/ * We are only interested in the id, so other attributes are omitted */ +@JsonClass(generateAdapter = true) data class MediaUploadResult( val id: String, ) diff --git a/core/network/src/main/kotlin/app/pachli/core/network/model/NewStatus.kt b/core/network/src/main/kotlin/app/pachli/core/network/model/NewStatus.kt index cd25bfdfa..728e276ab 100644 --- a/core/network/src/main/kotlin/app/pachli/core/network/model/NewStatus.kt +++ b/core/network/src/main/kotlin/app/pachli/core/network/model/NewStatus.kt @@ -17,32 +17,36 @@ package app.pachli.core.network.model import android.os.Parcelable -import com.google.gson.annotations.SerializedName +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass import kotlinx.parcelize.Parcelize +@JsonClass(generateAdapter = true) data class NewStatus( val status: String, - @SerializedName("spoiler_text") val warningText: String, - @SerializedName("in_reply_to_id") val inReplyToId: String?, + @Json(name = "spoiler_text") val warningText: String, + @Json(name = "in_reply_to_id") val inReplyToId: String?, val visibility: String, val sensitive: Boolean, - @SerializedName("media_ids") val mediaIds: List?, - @SerializedName("media_attributes") val mediaAttributes: List?, - @SerializedName("scheduled_at") val scheduledAt: String?, + @Json(name = "media_ids") val mediaIds: List?, + @Json(name = "media_attributes") val mediaAttributes: List?, + @Json(name = "scheduled_at") val scheduledAt: String?, val poll: NewPoll?, val language: String?, ) @Parcelize +@JsonClass(generateAdapter = true) data class NewPoll( val options: List, - @SerializedName("expires_in") val expiresIn: Int, + @Json(name = "expires_in") val expiresIn: Int, val multiple: Boolean, ) : Parcelable // It would be nice if we could reuse MediaToSend, // but the server requires a different format for focus @Parcelize +@JsonClass(generateAdapter = true) data class MediaAttribute( val id: String, val description: String?, diff --git a/core/network/src/main/kotlin/app/pachli/core/network/model/Notification.kt b/core/network/src/main/kotlin/app/pachli/core/network/model/Notification.kt index c7d1517ed..57d75b673 100644 --- a/core/network/src/main/kotlin/app/pachli/core/network/model/Notification.kt +++ b/core/network/src/main/kotlin/app/pachli/core/network/model/Notification.kt @@ -17,14 +17,12 @@ package app.pachli.core.network.model -import com.google.gson.JsonDeserializationContext -import com.google.gson.JsonDeserializer -import com.google.gson.JsonElement -import com.google.gson.JsonParseException -import com.google.gson.annotations.JsonAdapter +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass // TODO: These should be different subclasses per type, so that each subclass can // carry the non-null data that it needs. +@JsonClass(generateAdapter = true) data class Notification( val type: Type, val id: String, @@ -34,38 +32,48 @@ data class Notification( ) { /** From https://docs.joinmastodon.org/entities/Notification/#type */ - @JsonAdapter(NotificationTypeAdapter::class) enum class Type(val presentation: String) { + @Json(name = "unknown") UNKNOWN("unknown"), /** Someone mentioned you */ + @Json(name = "mention") MENTION("mention"), /** Someone boosted one of your statuses */ + @Json(name = "reblog") REBLOG("reblog"), /** Someone favourited one of your statuses */ + @Json(name = "favourite") FAVOURITE("favourite"), /** Someone followed you */ + @Json(name = "follow") FOLLOW("follow"), /** Someone requested to follow you */ + @Json(name = "follow_request") FOLLOW_REQUEST("follow_request"), /** A poll you have voted in or created has ended */ + @Json(name = "poll") POLL("poll"), /** Someone you enabled notifications for has posted a status */ + @Json(name = "status") STATUS("status"), /** Someone signed up (optionally sent to admins) */ + @Json(name = "admin.sign_up") SIGN_UP("admin.sign_up"), /** A status you interacted with has been updated */ + @Json(name = "update") UPDATE("update"), /** A new report has been filed */ + @Json(name = "admin.report") REPORT("admin.report"), ; @@ -101,18 +109,6 @@ data class Notification( return notification?.id == this.id } - class NotificationTypeAdapter : JsonDeserializer { - - @Throws(JsonParseException::class) - override fun deserialize( - json: JsonElement, - typeOfT: java.lang.reflect.Type, - context: JsonDeserializationContext, - ): Type { - return Type.byString(json.asString) - } - } - // for Pleroma compatibility that uses Mention type fun rewriteToStatusTypeIfNeeded(accountId: String): Notification { if (type == Type.MENTION && status != null) { diff --git a/core/network/src/main/kotlin/app/pachli/core/network/model/NotificationSubscribeResult.kt b/core/network/src/main/kotlin/app/pachli/core/network/model/NotificationSubscribeResult.kt index 5d9f2fa37..3134cc017 100644 --- a/core/network/src/main/kotlin/app/pachli/core/network/model/NotificationSubscribeResult.kt +++ b/core/network/src/main/kotlin/app/pachli/core/network/model/NotificationSubscribeResult.kt @@ -16,10 +16,12 @@ package app.pachli.core.network.model -import com.google.gson.annotations.SerializedName +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +@JsonClass(generateAdapter = true) data class NotificationSubscribeResult( val id: Int, val endpoint: String, - @SerializedName("server_key") val serverKey: String, + @Json(name = "server_key") val serverKey: String, ) 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 bafe9241d..b9c9240bc 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,19 +1,21 @@ package app.pachli.core.network.model -import com.google.gson.annotations.SerializedName +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass import java.util.Date +@JsonClass(generateAdapter = true) data class Poll( val id: String, - @SerializedName("expires_at") val expiresAt: Date?, + @Json(name = "expires_at") val expiresAt: Date?, val expired: Boolean, val multiple: Boolean, - @SerializedName("votes_count") val votesCount: Int, + @Json(name = "votes_count") val votesCount: Int, // nullable for compatibility with Pleroma - @SerializedName("voters_count") val votersCount: Int?, + @Json(name = "voters_count") val votersCount: Int?, val options: List, val voted: Boolean, - @SerializedName("own_votes") val ownVotes: List?, + @Json(name = "own_votes") val ownVotes: List?, ) { fun votedCopy(choices: List): Poll { @@ -42,7 +44,8 @@ data class Poll( ) } +@JsonClass(generateAdapter = true) data class PollOption( val title: String, - @SerializedName("votes_count") val votesCount: Int, + @Json(name = "votes_count") val votesCount: Int, ) diff --git a/core/network/src/main/kotlin/app/pachli/core/network/model/Relationship.kt b/core/network/src/main/kotlin/app/pachli/core/network/model/Relationship.kt index 51dc52101..693462781 100644 --- a/core/network/src/main/kotlin/app/pachli/core/network/model/Relationship.kt +++ b/core/network/src/main/kotlin/app/pachli/core/network/model/Relationship.kt @@ -17,25 +17,30 @@ package app.pachli.core.network.model -import app.pachli.core.network.json.GuardedBooleanAdapter -import com.google.gson.annotations.JsonAdapter -import com.google.gson.annotations.SerializedName +import app.pachli.core.network.json.Guarded +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +@JsonClass(generateAdapter = true) data class Relationship( val id: String, val following: Boolean, - @SerializedName("followed_by") val followedBy: Boolean, + @Json(name = "followed_by") val followedBy: Boolean, val blocking: Boolean, val muting: Boolean, - @SerializedName("muting_notifications") val mutingNotifications: Boolean, + @Json(name = "muting_notifications") val mutingNotifications: Boolean, val requested: Boolean, - @SerializedName("showing_reblogs") val showingReblogs: Boolean, - /* Pleroma extension, same as 'notifying' on Mastodon. - * Some instances like qoto.org have a custom subscription feature where 'subscribing' is a json object, - * so we use the custom GuardedBooleanAdapter to ignore the field if it is not a boolean. + @Json(name = "showing_reblogs") val showingReblogs: Boolean, + /** + * Pleroma extension, same as 'notifying' on Mastodon. + * + * Some instances like qoto.org have a custom subscription feature where + * 'subscribing' is a json object, so we use the custom `@Guarded` annotation + * to ignore the field if it is not a boolean. */ - @JsonAdapter(GuardedBooleanAdapter::class) val subscribing: Boolean? = null, - @SerializedName("domain_blocking") val blockingDomain: Boolean, + @Guarded + val subscribing: Boolean? = null, + @Json(name = "domain_blocking") val blockingDomain: Boolean, // nullable for backward compatibility / feature detection val note: String?, // since 3.3.0rc diff --git a/core/network/src/main/kotlin/app/pachli/core/network/model/Report.kt b/core/network/src/main/kotlin/app/pachli/core/network/model/Report.kt index c180c2306..9f51cba03 100644 --- a/core/network/src/main/kotlin/app/pachli/core/network/model/Report.kt +++ b/core/network/src/main/kotlin/app/pachli/core/network/model/Report.kt @@ -17,13 +17,15 @@ package app.pachli.core.network.model -import com.google.gson.annotations.SerializedName +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass import java.util.Date +@JsonClass(generateAdapter = true) data class Report( val id: String, val category: String, - val status_ids: List?, - @SerializedName("created_at") val createdAt: Date, - @SerializedName("target_account") val targetAccount: TimelineAccount, + @Json(name = "status_ids") val statusIds: List?, + @Json(name = "created_at") val createdAt: Date, + @Json(name = "target_account") val targetAccount: TimelineAccount, ) diff --git a/core/network/src/main/kotlin/app/pachli/core/network/model/ScheduledStatus.kt b/core/network/src/main/kotlin/app/pachli/core/network/model/ScheduledStatus.kt index 871fc1959..5d09c50eb 100644 --- a/core/network/src/main/kotlin/app/pachli/core/network/model/ScheduledStatus.kt +++ b/core/network/src/main/kotlin/app/pachli/core/network/model/ScheduledStatus.kt @@ -16,11 +16,13 @@ package app.pachli.core.network.model -import com.google.gson.annotations.SerializedName +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +@JsonClass(generateAdapter = true) data class ScheduledStatus( val id: String, - @SerializedName("scheduled_at") val scheduledAt: String, + @Json(name = "scheduled_at") val scheduledAt: String, val params: StatusParams, - @SerializedName("media_attachments") val mediaAttachments: ArrayList, + @Json(name = "media_attachments") val mediaAttachments: List, ) diff --git a/core/network/src/main/kotlin/app/pachli/core/network/model/SearchResult.kt b/core/network/src/main/kotlin/app/pachli/core/network/model/SearchResult.kt index 5b94e89e9..ab4472700 100644 --- a/core/network/src/main/kotlin/app/pachli/core/network/model/SearchResult.kt +++ b/core/network/src/main/kotlin/app/pachli/core/network/model/SearchResult.kt @@ -16,6 +16,9 @@ package app.pachli.core.network.model +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) data class SearchResult( val accounts: List, val statuses: List, diff --git a/core/network/src/main/kotlin/app/pachli/core/network/model/Status.kt b/core/network/src/main/kotlin/app/pachli/core/network/model/Status.kt index 7395bccb3..ff2780633 100644 --- a/core/network/src/main/kotlin/app/pachli/core/network/model/Status.kt +++ b/core/network/src/main/kotlin/app/pachli/core/network/model/Status.kt @@ -20,31 +20,33 @@ package app.pachli.core.network.model import android.text.SpannableStringBuilder import android.text.style.URLSpan import app.pachli.core.network.parseAsMastodonHtml -import com.google.gson.annotations.SerializedName +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass import java.util.Date +@JsonClass(generateAdapter = true) data class Status( val id: String, // not present if it's reblog val url: String?, val account: TimelineAccount, - @SerializedName("in_reply_to_id") val inReplyToId: String?, - @SerializedName("in_reply_to_account_id") val inReplyToAccountId: String?, + @Json(name = "in_reply_to_id") val inReplyToId: String?, + @Json(name = "in_reply_to_account_id") val inReplyToAccountId: String?, val reblog: Status?, val content: String, - @SerializedName("created_at") val createdAt: Date, - @SerializedName("edited_at") val editedAt: Date?, + @Json(name = "created_at") val createdAt: Date, + @Json(name = "edited_at") val editedAt: Date?, val emojis: List, - @SerializedName("reblogs_count") val reblogsCount: Int, - @SerializedName("favourites_count") val favouritesCount: Int, - @SerializedName("replies_count") val repliesCount: Int, + @Json(name = "reblogs_count") val reblogsCount: Int, + @Json(name = "favourites_count") val favouritesCount: Int, + @Json(name = "replies_count") val repliesCount: Int, val reblogged: Boolean, val favourited: Boolean, val bookmarked: Boolean, val sensitive: Boolean, - @SerializedName("spoiler_text") val spoilerText: String, + @Json(name = "spoiler_text") val spoilerText: String, val visibility: Visibility, - @SerializedName("media_attachments") val attachments: List, + @Json(name = "media_attachments") val attachments: List, val mentions: List, val tags: List?, val application: Application?, @@ -65,16 +67,16 @@ data class Status( enum class Visibility(val num: Int) { UNKNOWN(0), - @SerializedName("public") + @Json(name = "public") PUBLIC(1), - @SerializedName("unlisted") + @Json(name = "unlisted") UNLISTED(2), - @SerializedName("private") + @Json(name = "private") PRIVATE(3), - @SerializedName("direct") + @Json(name = "direct") DIRECT(4), ; @@ -156,13 +158,15 @@ data class Status( return builder.toString() } + @JsonClass(generateAdapter = true) data class Mention( val id: String, val url: String, - @SerializedName("acct") val username: String, - @SerializedName("username") val localUsername: String, + @Json(name = "acct") val username: String, + @Json(name = "username") val localUsername: String, ) + @JsonClass(generateAdapter = true) data class Application( val name: String, val website: String?, diff --git a/core/network/src/main/kotlin/app/pachli/core/network/model/StatusContext.kt b/core/network/src/main/kotlin/app/pachli/core/network/model/StatusContext.kt index 3916de135..c7be21f8d 100644 --- a/core/network/src/main/kotlin/app/pachli/core/network/model/StatusContext.kt +++ b/core/network/src/main/kotlin/app/pachli/core/network/model/StatusContext.kt @@ -16,6 +16,9 @@ package app.pachli.core.network.model +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) data class StatusContext( val ancestors: List, val descendants: List, diff --git a/core/network/src/main/kotlin/app/pachli/core/network/model/StatusEdit.kt b/core/network/src/main/kotlin/app/pachli/core/network/model/StatusEdit.kt index 3d3066ea5..29fda93c4 100644 --- a/core/network/src/main/kotlin/app/pachli/core/network/model/StatusEdit.kt +++ b/core/network/src/main/kotlin/app/pachli/core/network/model/StatusEdit.kt @@ -1,15 +1,17 @@ package app.pachli.core.network.model -import com.google.gson.annotations.SerializedName +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass import java.util.Date +@JsonClass(generateAdapter = true) data class StatusEdit( val content: String, - @SerializedName("spoiler_text") val spoilerText: String, + @Json(name = "spoiler_text") val spoilerText: String, val sensitive: Boolean, - @SerializedName("created_at") val createdAt: Date, + @Json(name = "created_at") val createdAt: Date, val account: TimelineAccount, val poll: Poll?, - @SerializedName("media_attachments") val mediaAttachments: List, + @Json(name = "media_attachments") val mediaAttachments: List, val emojis: List, ) diff --git a/core/network/src/main/kotlin/app/pachli/core/network/model/StatusParams.kt b/core/network/src/main/kotlin/app/pachli/core/network/model/StatusParams.kt index 405cecd2b..8b68c36a6 100644 --- a/core/network/src/main/kotlin/app/pachli/core/network/model/StatusParams.kt +++ b/core/network/src/main/kotlin/app/pachli/core/network/model/StatusParams.kt @@ -17,12 +17,14 @@ package app.pachli.core.network.model -import com.google.gson.annotations.SerializedName +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +@JsonClass(generateAdapter = true) data class StatusParams( val text: String, val sensitive: Boolean, val visibility: Status.Visibility, - @SerializedName("spoiler_text") val spoilerText: String, - @SerializedName("in_reply_to_id") val inReplyToId: String?, + @Json(name = "spoiler_text") val spoilerText: String, + @Json(name = "in_reply_to_id") val inReplyToId: String?, ) diff --git a/core/network/src/main/kotlin/app/pachli/core/network/model/StatusSource.kt b/core/network/src/main/kotlin/app/pachli/core/network/model/StatusSource.kt index a6d2ef7ad..057360531 100644 --- a/core/network/src/main/kotlin/app/pachli/core/network/model/StatusSource.kt +++ b/core/network/src/main/kotlin/app/pachli/core/network/model/StatusSource.kt @@ -17,10 +17,12 @@ package app.pachli.core.network.model -import com.google.gson.annotations.SerializedName +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +@JsonClass(generateAdapter = true) data class StatusSource( val id: String, val text: String, - @SerializedName("spoiler_text") val spoilerText: String, + @Json(name = "spoiler_text") val spoilerText: String, ) diff --git a/core/network/src/main/kotlin/app/pachli/core/network/model/TimelineAccount.kt b/core/network/src/main/kotlin/app/pachli/core/network/model/TimelineAccount.kt index d2298ab48..d0c571752 100644 --- a/core/network/src/main/kotlin/app/pachli/core/network/model/TimelineAccount.kt +++ b/core/network/src/main/kotlin/app/pachli/core/network/model/TimelineAccount.kt @@ -17,19 +17,21 @@ package app.pachli.core.network.model -import com.google.gson.annotations.SerializedName +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass /** * Same as [Account], but only with the attributes required in timelines. * Prefer this class over [Account] because it uses way less memory & deserializes faster from json. */ +@JsonClass(generateAdapter = true) data class TimelineAccount( val id: String, - @SerializedName("username") val localUsername: String, - @SerializedName("acct") val username: String, + @Json(name = "username") val localUsername: String, + @Json(name = "acct") val username: String, // should never be null per Api definition, but some servers break the contract @Deprecated("prefer the `name` property, which is not-null and not-empty") - @SerializedName("display_name") val displayName: String?, + @Json(name = "display_name") val displayName: String?, val url: String, val avatar: String, val note: String, diff --git a/core/network/src/main/kotlin/app/pachli/core/network/model/Translation.kt b/core/network/src/main/kotlin/app/pachli/core/network/model/Translation.kt index 2ad855a06..77aca90de 100644 --- a/core/network/src/main/kotlin/app/pachli/core/network/model/Translation.kt +++ b/core/network/src/main/kotlin/app/pachli/core/network/model/Translation.kt @@ -17,9 +17,11 @@ package app.pachli.core.network.model -import com.google.gson.annotations.SerializedName +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass /** https://docs.joinmastodon.org/entities/Translation/ */ +@JsonClass(generateAdapter = true) data class Translation( /** The translated text of the status (HTML), equivalent to [Status.content] */ val content: String, @@ -28,14 +30,14 @@ data class Translation( * The language of the source text, as auto-detected by the machine translation * (ISO 639 language code) */ - @SerializedName("detected_source_language") val detectedSourceLanguage: String, + @Json(name = "detected_source_language") val detectedSourceLanguage: String, // Not documented, see https://github.com/mastodon/documentation/issues/1248 /** * The translated spoiler text of the status (text), if it exists, equivalent to * [Status.spoilerText] */ - @SerializedName("spoiler_text") val spoilerText: String, + @Json(name = "spoiler_text") val spoilerText: String, // Not documented, see https://github.com/mastodon/documentation/issues/1248 /** The translated poll (if it exists) */ @@ -46,7 +48,7 @@ data class Translation( * Translated descriptions for media attachments, if any were attached. Other metadata has * to be determined from the original attachment. */ - @SerializedName("media_attachments") val attachments: List, + @Json(name = "media_attachments") val attachments: List, /** The service that provided the machine translation */ val provider: String, @@ -56,17 +58,20 @@ data class Translation( * A translated poll. Does not contain all the poll data, only the translated text. * Vote counts and other metadata has to be determined from the original poll object. */ +@JsonClass(generateAdapter = true) data class TranslatedPoll( val id: String, val options: List, ) /** A translated poll option. */ +@JsonClass(generateAdapter = true) data class TranslatedPollOption( val title: String, ) /** A translated attachment. Only the description is translated */ +@JsonClass(generateAdapter = true) data class TranslatedAttachment( val id: String, val description: String, diff --git a/core/network/src/main/kotlin/app/pachli/core/network/model/TrendingTag.kt b/core/network/src/main/kotlin/app/pachli/core/network/model/TrendingTag.kt index 50cc2d700..73ba7ee66 100644 --- a/core/network/src/main/kotlin/app/pachli/core/network/model/TrendingTag.kt +++ b/core/network/src/main/kotlin/app/pachli/core/network/model/TrendingTag.kt @@ -17,6 +17,7 @@ package app.pachli.core.network.model +import com.squareup.moshi.JsonClass import java.util.Date /** @@ -27,6 +28,7 @@ import java.util.Date * @param history A list of [TrendingTagHistory]. Each element contains metrics per day for this hashtag. * (@param following This is not listed in the APIs at the time of writing, but an instance is delivering it.) */ +@JsonClass(generateAdapter = true) data class TrendingTag( val name: String, val history: List, @@ -39,6 +41,7 @@ data class TrendingTag( * @param accounts The number of accounts that have posted with this hashtag. * @param uses The number of posts with this hashtag. */ +@JsonClass(generateAdapter = true) data class TrendingTagHistory( val day: String, val accounts: String, diff --git a/core/network/src/main/kotlin/app/pachli/core/network/model/TrendsLink.kt b/core/network/src/main/kotlin/app/pachli/core/network/model/TrendsLink.kt index bffdc4fae..4f7e108d6 100644 --- a/core/network/src/main/kotlin/app/pachli/core/network/model/TrendsLink.kt +++ b/core/network/src/main/kotlin/app/pachli/core/network/model/TrendsLink.kt @@ -17,19 +17,20 @@ package app.pachli.core.network.model -import com.google.gson.annotations.SerializedName +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass enum class PreviewCardKind { - @SerializedName("link") + @Json(name = "link") LINK, - @SerializedName("photo") + @Json(name = "photo") PHOTO, - @SerializedName("video") + @Json(name = "video") VIDEO, - @SerializedName("rich") + @Json(name = "rich") RICH, } @@ -53,6 +54,7 @@ interface PreviewCard { val blurhash: String? } +@JsonClass(generateAdapter = true) data class LinkHistory( val day: String, val accounts: Int, @@ -60,20 +62,21 @@ data class LinkHistory( ) /** Represents a https://docs.joinmastodon.org/entities/PreviewCard/#trends-link */ +@JsonClass(generateAdapter = true) data class TrendsLink( override val url: String, override val title: String, override val description: String, - @SerializedName("type") override val kind: PreviewCardKind, - @SerializedName("author_name") override val authorName: String, - @SerializedName("author_url") override val authorUrl: String, - @SerializedName("provider_name") override val providerName: String, - @SerializedName("provider_url") override val providerUrl: String, + @Json(name = "type") override val kind: PreviewCardKind, + @Json(name = "author_name") override val authorName: String, + @Json(name = "author_url") override val authorUrl: String, + @Json(name = "provider_name") override val providerName: String, + @Json(name = "provider_url") override val providerUrl: String, override val html: String, override val width: Int, override val height: Int, override val image: String? = null, - @SerializedName("embed_url") override val embedUrl: String, + @Json(name = "embed_url") override val embedUrl: String, override val blurhash: String? = null, val history: List, ) : PreviewCard diff --git a/core/network/src/main/kotlin/app/pachli/core/network/model/nodeinfo/NodeInfo.kt b/core/network/src/main/kotlin/app/pachli/core/network/model/nodeinfo/NodeInfo.kt index 99d75d121..831dc9c8d 100644 --- a/core/network/src/main/kotlin/app/pachli/core/network/model/nodeinfo/NodeInfo.kt +++ b/core/network/src/main/kotlin/app/pachli/core/network/model/nodeinfo/NodeInfo.kt @@ -26,13 +26,16 @@ import app.pachli.core.network.model.nodeinfo.NodeInfo.Error.NoSoftwareVersion import com.github.michaelbull.result.Err import com.github.michaelbull.result.Ok import com.github.michaelbull.result.Result +import com.squareup.moshi.JsonClass /** * The JRD document that links to one or more URLs that contain the schema. * * See https://nodeinfo.diaspora.software/protocol.html. */ +@JsonClass(generateAdapter = true) data class UnvalidatedJrd(val links: List) { + @JsonClass(generateAdapter = true) data class Link(val rel: String, val href: String) } @@ -42,7 +45,9 @@ data class UnvalidatedJrd(val links: List) { * See https://nodeinfo.diaspora.software/protocol.html and * https://nodeinfo.diaspora.software/schema.html. */ +@JsonClass(generateAdapter = true) data class UnvalidatedNodeInfo(val software: Software?) { + @JsonClass(generateAdapter = true) data class Software(val name: String?, val version: String?) } diff --git a/core/network/src/main/kotlin/app/pachli/core/network/retrofit/MastodonApi.kt b/core/network/src/main/kotlin/app/pachli/core/network/retrofit/MastodonApi.kt index 3eae7224d..2efebb10e 100644 --- a/core/network/src/main/kotlin/app/pachli/core/network/retrofit/MastodonApi.kt +++ b/core/network/src/main/kotlin/app/pachli/core/network/retrofit/MastodonApi.kt @@ -202,6 +202,14 @@ interface MastodonApi { @Body status: NewStatus, ): NetworkResult + @POST("api/v1/statuses") + suspend fun createScheduledStatus( + @Header("Authorization") auth: String, + @Header(DOMAIN_HEADER) domain: String, + @Header("Idempotency-Key") idempotencyKey: String, + @Body status: NewStatus, + ): NetworkResult + @GET("api/v1/statuses/{id}") suspend fun status( @Path("id") statusId: String, diff --git a/core/network/src/test/kotlin/app/pachli/core/network/ServerTest.kt b/core/network/src/test/kotlin/app/pachli/core/network/ServerTest.kt index dad95d246..a6be46667 100644 --- a/core/network/src/test/kotlin/app/pachli/core/network/ServerTest.kt +++ b/core/network/src/test/kotlin/app/pachli/core/network/ServerTest.kt @@ -44,9 +44,10 @@ import app.pachli.core.network.model.Users import app.pachli.core.network.model.nodeinfo.NodeInfo import com.github.michaelbull.result.Ok import com.github.michaelbull.result.Result +import com.google.common.reflect.TypeToken import com.google.common.truth.Truth.assertWithMessage -import com.google.gson.Gson -import com.google.gson.reflect.TypeToken +import com.squareup.moshi.Moshi +import com.squareup.moshi.adapter import io.github.z4kn4fein.semver.toVersion import java.io.BufferedReader import org.junit.Test @@ -274,8 +275,8 @@ class ServerTest( } } -class ServerVersionTest() { - private val gson = Gson() +class ServerVersionTest { + private val moshi = Moshi.Builder().build() private fun loadJsonAsString(fileName: String): String { return javaClass.getResourceAsStream("/$fileName")!! @@ -290,15 +291,15 @@ class ServerVersionTest() { * that have been seen by Fediverse Observer. These version strings * are then parsed and are all expected to parse correctly. */ + @OptIn(ExperimentalStdlibApi::class) @Test fun parseVersionString() { val mapType: TypeToken>> = object : TypeToken>>() {} - val serverVersions = gson.fromJson( - loadJsonAsString("server-versions.json5"), - mapType, - ) + val serverVersions = moshi.adapter>>() + .lenient() + .fromJson(loadJsonAsString("server-versions.json5"))!! // Sanity check that data was loaded correctly. Expect at least 5 distinct keys assertWithMessage("number of server types in server-versions.json5 is too low") diff --git a/core/network/src/test/kotlin/app/pachli/core/network/json/GuardedBooleanAdapterTest.kt b/core/network/src/test/kotlin/app/pachli/core/network/json/GuardedAdapterTest.kt similarity index 85% rename from core/network/src/test/kotlin/app/pachli/core/network/json/GuardedBooleanAdapterTest.kt rename to core/network/src/test/kotlin/app/pachli/core/network/json/GuardedAdapterTest.kt index a0930e70f..385385d9e 100644 --- a/core/network/src/test/kotlin/app/pachli/core/network/json/GuardedBooleanAdapterTest.kt +++ b/core/network/src/test/kotlin/app/pachli/core/network/json/GuardedAdapterTest.kt @@ -1,13 +1,18 @@ 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.gson.Gson -import org.junit.Assert.assertEquals +import com.google.common.truth.Truth.assertThat +import com.squareup.moshi.Moshi +import com.squareup.moshi.adapter import org.junit.Test -class GuardedBooleanAdapterTest { +@OptIn(ExperimentalStdlibApi::class) +class GuardedAdapterTest { - private val gson = Gson() + private val moshi = Moshi.Builder() + .add(GuardedAdapterFactory()) + .build() @Test fun `should deserialize Relationship when attribute 'subscribing' is a boolean`() { @@ -30,7 +35,7 @@ class GuardedBooleanAdapterTest { } """.trimIndent() - assertEquals( + assertThat(moshi.adapter().fromJson(jsonInput)).isEqualTo( Relationship( id = "1", following = true, @@ -45,7 +50,6 @@ class GuardedBooleanAdapterTest { note = "Hi", notifying = false, ), - gson.fromJson(jsonInput, Relationship::class.java), ) } @@ -70,7 +74,7 @@ class GuardedBooleanAdapterTest { } """.trimIndent() - assertEquals( + assertThat(moshi.adapter().fromJson(jsonInput)).isEqualTo( Relationship( id = "2", following = true, @@ -85,7 +89,6 @@ class GuardedBooleanAdapterTest { note = "Hi", notifying = false, ), - gson.fromJson(jsonInput, Relationship::class.java), ) } @@ -109,7 +112,7 @@ class GuardedBooleanAdapterTest { } """.trimIndent() - assertEquals( + assertThat(moshi.adapter().fromJson(jsonInput)).isEqualTo( Relationship( id = "3", following = true, @@ -124,7 +127,6 @@ class GuardedBooleanAdapterTest { note = "Hi", notifying = false, ), - gson.fromJson(jsonInput, Relationship::class.java), ) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d7e075692..34e1d19bd 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -39,7 +39,6 @@ filemoji-compat = "3.2.7" glide = "4.16.0" # Deliberate downgrade, https://github.com/tuskyapp/Tusky/issues/3631 glide-animation-plugin = "2.23.0" -gson = "2.10.1" hilt = "2.50" junit = "4.13.2" kotlin = "1.9.22" @@ -53,6 +52,7 @@ material-drawer = "9.0.2" material-typeface = "4.0.0.2-kotlin" mockito-inline = "5.2.0" mockito-kotlin = "5.2.1" +moshi = "1.15.1" networkresult-calladapter = "1.0.0" okhttp = "4.12.0" quadrant = "1.9.1" @@ -152,7 +152,6 @@ glide-animation-plugin = { module = "com.github.penfeizhou.android.animation:gli glide-compiler = { module = "com.github.bumptech.glide:ksp", version.ref = "glide" } glide-core = { module = "com.github.bumptech.glide:glide", version.ref = "glide" } glide-okhttp3-integration = { module = "com.github.bumptech.glide:okhttp3-integration", version.ref = "glide" } -gson = { module = "com.google.code.gson:gson", version.ref = "gson" } hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" } hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hilt" } @@ -175,10 +174,13 @@ material-typeface = { module = "com.mikepenz:google-material-typeface", version. mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version.ref = "mockito-kotlin" } mockito-inline = { module = "org.mockito:mockito-inline", version.ref = "mockito-inline" } mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" } +moshi = { module = "com.squareup.moshi:moshi", version.ref = "moshi" } +moshi-adapters = { module = "com.squareup.moshi:moshi-adapters", version.ref = "moshi" } +moshi-codegen = { module = "com.squareup.moshi:moshi-kotlin-codegen", version.ref = "moshi" } networkresult-calladapter = { module = "at.connyduck:networkresult-calladapter", version.ref = "networkresult-calladapter" } okhttp-core = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } okhttp-logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" } -retrofit-converter-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "retrofit" } +retrofit-converter-moshi = { module = "com.squareup.retrofit2:converter-moshi", version.ref = "retrofit" } retrofit-core = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } rxjava3-android = { module = "io.reactivex.rxjava3:rxandroid", version.ref = "rxandroid3" } @@ -217,7 +219,7 @@ lint-tests = ["junit", "lint-cli", "lint-tests"] material-drawer = ["material-drawer-core", "material-drawer-iconics"] mockito = ["mockito-kotlin", "mockito-inline"] okhttp = ["okhttp-core", "okhttp-logging-interceptor"] -retrofit = ["retrofit-core", "retrofit-converter-gson"] +retrofit = ["retrofit-core", "retrofit-converter-moshi"] room = ["androidx-room-ktx", "androidx-room-paging"] rxjava3 = ["rxjava3-core", "rxjava3-android", "rxjava3-kotlin"] xmldiff = ["diffx", "xmlwriter"] diff --git a/tools/mkserverversions/build.gradle.kts b/tools/mkserverversions/build.gradle.kts index d27df6163..7871f0f24 100644 --- a/tools/mkserverversions/build.gradle.kts +++ b/tools/mkserverversions/build.gradle.kts @@ -16,6 +16,7 @@ */ plugins { + id("com.google.devtools.ksp") id("com.apollographql.apollo3") version "3.8.2" } @@ -31,7 +32,9 @@ dependencies { implementation("io.github.oshai:kotlin-logging-jvm:5.1.0") implementation("ch.qos.logback:logback-classic:1.4.11") - implementation(libs.gson) + // Moshi + implementation(libs.moshi) + ksp(libs.moshi.codegen) // Testing testImplementation(kotlin("test")) diff --git a/tools/mkserverversions/src/main/kotlin/app/pachli/mkserverversions/Main.kt b/tools/mkserverversions/src/main/kotlin/app/pachli/mkserverversions/Main.kt index c0f4086f2..caa1f2317 100644 --- a/tools/mkserverversions/src/main/kotlin/app/pachli/mkserverversions/Main.kt +++ b/tools/mkserverversions/src/main/kotlin/app/pachli/mkserverversions/Main.kt @@ -25,7 +25,8 @@ import com.github.ajalt.clikt.core.CliktCommand import com.github.ajalt.clikt.core.UsageError import com.github.ajalt.clikt.parameters.options.flag import com.github.ajalt.clikt.parameters.options.option -import com.google.gson.GsonBuilder +import com.squareup.moshi.Moshi +import com.squareup.moshi.adapter import io.github.oshai.kotlinlogging.DelegatingKLogger import io.github.oshai.kotlinlogging.KotlinLogging import java.nio.file.Path @@ -65,6 +66,7 @@ class App : CliktCommand(help = """Update server-versions.json5""") { return null } + @OptIn(ExperimentalStdlibApi::class) override fun run() = runBlocking { System.setProperty("file.encoding", "UTF8") ((log as? DelegatingKLogger<*>)?.underlyingLogger as Logger).level = if (verbose) Level.INFO else Level.WARN @@ -102,8 +104,8 @@ class App : CliktCommand(help = """Update server-versions.json5""") { } } - val gson = GsonBuilder().setPrettyPrinting().create() - w.print(gson.toJson(m)) + val moshi = Moshi.Builder().build() + w.print(moshi.adapter>>().indent(" ").toJson(m)) w.close() } }