From e05fdc6d7be325d6ea6363d377f7b9cea0acaca8 Mon Sep 17 00:00:00 2001 From: Konrad Pozniak Date: Tue, 15 Mar 2022 21:34:57 +0100 Subject: [PATCH] Fix status diffing and improve timeline performance (#2386) * fix status & account diffing * introduce TimelineAccount * use TimelineAccount where possible * improve tests * improve ConversationEntity equals/hashcode * fix mistake in ConversationEntity * improve StatusViewData comparison * improve tests * fix typo in comment --- .../tusky/AccountsInListFragment.kt | 23 +- .../tusky/adapter/AccountAdapter.kt | 12 +- .../tusky/adapter/AccountViewHolder.java | 4 +- .../tusky/adapter/BlocksAdapter.kt | 4 +- .../tusky/adapter/FollowRequestViewHolder.kt | 4 +- .../tusky/adapter/MutesAdapter.kt | 4 +- .../tusky/adapter/NotificationsAdapter.java | 4 +- .../compose/ComposeAutoCompleteAdapter.java | 9 +- .../conversation/ConversationEntity.kt | 17 +- .../search/adapter/SearchAccountsAdapter.kt | 12 +- .../fragments/SearchAccountsFragment.kt | 8 +- .../timeline/TimelinePagingAdapter.kt | 4 +- .../timeline/TimelineTypeMappers.kt | 19 +- .../com/keylesspalace/tusky/entity/Account.kt | 80 ++++--- .../tusky/entity/Conversation.kt | 2 +- .../tusky/entity/Notification.kt | 2 +- .../tusky/entity/SearchResult.kt | 2 +- .../com/keylesspalace/tusky/entity/Status.kt | 67 +++++- .../tusky/entity/TimelineAccount.kt | 39 ++++ .../tusky/fragment/AccountListFragment.kt | 6 +- .../tusky/network/MastodonApi.kt | 19 +- .../tusky/viewdata/NotificationViewData.java | 7 +- .../tusky/viewdata/StatusViewData.kt | 20 +- .../viewmodel/AccountsInListViewModel.kt | 6 +- .../tusky/BottomSheetActivityTest.kt | 12 +- .../tusky/StatusComparisonTest.kt | 216 ++++++++++++++++++ .../tusky/components/timeline/StatusMocker.kt | 8 +- 27 files changed, 463 insertions(+), 147 deletions(-) create mode 100644 app/src/main/java/com/keylesspalace/tusky/entity/TimelineAccount.kt create mode 100644 app/src/test/java/com/keylesspalace/tusky/StatusComparisonTest.kt diff --git a/app/src/main/java/com/keylesspalace/tusky/AccountsInListFragment.kt b/app/src/main/java/com/keylesspalace/tusky/AccountsInListFragment.kt index 02fa07382..03a74458d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/AccountsInListFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/AccountsInListFragment.kt @@ -34,7 +34,7 @@ import com.keylesspalace.tusky.databinding.FragmentAccountsInListBinding import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.ViewModelFactory -import com.keylesspalace.tusky.entity.Account +import com.keylesspalace.tusky.entity.TimelineAccount import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.util.BindingHolder import com.keylesspalace.tusky.util.Either @@ -49,7 +49,7 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import java.io.IOException import javax.inject.Inject -private typealias AccountInfo = Pair +private typealias AccountInfo = Pair class AccountsInListFragment : DialogFragment(), Injectable { @@ -168,21 +168,21 @@ class AccountsInListFragment : DialogFragment(), Injectable { viewModel.deleteAccountFromList(listId, accountId) } - private fun onAddToList(account: Account) { + private fun onAddToList(account: TimelineAccount) { viewModel.addAccountToList(listId, account) } - private object AccountDiffer : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: Account, newItem: Account): Boolean { - return oldItem == newItem + private object AccountDiffer : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: TimelineAccount, newItem: TimelineAccount): Boolean { + return oldItem.id == newItem.id } - override fun areContentsTheSame(oldItem: Account, newItem: Account): Boolean { - return oldItem.deepEquals(newItem) + override fun areContentsTheSame(oldItem: TimelineAccount, newItem: TimelineAccount): Boolean { + return oldItem == newItem } } - inner class Adapter : ListAdapter>(AccountDiffer) { + inner class Adapter : ListAdapter>(AccountDiffer) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder { val binding = ItemFollowRequestBinding.inflate(LayoutInflater.from(parent.context), parent, false) @@ -209,12 +209,11 @@ class AccountsInListFragment : DialogFragment(), Injectable { private object SearchDiffer : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: AccountInfo, newItem: AccountInfo): Boolean { - return oldItem == newItem + return oldItem.first.id == newItem.first.id } override fun areContentsTheSame(oldItem: AccountInfo, newItem: AccountInfo): Boolean { - return oldItem.second == newItem.second && - oldItem.first.deepEquals(newItem.first) + return oldItem == newItem } } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/AccountAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/AccountAdapter.kt index 320f8126f..366dae7f9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/AccountAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/AccountAdapter.kt @@ -18,7 +18,7 @@ import android.view.LayoutInflater import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.entity.Account +import com.keylesspalace.tusky.entity.TimelineAccount import com.keylesspalace.tusky.interfaces.AccountActionListener import com.keylesspalace.tusky.util.removeDuplicates @@ -28,7 +28,7 @@ abstract class AccountAdapter internal constructo protected val animateAvatar: Boolean, protected val animateEmojis: Boolean ) : RecyclerView.Adapter() { - var accountList = mutableListOf() + var accountList = mutableListOf() private var bottomLoading: Boolean = false override fun getItemCount(): Int { @@ -73,12 +73,12 @@ abstract class AccountAdapter internal constructo } } - fun update(newAccounts: List) { + fun update(newAccounts: List) { accountList = removeDuplicates(newAccounts) notifyDataSetChanged() } - fun addItems(newAccounts: List) { + fun addItems(newAccounts: List) { val end = accountList.size val last = accountList[end - 1] if (newAccounts.none { it.id == last.id }) { @@ -100,7 +100,7 @@ abstract class AccountAdapter internal constructo } } - fun removeItem(position: Int): Account? { + fun removeItem(position: Int): TimelineAccount? { if (position < 0 || position >= accountList.size) { return null } @@ -109,7 +109,7 @@ abstract class AccountAdapter internal constructo return account } - fun addItem(account: Account, position: Int) { + fun addItem(account: TimelineAccount, position: Int) { if (position < 0 || position > accountList.size) { return } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/AccountViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/AccountViewHolder.java index 559426e38..75a6a577e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/AccountViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/AccountViewHolder.java @@ -9,7 +9,7 @@ import androidx.preference.PreferenceManager; import androidx.recyclerview.widget.RecyclerView; import com.keylesspalace.tusky.R; -import com.keylesspalace.tusky.entity.Account; +import com.keylesspalace.tusky.entity.TimelineAccount; import com.keylesspalace.tusky.interfaces.AccountActionListener; import com.keylesspalace.tusky.interfaces.LinkListener; import com.keylesspalace.tusky.util.CustomEmojiHelper; @@ -33,7 +33,7 @@ public class AccountViewHolder extends RecyclerView.ViewHolder { showBotOverlay = sharedPrefs.getBoolean("showBotOverlay", true); } - public void setupWithAccount(Account account, boolean animateAvatar, boolean animateEmojis) { + public void setupWithAccount(TimelineAccount account, boolean animateAvatar, boolean animateEmojis) { accountId = account.getId(); String format = username.getContext().getString(R.string.status_username_format); String formattedUsername = String.format(format, account.getUsername()); diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/BlocksAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/BlocksAdapter.kt index 33a236056..31bea715b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/BlocksAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/BlocksAdapter.kt @@ -22,7 +22,7 @@ import android.widget.ImageView import android.widget.TextView import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.entity.Account +import com.keylesspalace.tusky.entity.TimelineAccount import com.keylesspalace.tusky.interfaces.AccountActionListener import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.loadAvatar @@ -55,7 +55,7 @@ class BlocksAdapter( private val unblock: ImageButton = itemView.findViewById(R.id.blocked_user_unblock) private var id: String? = null - fun setupWithAccount(account: Account, animateAvatar: Boolean, animateEmojis: Boolean) { + fun setupWithAccount(account: TimelineAccount, animateAvatar: Boolean, animateEmojis: Boolean) { id = account.id val emojifiedName = account.name.emojify(account.emojis, displayName, animateEmojis) displayName.text = emojifiedName diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestViewHolder.kt index 2be8b7621..49e91bf39 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestViewHolder.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestViewHolder.kt @@ -22,7 +22,7 @@ import android.text.style.StyleSpan import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding -import com.keylesspalace.tusky.entity.Account +import com.keylesspalace.tusky.entity.TimelineAccount import com.keylesspalace.tusky.interfaces.AccountActionListener import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.loadAvatar @@ -34,7 +34,7 @@ class FollowRequestViewHolder( private val showHeader: Boolean ) : RecyclerView.ViewHolder(binding.root) { - fun setupWithAccount(account: Account, animateAvatar: Boolean, animateEmojis: Boolean) { + fun setupWithAccount(account: TimelineAccount, animateAvatar: Boolean, animateEmojis: Boolean) { val wrappedName = account.name.unicodeWrap() val emojifiedName: CharSequence = wrappedName.emojify(account.emojis, itemView, animateEmojis) binding.displayNameTextView.text = emojifiedName diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/MutesAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/MutesAdapter.kt index 9fca33e8f..dc2935494 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/MutesAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/MutesAdapter.kt @@ -9,7 +9,7 @@ import android.widget.TextView import androidx.core.view.ViewCompat import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.entity.Account +import com.keylesspalace.tusky.entity.TimelineAccount import com.keylesspalace.tusky.interfaces.AccountActionListener import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.loadAvatar @@ -69,7 +69,7 @@ class MutesAdapter( private var notifications = false fun setupWithAccount( - account: Account, + account: TimelineAccount, mutingNotifications: Boolean?, animateAvatar: Boolean, animateEmojis: Boolean diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java index d65f58821..2bb4f78a0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java @@ -40,10 +40,10 @@ import androidx.recyclerview.widget.RecyclerView; import com.bumptech.glide.Glide; import com.keylesspalace.tusky.R; import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding; -import com.keylesspalace.tusky.entity.Account; import com.keylesspalace.tusky.entity.Emoji; import com.keylesspalace.tusky.entity.Notification; import com.keylesspalace.tusky.entity.Status; +import com.keylesspalace.tusky.entity.TimelineAccount; import com.keylesspalace.tusky.interfaces.AccountActionListener; import com.keylesspalace.tusky.interfaces.LinkListener; import com.keylesspalace.tusky.interfaces.StatusActionListener; @@ -335,7 +335,7 @@ public class NotificationsAdapter extends RecyclerView.Adapter { this.statusDisplayOptions = statusDisplayOptions; } - void setMessage(Account account) { + void setMessage(TimelineAccount account) { Context context = message.getContext(); String format = context.getString(R.string.notification_follow_format); diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeAutoCompleteAdapter.java b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeAutoCompleteAdapter.java index b2fa94c34..5a04234bb 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeAutoCompleteAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeAutoCompleteAdapter.java @@ -16,7 +16,6 @@ package com.keylesspalace.tusky.components.compose; import android.content.Context; -import android.preference.PreferenceManager; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -28,9 +27,9 @@ import android.widget.TextView; import com.bumptech.glide.Glide; import com.keylesspalace.tusky.R; -import com.keylesspalace.tusky.entity.Account; import com.keylesspalace.tusky.entity.Emoji; import com.keylesspalace.tusky.entity.HashTag; +import com.keylesspalace.tusky.entity.TimelineAccount; import com.keylesspalace.tusky.util.CustomEmojiHelper; import com.keylesspalace.tusky.util.ImageLoadingHelper; @@ -144,7 +143,7 @@ public class ComposeAutoCompleteAdapter extends BaseAdapter AccountResult accountResult = ((AccountResult) getItem(position)); if (accountResult != null) { - Account account = accountResult.account; + TimelineAccount account = accountResult.account; String formattedUsername = context.getString( R.string.status_username_format, account.getUsername() @@ -268,9 +267,9 @@ public class ComposeAutoCompleteAdapter extends BaseAdapter } public final static class AccountResult extends AutocompleteResult { - private final Account account; + private final TimelineAccount account; - public AccountResult(Account account) { + public AccountResult(TimelineAccount account) { this.account = account; } } 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 8bb875e4b..88c9dbad1 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 @@ -16,18 +16,17 @@ package com.keylesspalace.tusky.components.conversation import android.text.Spanned -import android.text.SpannedString import androidx.room.Embedded import androidx.room.Entity import androidx.room.TypeConverters import com.keylesspalace.tusky.db.Converters -import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Conversation import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.HashTag import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.entity.TimelineAccount import com.keylesspalace.tusky.util.shouldTrimStatus import java.util.Date @@ -48,17 +47,15 @@ data class ConversationAccountEntity( val avatar: String, val emojis: List ) { - fun toAccount(): Account { - return Account( + fun toAccount(): TimelineAccount { + return TimelineAccount( id = id, username = username, displayName = displayName, + url = "", avatar = avatar, emojis = emojis, - url = "", localUsername = "", - note = SpannedString(""), - header = "" ) } } @@ -100,7 +97,7 @@ data class ConversationStatusEntity( if (inReplyToId != other.inReplyToId) return false if (inReplyToAccountId != other.inReplyToAccountId) return false if (account != other.account) return false - if (content.toString() != other.content.toString()) return false // TODO find a better method to compare two spanned strings + if (content.toString() != other.content.toString()) return false if (createdAt != other.createdAt) return false if (emojis != other.emojis) return false if (favouritesCount != other.favouritesCount) return false @@ -126,7 +123,7 @@ data class ConversationStatusEntity( result = 31 * result + (inReplyToId?.hashCode() ?: 0) result = 31 * result + (inReplyToAccountId?.hashCode() ?: 0) result = 31 * result + account.hashCode() - result = 31 * result + content.hashCode() + result = 31 * result + content.toString().hashCode() result = 31 * result + createdAt.hashCode() result = 31 * result + emojis.hashCode() result = 31 * result + favouritesCount @@ -176,7 +173,7 @@ data class ConversationStatusEntity( } } -fun Account.toEntity() = +fun TimelineAccount.toEntity() = ConversationAccountEntity( id = id, username = username, diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchAccountsAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchAccountsAdapter.kt index 71d582680..c4a3e8261 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchAccountsAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchAccountsAdapter.kt @@ -21,11 +21,11 @@ import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DiffUtil import com.keylesspalace.tusky.R import com.keylesspalace.tusky.adapter.AccountViewHolder -import com.keylesspalace.tusky.entity.Account +import com.keylesspalace.tusky.entity.TimelineAccount import com.keylesspalace.tusky.interfaces.LinkListener class SearchAccountsAdapter(private val linkListener: LinkListener, private val animateAvatars: Boolean, private val animateEmojis: Boolean) : - PagingDataAdapter(ACCOUNT_COMPARATOR) { + PagingDataAdapter(ACCOUNT_COMPARATOR) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AccountViewHolder { val view = LayoutInflater.from(parent.context) @@ -44,11 +44,11 @@ class SearchAccountsAdapter(private val linkListener: LinkListener, private val companion object { - val ACCOUNT_COMPARATOR = object : DiffUtil.ItemCallback() { - override fun areContentsTheSame(oldItem: Account, newItem: Account): Boolean = - oldItem.deepEquals(newItem) + val ACCOUNT_COMPARATOR = object : DiffUtil.ItemCallback() { + override fun areContentsTheSame(oldItem: TimelineAccount, newItem: TimelineAccount): Boolean = + oldItem == newItem - override fun areItemsTheSame(oldItem: Account, newItem: Account): Boolean = + override fun areItemsTheSame(oldItem: TimelineAccount, newItem: TimelineAccount): Boolean = oldItem.id == newItem.id } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchAccountsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchAccountsFragment.kt index d5e2a7aba..f59f84ff6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchAccountsFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchAccountsFragment.kt @@ -19,12 +19,12 @@ import androidx.paging.PagingData import androidx.paging.PagingDataAdapter import androidx.preference.PreferenceManager import com.keylesspalace.tusky.components.search.adapter.SearchAccountsAdapter -import com.keylesspalace.tusky.entity.Account +import com.keylesspalace.tusky.entity.TimelineAccount import com.keylesspalace.tusky.settings.PrefKeys import kotlinx.coroutines.flow.Flow -class SearchAccountsFragment : SearchFragment() { - override fun createAdapter(): PagingDataAdapter { +class SearchAccountsFragment : SearchFragment() { + override fun createAdapter(): PagingDataAdapter { val preferences = PreferenceManager.getDefaultSharedPreferences(binding.searchRecyclerView.context) return SearchAccountsAdapter( @@ -34,7 +34,7 @@ class SearchAccountsFragment : SearchFragment() { ) } - override val data: Flow> + override val data: Flow> get() = viewModel.accountsFlow companion object { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt index 063e9f394..0ea0b958a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt @@ -114,7 +114,7 @@ class TimelinePagingAdapter( oldItem: StatusViewData, newItem: StatusViewData ): Boolean { - return oldItem.viewDataId == newItem.viewDataId + return oldItem.id == newItem.id } override fun areContentsTheSame( @@ -128,7 +128,7 @@ class TimelinePagingAdapter( oldItem: StatusViewData, newItem: StatusViewData ): Any? { - return if (oldItem === newItem) { + return if (oldItem == newItem) { // If items are equal - update timestamp only listOf(StatusBaseViewHolder.Key.KEY_CREATED) } else // If items are different - update the whole view holder 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 a121cb409..252b98800 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 @@ -23,12 +23,12 @@ import com.google.gson.reflect.TypeToken import com.keylesspalace.tusky.db.TimelineAccountEntity import com.keylesspalace.tusky.db.TimelineStatusEntity import com.keylesspalace.tusky.db.TimelineStatusWithAccount -import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.HashTag import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.entity.TimelineAccount import com.keylesspalace.tusky.util.shouldTrimStatus import com.keylesspalace.tusky.util.trimTrailingWhitespace import com.keylesspalace.tusky.viewdata.StatusViewData @@ -44,7 +44,7 @@ private val emojisListType = object : TypeToken>() {}.type private val mentionListType = object : TypeToken>() {}.type private val tagListType = object : TypeToken>() {}.type -fun Account.toEntity(accountId: Long, gson: Gson): TimelineAccountEntity { +fun TimelineAccount.toEntity(accountId: Long, gson: Gson): TimelineAccountEntity { return TimelineAccountEntity( serverId = id, timelineUserId = accountId, @@ -58,25 +58,16 @@ fun Account.toEntity(accountId: Long, gson: Gson): TimelineAccountEntity { ) } -fun TimelineAccountEntity.toAccount(gson: Gson): Account { - return Account( +fun TimelineAccountEntity.toAccount(gson: Gson): TimelineAccount { + return TimelineAccount( id = serverId, localUsername = localUsername, username = username, displayName = displayName, - note = SpannedString(""), url = url, avatar = avatar, - header = "", - locked = false, - followingCount = 0, - followersCount = 0, - statusesCount = 0, - source = null, bot = bot, - emojis = gson.fromJson(emojis, emojisListType), - fields = null, - moved = null + emojis = gson.fromJson(emojis, emojisListType) ) } 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 66c8022c6..672bd5aae 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Account.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Account.kt @@ -45,37 +45,57 @@ data class Account( localUsername } else displayName - override fun hashCode(): Int { - return id.hashCode() - } - - override fun equals(other: Any?): Boolean { - if (other !is Account) { - return false - } - return other.id == this.id - } - - fun deepEquals(other: Account): Boolean { - return id == other.id && - localUsername == other.localUsername && - displayName == other.displayName && - note == other.note && - url == other.url && - avatar == other.avatar && - header == other.header && - locked == other.locked && - followersCount == other.followersCount && - followingCount == other.followingCount && - statusesCount == other.statusesCount && - source == other.source && - bot == other.bot && - emojis == other.emojis && - fields == other.fields && - moved == other.moved - } - fun isRemote(): Boolean = this.username != this.localUsername + + /** + * overriding equals & hashcode because Spanned does not always compare correctly otherwise + */ + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as Account + + if (id != other.id) return false + if (localUsername != other.localUsername) return false + if (username != other.username) return false + if (displayName != other.displayName) return false + if (note.toString() != other.note.toString()) return false + if (url != other.url) return false + if (avatar != other.avatar) return false + if (header != other.header) return false + if (locked != other.locked) return false + if (followersCount != other.followersCount) return false + if (followingCount != other.followingCount) return false + if (statusesCount != other.statusesCount) return false + if (source != other.source) return false + if (bot != other.bot) return false + if (emojis != other.emojis) return false + if (fields != other.fields) return false + if (moved != other.moved) return false + + return true + } + + override fun hashCode(): Int { + var result = id.hashCode() + result = 31 * result + localUsername.hashCode() + result = 31 * result + username.hashCode() + result = 31 * result + (displayName?.hashCode() ?: 0) + result = 31 * result + note.toString().hashCode() + result = 31 * result + url.hashCode() + result = 31 * result + avatar.hashCode() + result = 31 * result + header.hashCode() + result = 31 * result + locked.hashCode() + result = 31 * result + followersCount + result = 31 * result + followingCount + result = 31 * result + statusesCount + result = 31 * result + (source?.hashCode() ?: 0) + result = 31 * result + bot.hashCode() + result = 31 * result + (emojis?.hashCode() ?: 0) + result = 31 * result + (fields?.hashCode() ?: 0) + result = 31 * result + (moved?.hashCode() ?: 0) + return result + } } data class AccountSource( 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 cb09981db..e5a547f11 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Conversation.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Conversation.kt @@ -19,7 +19,7 @@ import com.google.gson.annotations.SerializedName data class Conversation( val id: String, - val accounts: List, + val accounts: List, @SerializedName("last_status") val lastStatus: Status?, // should never be null, but apparently its possible https://github.com/tuskyapp/Tusky/issues/1038 val unread: Boolean ) 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 6198867d9..ae2d74a90 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt @@ -24,7 +24,7 @@ import com.google.gson.annotations.JsonAdapter data class Notification( val type: Type, val id: String, - val account: Account, + val account: TimelineAccount, val status: Status? ) { 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 18e3d71b0..5bc78cf72 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/SearchResult.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/SearchResult.kt @@ -16,7 +16,7 @@ package com.keylesspalace.tusky.entity data class SearchResult( - val accounts: List, + val accounts: List, val statuses: List, val hashtags: 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 7a54655f1..f75ce4e76 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt @@ -25,7 +25,7 @@ import java.util.Date data class Status( val id: String, val url: String?, // not present if it's reblog - val account: Account, + val account: TimelineAccount, @SerializedName("in_reply_to_id") var inReplyToId: String?, @SerializedName("in_reply_to_account_id") val inReplyToAccountId: String?, val reblog: Status?, @@ -149,6 +149,71 @@ data class Status( return builder.toString() } + /** + * overriding equals & hashcode because Spanned does not always compare correctly otherwise + */ + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as Status + + if (id != other.id) return false + if (url != other.url) return false + if (account != other.account) return false + if (inReplyToId != other.inReplyToId) return false + if (inReplyToAccountId != other.inReplyToAccountId) return false + if (reblog != other.reblog) return false + if (content.toString() != other.content.toString()) return false + if (createdAt != other.createdAt) return false + if (emojis != other.emojis) return false + if (reblogsCount != other.reblogsCount) return false + if (favouritesCount != other.favouritesCount) return false + if (reblogged != other.reblogged) return false + if (favourited != other.favourited) return false + if (bookmarked != other.bookmarked) return false + if (sensitive != other.sensitive) return false + if (spoilerText != other.spoilerText) return false + if (visibility != other.visibility) return false + if (attachments != other.attachments) return false + if (mentions != other.mentions) return false + if (tags != other.tags) return false + if (application != other.application) return false + if (pinned != other.pinned) return false + if (muted != other.muted) return false + if (poll != other.poll) return false + if (card != other.card) return false + return true + } + + override fun hashCode(): Int { + var result = id.hashCode() + result = 31 * result + (url?.hashCode() ?: 0) + result = 31 * result + account.hashCode() + result = 31 * result + (inReplyToId?.hashCode() ?: 0) + result = 31 * result + (inReplyToAccountId?.hashCode() ?: 0) + result = 31 * result + (reblog?.hashCode() ?: 0) + result = 31 * result + content.toString().hashCode() + result = 31 * result + createdAt.hashCode() + result = 31 * result + emojis.hashCode() + result = 31 * result + reblogsCount + result = 31 * result + favouritesCount + result = 31 * result + reblogged.hashCode() + result = 31 * result + favourited.hashCode() + result = 31 * result + bookmarked.hashCode() + result = 31 * result + sensitive.hashCode() + result = 31 * result + spoilerText.hashCode() + result = 31 * result + visibility.hashCode() + result = 31 * result + attachments.hashCode() + result = 31 * result + mentions.hashCode() + result = 31 * result + (tags?.hashCode() ?: 0) + result = 31 * result + (application?.hashCode() ?: 0) + result = 31 * result + (pinned?.hashCode() ?: 0) + result = 31 * result + (muted?.hashCode() ?: 0) + result = 31 * result + (poll?.hashCode() ?: 0) + result = 31 * result + (card?.hashCode() ?: 0) + return result + } + data class Mention( val id: String, val url: 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 new file mode 100644 index 000000000..224129feb --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/entity/TimelineAccount.kt @@ -0,0 +1,39 @@ +/* Copyright 2017 Andrew Dawson + * + * 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.entity + +import com.google.gson.annotations.SerializedName + +/** + * 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. + */ +data class TimelineAccount( + val id: String, + @SerializedName("username") val localUsername: String, + @SerializedName("acct") val username: String, + @SerializedName("display_name") val displayName: String?, // should never be null per Api definition, but some servers break the contract + val url: String, + val avatar: String, + val bot: Boolean = false, + val emojis: List? = emptyList(), // nullable for backward compatibility +) { + + val name: String + get() = if (displayName.isNullOrEmpty()) { + localUsername + } else displayName +} diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/AccountListFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/AccountListFragment.kt index fad5c58a4..465b9f216 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/AccountListFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/AccountListFragment.kt @@ -42,8 +42,8 @@ import com.keylesspalace.tusky.components.account.AccountActivity import com.keylesspalace.tusky.databinding.FragmentAccountListBinding import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.di.Injectable -import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Relationship +import com.keylesspalace.tusky.entity.TimelineAccount import com.keylesspalace.tusky.interfaces.AccountActionListener import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.settings.PrefKeys @@ -255,7 +255,7 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct followRequestsAdapter.removeItem(position) } - private fun getFetchCallByListType(fromId: String?): Single>> { + private fun getFetchCallByListType(fromId: String?): Single>> { return when (type) { Type.FOLLOWS -> { val accountId = requireId(type, id) @@ -313,7 +313,7 @@ class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountAct ) } - private fun onFetchAccountsSuccess(accounts: List, linkHeader: String?) { + private fun onFetchAccountsSuccess(accounts: List, linkHeader: String?) { adapter.setBottomLoading(false) val links = HttpHeaderLink.parse(linkHeader) diff --git a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt index fe5def875..3ee8d4cec 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt @@ -37,6 +37,7 @@ import com.keylesspalace.tusky.entity.ScheduledStatus import com.keylesspalace.tusky.entity.SearchResult import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.StatusContext +import com.keylesspalace.tusky.entity.TimelineAccount import io.reactivex.rxjava3.core.Completable import io.reactivex.rxjava3.core.Single import okhttp3.MultipartBody @@ -178,13 +179,13 @@ interface MastodonApi { fun statusRebloggedBy( @Path("id") statusId: String, @Query("max_id") maxId: String? - ): Single>> + ): Single>> @GET("api/v1/statuses/{id}/favourited_by") fun statusFavouritedBy( @Path("id") statusId: String, @Query("max_id") maxId: String? - ): Single>> + ): Single>> @DELETE("api/v1/statuses/{id}") fun deleteStatus( @@ -286,7 +287,7 @@ interface MastodonApi { @Query("resolve") resolve: Boolean? = null, @Query("limit") limit: Int? = null, @Query("following") following: Boolean? = null - ): Single> + ): Single> @GET("api/v1/accounts/{id}") fun account( @@ -317,13 +318,13 @@ interface MastodonApi { fun accountFollowers( @Path("id") accountId: String, @Query("max_id") maxId: String? - ): Single>> + ): Single>> @GET("api/v1/accounts/{id}/following") fun accountFollowing( @Path("id") accountId: String, @Query("max_id") maxId: String? - ): Single>> + ): Single>> @FormUrlEncoded @POST("api/v1/accounts/{id}/follow") @@ -384,12 +385,12 @@ interface MastodonApi { @GET("api/v1/blocks") fun blocks( @Query("max_id") maxId: String? - ): Single>> + ): Single>> @GET("api/v1/mutes") fun mutes( @Query("max_id") maxId: String? - ): Single>> + ): Single>> @GET("api/v1/domain_blocks") fun domainBlocks( @@ -426,7 +427,7 @@ interface MastodonApi { @GET("api/v1/follow_requests") fun followRequests( @Query("max_id") maxId: String? - ): Single>> + ): Single>> @POST("api/v1/follow_requests/{id}/authorize") fun authorizeFollowRequest( @@ -481,7 +482,7 @@ interface MastodonApi { fun getAccountsInList( @Path("listId") listId: String, @Query("limit") limit: Int - ): Single> + ): Single> @FormUrlEncoded // @DELETE doesn't support fields diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java b/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java index 409b858d2..75f90ca40 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java @@ -19,6 +19,7 @@ import androidx.annotation.Nullable; import com.keylesspalace.tusky.entity.Account; import com.keylesspalace.tusky.entity.Notification; +import com.keylesspalace.tusky.entity.TimelineAccount; import java.util.Objects; @@ -44,11 +45,11 @@ public abstract class NotificationViewData { public static final class Concrete extends NotificationViewData { private final Notification.Type type; private final String id; - private final Account account; + private final TimelineAccount account; @Nullable private final StatusViewData.Concrete statusViewData; - public Concrete(Notification.Type type, String id, Account account, + public Concrete(Notification.Type type, String id, TimelineAccount account, @Nullable StatusViewData.Concrete statusViewData) { this.type = type; this.id = id; @@ -64,7 +65,7 @@ public abstract class NotificationViewData { return id; } - public Account getAccount() { + public TimelineAccount getAccount() { return account; } diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt index 92675eb61..d8f271578 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt @@ -22,12 +22,11 @@ import com.keylesspalace.tusky.entity.Status /** * Created by charlag on 11/07/2017. * - * * Class to represent data required to display either a notification or a placeholder. * It is either a [StatusViewData.Concrete] or a [StatusViewData.Placeholder]. */ -sealed class StatusViewData private constructor() { - abstract val viewDataId: Long +sealed class StatusViewData { + abstract val id: String data class Concrete( val status: Status, @@ -49,8 +48,8 @@ sealed class StatusViewData private constructor() { /** Whether the status meets the requirement to be collapse */ val isCollapsed: Boolean, ) : StatusViewData() { - override val viewDataId: Long - get() = status.id.hashCode().toLong() + override val id: String + get() = status.id val content: Spanned val spoilerText: String @@ -116,9 +115,6 @@ sealed class StatusViewData private constructor() { } } - val id: String - get() = status.id - /** Helper for Java */ fun copyWithStatus(status: Status): Concrete { return copy(status = status) @@ -140,10 +136,10 @@ sealed class StatusViewData private constructor() { } } - data class Placeholder(val id: String, val isLoading: Boolean) : StatusViewData() { - override val viewDataId: Long - get() = id.hashCode().toLong() - } + data class Placeholder( + override val id: String, + val isLoading: Boolean + ) : StatusViewData() fun asStatusOrNull() = this as? Concrete diff --git a/app/src/main/java/com/keylesspalace/tusky/viewmodel/AccountsInListViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/viewmodel/AccountsInListViewModel.kt index b02c2ac09..fd9893762 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewmodel/AccountsInListViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewmodel/AccountsInListViewModel.kt @@ -17,7 +17,7 @@ package com.keylesspalace.tusky.viewmodel import android.util.Log -import com.keylesspalace.tusky.entity.Account +import com.keylesspalace.tusky.entity.TimelineAccount import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.Either import com.keylesspalace.tusky.util.Either.Left @@ -28,7 +28,7 @@ import io.reactivex.rxjava3.core.Observable import io.reactivex.rxjava3.subjects.BehaviorSubject import javax.inject.Inject -data class State(val accounts: Either>, val searchResult: List?) +data class State(val accounts: Either>, val searchResult: List?) class AccountsInListViewModel @Inject constructor(private val api: MastodonApi) : RxAwareViewModel() { @@ -49,7 +49,7 @@ class AccountsInListViewModel @Inject constructor(private val api: MastodonApi) } } - fun addAccountToList(listId: String, account: Account) { + fun addAccountToList(listId: String, account: TimelineAccount) { api.addCountToList(listId, listOf(account.id)) .subscribe( { diff --git a/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt b/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt index c2a607977..ef6d26327 100644 --- a/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/BottomSheetActivityTest.kt @@ -19,9 +19,9 @@ import android.text.SpannedString import android.widget.LinearLayout import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.google.android.material.bottomsheet.BottomSheetBehavior -import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.SearchResult import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.entity.TimelineAccount import com.keylesspalace.tusky.network.MastodonApi import com.nhaarman.mockitokotlin2.doReturn import com.nhaarman.mockitokotlin2.mock @@ -57,19 +57,13 @@ class BottomSheetActivityTest { private val emptyCallback = Single.just(SearchResult(emptyList(), emptyList(), emptyList())) private val testScheduler = TestScheduler() - private val account = Account( + private val account = TimelineAccount( id = "1", localUsername = "admin", username = "admin", displayName = "Ad Min", - note = SpannedString(""), url = "http://mastodon.foo.bar", - avatar = "", - header = "", - locked = false, - followersCount = 0, - followingCount = 0, - statusesCount = 0 + avatar = "" ) private val accountSingle = Single.just(SearchResult(listOf(account), emptyList(), emptyList())) diff --git a/app/src/test/java/com/keylesspalace/tusky/StatusComparisonTest.kt b/app/src/test/java/com/keylesspalace/tusky/StatusComparisonTest.kt new file mode 100644 index 000000000..ed06e27c6 --- /dev/null +++ b/app/src/test/java/com/keylesspalace/tusky/StatusComparisonTest.kt @@ -0,0 +1,216 @@ +package com.keylesspalace.tusky + +import android.text.Spanned +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.gson.GsonBuilder +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.json.SpannedTypeAdapter +import com.keylesspalace.tusky.viewdata.StatusViewData +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@Config(sdk = [28]) +@RunWith(AndroidJUnit4::class) +class StatusComparisonTest { + + @Test + fun `two equal statuses - should be equal`() { + assertEquals(createStatus(), createStatus()) + } + + @Test + fun `status with different id - should not be equal`() { + assertNotEquals(createStatus(), createStatus(id = "987654321")) + } + + @Test + fun `status with different content - should not be equal`() { + val content: String = """ + \u003cp\u003e\u003cspan class=\"h-card\"\u003e\u003ca href=\"https://mastodon.social/@ConnyDuck\" class=\"u-url mention\" rel=\"nofollow noopener noreferrer\" target=\"_blank\"\u003e@\u003cspan\u003eConnyDuck@mastodon.social\u003c/span\u003e\u003c/a\u003e\u003c/span\u003e 123\u003c/p\u003e + """.trimIndent() + assertNotEquals(createStatus(), createStatus(content = content)) + } + + @Test + fun `accounts with different notes in json - should be equal because notes are not relevant for timelines`() { + assertEquals(createStatus(note = "Test"), createStatus(note = "Test 123456")) + } + + private val gson = GsonBuilder().registerTypeAdapter( + Spanned::class.java, SpannedTypeAdapter() + ).create() + + @Test + fun `two equal status view data - should be equal`() { + val viewdata1 = StatusViewData.Concrete( + status = createStatus(), + isExpanded = false, + isShowingContent = false, + isCollapsible = false, + isCollapsed = false + ) + val viewdata2 = StatusViewData.Concrete( + status = createStatus(), + isExpanded = false, + isShowingContent = false, + isCollapsible = false, + isCollapsed = false + ) + assertEquals(viewdata1, viewdata2) + } + + @Test + fun `status view data with different isExpanded - should not be equal`() { + val viewdata1 = StatusViewData.Concrete( + status = createStatus(), + isExpanded = true, + isShowingContent = false, + isCollapsible = false, + isCollapsed = false + ) + val viewdata2 = StatusViewData.Concrete( + status = createStatus(), + isExpanded = false, + isShowingContent = false, + isCollapsible = false, + isCollapsed = false + ) + assertNotEquals(viewdata1, viewdata2) + } + + @Test + fun `status view data with different statuses- should not be equal`() { + val viewdata1 = StatusViewData.Concrete( + status = createStatus(content = "whatever"), + isExpanded = true, + isShowingContent = false, + isCollapsible = false, + isCollapsed = false + ) + val viewdata2 = StatusViewData.Concrete( + status = createStatus(), + isExpanded = false, + isShowingContent = false, + isCollapsible = false, + isCollapsed = false + ) + assertNotEquals(viewdata1, viewdata2) + } + + private fun createStatus( + id: String = "123456", + content: String = """ + \u003cp\u003e\u003cspan class=\"h-card\"\u003e\u003ca href=\"https://mastodon.social/@ConnyDuck\" class=\"u-url mention\" rel=\"nofollow noopener noreferrer\" target=\"_blank\"\u003e@\u003cspan\u003eConnyDuck@mastodon.social\u003c/span\u003e\u003c/a\u003e\u003c/span\u003e Hi\u003c/p\u003e + """.trimIndent(), + note: String = "" + ): Status { + val statusJson = """ + { + "id": "$id", + "created_at": "2022-02-26T09:54:45.000Z", + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "sensitive": false, + "spoiler_text": "", + "visibility": "public", + "language": null, + "uri": "https://pixelfed.social/p/connyduck/403124983655733325", + "url": "https://pixelfed.social/p/connyduck/403124983655733325", + "replies_count": 3, + "reblogs_count": 28, + "favourites_count": 6, + "edited_at": null, + "favourited": true, + "reblogged": false, + "muted": false, + "bookmarked": false, + "content": "$content", + "reblog": null, + "account": { + "id": "419352", + "username": "connyduck", + "acct": "connyduck@pixelfed.social", + "display_name": "Conny Duck", + "locked": false, + "bot": false, + "discoverable": false, + "group": false, + "created_at": "2018-08-14T00:00:00.000Z", + "note": "$note", + "url": "https://pixelfed.social/connyduck", + "avatar": "https://files.mastodon.social/cache/accounts/avatars/000/419/352/original/31ce660c53962e0c.jpeg", + "avatar_static": "https://files.mastodon.social/cache/accounts/avatars/000/419/352/original/31ce660c53962e0c.jpeg", + "header": "https://mastodon.social/headers/original/missing.png", + "header_static": "https://mastodon.social/headers/original/missing.png", + "followers_count": 2, + "following_count": 0, + "statuses_count": 70, + "last_status_at": "2022-03-07", + "emojis": [], + "fields": [] + }, + "media_attachments": [ + { + "id": "107863694400783337", + "type": "image", + "url": "https://files.mastodon.social/cache/media_attachments/files/107/863/694/400/783/337/original/71c5bad1756bbc8f.jpg", + "preview_url": "https://files.mastodon.social/cache/media_attachments/files/107/863/694/400/783/337/small/71c5bad1756bbc8f.jpg", + "remote_url": "https://pixelfed-prod.nyc3.cdn.digitaloceanspaces.com/public/m/_v2/1138/affc38a2b-1c5f41/JRKoMNoj6dKa/9mXs0Fetvj4KwRbKypt8C1PZNVd7d3dQqod4roLZ.jpg", + "preview_remote_url": null, + "text_url": null, + "meta": { + "original": { + "width": 1371, + "height": 1080, + "size": "1371x1080", + "aspect": 1.2694444444444444 + }, + "small": { + "width": 451, + "height": 355, + "size": "451x355", + "aspect": 1.2704225352112677 + } + }, + "description": "Oilpainting of a kingfisher, photographed on my easel", + "blurhash": "UUG91|?wxHV@WTkDs.V?xZa_I:WBNFR*WBRk" + }, + { + "id": "107863694727565058", + "type": "image", + "url": "https://files.mastodon.social/cache/media_attachments/files/107/863/694/727/565/058/original/68daef05be7ac6b6.jpg", + "preview_url": "https://files.mastodon.social/cache/media_attachments/files/107/863/694/727/565/058/small/68daef05be7ac6b6.jpg", + "remote_url": "https://pixelfed-prod.nyc3.cdn.digitaloceanspaces.com/public/m/_v2/1138/affc38a2b-1c5f41/nBVJUnrEIjfO/M6i8GSP44Iv230KWXnMpvVobOqASXY3EkImyxySS.jpg", + "preview_remote_url": null, + "text_url": null, + "meta": { + "original": { + "width": 1087, + "height": 1080, + "size": "1087x1080", + "aspect": 1.0064814814814815 + }, + "small": { + "width": 401, + "height": 398, + "size": "401x398", + "aspect": 1.0075376884422111 + } + }, + "description": "Oilpainting of a kingfisher", + "blurhash": "U89u4pPJ4:SoJ6NNnkoxoBtSx0Von-RiNgt8" + } + ], + "mentions": [], + "tags": [], + "emojis": [], + "card": null, + "poll": null + } + """.trimIndent() + return gson.fromJson(statusJson, Status::class.java) + } +} 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 0ef5b6587..f7c998b51 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 @@ -3,8 +3,8 @@ package com.keylesspalace.tusky.components.timeline import android.text.SpannedString import com.google.gson.Gson import com.keylesspalace.tusky.db.TimelineStatusWithAccount -import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.entity.TimelineAccount import com.keylesspalace.tusky.viewdata.StatusViewData import java.util.ArrayList import java.util.Date @@ -14,15 +14,13 @@ private val fixedDate = Date(1638889052000) fun mockStatus(id: String = "100") = Status( id = id, url = "https://mastodon.example/@ConnyDuck/$id", - account = Account( + account = TimelineAccount( id = "1", localUsername = "connyduck", username = "connyduck@mastodon.example", displayName = "Conny Duck", - note = SpannedString(""), url = "https://mastodon.example/@ConnyDuck", - avatar = "https://mastodon.example/system/accounts/avatars/000/150/486/original/ab27d7ddd18a10ea.jpg", - header = "https://mastodon.example/system/accounts/header/000/106/476/original/e590545d7eb4da39.jpg" + avatar = "https://mastodon.example/system/accounts/avatars/000/150/486/original/ab27d7ddd18a10ea.jpg" ), inReplyToId = null, inReplyToAccountId = null,