From df7b11afc33737eaea35ba024fc4db557ffc3aa2 Mon Sep 17 00:00:00 2001 From: Christophe Beyls Date: Tue, 2 Apr 2024 21:01:04 +0200 Subject: [PATCH] Replace Gson library with Moshi (#4309) **! ! Warning**: Do not merge before testing every API call and database read involving JSON ! **Gson** is obsolete and has been superseded by **Moshi**. But more importantly, parsing Kotlin objects using Gson is _dangerous_ because Gson uses Java serialization and is **not Kotlin-aware**. This has two main consequences: - Fields of non-null types may end up null at runtime. Parsing will succeed, but the code may crash later with a `NullPointerException` when trying to access a field member; - Default values of constructor parameters are always ignored. When absent, reference types will be null, booleans will be false and integers will be zero. On the other hand, Kotlin-aware parsers like **Moshi** or **Kotlin Serialization** will validate at parsing time that all received fields comply with the Kotlin contract and avoid errors at runtime, making apps more stable and schema mismatches easier to detect (as long as logs are accessible): - Receiving a null value for a non-null type will generate a parsing error; - Optional types are declared explicitly by adding a default value. **A missing value with no default value declaration will generate a parsing error.** Migrating the entity declarations from Gson to Moshi will make the code more robust but is not an easy task because of the semantic differences. With Gson, both nullable and optional fields are represented with a null value. After converting to Moshi, some nullable entities can become non-null with a default value (if they are optional and not nullable), others can stay nullable with no default value (if they are mandatory and nullable), and others can become **nullable with a default value of null** (if they are optional _or_ nullable _or_ both). That third option is the safest bet when it's not clear if a field is optional or not, except for lists which can usually be declared as non-null with a default value of an empty list (I have yet to see a nullable array type in the Mastodon API). Fields that are currently declared as non-null present another challenge. In theory, they should remain as-is and everything will work fine. In practice, **because Gson is not aware of nullable types at all**, it's possible that some non-null fields currently hold a null value in some cases but the app does not report any error because the field is not accessed by Kotlin code in that scenario. After migrating to Moshi however, parsing such a field will now fail early if a null value or no value is received. These fields will have to be identified by heavily testing the app and looking for parsing errors (`JsonDataException`) and/or by going through the Mastodon documentation. A default value needs to be added for missing optional fields, and their type could optionally be changed to nullable, depending on the case. Gson is also currently used to serialize and deserialize objects to and from the local database, which is also challenging because backwards compatibility needs to be preserved. Fortunately, by default Gson omits writing null fields, so a field of type `List?` could be replaced with a field of type `List` with a default value of `emptyList()` and reading back the old data should still work. However, nullable lists that are written directly (not as a field of another object) will still be serialized to JSON as `"null"` so the deserializing code must still be handling null properly. Finally, changing the database schema is out of scope for this pull request, so database entities that also happen to be serialized with Gson will keep their original types even if they could be made non-null as an improvement. In the end this is all for the best, because the app will be more reliable and errors will be easier to detect by showing up earlier with a clear error message. Not to mention the performance benefits of using Moshi compared to Gson. - Replace Gson reflection with Moshi Kotlin codegen to generate all parsers at compile time. - Replace custom `Rfc3339DateJsonAdapter` with the one provided by moshi-adapters. - Replace custom `JsonDeserializer` classes for Enum types with `EnumJsonAdapter.create(T).withUnknownFallback()` from moshi-adapters to support fallback values. - Replace `GuardedBooleanAdapter` with the more generic `GuardedAdapter` which works with any type. Any nullable field may now be annotated with `@Guarded`. - Remove Proguard rules related to Json entities. Each Json entity needs to be annotated with `@JsonClass` with no exception, and adding this annotation will ensure that R8/Proguard will handle the entities properly. - Replace some nullable Boolean fields with non-null Boolean fields with a default value where possible. - Replace some nullable list fields with non-null list fields with a default value of `emptyList()` where possible. - Update `TimelineDao` to perform all Json conversions internally using `Converters` so no Gson or Moshi instance has to be passed to its methods. - ~~Create a custom `DraftAttachmentJsonAdapter` to serialize and deserialize `DraftAttachment` which is a special entity that supports more than one json name per field. A custom adapter is necessary because there is not direct equivalent of `@SerializedName(alternate = [...])` in Moshi.~~ Remove alternate names for some `DraftAttachment` fields which were used as a workaround to deserialize local data in 2-years old builds of Tusky. - Update tests to make them work with Moshi. - Simplify a few `equals()` implementations. - Change a few functions to `val`s - Turn `NetworkModule` into an `object` (since it contains no abstract methods). Please test the app thoroughly before merging. There may be some fields currently declared as mandatory that are actually optional. --- app/build.gradle | 3 +- app/proguard-rules.pro | 28 -- .../com/keylesspalace/tusky/MigrationsTest.kt | 2 +- .../tusky/adapter/EmojiAdapter.kt | 2 +- .../adapter/ReportNotificationViewHolder.kt | 3 +- .../tusky/adapter/StatusBaseViewHolder.java | 2 +- .../tusky/appstore/CacheUpdater.kt | 10 +- .../components/account/AccountActivity.kt | 8 +- .../components/compose/ComposeViewModel.kt | 2 +- .../conversation/ConversationEntity.kt | 6 +- .../conversation/ConversationViewData.kt | 4 +- .../conversation/ConversationsFragment.kt | 2 +- .../conversation/ConversationsViewModel.kt | 4 +- .../tusky/components/drafts/DraftHelper.kt | 19 +- .../components/login/LoginWebViewViewModel.kt | 4 +- .../notifications/NotificationHelper.java | 2 +- .../preference/AccountPreferencesFragment.kt | 2 +- .../fragments/SearchStatusesFragment.kt | 10 +- .../timeline/TimelineTypeMappers.kt | 81 +++-- .../components/timeline/util/TimelineUtils.kt | 4 +- .../viewmodel/CachedTimelineRemoteMediator.kt | 10 +- .../viewmodel/CachedTimelineViewModel.kt | 14 +- .../viewmodel/NetworkTimelineViewModel.kt | 2 +- .../viewmodel/TrendingTagsViewModel.kt | 2 +- .../viewthread/ViewThreadViewModel.kt | 11 +- .../com/keylesspalace/tusky/db/Converters.kt | 87 ++--- .../com/keylesspalace/tusky/db/DraftEntity.kt | 17 +- .../com/keylesspalace/tusky/db/TimelineDao.kt | 39 ++- .../keylesspalace/tusky/di/NetworkModule.kt | 47 ++- .../keylesspalace/tusky/entity/AccessToken.kt | 6 +- .../com/keylesspalace/tusky/entity/Account.kt | 47 +-- .../tusky/entity/Announcement.kt | 28 +- .../tusky/entity/AppCredentials.kt | 8 +- .../keylesspalace/tusky/entity/Attachment.kt | 58 ++-- .../com/keylesspalace/tusky/entity/Card.kt | 15 +- .../tusky/entity/Conversation.kt | 8 +- .../tusky/entity/DeletedStatus.kt | 21 +- .../com/keylesspalace/tusky/entity/Emoji.kt | 8 +- .../com/keylesspalace/tusky/entity/Error.kt | 6 +- .../com/keylesspalace/tusky/entity/Filter.kt | 11 +- .../tusky/entity/FilterKeyword.kt | 6 +- .../tusky/entity/FilterResult.kt | 7 +- .../keylesspalace/tusky/entity/FilterV1.kt | 11 +- .../com/keylesspalace/tusky/entity/HashTag.kt | 9 +- .../keylesspalace/tusky/entity/Instance.kt | 92 +++-- .../keylesspalace/tusky/entity/InstanceV1.kt | 75 +++-- .../com/keylesspalace/tusky/entity/Marker.kt | 8 +- .../keylesspalace/tusky/entity/MastoList.kt | 9 +- .../tusky/entity/MediaUploadResult.kt | 3 + .../keylesspalace/tusky/entity/NewStatus.kt | 28 +- .../tusky/entity/Notification.kt | 46 +-- .../entity/NotificationSubscribeResult.kt | 6 +- .../com/keylesspalace/tusky/entity/Poll.kt | 19 +- .../tusky/entity/Relationship.kt | 23 +- .../com/keylesspalace/tusky/entity/Report.kt | 10 +- .../tusky/entity/ScheduledStatus.kt | 8 +- .../tusky/entity/SearchResult.kt | 3 + .../com/keylesspalace/tusky/entity/Status.kt | 82 ++--- .../tusky/entity/StatusContext.kt | 3 + .../keylesspalace/tusky/entity/StatusEdit.kt | 12 +- .../tusky/entity/StatusParams.kt | 10 +- .../tusky/entity/StatusSource.kt | 6 +- .../tusky/entity/TimelineAccount.kt | 14 +- .../keylesspalace/tusky/entity/Translation.kt | 17 +- .../tusky/entity/TrendingTagsResult.kt | 9 +- .../keylesspalace/tusky/fragment/SFragment.kt | 12 +- .../{GuardedBooleanAdapter.kt => Guarded.kt} | 29 +- .../tusky/json/GuardedAdapter.kt | 63 ++++ .../keylesspalace/tusky/json/Iso8601Utils.kt | 313 ------------------ .../tusky/json/Rfc3339DateJsonAdapter.kt | 56 ---- .../tusky/network/FilterModel.kt | 4 +- .../receiver/SendStatusBroadcastReceiver.kt | 2 +- .../tusky/util/CustomEmojiHelper.kt | 4 +- .../util/ListStatusAccessibilityDelegate.kt | 2 +- .../tusky/viewdata/PollViewData.kt | 8 +- app/src/main/res/layout/activity_license.xml | 4 +- .../tusky/BottomSheetActivityTest.kt | 2 +- .../com/keylesspalace/tusky/FilterV1Test.kt | 4 +- .../tusky/StatusComparisonTest.kt | 8 +- .../components/compose/ComposeActivityTest.kt | 10 +- .../CachedTimelineRemoteMediatorTest.kt | 26 +- .../tusky/components/timeline/StatusMocker.kt | 10 +- .../viewthread/ViewThreadViewModelTest.kt | 8 +- .../keylesspalace/tusky/db/TimelineDaoTest.kt | 6 +- ...anAdapterTest.kt => GuardedAdapterTest.kt} | 16 +- .../tusky/usecase/TimelineCasesTest.kt | 2 +- gradle/libs.versions.toml | 11 +- 87 files changed, 767 insertions(+), 992 deletions(-) rename app/src/main/java/com/keylesspalace/tusky/json/{GuardedBooleanAdapter.kt => Guarded.kt} (51%) create mode 100644 app/src/main/java/com/keylesspalace/tusky/json/GuardedAdapter.kt delete mode 100644 app/src/main/java/com/keylesspalace/tusky/json/Iso8601Utils.kt delete mode 100644 app/src/main/java/com/keylesspalace/tusky/json/Rfc3339DateJsonAdapter.kt rename app/src/test/java/com/keylesspalace/tusky/json/{GuardedBooleanAdapterTest.kt => GuardedAdapterTest.kt} (89%) diff --git a/app/build.gradle b/app/build.gradle index f801f1608..31ee02b58 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -142,7 +142,8 @@ dependencies { implementation libs.android.material - implementation libs.gson + implementation libs.bundles.moshi + ksp libs.moshi.kotlin.codegen implementation libs.bundles.retrofit implementation libs.networkresult.calladapter diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index f089e5f80..0bcca6c2a 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -39,34 +39,6 @@ # TUSKY SPECIFIC OPTIONS -# keep members of our model classes, they are used in json de/serialization --keepclassmembers class com.keylesspalace.tusky.entity.* { *; } - --keep public enum com.keylesspalace.tusky.entity.*$** { - **[] $VALUES; - public *; -} - --keepclassmembers class com.keylesspalace.tusky.components.conversation.ConversationAccountEntity { *; } --keepclassmembers class com.keylesspalace.tusky.db.DraftAttachment { *; } - --keep enum com.keylesspalace.tusky.db.DraftAttachment$Type { - public *; -} - -# 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 - # preserve line numbers for crash reporting -keepattributes SourceFile,LineNumberTable -renamesourcefileattribute SourceFile diff --git a/app/src/androidTest/java/com/keylesspalace/tusky/MigrationsTest.kt b/app/src/androidTest/java/com/keylesspalace/tusky/MigrationsTest.kt index 69641cc41..ccfc4ca62 100644 --- a/app/src/androidTest/java/com/keylesspalace/tusky/MigrationsTest.kt +++ b/app/src/androidTest/java/com/keylesspalace/tusky/MigrationsTest.kt @@ -19,7 +19,7 @@ class MigrationsTest { @Rule var helper: MigrationTestHelper = MigrationTestHelper( InstrumentationRegistry.getInstrumentation(), - AppDatabase::class.java.canonicalName, + AppDatabase::class.java.canonicalName!!, FrameworkSQLiteOpenHelperFactory() ) diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/EmojiAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/EmojiAdapter.kt index 37a0b11b3..f2162fad7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/EmojiAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/EmojiAdapter.kt @@ -31,7 +31,7 @@ class EmojiAdapter( private val animate: Boolean ) : RecyclerView.Adapter>() { - private val emojiList: List = emojiList.filter { emoji -> emoji.visibleInPicker == null || emoji.visibleInPicker } + private val emojiList: List = emojiList.filter { emoji -> emoji.visibleInPicker } .sortedBy { it.shortcode.lowercase(Locale.ROOT) } override fun getItemCount() = emojiList.size diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/ReportNotificationViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/ReportNotificationViewHolder.kt index 3eef2f833..d4a20821f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/ReportNotificationViewHolder.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/ReportNotificationViewHolder.kt @@ -28,7 +28,6 @@ import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.getRelativeTimeSpanString import com.keylesspalace.tusky.util.loadAvatar import com.keylesspalace.tusky.util.unicodeWrap -import java.util.Date class ReportNotificationViewHolder( private val binding: ItemReportNotificationBinding @@ -54,7 +53,7 @@ class ReportNotificationViewHolder( binding.notificationTopText.setCompoundDrawablesWithIntrinsicBounds(icon, null, null, null) binding.notificationTopText.text = itemView.context.getString(R.string.notification_header_report_format, reporterName, reporteeName) - 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) + binding.notificationSummary.text = itemView.context.getString(R.string.notification_summary_report_format, getRelativeTimeSpanString(itemView.context, report.createdAt.time, System.currentTimeMillis()), report.statusIds?.size ?: 0) binding.notificationCategory.text = getTranslatedCategory(itemView.context, report.category) // Fancy avatar inset diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java index f4b0eb236..03267c49b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java @@ -815,7 +815,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { setTranslationStatus(status, listener); - setRebloggingEnabled(actionable.rebloggingAllowed(), actionable.getVisibility()); + setRebloggingEnabled(actionable.isRebloggingAllowed(), actionable.getVisibility()); setSpoilerAndContent(status, statusDisplayOptions, listener); diff --git a/app/src/main/java/com/keylesspalace/tusky/appstore/CacheUpdater.kt b/app/src/main/java/com/keylesspalace/tusky/appstore/CacheUpdater.kt index c6ef00e81..627a84344 100644 --- a/app/src/main/java/com/keylesspalace/tusky/appstore/CacheUpdater.kt +++ b/app/src/main/java/com/keylesspalace/tusky/appstore/CacheUpdater.kt @@ -1,6 +1,5 @@ package com.keylesspalace.tusky.appstore -import com.google.gson.Gson import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase import javax.inject.Inject @@ -13,8 +12,7 @@ import kotlinx.coroutines.launch class CacheUpdater @Inject constructor( eventHub: EventHub, accountManager: AccountManager, - appDatabase: AppDatabase, - gson: Gson + appDatabase: AppDatabase ) { private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) @@ -30,8 +28,7 @@ class CacheUpdater @Inject constructor( val status = event.status timelineDao.update( accountId = accountId, - status = status, - gson = gson + status = status ) } is UnfollowEvent -> @@ -39,8 +36,7 @@ class CacheUpdater @Inject constructor( is StatusDeletedEvent -> timelineDao.delete(accountId, event.statusId) is PollVoteEvent -> { - val pollString = gson.toJson(event.poll) - timelineDao.setVoted(accountId, event.statusId, pollString) + timelineDao.setVoted(accountId, event.statusId, event.poll) } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt index 35ec65c7a..66aa8a1c3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt @@ -527,8 +527,8 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide ) setClickableText(binding.accountNoteTextView, emojifiedNote, emptyList(), null, this) - accountFieldAdapter.fields = account.fields.orEmpty() - accountFieldAdapter.emojis = account.emojis.orEmpty() + accountFieldAdapter.fields = account.fields + accountFieldAdapter.emojis = account.emojis accountFieldAdapter.notifyDataSetChanged() binding.accountLockedImageView.visible(account.locked) @@ -669,7 +669,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide */ private fun updateRemoteAccount() { loadedAccount?.let { account -> - if (account.isRemote()) { + if (account.isRemote) { binding.accountRemoveView.show() binding.accountRemoveView.setOnClickListener { openLink(account.url) @@ -1097,7 +1097,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide } private fun getFullUsername(account: Account): String { - return if (account.isRemote()) { + return if (account.isRemote) { "@" + account.username } else { val localUsername = account.localUsername diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt index 99224825d..5ba8f9a06 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt @@ -386,7 +386,7 @@ class ComposeViewModel @Inject constructor( val tootToSend = StatusToSend( text = content, warningText = spoilerText, - visibility = _statusVisibility.value.serverString(), + visibility = _statusVisibility.value.serverString, sensitive = attachedMedia.isNotEmpty() && (_markMediaAsSensitive.value || _showContentWarning.value), media = attachedMedia, scheduledAt = _scheduledAt.value, diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt index 438f3eeba..d38898c77 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt @@ -27,6 +27,7 @@ import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.TimelineAccount import com.keylesspalace.tusky.viewdata.StatusViewData +import com.squareup.moshi.JsonClass import java.util.Date @Entity(primaryKeys = ["id", "accountId"]) @@ -50,6 +51,7 @@ data class ConversationEntity( } } +@JsonClass(generateAdapter = true) data class ConversationAccountEntity( val id: String, val localUsername: String, @@ -131,7 +133,7 @@ data class ConversationStatusEntity( poll = poll, card = null, language = language, - filtered = null + filtered = emptyList() ), isExpanded = expanded, isShowingContent = showingHiddenContent, @@ -172,7 +174,7 @@ fun Status.toEntity(expanded: Boolean, contentShowing: Boolean, contentCollapsed showingHiddenContent = contentShowing, expanded = expanded, collapsed = contentCollapsed, - muted = muted ?: false, + muted = muted, poll = poll, language = language ) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewData.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewData.kt index b197084df..944425438 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewData.kt @@ -29,7 +29,7 @@ data class ConversationViewData( accountId: Long, favourited: Boolean = lastStatus.status.favourited, bookmarked: Boolean = lastStatus.status.bookmarked, - muted: Boolean = lastStatus.status.muted ?: false, + muted: Boolean = lastStatus.status.muted, poll: Poll? = lastStatus.status.poll, expanded: Boolean = lastStatus.isExpanded, collapsed: Boolean = lastStatus.isCollapsed, @@ -57,7 +57,7 @@ data class ConversationViewData( fun StatusViewData.Concrete.toConversationStatusEntity( favourited: Boolean = status.favourited, bookmarked: Boolean = status.bookmarked, - muted: Boolean = status.muted ?: false, + muted: Boolean = status.muted, poll: Poll? = status.poll, expanded: Boolean = isExpanded, collapsed: Boolean = isCollapsed, diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt index caa264d8f..14d6ba7f8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt @@ -300,7 +300,7 @@ class ConversationsFragment : val popup = PopupMenu(requireContext(), view) popup.inflate(R.menu.conversation_more) - if (conversation.lastStatus.status.muted == true) { + if (conversation.lastStatus.status.muted) { popup.menu.removeItem(R.id.status_mute_conversation) } else { popup.menu.removeItem(R.id.status_unmute_conversation) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt index c4cd2a6f7..2972293ae 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt @@ -159,12 +159,12 @@ class ConversationsViewModel @Inject constructor( try { timelineCases.muteConversation( conversation.lastStatus.id, - !(conversation.lastStatus.status.muted ?: false) + !conversation.lastStatus.status.muted ) val newConversation = conversation.toEntity( accountId = accountManager.activeAccount!!.id, - muted = !(conversation.lastStatus.status.muted ?: false) + muted = !conversation.lastStatus.status.muted ) database.conversationDao().insert(newConversation) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftHelper.kt b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftHelper.kt index 68a7dec68..b0e856fd1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftHelper.kt @@ -101,16 +101,17 @@ class DraftHelper @Inject constructor( } } - val attachments: MutableList = mutableListOf() - for (i in mediaUris.indices) { - attachments.add( - DraftAttachment( - uriString = uris[i].toString(), - description = mediaDescriptions[i], - focus = mediaFocus[i], - type = types[i] + val attachments: List = buildList(mediaUris.size) { + for (i in mediaUris.indices) { + add( + DraftAttachment( + uriString = uris[i].toString(), + description = mediaDescriptions[i], + focus = mediaFocus[i], + type = types[i] + ) ) - ) + } } val draft = DraftEntity( diff --git a/app/src/main/java/com/keylesspalace/tusky/components/login/LoginWebViewViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/login/LoginWebViewViewModel.kt index 6083fad07..231d1c0be 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/login/LoginWebViewViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/login/LoginWebViewViewModel.kt @@ -41,13 +41,13 @@ class LoginWebViewViewModel @Inject constructor( viewModelScope.launch { api.getInstance().fold( { instance -> - _instanceRules.value = instance.rules.orEmpty().map { rule -> rule.text } + _instanceRules.value = instance.rules.map { rule -> rule.text } }, { throwable -> if (throwable.isHttpNotFound()) { api.getInstanceV1(domain).fold( { instance -> - _instanceRules.value = instance.rules?.map { rule -> rule.text }.orEmpty() + _instanceRules.value = instance.rules.map { rule -> rule.text } }, { throwable -> Log.w( diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java index b668b25f8..a2059ba64 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java @@ -840,7 +840,7 @@ public class NotificationHelper { PollOption option = options.get(i); builder.append(buildDescription(option.getTitle(), PollViewDataKt.calculatePercent(option.getVotesCount(), poll.getVotersCount(), poll.getVotesCount()), - poll.getOwnVotes() != null && poll.getOwnVotes().contains(i), + poll.getOwnVotes().contains(i), context)); builder.append('\n'); } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt index f369f51eb..b9f77a1db 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt @@ -180,7 +180,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { key = PrefKeys.DEFAULT_POST_PRIVACY setSummaryProvider { entry } val visibility = accountManager.activeAccount?.defaultPostPrivacy ?: Status.Visibility.PUBLIC - value = visibility.serverString() + value = visibility.serverString setIcon(getIconForVisibility(visibility)) setOnPreferenceChangeListener { _, newValue -> setIcon( diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt index 73b628a49..abbfea9d0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt @@ -284,7 +284,7 @@ class SearchStatusesFragment : SearchFragment(), Status Status.Visibility.PUBLIC, Status.Visibility.UNLISTED -> { val textId = getString( - if (status.isPinned()) R.string.unpin_action else R.string.pin_action + if (status.pinned) R.string.unpin_action else R.string.pin_action ) menu.add(0, R.id.pin, 1, textId) } @@ -320,7 +320,7 @@ class SearchStatusesFragment : SearchFragment(), Status } if (mutable) { muteConversationItem.setTitle( - if (status.muted == true) { + if (status.muted) { R.string.action_unmute_conversation } else { R.string.action_mute_conversation @@ -392,7 +392,7 @@ class SearchStatusesFragment : SearchFragment(), Status R.id.status_mute_conversation -> { searchAdapter.peek(position)?.let { foundStatus -> - viewModel.muteConversation(foundStatus, status.muted != true) + viewModel.muteConversation(foundStatus, !status.muted) } return@setOnMenuItemClickListener true } @@ -438,7 +438,7 @@ class SearchStatusesFragment : SearchFragment(), Status } R.id.pin -> { - viewModel.pinAccount(status, !status.isPinned()) + viewModel.pinAccount(status, !status.pinned) return@setOnMenuItemClickListener true } @@ -562,7 +562,7 @@ class SearchStatusesFragment : SearchFragment(), Status { deletedStatus -> removeItem(position) - val redraftStatus = if (deletedStatus.isEmpty()) { + val redraftStatus = if (deletedStatus.isEmpty) { status.toDeletedStatus() } else { deletedStatus diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt index 8ad59036e..4f8251d9e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt @@ -13,11 +13,11 @@ * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ +@file:OptIn(ExperimentalStdlibApi::class) + package com.keylesspalace.tusky.components.timeline import android.util.Log -import com.google.gson.Gson -import com.google.gson.reflect.TypeToken import com.keylesspalace.tusky.db.TimelineAccountEntity import com.keylesspalace.tusky.db.TimelineStatusEntity import com.keylesspalace.tusky.db.TimelineStatusWithAccount @@ -30,6 +30,8 @@ import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.TimelineAccount import com.keylesspalace.tusky.viewdata.StatusViewData import com.keylesspalace.tusky.viewdata.TranslationViewData +import com.squareup.moshi.Moshi +import com.squareup.moshi.adapter import java.util.Date private const val TAG = "TimelineTypeMappers" @@ -39,12 +41,7 @@ data class Placeholder( val loading: Boolean ) -private val attachmentArrayListType = object : TypeToken>() {}.type -private val emojisListType = object : TypeToken>() {}.type -private val mentionListType = object : TypeToken>() {}.type -private val tagListType = object : TypeToken>() {}.type - -fun TimelineAccount.toEntity(accountId: Long, gson: Gson): TimelineAccountEntity { +fun TimelineAccount.toEntity(accountId: Long, moshi: Moshi): TimelineAccountEntity { return TimelineAccountEntity( serverId = id, timelineUserId = accountId, @@ -53,12 +50,12 @@ fun TimelineAccount.toEntity(accountId: Long, gson: Gson): TimelineAccountEntity displayName = name, url = url, avatar = avatar, - emojis = gson.toJson(emojis), + emojis = moshi.adapter>().toJson(emojis), bot = bot ) } -fun TimelineAccountEntity.toAccount(gson: Gson): TimelineAccount { +fun TimelineAccountEntity.toAccount(moshi: Moshi): TimelineAccount { return TimelineAccount( id = serverId, localUsername = localUsername, @@ -68,7 +65,7 @@ fun TimelineAccountEntity.toAccount(gson: Gson): TimelineAccount { url = url, avatar = avatar, bot = bot, - emojis = gson.fromJson(emojis, emojisListType) + emojis = moshi.adapter?>().fromJson(emojis).orEmpty() ) } @@ -107,13 +104,13 @@ fun Placeholder.toEntity(timelineUserId: Long): TimelineStatusEntity { card = null, repliesCount = 0, language = null, - filtered = null + filtered = emptyList() ) } fun Status.toEntity( timelineUserId: Long, - gson: Gson, + moshi: Moshi, expanded: Boolean, contentShowing: Boolean, contentCollapsed: Boolean @@ -128,7 +125,7 @@ fun Status.toEntity( content = actionableStatus.content, createdAt = actionableStatus.createdAt.time, editedAt = actionableStatus.editedAt?.time, - emojis = actionableStatus.emojis.let(gson::toJson), + emojis = actionableStatus.emojis.let { moshi.adapter>().toJson(it) }, reblogsCount = actionableStatus.reblogsCount, favouritesCount = actionableStatus.favouritesCount, reblogged = actionableStatus.reblogged, @@ -137,44 +134,44 @@ fun Status.toEntity( sensitive = actionableStatus.sensitive, spoilerText = actionableStatus.spoilerText, visibility = actionableStatus.visibility, - attachments = actionableStatus.attachments.let(gson::toJson), - mentions = actionableStatus.mentions.let(gson::toJson), - tags = actionableStatus.tags.let(gson::toJson), - application = actionableStatus.application.let(gson::toJson), + attachments = actionableStatus.attachments.let { moshi.adapter>().toJson(it) }, + mentions = actionableStatus.mentions.let { moshi.adapter>().toJson(it) }, + tags = actionableStatus.tags.let { moshi.adapter?>().toJson(it) }, + application = actionableStatus.application.let { moshi.adapter().toJson(it) }, reblogServerId = reblog?.id, reblogAccountId = reblog?.let { this.account.id }, - poll = actionableStatus.poll.let(gson::toJson), + poll = actionableStatus.poll.let { moshi.adapter().toJson(it) }, muted = actionableStatus.muted, expanded = expanded, contentShowing = contentShowing, contentCollapsed = contentCollapsed, - pinned = actionableStatus.pinned == true, - card = actionableStatus.card?.let(gson::toJson), + pinned = actionableStatus.pinned, + card = actionableStatus.card?.let { moshi.adapter().toJson(it) }, repliesCount = actionableStatus.repliesCount, language = actionableStatus.language, filtered = actionableStatus.filtered ) } -fun TimelineStatusWithAccount.toViewData(gson: Gson, isDetailed: Boolean = false, translation: TranslationViewData? = null): StatusViewData { +fun TimelineStatusWithAccount.toViewData(moshi: Moshi, isDetailed: Boolean = false, translation: TranslationViewData? = null): StatusViewData { if (this.account == null) { Log.d(TAG, "Constructing Placeholder(${this.status.serverId}, ${this.status.expanded})") return StatusViewData.Placeholder(this.status.serverId, this.status.expanded) } - 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) + val attachments: List = status.attachments?.let { moshi.adapter?>().fromJson(it) }.orEmpty() + val mentions: List = status.mentions?.let { moshi.adapter?>().fromJson(it) }.orEmpty() + 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) }.orEmpty() + 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.toAccount(gson), + account = account.toAccount(moshi), inReplyToId = status.inReplyToId, inReplyToAccountId = status.inReplyToAccountId, reblog = null, @@ -195,12 +192,12 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson, isDetailed: Boolean = false tags = tags, application = application, pinned = false, - muted = status.muted, + muted = status.muted ?: false, poll = poll, card = card, repliesCount = status.repliesCount, language = status.language, - filtered = status.filtered, + filtered = status.filtered.orEmpty(), ) } val status = if (reblog != null) { @@ -208,7 +205,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson, isDetailed: Boolean = false id = status.serverId, // no url for reblogs url = null, - account = this.reblogAccount!!.toAccount(gson), + account = this.reblogAccount!!.toAccount(moshi), inReplyToId = null, inReplyToAccountId = null, reblog = reblog, @@ -216,7 +213,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson, isDetailed: Boolean = false // lie but whatever? createdAt = Date(status.createdAt), editedAt = null, - emojis = listOf(), + emojis = emptyList(), reblogsCount = 0, favouritesCount = 0, reblogged = false, @@ -225,23 +222,23 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson, isDetailed: Boolean = false sensitive = false, spoilerText = "", visibility = status.visibility, - attachments = ArrayList(), - mentions = listOf(), - tags = listOf(), + attachments = emptyList(), + mentions = emptyList(), + tags = emptyList(), application = null, pinned = status.pinned, - muted = status.muted, + muted = status.muted ?: false, poll = null, card = null, repliesCount = status.repliesCount, language = status.language, - filtered = status.filtered + filtered = status.filtered.orEmpty() ) } else { Status( id = status.serverId, url = status.url, - account = account.toAccount(gson), + account = account.toAccount(moshi), inReplyToId = status.inReplyToId, inReplyToAccountId = status.inReplyToAccountId, reblog = null, @@ -262,12 +259,12 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson, isDetailed: Boolean = false tags = tags, application = application, pinned = status.pinned, - muted = status.muted, + muted = status.muted ?: false, poll = poll, card = card, repliesCount = status.repliesCount, language = status.language, - filtered = status.filtered + filtered = status.filtered.orEmpty() ) } return StatusViewData.Concrete( diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/util/TimelineUtils.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/util/TimelineUtils.kt index 617df17a1..91d436f63 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/util/TimelineUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/util/TimelineUtils.kt @@ -1,11 +1,11 @@ package com.keylesspalace.tusky.components.timeline.util -import com.google.gson.JsonParseException +import com.squareup.moshi.JsonDataException import java.io.IOException import retrofit2.HttpException fun Throwable.isExpected() = - this is IOException || this is HttpException || this is JsonParseException + this is IOException || this is HttpException || this is JsonDataException inline fun ifExpected(t: Throwable, cb: () -> T): T { if (t.isExpected()) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt index 97125b6f9..625cdf910 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt @@ -21,7 +21,6 @@ import androidx.paging.LoadType import androidx.paging.PagingState import androidx.paging.RemoteMediator import androidx.room.withTransaction -import com.google.gson.Gson import com.keylesspalace.tusky.components.timeline.Placeholder import com.keylesspalace.tusky.components.timeline.toEntity import com.keylesspalace.tusky.components.timeline.util.ifExpected @@ -31,6 +30,7 @@ import com.keylesspalace.tusky.db.TimelineStatusEntity import com.keylesspalace.tusky.db.TimelineStatusWithAccount import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.MastodonApi +import com.squareup.moshi.Moshi import retrofit2.HttpException @OptIn(ExperimentalPagingApi::class) @@ -38,7 +38,7 @@ class CachedTimelineRemoteMediator( accountManager: AccountManager, private val api: MastodonApi, private val db: AppDatabase, - private val gson: Gson + private val moshi: Moshi ) : RemoteMediator() { private var initialRefresh = false @@ -143,8 +143,8 @@ class CachedTimelineRemoteMediator( } for (status in statuses) { - timelineDao.insertAccount(status.account.toEntity(activeAccount.id, gson)) - status.reblog?.account?.toEntity(activeAccount.id, gson)?.let { rebloggedAccount -> + timelineDao.insertAccount(status.account.toEntity(activeAccount.id, moshi)) + status.reblog?.account?.toEntity(activeAccount.id, moshi)?.let { rebloggedAccount -> timelineDao.insertAccount(rebloggedAccount) } @@ -172,7 +172,7 @@ class CachedTimelineRemoteMediator( timelineDao.insertStatus( status.toEntity( timelineUserId = activeAccount.id, - gson = gson, + moshi = moshi, expanded = expanded, contentShowing = contentShowing, contentCollapsed = contentCollapsed diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt index 1bda78ac0..8177c2fa0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt @@ -29,7 +29,6 @@ import androidx.room.withTransaction import at.connyduck.calladapter.networkresult.NetworkResult import at.connyduck.calladapter.networkresult.map import at.connyduck.calladapter.networkresult.onFailure -import com.google.gson.Gson import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.components.preference.PreferencesFragment.ReadingOrder.NEWEST_FIRST import com.keylesspalace.tusky.components.preference.PreferencesFragment.ReadingOrder.OLDEST_FIRST @@ -49,6 +48,7 @@ import com.keylesspalace.tusky.usecase.TimelineCases import com.keylesspalace.tusky.util.EmptyPagingSource import com.keylesspalace.tusky.viewdata.StatusViewData import com.keylesspalace.tusky.viewdata.TranslationViewData +import com.squareup.moshi.Moshi import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.asExecutor @@ -69,7 +69,7 @@ class CachedTimelineViewModel @Inject constructor( sharedPreferences: SharedPreferences, filterModel: FilterModel, private val db: AppDatabase, - private val gson: Gson + private val moshi: Moshi ) : TimelineViewModel( timelineCases, api, @@ -87,7 +87,7 @@ class CachedTimelineViewModel @Inject constructor( @OptIn(ExperimentalPagingApi::class) override val statuses = Pager( config = PagingConfig(pageSize = LOAD_AT_ONCE), - remoteMediator = CachedTimelineRemoteMediator(accountManager, api, db, gson), + remoteMediator = CachedTimelineRemoteMediator(accountManager, api, db, moshi), pagingSourceFactory = { val activeAccount = accountManager.activeAccount if (activeAccount == null) { @@ -108,7 +108,7 @@ class CachedTimelineViewModel @Inject constructor( pagingData.map(Dispatchers.Default.asExecutor()) { timelineStatus -> val translation = translations[timelineStatus.status.serverId] timelineStatus.toViewData( - gson, + moshi, isDetailed = false, translation = translation ) @@ -218,15 +218,15 @@ class CachedTimelineViewModel @Inject constructor( } for (status in statuses) { - timelineDao.insertAccount(status.account.toEntity(activeAccount.id, gson)) - status.reblog?.account?.toEntity(activeAccount.id, gson) + timelineDao.insertAccount(status.account.toEntity(activeAccount.id, moshi)) + status.reblog?.account?.toEntity(activeAccount.id, moshi) ?.let { rebloggedAccount -> timelineDao.insertAccount(rebloggedAccount) } timelineDao.insertStatus( status.toEntity( timelineUserId = activeAccount.id, - gson = gson, + moshi = moshi, expanded = activeAccount.alwaysOpenSpoiler, contentShowing = activeAccount.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive, contentCollapsed = true diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt index b5e9c6bdd..60eed28e0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt @@ -259,7 +259,7 @@ class NetworkTimelineViewModel @Inject constructor( override fun clearWarning(status: StatusViewData.Concrete) { updateActionableStatusById(status.id) { - it.copy(filtered = null) + it.copy(filtered = emptyList()) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/trending/viewmodel/TrendingTagsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/trending/viewmodel/TrendingTagsViewModel.kt index 5a7ad9232..3237d8ead 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/trending/viewmodel/TrendingTagsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/trending/viewmodel/TrendingTagsViewModel.kt @@ -104,7 +104,7 @@ class TrendingTagsViewModel @Inject constructor( .sortedByDescending { tag -> tag.history.sumOf { it.uses.toLongOrNull() ?: 0 } } .toViewData() - val header = TrendingViewData.Header(firstTag.start(), firstTag.end()) + val header = TrendingViewData.Header(firstTag.start, firstTag.end) TrendingTagsUiState(listOf(header) + tags, LoadingState.LOADED) } }, diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModel.kt index afde9ebfb..795707c8d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModel.kt @@ -24,7 +24,6 @@ import at.connyduck.calladapter.networkresult.getOrElse import at.connyduck.calladapter.networkresult.getOrThrow import at.connyduck.calladapter.networkresult.map import at.connyduck.calladapter.networkresult.onFailure -import com.google.gson.Gson import com.keylesspalace.tusky.appstore.BlockEvent import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.StatusChangedEvent @@ -44,6 +43,7 @@ import com.keylesspalace.tusky.util.isHttpNotFound import com.keylesspalace.tusky.util.toViewData import com.keylesspalace.tusky.viewdata.StatusViewData import com.keylesspalace.tusky.viewdata.TranslationViewData +import com.squareup.moshi.Moshi import javax.inject.Inject import kotlinx.coroutines.Job import kotlinx.coroutines.async @@ -64,7 +64,7 @@ class ViewThreadViewModel @Inject constructor( eventHub: EventHub, private val accountManager: AccountManager, private val db: AppDatabase, - private val gson: Gson + private val moshi: Moshi ) : ViewModel() { private val _uiState = MutableStateFlow(ThreadUiState.Loading as ThreadUiState) @@ -113,7 +113,7 @@ class ViewThreadViewModel @Inject constructor( var detailedStatus = if (timelineStatus != null) { Log.d(TAG, "Loaded status from local timeline") val viewData = timelineStatus.toViewData( - gson, + moshi, isDetailed = true, ) as StatusViewData.Concrete @@ -148,8 +148,7 @@ class ViewThreadViewModel @Inject constructor( api.status(id).getOrNull()?.let { result -> db.timelineDao().update( accountId = accountManager.activeAccount!!.id, - status = result, - gson = gson + status = result ) detailedStatus = result.toViewData(isDetailed = true) } @@ -520,7 +519,7 @@ class ViewThreadViewModel @Inject constructor( fun clearWarning(viewData: StatusViewData.Concrete) { updateStatus(viewData.id) { status -> - status.copy(filtered = null) + status.copy(filtered = emptyList()) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt b/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt index c999c8210..026978802 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt @@ -17,38 +17,40 @@ package com.keylesspalace.tusky.db import androidx.room.ProvidedTypeConverter import androidx.room.TypeConverter -import com.google.gson.Gson -import com.google.gson.reflect.TypeToken import com.keylesspalace.tusky.TabData import com.keylesspalace.tusky.components.conversation.ConversationAccountEntity import com.keylesspalace.tusky.createTabDataFromId import com.keylesspalace.tusky.entity.Attachment +import com.keylesspalace.tusky.entity.Card import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.FilterResult import com.keylesspalace.tusky.entity.HashTag import com.keylesspalace.tusky.entity.NewPoll import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Status +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(emojiListJson: String?): List { + return emojiListJson?.let { moshi.adapter?>().fromJson(it) }.orEmpty() } @TypeConverter - fun emojiListToJson(emojiList: List?): String { - return gson.toJson(emojiList) + fun emojiListToJson(emojiList: List): String { + return moshi.adapter>().toJson(emojiList) } @TypeConverter @@ -83,55 +85,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) + fun accountListToJson(accountList: List): String { + return moshi.adapter>().toJson(accountList) } @TypeConverter - fun jsonToAccountList(accountListJson: String?): List? { - return gson.fromJson( - accountListJson, - object : TypeToken>() {}.type - ) + fun jsonToAccountList(accountListJson: String?): List { + return accountListJson?.let { moshi.adapter?>().fromJson(it) }.orEmpty() } @TypeConverter - fun attachmentListToJson(attachmentList: List?): String { - return gson.toJson(attachmentList) + fun attachmentListToJson(attachmentList: List): String { + return moshi.adapter>().toJson(attachmentList) } @TypeConverter - fun jsonToAttachmentList(attachmentListJson: String?): List? { - return gson.fromJson(attachmentListJson, object : TypeToken>() {}.type) + fun jsonToAttachmentList(attachmentListJson: String?): List { + return attachmentListJson?.let { moshi.adapter?>().fromJson(it) }.orEmpty() } @TypeConverter - fun mentionListToJson(mentionArray: List?): String? { - return gson.toJson(mentionArray) + fun mentionListToJson(mentionArray: List): String { + return moshi.adapter>().toJson(mentionArray) } @TypeConverter - fun jsonToMentionArray(mentionListJson: String?): List? { - return gson.fromJson(mentionListJson, object : TypeToken>() {}.type) + fun jsonToMentionArray(mentionListJson: String?): List { + return mentionListJson?.let { moshi.adapter?>().fromJson(it) }.orEmpty() } @TypeConverter - fun tagListToJson(tagArray: List?): String? { - return gson.toJson(tagArray) + fun tagListToJson(tagArray: List?): String { + 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 @@ -145,45 +144,47 @@ class Converters @Inject constructor( } @TypeConverter - fun pollToJson(poll: Poll?): String? { - return gson.toJson(poll) + fun pollToJson(poll: Poll?): String { + 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) + fun newPollToJson(newPoll: NewPoll?): String { + 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) + fun draftAttachmentListToJson(draftAttachments: List): String { + return moshi.adapter>().toJson(draftAttachments) } @TypeConverter - fun jsonToDraftAttachmentList(draftAttachmentListJson: String?): List? { - return gson.fromJson( - draftAttachmentListJson, - object : TypeToken>() {}.type - ) + fun jsonToDraftAttachmentList(draftAttachmentListJson: String?): List { + return draftAttachmentListJson?.let { moshi.adapter?>().fromJson(it) }.orEmpty() } @TypeConverter - fun filterResultListToJson(filterResults: List?): String? { - return gson.toJson(filterResults) + fun filterResultListToJson(filterResults: List?): String { + 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 cardToJson(card: Card?): String { + return moshi.adapter().toJson(card) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/db/DraftEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/DraftEntity.kt index 3dacaef49..a5928ca0b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/DraftEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/DraftEntity.kt @@ -21,10 +21,10 @@ import androidx.core.net.toUri import androidx.room.Entity import androidx.room.PrimaryKey import androidx.room.TypeConverters -import com.google.gson.annotations.SerializedName import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.NewPoll import com.keylesspalace.tusky.entity.Status +import com.squareup.moshi.JsonClass import kotlinx.parcelize.Parcelize @Entity @@ -46,21 +46,18 @@ 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 - */ +@JsonClass(generateAdapter = true) @Parcelize 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 + val uriString: String, + val description: String?, + val focus: Attachment.Focus?, + val type: Type ) : Parcelable { val uri: Uri get() = uriString.toUri() + @JsonClass(generateAdapter = false) enum class Type { IMAGE, VIDEO, diff --git a/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt index aa3d55b74..3cd49baac 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt @@ -21,7 +21,11 @@ import androidx.room.Insert import androidx.room.OnConflictStrategy.Companion.REPLACE import androidx.room.Query import androidx.room.TypeConverters -import com.google.gson.Gson +import com.keylesspalace.tusky.entity.Attachment +import com.keylesspalace.tusky.entity.Card +import com.keylesspalace.tusky.entity.Emoji +import com.keylesspalace.tusky.entity.HashTag +import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Status @Dao @@ -89,13 +93,13 @@ AND ) abstract suspend fun deleteRange(accountId: Long, minId: String, maxId: String): Int - suspend fun update(accountId: Long, status: Status, gson: Gson) { + suspend fun update(accountId: Long, status: Status) { update( accountId = accountId, statusId = status.id, content = status.content, editedAt = status.editedAt?.time, - emojis = gson.toJson(status.emojis), + emojis = status.emojis, reblogsCount = status.reblogsCount, favouritesCount = status.favouritesCount, repliesCount = status.repliesCount, @@ -105,13 +109,13 @@ AND sensitive = status.sensitive, spoilerText = status.spoilerText, visibility = status.visibility, - attachments = gson.toJson(status.attachments), - mentions = gson.toJson(status.mentions), - tags = gson.toJson(status.tags), - poll = gson.toJson(status.poll), + attachments = status.attachments, + mentions = status.mentions, + tags = status.tags, + poll = status.poll, muted = status.muted, - pinned = status.pinned ?: false, - card = gson.toJson(status.card), + pinned = status.pinned, + card = status.card, language = status.language ) } @@ -141,12 +145,12 @@ AND WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)""" ) @TypeConverters(Converters::class) - abstract suspend fun update( + protected abstract suspend fun update( accountId: Long, statusId: String, content: String?, editedAt: Long?, - emojis: String?, + emojis: List, reblogsCount: Int, favouritesCount: Int, repliesCount: Int, @@ -156,13 +160,13 @@ AND sensitive: Boolean, spoilerText: String, visibility: Status.Visibility, - attachments: String?, - mentions: String?, - tags: String?, - poll: String?, + attachments: List, + mentions: List, + tags: List?, + poll: Poll?, muted: Boolean?, pinned: Boolean, - card: String?, + card: Card?, language: String? ) @@ -243,7 +247,8 @@ AND serverId = :statusId""" """UPDATE TimelineStatusEntity SET poll = :poll WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)""" ) - abstract suspend fun setVoted(accountId: Long, statusId: String, poll: String) + @TypeConverters(Converters::class) + abstract suspend fun setVoted(accountId: Long, statusId: String, poll: Poll) @Query( """UPDATE TimelineStatusEntity SET expanded = :expanded diff --git a/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt index 3fdc80233..b06ed2afd 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt @@ -20,11 +20,12 @@ import android.content.SharedPreferences import android.os.Build import android.util.Log import at.connyduck.calladapter.networkresult.NetworkResultCallAdapterFactory -import com.google.gson.Gson -import com.google.gson.GsonBuilder import com.keylesspalace.tusky.BuildConfig import com.keylesspalace.tusky.db.AccountManager -import com.keylesspalace.tusky.json.Rfc3339DateJsonAdapter +import com.keylesspalace.tusky.entity.Attachment +import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.json.GuardedAdapter import com.keylesspalace.tusky.network.InstanceSwitchAuthInterceptor import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MediaUploadApi @@ -33,6 +34,9 @@ import com.keylesspalace.tusky.settings.PrefKeys.HTTP_PROXY_PORT import com.keylesspalace.tusky.settings.PrefKeys.HTTP_PROXY_SERVER import com.keylesspalace.tusky.settings.ProxyConfiguration import com.keylesspalace.tusky.util.getNonNullString +import com.squareup.moshi.Moshi +import com.squareup.moshi.adapters.EnumJsonAdapter +import com.squareup.moshi.adapters.Rfc3339DateJsonAdapter import dagger.Module import dagger.Provides import java.net.IDN @@ -46,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 /** @@ -54,13 +58,32 @@ import retrofit2.create */ @Module -class NetworkModule { +object NetworkModule { + + private const val TAG = "NetworkModule" @Provides @Singleton - fun providesGson(): Gson = GsonBuilder() - .registerTypeAdapter(Date::class.java, Rfc3339DateJsonAdapter()) - .create() + fun providesMoshi(): Moshi = Moshi.Builder() + .add(Date::class.java, Rfc3339DateJsonAdapter()) + .add(GuardedAdapter.ANNOTATION_FACTORY) + // Enum types with fallback value + .add( + Attachment.Type::class.java, + EnumJsonAdapter.create(Attachment.Type::class.java) + .withUnknownFallback(Attachment.Type.UNKNOWN) + ) + .add( + Notification.Type::class.java, + EnumJsonAdapter.create(Notification.Type::class.java) + .withUnknownFallback(Notification.Type.UNKNOWN) + ) + .add( + Status.Visibility::class.java, + EnumJsonAdapter.create(Status.Visibility::class.java) + .withUnknownFallback(Status.Visibility.UNKNOWN) + ) + .build() @Provides @Singleton @@ -113,10 +136,10 @@ class NetworkModule { @Provides @Singleton - fun providesRetrofit(httpClient: OkHttpClient, gson: Gson): Retrofit { + fun providesRetrofit(httpClient: OkHttpClient, 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() } @@ -138,8 +161,4 @@ class NetworkModule { .build() .create() } - - companion object { - private const val TAG = "NetworkModule" - } } diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/AccessToken.kt b/app/src/main/java/com/keylesspalace/tusky/entity/AccessToken.kt index e974ce196..150b4b4d0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/AccessToken.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/AccessToken.kt @@ -15,8 +15,10 @@ package com.keylesspalace.tusky.entity -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/app/src/main/java/com/keylesspalace/tusky/entity/Account.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Account.kt index 58c879e52..15e2f99af 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Account.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Account.kt @@ -15,32 +15,34 @@ package com.keylesspalace.tusky.entity -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?, - @SerializedName("created_at") val createdAt: Date, + @Json(name = "display_name") val displayName: String? = null, + @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 - val emojis: List? = emptyList(), - // nullable for backward compatibility - val fields: List? = emptyList(), + // default value for backward compatibility + val emojis: List = emptyList(), + // default value for backward compatibility + val fields: List = emptyList(), val moved: Account? = null, - val roles: List? = emptyList() + val roles: List = emptyList() ) { val name: String @@ -50,28 +52,33 @@ data class Account( displayName } - fun isRemote(): Boolean = this.username != this.localUsername + val isRemote: Boolean + get() = this.username != this.localUsername } +@JsonClass(generateAdapter = true) data class AccountSource( - val privacy: Status.Visibility?, - val sensitive: Boolean?, - val note: String?, - val fields: List?, - val language: String? + val privacy: Status.Visibility = Status.Visibility.PUBLIC, + val sensitive: Boolean? = null, + val note: String? = null, + val fields: List = emptyList(), + val language: String? = null ) +@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? = null ) +@JsonClass(generateAdapter = true) data class StringField( val name: String, val value: String ) +@JsonClass(generateAdapter = true) data class Role( val name: String, val color: String diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Announcement.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Announcement.kt index 5837815b0..792c2423b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Announcement.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Announcement.kt @@ -15,18 +15,20 @@ package com.keylesspalace.tusky.entity -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, - val read: Boolean, + @Json(name = "starts_at") val startsAt: Date? = null, + @Json(name = "ends_at") val endsAt: Date? = null, + @Json(name = "all_day") val allDay: Boolean, + @Json(name = "published_at") val publishedAt: Date, + @Json(name = "updated_at") val updatedAt: Date, + val read: Boolean = false, val mentions: List, val statuses: List, val tags: List, @@ -36,21 +38,21 @@ data class Announcement( override fun equals(other: Any?): Boolean { if (this === other) return true - if (other == null || javaClass != other.javaClass) return false + if (other !is Announcement) return false - val announcement = other as Announcement? - return id == announcement?.id + return id == other.id } override fun hashCode(): Int { 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? + val me: Boolean = false, + val url: String? = null, + @Json(name = "static_url") val staticUrl: String? = null ) } diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/AppCredentials.kt b/app/src/main/java/com/keylesspalace/tusky/entity/AppCredentials.kt index fe6b0c3ce..50914132c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/AppCredentials.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/AppCredentials.kt @@ -15,9 +15,11 @@ package com.keylesspalace.tusky.entity -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/app/src/main/java/com/keylesspalace/tusky/entity/Attachment.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Attachment.kt index c1e938ab0..5823a456f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Attachment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Attachment.kt @@ -16,70 +16,50 @@ package com.keylesspalace.tusky.entity 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 +@JsonClass(generateAdapter = true) @Parcelize data class Attachment( val id: String, val url: String, // can be null for e.g. audio attachments - @SerializedName("preview_url") val previewUrl: String?, - val meta: MetaData?, + @Json(name = "preview_url") val previewUrl: String? = null, + val meta: MetaData? = null, val type: Type, - val description: String?, - val blurhash: String? + val description: String? = null, + val blurhash: String? = null ) : Parcelable { - @JsonAdapter(MediaTypeDeserializer::class) + @JsonClass(generateAdapter = false) 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") 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]. */ + @JsonClass(generateAdapter = true) @Parcelize data class MetaData( - val focus: Focus?, - val duration: Float?, - val original: Size?, - val small: Size? + val focus: Focus? = null, + val duration: Float? = null, + val original: Size? = null, + val small: Size? = null ) : Parcelable /** @@ -88,6 +68,7 @@ data class Attachment( * See here for more details what the x and y mean: * https://github.com/jonom/jquery-focuspoint#1-calculate-your-images-focus-point */ + @JsonClass(generateAdapter = true) @Parcelize data class Focus( val x: Float, @@ -99,10 +80,11 @@ data class Attachment( /** * The size of an image, used to specify the width/height. */ + @JsonClass(generateAdapter = true) @Parcelize data class Size( val width: Int, val height: Int, - val aspect: Double + val aspect: Double = 0.0 ) : Parcelable } diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Card.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Card.kt index 05cac1a0f..6d318a3c2 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Card.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Card.kt @@ -15,19 +15,21 @@ package com.keylesspalace.tusky.entity -import com.google.gson.annotations.SerializedName +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +@JsonClass(generateAdapter = true) data class Card( val url: String, val title: String, val description: String, - @SerializedName("author_name") val authorName: String, - val image: String, + @Json(name = "author_name") val authorName: String, + val image: String? = null, val type: String, val width: Int, val height: Int, - val blurhash: String?, - @SerializedName("embed_url") val embedUrl: String? + val blurhash: String? = null, + @Json(name = "embed_url") val embedUrl: String? = null ) { override fun hashCode() = url.hashCode() @@ -36,8 +38,7 @@ data class Card( if (other !is Card) { return false } - val account = other as Card? - return account?.url == this.url + return other.url == this.url } companion object { diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Conversation.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Conversation.kt index 554b6cb1d..177073fb7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Conversation.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Conversation.kt @@ -15,12 +15,14 @@ package com.keylesspalace.tusky.entity -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?, + // should never be null, but apparently it's possible https://github.com/tuskyapp/Tusky/issues/1038 + @Json(name = "last_status") val lastStatus: Status? = null, val unread: Boolean ) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/DeletedStatus.kt b/app/src/main/java/com/keylesspalace/tusky/entity/DeletedStatus.kt index c400a1af6..eb6f5a6a5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/DeletedStatus.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/DeletedStatus.kt @@ -15,21 +15,22 @@ package com.keylesspalace.tusky.entity -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? = null, + @Json(name = "spoiler_text") val spoilerText: String, val visibility: Status.Visibility, val sensitive: Boolean, - @SerializedName("media_attachments") val attachments: List?, - val poll: Poll?, - @SerializedName("created_at") val createdAt: Date, - val language: String? + @Json(name = "media_attachments") val attachments: List, + val poll: Poll? = null, + @Json(name = "created_at") val createdAt: Date, + val language: String? = null ) { - fun isEmpty(): Boolean { - return text == null && attachments == null - } + val isEmpty: Boolean + get() = text == null && attachments.isEmpty() } diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Emoji.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Emoji.kt index 130831a2d..c4325a60d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Emoji.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Emoji.kt @@ -16,13 +16,15 @@ package com.keylesspalace.tusky.entity 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) @Parcelize 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 = true ) : Parcelable diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Error.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Error.kt index f78cafacd..5a170d59c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Error.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Error.kt @@ -17,8 +17,12 @@ package com.keylesspalace.tusky.entity +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? = null ) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Filter.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Filter.kt index 14f9f80c2..f85be3834 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Filter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Filter.kt @@ -1,18 +1,21 @@ package com.keylesspalace.tusky.entity 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 +@JsonClass(generateAdapter = true) @Parcelize 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? = null, + @Json(name = "filter_action") val filterAction: String, + // This field is mandatory according to the API documentation but is in fact optional in some instances + val keywords: List = emptyList(), // val statuses: List, ) : Parcelable { enum class Action(val action: String) { diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/FilterKeyword.kt b/app/src/main/java/com/keylesspalace/tusky/entity/FilterKeyword.kt index c62ac4090..8947975ce 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/FilterKeyword.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/FilterKeyword.kt @@ -1,12 +1,14 @@ package com.keylesspalace.tusky.entity 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) @Parcelize 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/app/src/main/java/com/keylesspalace/tusky/entity/FilterResult.kt b/app/src/main/java/com/keylesspalace/tusky/entity/FilterResult.kt index f51af22ff..c8ffa6950 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/FilterResult.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/FilterResult.kt @@ -1,9 +1,10 @@ package com.keylesspalace.tusky.entity -import com.google.gson.annotations.SerializedName +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? = null, +// @Json(name = "status_matches") val statusMatches: List? = null ) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/FilterV1.kt b/app/src/main/java/com/keylesspalace/tusky/entity/FilterV1.kt index a93ccff5e..7a8c9b17e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/FilterV1.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/FilterV1.kt @@ -15,16 +15,18 @@ package com.keylesspalace.tusky.entity -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? = null, val irreversible: Boolean, - @SerializedName("whole_word") val wholeWord: Boolean + @Json(name = "whole_word") val wholeWord: Boolean ) { companion object { const val HOME = "home" @@ -42,8 +44,7 @@ data class FilterV1( if (other !is FilterV1) { return false } - val filter = other as FilterV1? - return filter?.id.equals(id) + return other.id == id } fun toFilter(): Filter { diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/HashTag.kt b/app/src/main/java/com/keylesspalace/tusky/entity/HashTag.kt index e2401d939..384d6f4d2 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/HashTag.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/HashTag.kt @@ -1,3 +1,10 @@ package com.keylesspalace.tusky.entity -data class HashTag(val name: String, val url: String, val following: Boolean? = null) +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class HashTag( + val name: String, + val url: String, + val following: Boolean? = null +) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Instance.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Instance.kt index 51067fd83..92e71ba65 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Instance.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Instance.kt @@ -1,72 +1,98 @@ package com.keylesspalace.tusky.entity -import com.google.gson.annotations.SerializedName +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +@JsonClass(generateAdapter = true) data class Instance( val domain: String, // val title: String, val version: String, -// @SerializedName("source_url") val sourceUrl: String, +// @Json(name = "source_url") val sourceUrl: String, // val description: String, // val usage: Usage, // val thumbnail: Thumbnail, // val languages: List, - val configuration: Configuration?, + val configuration: Configuration? = null, // val registrations: Registrations, // val contact: Contact, - val rules: List?, - val pleroma: PleromaConfiguration? + val rules: List = emptyList(), + val pleroma: PleromaConfiguration? = null ) { + @JsonClass(generateAdapter = true) data class Usage(val users: Users) { - data class Users(@SerializedName("active_month") val activeMonth: Int) + @JsonClass(generateAdapter = true) + data class Users(@Json(name = "active_month") val activeMonth: Int) } + + @JsonClass(generateAdapter = true) data class Thumbnail( val url: String, - val blurhash: String?, - val versions: Versions? + val blurhash: String? = null, + val versions: Versions? = null ) { + @JsonClass(generateAdapter = true) data class Versions( - @SerializedName("@1x") val at1x: String?, - @SerializedName("@2x") val at2x: String? + @Json(name = "@1x") val at1x: String? = null, + @Json(name = "@2x") val at2x: String? = null ) } + + @JsonClass(generateAdapter = true) data class Configuration( - val urls: Urls?, - val accounts: Accounts?, - val statuses: Statuses?, - @SerializedName("media_attachments") val mediaAttachments: MediaAttachments?, - val polls: Polls?, - val translation: Translation? + val urls: Urls? = null, + val accounts: Accounts? = null, + val statuses: Statuses? = null, + @Json(name = "media_attachments") val mediaAttachments: MediaAttachments? = null, + val polls: Polls? = null, + val translation: Translation? = null ) { - data class Urls(@SerializedName("streaming_api") val streamingApi: String) - data class Accounts(@SerializedName("max_featured_tags") val maxFeaturedTags: Int) + @JsonClass(generateAdapter = true) + data class Urls(@Json(name = "streaming_api") val streamingApi: String? = null) + + @JsonClass(generateAdapter = true) + data class Accounts(@Json(name = "max_featured_tags") val maxFeaturedTags: Int) + + @JsonClass(generateAdapter = true) data class Statuses( - @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? = null, + @Json(name = "max_media_attachments") val maxMediaAttachments: Int? = null, + @Json(name = "characters_reserved_per_url") val charactersReservedPerUrl: Int? = null ) + + @JsonClass(generateAdapter = true) data class MediaAttachments( // Warning: This is an array in mastodon and a dictionary in friendica - // @SerializedName("supported_mime_types") val supportedMimeTypes: List, - @SerializedName("image_size_limit") val imageSizeLimitBytes: Long, - @SerializedName("image_matrix_limit") val imagePixelCountLimit: Long, - @SerializedName("video_size_limit") val videoSizeLimitBytes: Long, - @SerializedName("video_matrix_limit") val videoPixelCountLimit: Long, - @SerializedName("video_frame_rate_limit") val videoFrameRateLimit: Int + // @Json(name = "supported_mime_types") val supportedMimeTypes: List = emptyList(), + @Json(name = "image_size_limit") val imageSizeLimitBytes: Long? = null, + @Json(name = "image_matrix_limit") val imagePixelCountLimit: Long? = null, + @Json(name = "video_size_limit") val videoSizeLimitBytes: Long? = null, + @Json(name = "video_matrix_limit") val videoPixelCountLimit: Long? = null, + @Json(name = "video_frame_rate_limit") val videoFrameRateLimit: Int? = null ) + + @JsonClass(generateAdapter = true) data class Polls( - @SerializedName("max_options") val maxOptions: Int, - @SerializedName("max_characters_per_option") val maxCharactersPerOption: Int, - @SerializedName("min_expiration") val minExpirationSeconds: Int, - @SerializedName("max_expiration") val maxExpirationSeconds: Int + @Json(name = "max_options") val maxOptions: Int? = null, + @Json(name = "max_characters_per_option") val maxCharactersPerOption: Int? = null, + @Json(name = "min_expiration") val minExpirationSeconds: Int? = null, + @Json(name = "max_expiration") val maxExpirationSeconds: Int? = null ) + + @JsonClass(generateAdapter = true) data class Translation(val enabled: Boolean) } + + @JsonClass(generateAdapter = true) data class Registrations( val enabled: Boolean, - @SerializedName("approval_required") val approvalRequired: Boolean, - val message: String? + @Json(name = "approval_required") val approvalRequired: Boolean, + val message: String? = null ) + + @JsonClass(generateAdapter = true) data class Contact(val email: String, val account: Account) + + @JsonClass(generateAdapter = true) data class Rule(val id: String, val text: String) } diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/InstanceV1.kt b/app/src/main/java/com/keylesspalace/tusky/entity/InstanceV1.kt index d79e247b8..beddfdff1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/InstanceV1.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/InstanceV1.kt @@ -15,8 +15,10 @@ package com.keylesspalace.tusky.entity -import com.google.gson.annotations.SerializedName +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +@JsonClass(generateAdapter = true) data class InstanceV1( val uri: String, // val title: String, @@ -27,14 +29,14 @@ 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?, - val configuration: InstanceConfiguration?, - @SerializedName("max_media_attachments") val maxMediaAttachments: Int?, - val pleroma: PleromaConfiguration?, - @SerializedName("upload_limit") val uploadLimit: Int?, - val rules: List? + // @Json(name = "contact_account") val contactAccount: Account?, + @Json(name = "max_toot_chars") val maxTootChars: Int? = null, + @Json(name = "poll_limits") val pollConfiguration: PollConfiguration? = null, + val configuration: InstanceConfiguration? = null, + @Json(name = "max_media_attachments") val maxMediaAttachments: Int? = null, + val pleroma: PleromaConfiguration? = null, + @Json(name = "upload_limit") val uploadLimit: Int? = null, + val rules: List = emptyList() ) { override fun hashCode(): Int { return uri.hashCode() @@ -44,54 +46,61 @@ data class InstanceV1( if (other !is InstanceV1) { return false } - val instance = other as InstanceV1? - return instance?.uri.equals(uri) + return other.uri == uri } } +@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? = null, + @Json(name = "max_option_chars") val maxOptionChars: Int? = null, + @Json(name = "max_characters_per_option") val maxCharactersPerOption: Int? = null, + @Json(name = "min_expiration") val minExpiration: Int? = null, + @Json(name = "max_expiration") val maxExpiration: Int? = null ) +@JsonClass(generateAdapter = true) data class InstanceConfiguration( - val statuses: StatusConfiguration?, - @SerializedName("media_attachments") val mediaAttachments: MediaAttachmentConfiguration?, - val polls: PollConfiguration? + val statuses: StatusConfiguration? = null, + @Json(name = "media_attachments") val mediaAttachments: MediaAttachmentConfiguration? = null, + val polls: PollConfiguration? = null ) +@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? = null, + @Json(name = "max_media_attachments") val maxMediaAttachments: Int? = null, + @Json(name = "characters_reserved_per_url") val charactersReservedPerUrl: Int? = null ) +@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 = emptyList(), + @Json(name = "image_size_limit") val imageSizeLimit: Int? = null, + @Json(name = "image_matrix_limit") val imageMatrixLimit: Int? = null, + @Json(name = "video_size_limit") val videoSizeLimit: Int? = null, + @Json(name = "video_frame_rate_limit") val videoFrameRateLimit: Int? = null, + @Json(name = "video_matrix_limit") val videoMatrixLimit: Int? = null ) +@JsonClass(generateAdapter = true) data class PleromaConfiguration( - val metadata: PleromaMetadata? + val metadata: PleromaMetadata? = null ) +@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? = null, + @Json(name = "name_length") val nameLength: Int? = null, + @Json(name = "value_length") val valueLength: Int? = null ) +@JsonClass(generateAdapter = true) data class InstanceRules( val id: String, val text: String diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Marker.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Marker.kt index 78572054d..a1ecfb5f9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Marker.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Marker.kt @@ -1,15 +1,17 @@ package com.keylesspalace.tusky.entity -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/app/src/main/java/com/keylesspalace/tusky/entity/MastoList.kt b/app/src/main/java/com/keylesspalace/tusky/entity/MastoList.kt index d1e807f30..4a552a95c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/MastoList.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/MastoList.kt @@ -16,17 +16,18 @@ package com.keylesspalace.tusky.entity -import com.google.gson.annotations.SerializedName +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass /** * Created by charlag on 1/4/18. */ - +@JsonClass(generateAdapter = true) data class MastoList( val id: String, val title: String, - val exclusive: Boolean?, - @SerializedName("replies_policy") val repliesPolicy: String? + val exclusive: Boolean? = null, + @Json(name = "replies_policy") val repliesPolicy: String? = null ) { enum class ReplyPolicy(val policy: String) { NONE("none"), diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/MediaUploadResult.kt b/app/src/main/java/com/keylesspalace/tusky/entity/MediaUploadResult.kt index 15910f621..0202588bd 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/MediaUploadResult.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/MediaUploadResult.kt @@ -1,9 +1,12 @@ package com.keylesspalace.tusky.entity +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/app/src/main/java/com/keylesspalace/tusky/entity/NewStatus.kt b/app/src/main/java/com/keylesspalace/tusky/entity/NewStatus.kt index 1a353eadf..ec0de23c3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/NewStatus.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/NewStatus.kt @@ -16,35 +16,39 @@ package com.keylesspalace.tusky.entity 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? = null, val visibility: String, val sensitive: Boolean, - @SerializedName("media_ids") val mediaIds: List?, - @SerializedName("media_attributes") val mediaAttributes: List?, - @SerializedName("scheduled_at") val scheduledAt: String?, - val poll: NewPoll?, - val language: String? + @Json(name = "media_ids") val mediaIds: List = emptyList(), + @Json(name = "media_attributes") val mediaAttributes: List = emptyList(), + @Json(name = "scheduled_at") val scheduledAt: String? = null, + val poll: NewPoll? = null, + val language: String? = null ) +@JsonClass(generateAdapter = true) @Parcelize 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 +@JsonClass(generateAdapter = true) @Parcelize data class MediaAttribute( val id: String, - val description: String?, - val focus: String?, - val thumbnail: String? + val description: String? = null, + val focus: String? = null, + val thumbnail: String? = null ) : Parcelable diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt index 03a61af54..25b8b1f92 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt @@ -16,65 +16,68 @@ package com.keylesspalace.tusky.entity import androidx.annotation.StringRes -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.keylesspalace.tusky.R +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +@JsonClass(generateAdapter = true) data class Notification( val type: Type, val id: String, val account: TimelineAccount, - val status: Status?, - val report: Report? + val status: Status? = null, + val report: Report? = null ) { /** From https://docs.joinmastodon.org/entities/Notification/#type */ - @JsonAdapter(NotificationTypeAdapter::class) + @JsonClass(generateAdapter = false) enum class Type(val presentation: String, @StringRes val uiString: Int) { UNKNOWN("unknown", R.string.notification_unknown_name), /** Someone mentioned you */ + @Json(name = "mention") MENTION("mention", R.string.notification_mention_name), /** Someone boosted one of your statuses */ + @Json(name = "reblog") REBLOG("reblog", R.string.notification_boost_name), /** Someone favourited one of your statuses */ + @Json(name = "favourite") FAVOURITE("favourite", R.string.notification_favourite_name), /** Someone followed you */ + @Json(name = "follow") FOLLOW("follow", R.string.notification_follow_name), /** Someone requested to follow you */ + @Json(name = "follow_request") FOLLOW_REQUEST("follow_request", R.string.notification_follow_request_name), /** A poll you have voted in or created has ended */ + @Json(name = "poll") POLL("poll", R.string.notification_poll_name), /** Someone you enabled notifications for has posted a status */ + @Json(name = "status") STATUS("status", R.string.notification_subscription_name), /** Someone signed up (optionally sent to admins) */ + @Json(name = "admin.sign_up") SIGN_UP("admin.sign_up", R.string.notification_sign_up_name), /** A status you interacted with has been updated */ + @Json(name = "update") UPDATE("update", R.string.notification_update_name), /** A new report has been filed */ + @Json(name = "admin.report") REPORT("admin.report", R.string.notification_report_name); companion object { @JvmStatic fun byString(s: String): Type { - entries.forEach { - if (s == it.presentation) { - return it - } - } - return UNKNOWN + return entries.firstOrNull { it.presentation == s } ?: UNKNOWN } /** Notification types for UI display (omits UNKNOWN) */ @@ -95,20 +98,7 @@ data class Notification( if (other !is Notification) { return false } - val notification = other as 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) - } + return other.id == this.id } /** Helper for Java */ diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/NotificationSubscribeResult.kt b/app/src/main/java/com/keylesspalace/tusky/entity/NotificationSubscribeResult.kt index 6bdaa1438..d0d84b93f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/NotificationSubscribeResult.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/NotificationSubscribeResult.kt @@ -15,10 +15,12 @@ package com.keylesspalace.tusky.entity -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/app/src/main/java/com/keylesspalace/tusky/entity/Poll.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Poll.kt index 86a3d8b02..11d8ae946 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Poll.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Poll.kt @@ -1,25 +1,27 @@ package com.keylesspalace.tusky.entity -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? = null, 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? = null, val options: List, - val voted: Boolean, - @SerializedName("own_votes") val ownVotes: List? + val voted: Boolean = false, + @Json(name = "own_votes") val ownVotes: List = emptyList() ) { fun votedCopy(choices: List): Poll { val newOptions = options.mapIndexed { index, option -> if (choices.contains(index)) { - option.copy(votesCount = option.votesCount + 1) + option.copy(votesCount = (option.votesCount ?: 0) + 1) } else { option } @@ -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? = null ) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Relationship.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Relationship.kt index 998545840..3ad7f1f41 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Relationship.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Relationship.kt @@ -15,27 +15,28 @@ package com.keylesspalace.tusky.entity -import com.google.gson.annotations.JsonAdapter -import com.google.gson.annotations.SerializedName -import com.keylesspalace.tusky.json.GuardedBooleanAdapter +import com.keylesspalace.tusky.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, + @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 GuardedBooleanAdapter to ignore the field if it is not a boolean. + * so we use GuardedAdapter 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?, + val note: String? = null, // since 3.3.0rc - val notifying: Boolean? + val notifying: Boolean? = null ) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Report.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Report.kt index 8de7b957d..faac322ca 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Report.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Report.kt @@ -1,12 +1,14 @@ package com.keylesspalace.tusky.entity -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? = null, + @Json(name = "created_at") val createdAt: Date, + @Json(name = "target_account") val targetAccount: TimelineAccount ) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/ScheduledStatus.kt b/app/src/main/java/com/keylesspalace/tusky/entity/ScheduledStatus.kt index dfaeb499c..6be354d10 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/ScheduledStatus.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/ScheduledStatus.kt @@ -15,11 +15,13 @@ package com.keylesspalace.tusky.entity -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/app/src/main/java/com/keylesspalace/tusky/entity/SearchResult.kt b/app/src/main/java/com/keylesspalace/tusky/entity/SearchResult.kt index 5bc78cf72..27bce6d8b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/SearchResult.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/SearchResult.kt @@ -15,6 +15,9 @@ package com.keylesspalace.tusky.entity +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) data class SearchResult( val accounts: List, val statuses: List, diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt index ffcd1f60b..1176752d4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt @@ -17,44 +17,47 @@ package com.keylesspalace.tusky.entity import android.text.SpannableStringBuilder import android.text.style.URLSpan -import com.google.gson.annotations.SerializedName import com.keylesspalace.tusky.util.parseAsMastodonHtml +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 url: String? = null, val account: TimelineAccount, - @SerializedName("in_reply_to_id") val inReplyToId: String?, - @SerializedName("in_reply_to_account_id") val inReplyToAccountId: String?, - val reblog: Status?, + @Json(name = "in_reply_to_id") val inReplyToId: String? = null, + @Json(name = "in_reply_to_account_id") val inReplyToAccountId: String? = null, + val reblog: Status? = null, 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? = null, val emojis: List, - @SerializedName("reblogs_count") val reblogsCount: Int, - @SerializedName("favourites_count") val favouritesCount: Int, - @SerializedName("replies_count") val repliesCount: Int, - val reblogged: Boolean, - val favourited: Boolean, - val bookmarked: Boolean, + @Json(name = "reblogs_count") val reblogsCount: Int, + @Json(name = "favourites_count") val favouritesCount: Int, + @Json(name = "replies_count") val repliesCount: Int, + val reblogged: Boolean = false, + val favourited: Boolean = false, + val bookmarked: Boolean = false, 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?, - val pinned: Boolean?, - val muted: Boolean?, - val poll: Poll?, + // Use null to mark the absence of tags because of semantic differences in LinkHelper + val tags: List? = null, + val application: Application? = null, + val pinned: Boolean = false, + val muted: Boolean = false, + val poll: Poll? = null, /** Preview card for links included within status content. */ - val card: Card?, + val card: Card? = null, /** ISO 639 language code for this status. */ - val language: String?, + val language: String? = null, /** If the current token has an authorized user: The filter and keywords that matched this status. */ - val filtered: List? + val filtered: List = emptyList() ) { val actionableId: String @@ -70,30 +73,30 @@ data class Status( fun copyWithPoll(poll: Poll?): Status = copy(poll = poll) fun copyWithPinned(pinned: Boolean): Status = copy(pinned = pinned) + @JsonClass(generateAdapter = false) 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); - fun serverString(): String { - return when (this) { + val serverString: String + get() = when (this) { PUBLIC -> "public" UNLISTED -> "unlisted" PRIVATE -> "private" DIRECT -> "direct" UNKNOWN -> "unknown" } - } companion object { @@ -123,13 +126,10 @@ data class Status( } } - fun rebloggingAllowed(): Boolean { - return (visibility != Visibility.DIRECT && visibility != Visibility.UNKNOWN) - } - - fun isPinned(): Boolean { - return pinned ?: false - } + val isRebloggingAllowed: Boolean + get() { + return (visibility != Visibility.DIRECT && visibility != Visibility.UNKNOWN) + } fun toDeletedStatus(): DeletedStatus { return DeletedStatus( @@ -164,16 +164,18 @@ 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? + val website: String? = null ) companion object { diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/StatusContext.kt b/app/src/main/java/com/keylesspalace/tusky/entity/StatusContext.kt index ce5bb1440..35da03109 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/StatusContext.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/StatusContext.kt @@ -15,6 +15,9 @@ package com.keylesspalace.tusky.entity +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) data class StatusContext( val ancestors: List, val descendants: List diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/StatusEdit.kt b/app/src/main/java/com/keylesspalace/tusky/entity/StatusEdit.kt index 0e77b0fd9..0e922a70e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/StatusEdit.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/StatusEdit.kt @@ -1,15 +1,17 @@ package com.keylesspalace.tusky.entity -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, + val poll: Poll? = null, + @Json(name = "media_attachments") val mediaAttachments: List, val emojis: List ) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/StatusParams.kt b/app/src/main/java/com/keylesspalace/tusky/entity/StatusParams.kt index d3235337b..7c378d6cd 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/StatusParams.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/StatusParams.kt @@ -15,12 +15,14 @@ package com.keylesspalace.tusky.entity -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 sensitive: Boolean? = null, 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? = null, + @Json(name = "in_reply_to_id") val inReplyToId: String? = null ) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/StatusSource.kt b/app/src/main/java/com/keylesspalace/tusky/entity/StatusSource.kt index 98a01d8b9..9b2fc97be 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/StatusSource.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/StatusSource.kt @@ -15,10 +15,12 @@ package com.keylesspalace.tusky.entity -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/app/src/main/java/com/keylesspalace/tusky/entity/TimelineAccount.kt b/app/src/main/java/com/keylesspalace/tusky/entity/TimelineAccount.kt index a7d9f8822..649c9ff95 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/TimelineAccount.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/TimelineAccount.kt @@ -15,24 +15,26 @@ package com.keylesspalace.tusky.entity -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 - @SerializedName("display_name") val displayName: String?, + @Json(name = "display_name") val displayName: String? = null, val url: String, val avatar: String, val note: String, val bot: Boolean = false, - // nullable for backward compatibility - val emojis: List? = emptyList() + // optional for backward compatibility + val emojis: List = emptyList() ) { val name: String diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Translation.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Translation.kt index e767556c3..6728b232e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Translation.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Translation.kt @@ -1,7 +1,9 @@ package com.keylesspalace.tusky.entity -import com.google.gson.annotations.SerializedName +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +@JsonClass(generateAdapter = true) data class MediaTranslation( val id: String, val description: String, @@ -12,22 +14,25 @@ data class MediaTranslation( * * See [doc](https://docs.joinmastodon.org/entities/Translation/). */ +@JsonClass(generateAdapter = true) data class Translation( val content: String, - @SerializedName("spoiler_text") - val spoilerText: String?, - val poll: TranslatedPoll?, - @SerializedName("media_attachments") + @Json(name = "spoiler_text") + val spoilerText: String? = null, + val poll: TranslatedPoll? = null, + @Json(name = "media_attachments") val mediaAttachments: List, - @SerializedName("detected_source_language") + @Json(name = "detected_source_language") val detectedSourceLanguage: String, val provider: String, ) +@JsonClass(generateAdapter = true) data class TranslatedPoll( val options: List ) +@JsonClass(generateAdapter = true) data class TranslatedPollOption( val title: String ) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/TrendingTagsResult.kt b/app/src/main/java/com/keylesspalace/tusky/entity/TrendingTagsResult.kt index 786695559..e8cba0e43 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/TrendingTagsResult.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/TrendingTagsResult.kt @@ -15,6 +15,7 @@ package com.keylesspalace.tusky.entity +import com.squareup.moshi.JsonClass import java.util.Date /** @@ -25,6 +26,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 @@ -37,11 +39,14 @@ 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, val uses: String ) -fun TrendingTag.start() = Date(history.last().day.toLong() * 1000L) -fun TrendingTag.end() = Date(history.first().day.toLong() * 1000L) +val TrendingTag.start + get() = Date(history.last().day.toLong() * 1000L) +val TrendingTag.end + get() = Date(history.first().day.toLong() * 1000L) diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.kt index 2daeec40f..110a99e95 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.kt @@ -180,7 +180,7 @@ abstract class SFragment : Fragment(), Injectable { R.id.pin, 1, getString( - if (status.isPinned()) R.string.unpin_action else R.string.pin_action + if (status.pinned) R.string.unpin_action else R.string.pin_action ) ) } @@ -212,7 +212,7 @@ abstract class SFragment : Fragment(), Injectable { muteConversationItem.isVisible = mutable if (mutable) { muteConversationItem.setTitle( - if (status.muted != true) { + if (!status.muted) { R.string.action_mute_conversation } else { R.string.action_unmute_conversation @@ -328,10 +328,10 @@ abstract class SFragment : Fragment(), Injectable { R.id.pin -> { lifecycleScope.launch { - timelineCases.pin(status.id, !status.isPinned()) + timelineCases.pin(status.id, !status.pinned) .onFailure { e: Throwable -> val message = e.message - ?: getString(if (status.isPinned()) R.string.failed_to_unpin else R.string.failed_to_pin) + ?: getString(if (status.pinned) R.string.failed_to_unpin else R.string.failed_to_pin) Snackbar.make(requireView(), message, Snackbar.LENGTH_LONG) .show() } @@ -341,7 +341,7 @@ abstract class SFragment : Fragment(), Injectable { R.id.status_mute_conversation -> { lifecycleScope.launch { - timelineCases.muteConversation(status.id, status.muted != true) + timelineCases.muteConversation(status.id, !status.muted) } return@setOnMenuItemClickListener true } @@ -444,7 +444,7 @@ abstract class SFragment : Fragment(), Injectable { timelineCases.delete(id).fold( { deletedStatus -> removeItem(position) - val sourceStatus = if (deletedStatus.isEmpty()) { + val sourceStatus = if (deletedStatus.isEmpty) { status.toDeletedStatus() } else { deletedStatus diff --git a/app/src/main/java/com/keylesspalace/tusky/json/GuardedBooleanAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/json/Guarded.kt similarity index 51% rename from app/src/main/java/com/keylesspalace/tusky/json/GuardedBooleanAdapter.kt rename to app/src/main/java/com/keylesspalace/tusky/json/Guarded.kt index 0c8a298bd..3d37b3867 100644 --- a/app/src/main/java/com/keylesspalace/tusky/json/GuardedBooleanAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/json/Guarded.kt @@ -1,4 +1,5 @@ -/* Copyright 2022 Tusky Contributors +/* + * Copyright 2024 Tusky Contributors * * This file is a part of Tusky. * @@ -11,27 +12,13 @@ * Public License for more details. * * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . */ + * see . + */ package com.keylesspalace.tusky.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 +import com.squareup.moshi.JsonQualifier -class GuardedBooleanAdapter : JsonDeserializer { - @Throws(JsonParseException::class) - override fun deserialize( - json: JsonElement, - typeOfT: Type, - context: JsonDeserializationContext - ): Boolean? { - return if (json.isJsonObject) { - null - } else { - json.asBoolean - } - } -} +@Retention(AnnotationRetention.RUNTIME) +@JsonQualifier +internal annotation class Guarded diff --git a/app/src/main/java/com/keylesspalace/tusky/json/GuardedAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/json/GuardedAdapter.kt new file mode 100644 index 000000000..11cb1f3af --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/json/GuardedAdapter.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2024 Tusky Contributors + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . + */ + +package com.keylesspalace.tusky.json + +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.JsonDataException +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 + +/** + * This adapter tries to parse the value using a delegated parser + * and returns null in case of error. + */ +class GuardedAdapter private constructor( + private val delegate: JsonAdapter +) : JsonAdapter() { + + override fun fromJson(reader: JsonReader): T? { + return try { + delegate.fromJson(reader) + } catch (e: JsonDataException) { + reader.skipValue() + null + } + } + + override fun toJson(writer: JsonWriter, value: T?) { + delegate.toJson(writer, value) + } + + companion object { + val ANNOTATION_FACTORY = object : Factory { + override fun create( + type: Type, + annotations: Set, + 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/app/src/main/java/com/keylesspalace/tusky/json/Iso8601Utils.kt b/app/src/main/java/com/keylesspalace/tusky/json/Iso8601Utils.kt deleted file mode 100644 index 0a4c9a059..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/json/Iso8601Utils.kt +++ /dev/null @@ -1,313 +0,0 @@ -package com.keylesspalace.tusky.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/app/src/main/java/com/keylesspalace/tusky/json/Rfc3339DateJsonAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/json/Rfc3339DateJsonAdapter.kt deleted file mode 100644 index c1241abcc..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/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 com.keylesspalace.tusky.json - -import android.util.Log -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 - -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) { - Log.w("Rfc3339DateJsonAdapter", jpe) - null - } - } - } - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/network/FilterModel.kt b/app/src/main/java/com/keylesspalace/tusky/network/FilterModel.kt index 26a7141ec..d70e4fa3f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/FilterModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/FilterModel.kt @@ -47,11 +47,11 @@ class FilterModel @Inject constructor() { } } - val matchingKind = status.filtered?.filter { result -> + val matchingKind = status.filtered.filter { result -> result.filter.kinds.contains(kind) } - return if (matchingKind.isNullOrEmpty()) { + return if (matchingKind.isEmpty()) { Filter.Action.NONE } else { matchingKind.maxOf { it.filter.action } diff --git a/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt b/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt index 083cdeca5..9777867e4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt +++ b/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt @@ -94,7 +94,7 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() { StatusToSend( text = text, warningText = spoiler, - visibility = visibility.serverString(), + visibility = visibility.serverString, sensitive = false, media = emptyList(), scheduledAt = null, diff --git a/app/src/main/java/com/keylesspalace/tusky/util/CustomEmojiHelper.kt b/app/src/main/java/com/keylesspalace/tusky/util/CustomEmojiHelper.kt index 8be617389..2c462caa8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/CustomEmojiHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/CustomEmojiHelper.kt @@ -39,8 +39,8 @@ import java.util.regex.Pattern * @param view a reference to the a view the emojis will be shown in (should be the TextView, but parents of the TextView are also acceptable) * @return the text with the shortcodes replaced by EmojiSpans */ -fun CharSequence.emojify(emojis: List?, view: View, animate: Boolean): CharSequence { - if (emojis.isNullOrEmpty()) { +fun CharSequence.emojify(emojis: List, view: View, animate: Boolean): CharSequence { + if (emojis.isEmpty()) { return this } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ListStatusAccessibilityDelegate.kt b/app/src/main/java/com/keylesspalace/tusky/util/ListStatusAccessibilityDelegate.kt index 118cb01fd..537c7acdc 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ListStatusAccessibilityDelegate.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ListStatusAccessibilityDelegate.kt @@ -55,7 +55,7 @@ class ListStatusAccessibilityDelegate( info.addAction(replyAction) val actionable = status.actionable - if (actionable.rebloggingAllowed()) { + if (actionable.isRebloggingAllowed) { info.addAction(if (actionable.reblogged) unreblogAction else reblogAction) } info.addAction(if (actionable.favourited) unfavouriteAction else favouriteAction) diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/PollViewData.kt b/app/src/main/java/com/keylesspalace/tusky/viewdata/PollViewData.kt index ce45dd414..07d95982e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/PollViewData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/PollViewData.kt @@ -43,8 +43,8 @@ data class PollOptionViewData( var voted: Boolean ) -fun calculatePercent(fraction: Int, totalVoters: Int?, totalVotes: Int): Int { - return if (fraction == 0) { +fun calculatePercent(fraction: Int?, totalVoters: Int?, totalVotes: Int): Int { + return if (fraction == null || fraction == 0) { 0 } else { val total = totalVoters ?: totalVotes @@ -76,7 +76,7 @@ fun Poll?.toViewData(): PollViewData? { votersCount = votersCount, options = options.mapIndexed { index, option -> option.toViewData( - ownVotes?.contains(index) == true + ownVotes.contains(index) ) }, voted = voted @@ -86,7 +86,7 @@ fun Poll?.toViewData(): PollViewData? { fun PollOption.toViewData(voted: Boolean): PollOptionViewData { return PollOptionViewData( title = title, - votesCount = votesCount, + votesCount = votesCount ?: 0, selected = false, voted = voted ) diff --git a/app/src/main/res/layout/activity_license.xml b/app/src/main/res/layout/activity_license.xml index 43c860775..017eedb97 100644 --- a/app/src/main/res/layout/activity_license.xml +++ b/app/src/main/res/layout/activity_license.xml @@ -101,8 +101,8 @@ android:layout_marginStart="12dp" android:layout_marginTop="12dp" license:license="@string/license_apache_2" - license:link="https://github.com/google/gson" - license:name="Gson" /> + license:link="https://github.com/square/moshi" + license:name="Moshi" /> ().fromJson(statusJson)!! } } diff --git a/app/src/test/java/com/keylesspalace/tusky/components/compose/ComposeActivityTest.kt b/app/src/test/java/com/keylesspalace/tusky/components/compose/ComposeActivityTest.kt index 1c29d57aa..4975c1c42 100644 --- a/app/src/test/java/com/keylesspalace/tusky/components/compose/ComposeActivityTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/components/compose/ComposeActivityTest.kt @@ -22,7 +22,6 @@ import android.os.Looper.getMainLooper import android.widget.EditText import androidx.test.ext.junit.runners.AndroidJUnit4 import at.connyduck.calladapter.networkresult.NetworkResult -import com.google.gson.Gson import com.keylesspalace.tusky.R import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository import com.keylesspalace.tusky.db.AccountEntity @@ -31,6 +30,7 @@ import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.EmojisEntity import com.keylesspalace.tusky.db.InstanceDao import com.keylesspalace.tusky.db.InstanceInfoEntity +import com.keylesspalace.tusky.di.NetworkModule import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.entity.Instance import com.keylesspalace.tusky.entity.InstanceConfiguration @@ -38,6 +38,7 @@ import com.keylesspalace.tusky.entity.InstanceV1 import com.keylesspalace.tusky.entity.SearchResult import com.keylesspalace.tusky.entity.StatusConfiguration import com.keylesspalace.tusky.network.MastodonApi +import com.squareup.moshi.adapter import java.util.Locale import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob @@ -98,7 +99,7 @@ class ComposeActivityTest { private var instanceV1ResponseCallback: (() -> InstanceV1)? = null private var instanceResponseCallback: (() -> Instance)? = null private var composeOptions: ComposeActivity.ComposeOptions? = null - private val gson = Gson() + private val moshi = NetworkModule.providesMoshi() @Before fun setupActivity() { @@ -583,7 +584,7 @@ class ComposeActivityTest { private fun getConfiguration(maximumStatusCharacters: Int?, charactersReservedPerUrl: Int?): Instance.Configuration { return Instance.Configuration( - Instance.Configuration.Urls(streamingApi = ""), + Instance.Configuration.Urls(), Instance.Configuration.Accounts(1), Instance.Configuration.Statuses( maximumStatusCharacters ?: InstanceInfoRepository.DEFAULT_CHARACTER_LIMIT, @@ -622,8 +623,9 @@ class ComposeActivityTest { ) } + @OptIn(ExperimentalStdlibApi::class) private fun getSampleFriendicaInstance(): Instance { - return gson.fromJson(sampleFriendicaResponse, Instance::class.java) + return moshi.adapter().fromJson(sampleFriendicaResponse)!! } companion object { diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRemoteMediatorTest.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRemoteMediatorTest.kt index 1724d485c..2a3daca3e 100644 --- a/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRemoteMediatorTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRemoteMediatorTest.kt @@ -10,13 +10,13 @@ import androidx.paging.RemoteMediator import androidx.room.Room import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry -import com.google.gson.Gson import com.keylesspalace.tusky.components.timeline.viewmodel.CachedTimelineRemoteMediator import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.Converters import com.keylesspalace.tusky.db.TimelineStatusWithAccount +import com.keylesspalace.tusky.di.NetworkModule import java.io.IOException import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.runBlocking @@ -54,6 +54,8 @@ class CachedTimelineRemoteMediatorTest { private lateinit var db: AppDatabase + private val moshi = NetworkModule.providesMoshi() + @Before @ExperimentalCoroutinesApi fun setup() { @@ -61,7 +63,7 @@ class CachedTimelineRemoteMediatorTest { val context = InstrumentationRegistry.getInstrumentation().targetContext db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java) - .addTypeConverter(Converters(Gson())) + .addTypeConverter(Converters(moshi)) .build() } @@ -80,7 +82,7 @@ class CachedTimelineRemoteMediatorTest { onBlocking { homeTimeline(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } doReturn Response.error(500, "".toResponseBody()) }, db = db, - gson = Gson() + moshi = moshi ) val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state()) } @@ -99,7 +101,7 @@ class CachedTimelineRemoteMediatorTest { onBlocking { homeTimeline(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } doThrow IOException() }, db = db, - gson = Gson() + moshi = moshi ) val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state()) } @@ -115,7 +117,7 @@ class CachedTimelineRemoteMediatorTest { accountManager = accountManager, api = mock(), db = db, - gson = Gson() + moshi = moshi ) val state = state( @@ -166,7 +168,7 @@ class CachedTimelineRemoteMediatorTest { ) }, db = db, - gson = Gson() + moshi = moshi ) val state = state( @@ -229,7 +231,7 @@ class CachedTimelineRemoteMediatorTest { ) }, db = db, - gson = Gson() + moshi = moshi ) val state = state( @@ -289,7 +291,7 @@ class CachedTimelineRemoteMediatorTest { ) }, db = db, - gson = Gson() + moshi = moshi ) val state = state( @@ -334,7 +336,7 @@ class CachedTimelineRemoteMediatorTest { ) }, db = db, - gson = Gson() + moshi = moshi ) val state = state( @@ -385,7 +387,7 @@ class CachedTimelineRemoteMediatorTest { ) }, db = db, - gson = Gson() + moshi = moshi ) val state = state( @@ -441,7 +443,7 @@ class CachedTimelineRemoteMediatorTest { ) }, db = db, - gson = Gson() + moshi = moshi ) val state = state( @@ -493,7 +495,7 @@ class CachedTimelineRemoteMediatorTest { ) }, db = db, - gson = Gson() + moshi = moshi ) val state = state( diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/StatusMocker.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/StatusMocker.kt index 839b16dfd..7bcd656fd 100644 --- a/app/src/test/java/com/keylesspalace/tusky/components/timeline/StatusMocker.kt +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/StatusMocker.kt @@ -1,7 +1,7 @@ package com.keylesspalace.tusky.components.timeline -import com.google.gson.Gson import com.keylesspalace.tusky.db.TimelineStatusWithAccount +import com.keylesspalace.tusky.di.NetworkModule import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.TimelineAccount import com.keylesspalace.tusky.viewdata.StatusViewData @@ -54,7 +54,7 @@ fun mockStatus( poll = null, card = null, language = null, - filtered = null + filtered = emptyList() ) fun mockStatusViewData( @@ -91,19 +91,19 @@ fun mockStatusEntityWithAccount( expanded: Boolean = false ): TimelineStatusWithAccount { val mockedStatus = mockStatus(id) - val gson = Gson() + val moshi = NetworkModule.providesMoshi() return TimelineStatusWithAccount( status = mockedStatus.toEntity( timelineUserId = userId, - gson = gson, + moshi = moshi, expanded = expanded, contentShowing = false, contentCollapsed = true ), account = mockedStatus.account.toEntity( accountId = userId, - gson = gson + moshi = moshi ) ) } diff --git a/app/src/test/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModelTest.kt b/app/src/test/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModelTest.kt index f1548ef48..6b12da618 100644 --- a/app/src/test/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModelTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModelTest.kt @@ -6,7 +6,6 @@ import androidx.room.Room import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import at.connyduck.calladapter.networkresult.NetworkResult -import com.google.gson.Gson import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.StatusChangedEvent import com.keylesspalace.tusky.components.timeline.mockStatus @@ -15,6 +14,7 @@ import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.Converters +import com.keylesspalace.tusky.di.NetworkModule import com.keylesspalace.tusky.entity.StatusContext import com.keylesspalace.tusky.network.FilterModel import com.keylesspalace.tusky.network.MastodonApi @@ -44,6 +44,7 @@ class ViewThreadViewModelTest { private lateinit var db: AppDatabase private val threadId = "1234" + private val moshi = NetworkModule.providesMoshi() /** * Execute each task synchronously. @@ -95,12 +96,11 @@ class ViewThreadViewModelTest { } val context = InstrumentationRegistry.getInstrumentation().targetContext db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java) - .addTypeConverter(Converters(Gson())) + .addTypeConverter(Converters(moshi)) .allowMainThreadQueries() .build() - val gson = Gson() - viewModel = ViewThreadViewModel(api, filterModel, timelineCases, eventHub, accountManager, db, gson) + viewModel = ViewThreadViewModel(api, filterModel, timelineCases, eventHub, accountManager, db, moshi) } @After diff --git a/app/src/test/java/com/keylesspalace/tusky/db/TimelineDaoTest.kt b/app/src/test/java/com/keylesspalace/tusky/db/TimelineDaoTest.kt index ca3bcfa4e..c6d8ba463 100644 --- a/app/src/test/java/com/keylesspalace/tusky/db/TimelineDaoTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/db/TimelineDaoTest.kt @@ -4,9 +4,9 @@ import androidx.paging.PagingSource import androidx.room.Room import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry -import com.google.gson.Gson import com.keylesspalace.tusky.components.timeline.Placeholder import com.keylesspalace.tusky.components.timeline.toEntity +import com.keylesspalace.tusky.di.NetworkModule import com.keylesspalace.tusky.entity.Status import kotlinx.coroutines.runBlocking import org.junit.After @@ -23,11 +23,13 @@ class TimelineDaoTest { private lateinit var timelineDao: TimelineDao private lateinit var db: AppDatabase + private val moshi = NetworkModule.providesMoshi() + @Before fun createDb() { val context = InstrumentationRegistry.getInstrumentation().targetContext db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java) - .addTypeConverter(Converters(Gson())) + .addTypeConverter(Converters(moshi)) .allowMainThreadQueries() .build() timelineDao = db.timelineDao() diff --git a/app/src/test/java/com/keylesspalace/tusky/json/GuardedBooleanAdapterTest.kt b/app/src/test/java/com/keylesspalace/tusky/json/GuardedAdapterTest.kt similarity index 89% rename from app/src/test/java/com/keylesspalace/tusky/json/GuardedBooleanAdapterTest.kt rename to app/src/test/java/com/keylesspalace/tusky/json/GuardedAdapterTest.kt index 15e05d539..87ae8e5f5 100644 --- a/app/src/test/java/com/keylesspalace/tusky/json/GuardedBooleanAdapterTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/json/GuardedAdapterTest.kt @@ -1,13 +1,17 @@ package com.keylesspalace.tusky.json -import com.google.gson.Gson import com.keylesspalace.tusky.entity.Relationship +import com.squareup.moshi.Moshi +import com.squareup.moshi.adapter import org.junit.Assert.assertEquals import org.junit.Test -class GuardedBooleanAdapterTest { +@OptIn(ExperimentalStdlibApi::class) +class GuardedAdapterTest { - private val gson = Gson() + private val moshi = Moshi.Builder() + .add(GuardedAdapter.ANNOTATION_FACTORY) + .build() @Test fun `should deserialize Relationship when attribute 'subscribing' is a boolean`() { @@ -45,7 +49,7 @@ class GuardedBooleanAdapterTest { note = "Hi", notifying = false ), - gson.fromJson(jsonInput, Relationship::class.java) + moshi.adapter().fromJson(jsonInput) ) } @@ -85,7 +89,7 @@ class GuardedBooleanAdapterTest { note = "Hi", notifying = false ), - gson.fromJson(jsonInput, Relationship::class.java) + moshi.adapter().fromJson(jsonInput) ) } @@ -124,7 +128,7 @@ class GuardedBooleanAdapterTest { note = "Hi", notifying = false ), - gson.fromJson(jsonInput, Relationship::class.java) + moshi.adapter().fromJson(jsonInput) ) } } diff --git a/app/src/test/java/com/keylesspalace/tusky/usecase/TimelineCasesTest.kt b/app/src/test/java/com/keylesspalace/tusky/usecase/TimelineCasesTest.kt index 988cd6e44..7ae23d215 100644 --- a/app/src/test/java/com/keylesspalace/tusky/usecase/TimelineCasesTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/usecase/TimelineCasesTest.kt @@ -104,7 +104,7 @@ class TimelineCasesTest { poll = null, card = null, language = null, - filtered = null + filtered = emptyList() ) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b3bac967a..44b979365 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -32,7 +32,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" kotlin = "1.9.23" image-cropper = "4.3.2" material = "1.11.0" @@ -40,6 +39,7 @@ material-drawer = "8.4.5" material-typeface = "4.0.0.2-kotlin" mockito-inline = "5.2.0" mockito-kotlin = "5.2.1" +moshi = "1.15.1" networkresult-calladapter = "1.1.0" okhttp = "4.12.0" retrofit = "2.11.0" @@ -107,7 +107,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" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } image-cropper = { module = "com.github.CanHub:Android-Image-Cropper", version.ref = "image-cropper" } @@ -117,10 +116,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-core = { module = "com.squareup.moshi:moshi", version.ref = "moshi" } +moshi-adapters = { module = "com.squareup.moshi:moshi-adapters", version.ref = "moshi" } +moshi-kotlin-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" } sparkbutton = { module = "at.connyduck.sparkbutton:sparkbutton", version.ref = "sparkbutton" } @@ -143,7 +145,8 @@ filemojicompat = ["filemojicompat-core", "filemojicompat-ui", "filemojicompat-de glide = ["glide-core", "glide-okhttp3-integration", "glide-animation-plugin"] material-drawer = ["material-drawer-core", "material-drawer-iconics"] mockito = ["mockito-kotlin", "mockito-inline"] +moshi = ["moshi-core", "moshi-adapters"] 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"] xmldiff = ["diffx", "xmlwriter"]