diff --git a/app/build.gradle b/app/build.gradle index f801f16085..31ee02b589 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 f089e5f80d..0bcca6c2ac 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 69641cc41f..ccfc4ca624 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 37a0b11b3e..f2162fad75 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 3eef2f8335..d4a20821f6 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 f4b0eb2364..03267c49b2 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 void setupWithStatus(@NonNull StatusViewData.Concrete status, 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 c6ef00e813..627a843443 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 35ec65c7ae..66aa8a1c31 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 99224825de..5ba8f9a061 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 438f3eeba3..d38898c77f 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 b197084df7..944425438a 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 caa264d8f9..14d6ba7f87 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 c4cd2a6f74..2972293ae1 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 68a7dec683..b0e856fd1e 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 6083fad076..231d1c0be2 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 b668b25f83..a2059ba64d 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 @@ private static String bodyForType(Notification notification, Context context, Bo 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 f369f51eb0..b9f77a1db7 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 73b628a491..abbfea9d0f 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 8ad59036e7..4f8251d9ed 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 617df17a15..91d436f63e 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 97125b6f99..625cdf9106 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 1bda78ac0a..8177c2fa0e 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 b5e9c6bdd6..60eed28e05 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 5a7ad9232e..3237d8eadb 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 afde9ebfb0..795707c8d1 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 c999c82109..026978802c 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 3dacaef494..a5928ca0b6 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 aa3d55b749..3cd49baacd 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 3fdc802335..b06ed2afd4 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 e974ce196e..150b4b4d0a 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 58c879e522..15e2f99af7 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 5837815b0b..792c2423ba 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 fe6b0c3ce8..50914132c1 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 c1e938ab00..5823a456f9 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 05cac1a0fa..6d318a3c22 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 554b6cb1d9..177073fb7f 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 c400a1af60..eb6f5a6a5a 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 130831a2d6..c4325a60d1 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 f78cafacdc..5a170d59c0 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 14f9f80c2c..f85be3834b 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 c62ac40909..8947975cee 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 f51af22fff..c8ffa69503 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 a93ccff5ef..7a8c9b17e1 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 e2401d9398..384d6f4d28 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 51067fd830..92e71ba659 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 d79e247b8a..beddfdff1f 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 78572054df..a1ecfb5f9d 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 d1e807f30b..4a552a95ce 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 15910f6216..0202588bd9 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 1a353eadf5..ec0de23c37 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 03a61af54a..25b8b1f927 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 6bdaa14388..d0d84b93f5 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 86a3d8b02d..11d8ae9460 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 9985458404..3ad7f1f41e 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 8de7b957d6..faac322ca5 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 dfaeb499c7..6be354d10a 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 5bc78cf72f..27bce6d8bc 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 ffcd1f60b7..1176752d45 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 ce5bb1440b..35da031093 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 0e77b0fd9f..0e922a70e8 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 d3235337b4..7c378d6cdd 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 98a01d8b94..9b2fc97be3 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 a7d9f88225..649c9ff953 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 e767556c31..6728b232ec 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 7866955596..e8cba0e439 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 2daeec40f6..110a99e95e 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 0c8a298bdc..3d37b38677 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 0000000000..11cb1f3af2 --- /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 0a4c9a059d..0000000000 --- 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 c1241abcc2..0000000000 --- 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 26a7141ec0..d70e4fa3f7 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 083cdeca51..9777867e45 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 8be617389b..2c462caa81 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 118cb01fd4..537c7acdc1 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 ce45dd4140..07d95982e2 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 43c8607751..017eedb971 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 1c29d57aa4..4975c1c42a 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 1724d485c3..2a3daca3ec 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 839b16dfd1..7bcd656fde 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 f1548ef485..6b12da6181 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 ca3bcfa4ee..c6d8ba463a 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 15e05d539d..87ae8e5f59 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 988cd6e441..7ae23d2150 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 b3bac967a8..44b979365c 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"]