Merge branch 'develop' into account_header

This commit is contained in:
Conny Duck 2024-04-14 16:16:45 +02:00
commit 763dc3bd4e
No known key found for this signature in database
114 changed files with 1038 additions and 1294 deletions

View File

@ -72,7 +72,7 @@ android {
lint {
lintConfig file("lint.xml")
// Regenerate by running `./gradlew app:newLintBaseline`
// Regenerate by deleting the file and running `./gradlew app:lintGreenDebug`
baseline = file("lint-baseline.xml")
}
@ -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

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<issues format="6" by="lint 8.2.2" type="baseline" client="gradle" dependencies="false" name="AGP (8.2.2)" variant="all" version="8.2.2">
<issues format="6" by="lint 8.3.1" type="baseline" client="gradle" dependencies="false" name="AGP (8.3.1)" variant="all" version="8.3.1">
<issue
id="GestureBackNavigation"
@ -19,7 +19,7 @@
errorLine2=" ~~~~~~~~~~~">
<location
file="src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java"
line="101"
line="104"
column="32"/>
</issue>
@ -53,14 +53,14 @@
<issue
id="PrivateResource"
message="Overriding `@layout/exo_player_control_view` which is marked as private in androidx.media3:media3-ui:1.2.1. If deliberate, use tools:override=&quot;true&quot;, otherwise pick a different name.">
message="Overriding `@layout/exo_player_control_view` which is marked as private in androidx.media3:media3-ui:1.3.0. If deliberate, use tools:override=&quot;true&quot;, otherwise pick a different name.">
<location
file="src/main/res/layout/exo_player_control_view.xml"/>
</issue>
<issue
id="PrivateResource"
message="The resource `@color/exo_bottom_bar_background` is marked as private in androidx.media3:media3-ui:1.2.1"
message="The resource `@color/exo_bottom_bar_background` is marked as private in androidx.media3:media3-ui:1.3.0"
errorLine1=" android:background=&quot;@color/exo_bottom_bar_background&quot;"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
@ -71,7 +71,7 @@
<issue
id="PrivateResource"
message="The resource `@dimen/exo_styled_controls_padding` is marked as private in androidx.media3:media3-ui:1.2.1"
message="The resource `@dimen/exo_styled_controls_padding` is marked as private in androidx.media3:media3-ui:1.3.0"
errorLine1=" android:padding=&quot;@dimen/exo_styled_controls_padding&quot;"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
@ -82,7 +82,7 @@
<issue
id="PrivateResource"
message="The resource `@layout/exo_player_control_rewind_button` is marked as private in androidx.media3:media3-ui:1.2.1"
message="The resource `@layout/exo_player_control_rewind_button` is marked as private in androidx.media3:media3-ui:1.3.0"
errorLine1=" &lt;include layout=&quot;@layout/exo_player_control_rewind_button&quot; />"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
@ -93,7 +93,7 @@
<issue
id="PrivateResource"
message="The resource `@layout/exo_player_control_ffwd_button` is marked as private in androidx.media3:media3-ui:1.2.1"
message="The resource `@layout/exo_player_control_ffwd_button` is marked as private in androidx.media3:media3-ui:1.3.0"
errorLine1=" &lt;include layout=&quot;@layout/exo_player_control_ffwd_button&quot; />"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
@ -104,7 +104,7 @@
<issue
id="PrivateResource"
message="The resource `@dimen/exo_styled_bottom_bar_height` is marked as private in androidx.media3:media3-ui:1.2.1"
message="The resource `@dimen/exo_styled_bottom_bar_height` is marked as private in androidx.media3:media3-ui:1.3.0"
errorLine1=" android:layout_height=&quot;@dimen/exo_styled_bottom_bar_height&quot;"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
@ -115,7 +115,7 @@
<issue
id="PrivateResource"
message="The resource `@dimen/exo_styled_bottom_bar_margin_top` is marked as private in androidx.media3:media3-ui:1.2.1"
message="The resource `@dimen/exo_styled_bottom_bar_margin_top` is marked as private in androidx.media3:media3-ui:1.3.0"
errorLine1=" android:layout_marginTop=&quot;@dimen/exo_styled_bottom_bar_margin_top&quot;"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
@ -126,7 +126,7 @@
<issue
id="PrivateResource"
message="The resource `@color/exo_bottom_bar_background` is marked as private in androidx.media3:media3-ui:1.2.1"
message="The resource `@color/exo_bottom_bar_background` is marked as private in androidx.media3:media3-ui:1.3.0"
errorLine1=" android:background=&quot;@color/exo_bottom_bar_background&quot;"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
@ -137,7 +137,7 @@
<issue
id="PrivateResource"
message="The resource `@dimen/exo_styled_bottom_bar_time_padding` is marked as private in androidx.media3:media3-ui:1.2.1"
message="The resource `@dimen/exo_styled_bottom_bar_time_padding` is marked as private in androidx.media3:media3-ui:1.3.0"
errorLine1=" android:paddingStart=&quot;@dimen/exo_styled_bottom_bar_time_padding&quot;"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
@ -148,7 +148,7 @@
<issue
id="PrivateResource"
message="The resource `@dimen/exo_styled_bottom_bar_time_padding` is marked as private in androidx.media3:media3-ui:1.2.1"
message="The resource `@dimen/exo_styled_bottom_bar_time_padding` is marked as private in androidx.media3:media3-ui:1.3.0"
errorLine1=" android:paddingEnd=&quot;@dimen/exo_styled_bottom_bar_time_padding&quot;"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
@ -159,7 +159,7 @@
<issue
id="PrivateResource"
message="The resource `@dimen/exo_styled_bottom_bar_time_padding` is marked as private in androidx.media3:media3-ui:1.2.1"
message="The resource `@dimen/exo_styled_bottom_bar_time_padding` is marked as private in androidx.media3:media3-ui:1.3.0"
errorLine1=" android:paddingLeft=&quot;@dimen/exo_styled_bottom_bar_time_padding&quot;"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
@ -170,7 +170,7 @@
<issue
id="PrivateResource"
message="The resource `@dimen/exo_styled_bottom_bar_time_padding` is marked as private in androidx.media3:media3-ui:1.2.1"
message="The resource `@dimen/exo_styled_bottom_bar_time_padding` is marked as private in androidx.media3:media3-ui:1.3.0"
errorLine1=" android:paddingRight=&quot;@dimen/exo_styled_bottom_bar_time_padding&quot;"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
@ -181,7 +181,7 @@
<issue
id="PrivateResource"
message="The resource `@dimen/exo_styled_progress_layout_height` is marked as private in androidx.media3:media3-ui:1.2.1"
message="The resource `@dimen/exo_styled_progress_layout_height` is marked as private in androidx.media3:media3-ui:1.3.0"
errorLine1=" android:layout_height=&quot;@dimen/exo_styled_progress_layout_height&quot;"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
@ -192,7 +192,7 @@
<issue
id="PrivateResource"
message="The resource `@dimen/exo_styled_progress_margin_bottom` is marked as private in androidx.media3:media3-ui:1.2.1"
message="The resource `@dimen/exo_styled_progress_margin_bottom` is marked as private in androidx.media3:media3-ui:1.3.0"
errorLine1=" android:layout_marginBottom=&quot;@dimen/exo_styled_progress_margin_bottom&quot;/>"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
@ -203,7 +203,7 @@
<issue
id="PrivateResource"
message="The resource `@dimen/exo_styled_minimal_controls_margin_bottom` is marked as private in androidx.media3:media3-ui:1.2.1"
message="The resource `@dimen/exo_styled_minimal_controls_margin_bottom` is marked as private in androidx.media3:media3-ui:1.3.0"
errorLine1=" android:layout_marginBottom=&quot;@dimen/exo_styled_minimal_controls_margin_bottom&quot;"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
@ -230,7 +230,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="329"
line="331"
column="5"/>
</issue>
@ -241,7 +241,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="387"
line="389"
column="5"/>
</issue>
@ -252,7 +252,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="565"
line="570"
column="5"/>
</issue>
@ -263,7 +263,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="785"
line="790"
column="5"/>
</issue>
@ -274,7 +274,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/AndroidManifest.xml"
line="22"
line="21"
column="31"/>
</issue>
@ -285,7 +285,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/AndroidManifest.xml"
line="22"
line="21"
column="31"/>
</issue>
@ -296,7 +296,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/AndroidManifest.xml"
line="22"
line="21"
column="31"/>
</issue>
@ -307,7 +307,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/AndroidManifest.xml"
line="22"
line="21"
column="31"/>
</issue>
@ -318,7 +318,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/AndroidManifest.xml"
line="22"
line="21"
column="31"/>
</issue>
@ -329,7 +329,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/AndroidManifest.xml"
line="22"
line="21"
column="31"/>
</issue>
@ -340,7 +340,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/AndroidManifest.xml"
line="22"
line="21"
column="31"/>
</issue>
@ -351,7 +351,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/AndroidManifest.xml"
line="22"
line="21"
column="31"/>
</issue>
@ -362,7 +362,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/AndroidManifest.xml"
line="22"
line="21"
column="31"/>
</issue>
@ -373,7 +373,7 @@
errorLine2=" ~~~~~">
<location
file="src/main/AndroidManifest.xml"
line="16"
line="15"
column="30"/>
</issue>
@ -384,7 +384,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt"
line="523"
line="532"
column="9"/>
</issue>
@ -498,59 +498,15 @@
column="13"/>
</issue>
<issue
id="ObsoleteSdkInt"
message="Unnecessary; SDK_INT is always >= 24"
errorLine1=" if (Build.VERSION.SDK_INT > 23) {"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/com/keylesspalace/tusky/fragment/ViewVideoFragment.kt"
line="288"
column="13"/>
</issue>
<issue
id="ObsoleteSdkInt"
message="Unnecessary; SDK_INT is never &lt; 24"
errorLine1=" if (Build.VERSION.SDK_INT &lt;= 23 || player == null) {"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/com/keylesspalace/tusky/fragment/ViewVideoFragment.kt"
line="297"
column="13"/>
</issue>
<issue
id="ObsoleteSdkInt"
message="Unnecessary; SDK_INT is never &lt; 24"
errorLine1=" if (Build.VERSION.SDK_INT &lt;= 23) {"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/com/keylesspalace/tusky/fragment/ViewVideoFragment.kt"
line="318"
column="13"/>
</issue>
<issue
id="ObsoleteSdkInt"
message="Unnecessary; SDK_INT is always >= 24"
errorLine1=" if (Build.VERSION.SDK_INT > 23) {"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/com/keylesspalace/tusky/fragment/ViewVideoFragment.kt"
line="330"
column="13"/>
</issue>
<issue
id="StringFormatTrivial"
message="This formatting string is trivial. Rather than using `String.format` to create your String, it will be more performant to concatenate your arguments with `+`. "
errorLine1=" (error) -> Log.e(TAG, String.format(&quot;Failed to %s account id %s&quot;, accept ? &quot;accept&quot; : &quot;reject&quot;, id))"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
errorLine1=" (error) -> Log.e(TAG, String.format(&quot;Failed to %s account id %s&quot;, accept ? &quot;accept&quot; : &quot;reject&quot;, id))"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java"
line="807"
column="61"/>
line="808"
column="49"/>
</issue>
<issue
@ -560,7 +516,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java"
line="828"
line="829"
column="61"/>
</issue>
@ -611,12 +567,12 @@
<issue
id="ReportShortcutUsage"
message="Calling this method indicates use of dynamic shortcuts, but there are no calls to methods that track shortcut usage, such as `pushDynamicShortcut` or `reportShortcutUsed`. Calling these methods is recommended, as they track shortcut usage and allow launchers to adjust which shortcuts appear based on activation history. Please see https://developer.android.com/develop/ui/views/launch/shortcuts/managing-shortcuts#track-usage"
errorLine1=" ShortcutManagerCompat.addDynamicShortcuts(context, listOf(shortcutInfo))"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
errorLine1=" ShortcutManagerCompat.addDynamicShortcuts(context, listOf(shortcutInfo))"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/com/keylesspalace/tusky/util/ShareShortcutHelper.kt"
line="91"
column="9"/>
line="96"
column="13"/>
</issue>
<issue
@ -1165,7 +1121,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java"
line="200"
line="196"
column="19"/>
</issue>
@ -1176,7 +1132,7 @@
errorLine2=" ~~~~~~">
<location
file="src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java"
line="789"
line="791"
column="38"/>
</issue>
@ -1187,7 +1143,7 @@
errorLine2=" ~~~~~~">
<location
file="src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java"
line="794"
line="796"
column="40"/>
</issue>
@ -1198,7 +1154,7 @@
errorLine2=" ~~~~~~">
<location
file="src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java"
line="799"
line="801"
column="58"/>
</issue>
@ -1209,7 +1165,7 @@
errorLine2=" ~~~~~~">
<location
file="src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java"
line="812"
line="813"
column="47"/>
</issue>
@ -1220,7 +1176,7 @@
errorLine2=" ~~~~~~">
<location
file="src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java"
line="827"
line="828"
column="30"/>
</issue>

View File

@ -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

View File

@ -19,7 +19,7 @@ class MigrationsTest {
@Rule
var helper: MigrationTestHelper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
AppDatabase::class.java.canonicalName,
AppDatabase::class.java.canonicalName!!,
FrameworkSQLiteOpenHelperFactory()
)

View File

@ -31,7 +31,7 @@ class EmojiAdapter(
private val animate: Boolean
) : RecyclerView.Adapter<BindingHolder<ItemEmojiButtonBinding>>() {
private val emojiList: List<Emoji> = emojiList.filter { emoji -> emoji.visibleInPicker == null || emoji.visibleInPicker }
private val emojiList: List<Emoji> = emojiList.filter { emoji -> emoji.visibleInPicker }
.sortedBy { it.shortcode.lowercase(Locale.ROOT) }
override fun getItemCount() = emojiList.size

View File

@ -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

View File

@ -815,7 +815,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
setTranslationStatus(status, listener);
setRebloggingEnabled(actionable.rebloggingAllowed(), actionable.getVisibility());
setRebloggingEnabled(actionable.isRebloggingAllowed(), actionable.getVisibility());
setSpoilerAndContent(status, statusDisplayOptions, listener);

View File

@ -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)
}
}
}

View File

@ -15,11 +15,11 @@ interface Event
@Singleton
class EventHub @Inject constructor() {
private val sharedEventFlow = MutableSharedFlow<Event>()
val events: SharedFlow<Event> = sharedEventFlow.asSharedFlow()
private val _events = MutableSharedFlow<Event>()
val events: SharedFlow<Event> = _events.asSharedFlow()
suspend fun dispatch(event: Event) {
sharedEventFlow.emit(event)
_events.emit(event)
}
// TODO remove as soon as NotificationsFragment is Kotlin

View File

@ -514,8 +514,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)
@ -656,7 +656,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)
@ -1084,7 +1084,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

View File

@ -21,9 +21,13 @@ import com.keylesspalace.tusky.util.Success
import com.keylesspalace.tusky.util.getDomain
import javax.inject.Inject
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
@ -42,8 +46,8 @@ class AccountViewModel @Inject constructor(
private val _noteSaved = MutableStateFlow(false)
val noteSaved: StateFlow<Boolean> = _noteSaved.asStateFlow()
private val _isRefreshing = MutableStateFlow(false)
val isRefreshing: StateFlow<Boolean> = _isRefreshing.asStateFlow()
private val _isRefreshing = MutableSharedFlow<Boolean>(1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
val isRefreshing: SharedFlow<Boolean> = _isRefreshing.asSharedFlow()
private var isDataLoading = false
@ -84,13 +88,13 @@ class AccountViewModel @Inject constructor(
_accountData.value = Success(account)
isDataLoading = false
_isRefreshing.value = false
_isRefreshing.emit(false)
},
{ t ->
Log.w(TAG, "failed obtaining account", t)
_accountData.value = Error(cause = t)
isDataLoading = false
_isRefreshing.value = false
_isRefreshing.emit(false)
}
)
}

View File

@ -28,6 +28,7 @@ import javax.inject.Inject
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
@ -53,13 +54,13 @@ class ListsForAccountViewModel @Inject constructor(
) : ViewModel() {
private val _states = MutableSharedFlow<List<AccountListState>>(1)
val states: SharedFlow<List<AccountListState>> = _states
val states: SharedFlow<List<AccountListState>> = _states.asSharedFlow()
private val _loadError = MutableSharedFlow<Throwable>(1)
val loadError: SharedFlow<Throwable> = _loadError
val loadError: SharedFlow<Throwable> = _loadError.asSharedFlow()
private val _actionError = MutableSharedFlow<ActionError>(1)
val actionError: SharedFlow<ActionError> = _actionError
val actionError: SharedFlow<ActionError> = _actionError.asSharedFlow()
fun load(accountId: String?) {
_loadError.resetReplayCache()

View File

@ -21,7 +21,7 @@ import com.keylesspalace.tusky.util.getFormattedDescription
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.viewdata.AttachmentViewData
import java.util.Random
import kotlin.random.Random
class AccountMediaGridAdapter(
private val useBlurhash: Boolean,
@ -60,7 +60,6 @@ class AccountMediaGridAdapter(
)
private val itemBgBaseHSV = FloatArray(3)
private val random = Random()
override fun onCreateViewHolder(
parent: ViewGroup,
@ -72,7 +71,7 @@ class AccountMediaGridAdapter(
false
)
Color.colorToHSV(baseItemBackgroundColor, itemBgBaseHSV)
itemBgBaseHSV[2] = itemBgBaseHSV[2] + random.nextFloat() / 3f - 1f / 6f
itemBgBaseHSV[2] = itemBgBaseHSV[2] + Random.nextFloat() / 3f - 1f / 6f
binding.root.setBackgroundColor(Color.HSVToColor(itemBgBaseHSV))
return BindingHolder(binding)
}

View File

@ -145,12 +145,12 @@ class ComposeAutoCompleteAdapter(
}
}
sealed class AutocompleteResult {
class AccountResult(val account: TimelineAccount) : AutocompleteResult()
sealed interface AutocompleteResult {
class AccountResult(val account: TimelineAccount) : AutocompleteResult
class HashtagResult(val hashtag: String) : AutocompleteResult()
class HashtagResult(val hashtag: String) : AutocompleteResult
class EmojiResult(val emoji: Emoji) : AutocompleteResult()
class EmojiResult(val emoji: Emoji) : AutocompleteResult
}
interface AutocompletionProvider {

View File

@ -47,6 +47,7 @@ import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.shareIn
@ -107,12 +108,12 @@ class ComposeViewModel @Inject constructor(
private val _media = MutableStateFlow(emptyList<QueuedMedia>())
val media: StateFlow<List<QueuedMedia>> = _media.asStateFlow()
val uploadError =
MutableSharedFlow<Throwable>(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
private val _uploadError = MutableSharedFlow<Throwable>(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val uploadError: SharedFlow<Throwable> = _uploadError.asSharedFlow()
private val _closeConfirmation = MutableStateFlow(ConfirmationKind.NONE)
val closeConfirmation: StateFlow<ConfirmationKind> = _closeConfirmation.asStateFlow()
@ -202,7 +203,7 @@ class ComposeViewModel @Inject constructor(
)
is UploadEvent.ErrorEvent -> {
_media.update { mediaList -> mediaList.filter { it.localId != mediaItem.localId } }
uploadError.emit(event.error)
_uploadError.emit(event.error)
return@collect
}
}
@ -385,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,

View File

@ -42,7 +42,7 @@ fun downsizeImage(
tempFile: File
): Boolean {
val decodeBoundsInputStream = try {
contentResolver.openInputStream(uri)
contentResolver.openInputStream(uri) ?: return false
} catch (e: FileNotFoundException) {
return false
}
@ -66,7 +66,7 @@ fun downsizeImage(
return false
}
val decodeBitmapInputStream = try {
contentResolver.openInputStream(uri)
contentResolver.openInputStream(uri) ?: return false
} catch (e: FileNotFoundException) {
return false
}

View File

@ -15,7 +15,6 @@
package com.keylesspalace.tusky.components.compose
import android.annotation.SuppressLint
import android.content.ContentResolver
import android.content.Context
import android.media.MediaMetadataRetriever
@ -31,7 +30,7 @@ import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfo
import com.keylesspalace.tusky.network.MediaUploadApi
import com.keylesspalace.tusky.network.ProgressRequestBody
import com.keylesspalace.tusky.network.asRequestBody
import com.keylesspalace.tusky.util.MEDIA_SIZE_UNKNOWN
import com.keylesspalace.tusky.util.getImageSquarePixels
import com.keylesspalace.tusky.util.getMediaSize
@ -41,7 +40,6 @@ import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.IOException
import java.util.Date
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.CoroutineScope
@ -64,13 +62,13 @@ import retrofit2.HttpException
sealed interface FinalUploadEvent
sealed class UploadEvent {
data class ProgressEvent(val percentage: Int) : UploadEvent()
sealed interface UploadEvent {
data class ProgressEvent(val percentage: Int) : UploadEvent
data class FinishedEvent(
val mediaId: String,
val processed: Boolean
) : UploadEvent(), FinalUploadEvent
data class ErrorEvent(val error: Throwable) : UploadEvent(), FinalUploadEvent
) : UploadEvent, FinalUploadEvent
data class ErrorEvent(val error: Throwable) : UploadEvent, FinalUploadEvent
}
data class UploadData(
@ -246,7 +244,6 @@ class MediaUploader @Inject constructor(
private val contentResolver = context.contentResolver
@SuppressLint("Recycle") // stream is closed in ProgressRequestBody
private suspend fun upload(media: QueuedMedia): Flow<UploadEvent> {
return callbackFlow {
var mimeType = contentResolver.getType(media.uri)
@ -265,22 +262,20 @@ class MediaUploader @Inject constructor(
}
val map = MimeTypeMap.getSingleton()
val fileExtension = map.getExtensionFromMimeType(mimeType)
val filename = "%s_%s_%s.%s".format(
val filename = "%s_%d_%s.%s".format(
context.getString(R.string.app_name),
Date().time.toString(),
System.currentTimeMillis(),
randomAlphanumericString(10),
fileExtension
)
val stream = contentResolver.openInputStream(media.uri)
if (mimeType == null) mimeType = "multipart/form-data"
var lastProgress = -1
val fileBody = ProgressRequestBody(
stream!!,
media.mediaSize,
mimeType.toMediaTypeOrNull()!!
val fileBody = media.uri.asRequestBody(
contentResolver,
requireNotNull(mimeType.toMediaTypeOrNull()) { "Invalid Content Type" },
media.mediaSize
) { percentage ->
if (percentage != lastProgress) {
trySend(UploadEvent.ProgressEvent(percentage))

View File

@ -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
)

View File

@ -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,

View File

@ -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)

View File

@ -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)

View File

@ -10,6 +10,8 @@ import at.connyduck.calladapter.networkresult.onFailure
import com.keylesspalace.tusky.R
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
class DomainBlocksViewModel @Inject constructor(
@ -18,12 +20,13 @@ class DomainBlocksViewModel @Inject constructor(
val domainPager = repo.domainPager.cachedIn(viewModelScope)
val uiEvents = MutableSharedFlow<SnackbarEvent>()
private val _uiEvents = MutableSharedFlow<SnackbarEvent>()
val uiEvents: SharedFlow<SnackbarEvent> = _uiEvents.asSharedFlow()
fun block(domain: String) {
viewModelScope.launch {
repo.block(domain).onFailure { e ->
uiEvents.emit(
_uiEvents.emit(
SnackbarEvent(
message = R.string.error_blocking_domain,
domain = domain,
@ -39,7 +42,7 @@ class DomainBlocksViewModel @Inject constructor(
fun unblock(domain: String) {
viewModelScope.launch {
repo.unblock(domain).fold({
uiEvents.emit(
_uiEvents.emit(
SnackbarEvent(
message = R.string.confirmation_domain_unmuted,
domain = domain,
@ -49,7 +52,7 @@ class DomainBlocksViewModel @Inject constructor(
)
)
}, { e ->
uiEvents.emit(
_uiEvents.emit(
SnackbarEvent(
message = R.string.error_unblocking_domain,
domain = domain,

View File

@ -101,16 +101,17 @@ class DraftHelper @Inject constructor(
}
}
val attachments: MutableList<DraftAttachment> = 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<DraftAttachment> = 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(

View File

@ -11,8 +11,9 @@ import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.isHttpNotFound
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
class FiltersViewModel @Inject constructor(
@ -30,8 +31,8 @@ class FiltersViewModel @Inject constructor(
data class State(val filters: List<Filter>, val loadingState: LoadingState)
val state: Flow<State> get() = _state
private val _state = MutableStateFlow(State(emptyList(), LoadingState.INITIAL))
val state: StateFlow<State> = _state.asStateFlow()
fun load() {
this@FiltersViewModel._state.value = _state.value.copy(loadingState = LoadingState.LOADING)

View File

@ -91,15 +91,15 @@ data class LoginData(
val oauthRedirectUrl: Uri
) : Parcelable
sealed class LoginResult : Parcelable {
sealed interface LoginResult : Parcelable {
@Parcelize
data class Ok(val code: String) : LoginResult()
data class Ok(val code: String) : LoginResult
@Parcelize
data class Err(val errorMessage: String) : LoginResult()
data class Err(val errorMessage: String) : LoginResult
@Parcelize
data object Cancel : LoginResult()
data object Cancel : LoginResult
}
/** Activity to do Oauth process using WebView. */

View File

@ -39,15 +39,15 @@ class LoginWebViewViewModel @Inject constructor(
if (this.domain == null) {
this.domain = domain
viewModelScope.launch {
api.getInstance().fold(
api.getInstance(domain).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(

View File

@ -840,7 +840,7 @@ public class NotificationHelper {
PollOption option = options.get(i);
builder.append(buildDescription(option.getTitle(),
PollViewDataKt.calculatePercent(option.getVotesCount(), poll.getVotersCount(), poll.getVotesCount()),
poll.getOwnVotes() != null && poll.getOwnVotes().contains(i),
poll.getOwnVotes().contains(i),
context));
builder.append('\n');
}

View File

@ -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(

View File

@ -284,7 +284,7 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), 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<StatusViewData.Concrete>(), 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<StatusViewData.Concrete>(), 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<StatusViewData.Concrete>(), Status
}
R.id.pin -> {
viewModel.pinAccount(status, !status.isPinned())
viewModel.pinAccount(status, !status.pinned)
return@setOnMenuItemClickListener true
}
@ -562,7 +562,7 @@ class SearchStatusesFragment : SearchFragment<StatusViewData.Concrete>(), Status
{ deletedStatus ->
removeItem(position)
val redraftStatus = if (deletedStatus.isEmpty()) {
val redraftStatus = if (deletedStatus.isEmpty) {
status.toDeletedStatus()
} else {
deletedStatus

View File

@ -13,11 +13,11 @@
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
@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<ArrayList<Attachment>>() {}.type
private val emojisListType = object : TypeToken<List<Emoji>>() {}.type
private val mentionListType = object : TypeToken<List<Status.Mention>>() {}.type
private val tagListType = object : TypeToken<List<HashTag>>() {}.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<List<Emoji>>().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<List<Emoji>?>().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<List<Emoji>>().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<List<Attachment>>().toJson(it) },
mentions = actionableStatus.mentions.let { moshi.adapter<List<Status.Mention>>().toJson(it) },
tags = actionableStatus.tags.let { moshi.adapter<List<HashTag>?>().toJson(it) },
application = actionableStatus.application.let { moshi.adapter<Status.Application?>().toJson(it) },
reblogServerId = reblog?.id,
reblogAccountId = reblog?.let { this.account.id },
poll = actionableStatus.poll.let(gson::toJson),
poll = actionableStatus.poll.let { moshi.adapter<Poll?>().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<Card>().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<Attachment> = gson.fromJson(status.attachments, attachmentArrayListType) ?: arrayListOf()
val mentions: List<Status.Mention> = gson.fromJson(status.mentions, mentionListType) ?: emptyList()
val tags: List<HashTag>? = gson.fromJson(status.tags, tagListType)
val application = gson.fromJson(status.application, Status.Application::class.java)
val emojis: List<Emoji> = 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<Attachment> = status.attachments?.let { moshi.adapter<List<Attachment>?>().fromJson(it) }.orEmpty()
val mentions: List<Status.Mention> = status.mentions?.let { moshi.adapter<List<Status.Mention>?>().fromJson(it) }.orEmpty()
val tags: List<HashTag>? = status.tags?.let { moshi.adapter<List<HashTag>?>().fromJson(it) }
val application = status.application?.let { moshi.adapter<Status.Application?>().fromJson(it) }
val emojis: List<Emoji> = status.emojis?.let { moshi.adapter<List<Emoji>?>().fromJson(it) }.orEmpty()
val poll: Poll? = status.poll?.let { moshi.adapter<Poll?>().fromJson(it) }
val card: Card? = status.card?.let { moshi.adapter<Card?>().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(

View File

@ -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 <T> ifExpected(t: Throwable, cb: () -> T): T {
if (t.isExpected()) {

View File

@ -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<Int, TimelineStatusWithAccount>() {
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

View File

@ -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

View File

@ -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())
}
}

View File

@ -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)
}
},

View File

@ -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
@ -51,6 +51,8 @@ import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@ -62,20 +64,18 @@ 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)
val uiState: Flow<ThreadUiState> = _uiState.asStateFlow()
private val _errors =
MutableSharedFlow<Throwable>(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val errors: Flow<Throwable>
get() = _errors
private val _errors = MutableSharedFlow<Throwable>(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val errors: SharedFlow<Throwable> = _errors.asSharedFlow()
var isInitialLoad: Boolean = true
@ -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())
}
}

View File

@ -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<Emoji>? {
return gson.fromJson(emojiListJson, object : TypeToken<List<Emoji>>() {}.type)
fun jsonToEmojiList(emojiListJson: String?): List<Emoji> {
return emojiListJson?.let { moshi.adapter<List<Emoji>?>().fromJson(it) }.orEmpty()
}
@TypeConverter
fun emojiListToJson(emojiList: List<Emoji>?): String {
return gson.toJson(emojiList)
fun emojiListToJson(emojiList: List<Emoji>): String {
return moshi.adapter<List<Emoji>>().toJson(emojiList)
}
@TypeConverter
@ -83,55 +85,52 @@ class Converters @Inject constructor(
@TypeConverter
fun accountToJson(account: ConversationAccountEntity?): String {
return gson.toJson(account)
return moshi.adapter<ConversationAccountEntity?>().toJson(account)
}
@TypeConverter
fun jsonToAccount(accountJson: String?): ConversationAccountEntity? {
return gson.fromJson(accountJson, ConversationAccountEntity::class.java)
return accountJson?.let { moshi.adapter<ConversationAccountEntity?>().fromJson(it) }
}
@TypeConverter
fun accountListToJson(accountList: List<ConversationAccountEntity>?): String {
return gson.toJson(accountList)
fun accountListToJson(accountList: List<ConversationAccountEntity>): String {
return moshi.adapter<List<ConversationAccountEntity>>().toJson(accountList)
}
@TypeConverter
fun jsonToAccountList(accountListJson: String?): List<ConversationAccountEntity>? {
return gson.fromJson(
accountListJson,
object : TypeToken<List<ConversationAccountEntity>>() {}.type
)
fun jsonToAccountList(accountListJson: String?): List<ConversationAccountEntity> {
return accountListJson?.let { moshi.adapter<List<ConversationAccountEntity>?>().fromJson(it) }.orEmpty()
}
@TypeConverter
fun attachmentListToJson(attachmentList: List<Attachment>?): String {
return gson.toJson(attachmentList)
fun attachmentListToJson(attachmentList: List<Attachment>): String {
return moshi.adapter<List<Attachment>>().toJson(attachmentList)
}
@TypeConverter
fun jsonToAttachmentList(attachmentListJson: String?): List<Attachment>? {
return gson.fromJson(attachmentListJson, object : TypeToken<List<Attachment>>() {}.type)
fun jsonToAttachmentList(attachmentListJson: String?): List<Attachment> {
return attachmentListJson?.let { moshi.adapter<List<Attachment>?>().fromJson(it) }.orEmpty()
}
@TypeConverter
fun mentionListToJson(mentionArray: List<Status.Mention>?): String? {
return gson.toJson(mentionArray)
fun mentionListToJson(mentionArray: List<Status.Mention>): String {
return moshi.adapter<List<Status.Mention>>().toJson(mentionArray)
}
@TypeConverter
fun jsonToMentionArray(mentionListJson: String?): List<Status.Mention>? {
return gson.fromJson(mentionListJson, object : TypeToken<List<Status.Mention>>() {}.type)
fun jsonToMentionArray(mentionListJson: String?): List<Status.Mention> {
return mentionListJson?.let { moshi.adapter<List<Status.Mention>?>().fromJson(it) }.orEmpty()
}
@TypeConverter
fun tagListToJson(tagArray: List<HashTag>?): String? {
return gson.toJson(tagArray)
fun tagListToJson(tagArray: List<HashTag>?): String {
return moshi.adapter<List<HashTag>?>().toJson(tagArray)
}
@TypeConverter
fun jsonToTagArray(tagListJson: String?): List<HashTag>? {
return gson.fromJson(tagListJson, object : TypeToken<List<HashTag>>() {}.type)
return tagListJson?.let { moshi.adapter<List<HashTag>?>().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<Poll?>().toJson(poll)
}
@TypeConverter
fun jsonToPoll(pollJson: String?): Poll? {
return gson.fromJson(pollJson, Poll::class.java)
return pollJson?.let { moshi.adapter<Poll?>().fromJson(it) }
}
@TypeConverter
fun newPollToJson(newPoll: NewPoll?): String? {
return gson.toJson(newPoll)
fun newPollToJson(newPoll: NewPoll?): String {
return moshi.adapter<NewPoll?>().toJson(newPoll)
}
@TypeConverter
fun jsonToNewPoll(newPollJson: String?): NewPoll? {
return gson.fromJson(newPollJson, NewPoll::class.java)
return newPollJson?.let { moshi.adapter<NewPoll?>().fromJson(it) }
}
@TypeConverter
fun draftAttachmentListToJson(draftAttachments: List<DraftAttachment>?): String? {
return gson.toJson(draftAttachments)
fun draftAttachmentListToJson(draftAttachments: List<DraftAttachment>): String {
return moshi.adapter<List<DraftAttachment>>().toJson(draftAttachments)
}
@TypeConverter
fun jsonToDraftAttachmentList(draftAttachmentListJson: String?): List<DraftAttachment>? {
return gson.fromJson(
draftAttachmentListJson,
object : TypeToken<List<DraftAttachment>>() {}.type
)
fun jsonToDraftAttachmentList(draftAttachmentListJson: String?): List<DraftAttachment> {
return draftAttachmentListJson?.let { moshi.adapter<List<DraftAttachment>?>().fromJson(it) }.orEmpty()
}
@TypeConverter
fun filterResultListToJson(filterResults: List<FilterResult>?): String? {
return gson.toJson(filterResults)
fun filterResultListToJson(filterResults: List<FilterResult>?): String {
return moshi.adapter<List<FilterResult>?>().toJson(filterResults)
}
@TypeConverter
fun jsonToFilterResultList(filterResultListJson: String?): List<FilterResult>? {
return gson.fromJson(filterResultListJson, object : TypeToken<List<FilterResult>>() {}.type)
return filterResultListJson?.let { moshi.adapter<List<FilterResult>?>().fromJson(it) }
}
@TypeConverter
fun cardToJson(card: Card?): String {
return moshi.adapter<Card?>().toJson(card)
}
}

View File

@ -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,

View File

@ -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<Emoji>,
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<Attachment>,
mentions: List<Status.Mention>,
tags: List<HashTag>?,
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

View File

@ -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"
}
}

View File

@ -70,6 +70,8 @@ object PlayerModule {
context,
MediaCodecSelector.DEFAULT,
DefaultRenderersFactory.DEFAULT_ALLOWED_VIDEO_JOINING_TIME_MS,
// enableDecoderFallback = true, helps playing videos even if one decoder fails
true,
eventHandler,
videoRendererEventListener,
DefaultRenderersFactory.MAX_DROPPED_VIDEO_FRAME_COUNT_TO_NOTIFY
@ -77,6 +79,8 @@ object PlayerModule {
MediaCodecAudioRenderer(
context,
MediaCodecSelector.DEFAULT,
// enableDecoderFallback = true
true,
eventHandler,
audioRendererEventListener,
audioSink

View File

@ -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
)

View File

@ -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<Emoji>? = emptyList(),
// nullable for backward compatibility
val fields: List<Field>? = emptyList(),
// default value for backward compatibility
val emojis: List<Emoji> = emptyList(),
// default value for backward compatibility
val fields: List<Field> = emptyList(),
val moved: Account? = null,
val roles: List<Role>? = emptyList()
val roles: List<Role> = 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<StringField>?,
val language: String?
val privacy: Status.Visibility = Status.Visibility.PUBLIC,
val sensitive: Boolean? = null,
val note: String? = null,
val fields: List<StringField> = 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

View File

@ -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<Status.Mention>,
val statuses: List<Status>,
val tags: List<HashTag>,
@ -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
)
}

View File

@ -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
)

View File

@ -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<Type> {
@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 width: Int = 0,
val height: Int = 0,
val aspect: Double = 0.0
) : Parcelable
}

View File

@ -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 {

View File

@ -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<TimelineAccount>,
// 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
)

View File

@ -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<Attachment>?,
val poll: Poll?,
@SerializedName("created_at") val createdAt: Date,
val language: String?
@Json(name = "media_attachments") val attachments: List<Attachment>,
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()
}

View File

@ -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

View File

@ -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
)

View File

@ -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<String>,
@SerializedName("expires_at") val expiresAt: Date?,
@SerializedName("filter_action") private val filterAction: String,
val keywords: List<FilterKeyword>
@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<FilterKeyword> = emptyList(),
// val statuses: List<FilterStatus>,
) : Parcelable {
enum class Action(val action: String) {

View File

@ -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

View File

@ -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<String>?,
@SerializedName("status_matches") val statusMatches: List<String>?
// @Json(name = "keyword_matches") val keywordMatches: List<String>? = null,
// @Json(name = "status_matches") val statusMatches: List<String>? = null
)

View File

@ -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<String>,
@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 {

View File

@ -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
)

View File

@ -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<String>,
val configuration: Configuration?,
val configuration: Configuration? = null,
// val registrations: Registrations,
// val contact: Contact,
val rules: List<Rule>?,
val pleroma: PleromaConfiguration?
val rules: List<Rule> = 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<String>,
@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<String> = 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)
}

View File

@ -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<String, Int>?,
// val thumbnail: String?,
// val languages: List<String>,
// @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<InstanceRules>?
// @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<InstanceRules> = 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<String>?,
@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<String> = 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

View File

@ -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
)

View File

@ -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"),

View File

@ -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
)

View File

@ -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<String>?,
@SerializedName("media_attributes") val mediaAttributes: List<MediaAttribute>?,
@SerializedName("scheduled_at") val scheduledAt: String?,
val poll: NewPoll?,
val language: String?
@Json(name = "media_ids") val mediaIds: List<String> = emptyList(),
@Json(name = "media_attributes") val mediaAttributes: List<MediaAttribute> = 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<String>,
@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

View File

@ -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<Type> {
@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 */

View File

@ -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
)

View File

@ -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<PollOption>,
val voted: Boolean,
@SerializedName("own_votes") val ownVotes: List<Int>?
val voted: Boolean = false,
@Json(name = "own_votes") val ownVotes: List<Int> = emptyList()
) {
fun votedCopy(choices: List<Int>): 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
)

View File

@ -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
)

View File

@ -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<String>?,
@SerializedName("created_at") val createdAt: Date,
@SerializedName("target_account") val targetAccount: TimelineAccount
@Json(name = "status_ids") val statusIds: List<String>? = null,
@Json(name = "created_at") val createdAt: Date,
@Json(name = "target_account") val targetAccount: TimelineAccount
)

View File

@ -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<Attachment>
@Json(name = "media_attachments") val mediaAttachments: List<Attachment>
)

View File

@ -15,6 +15,9 @@
package com.keylesspalace.tusky.entity
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class SearchResult(
val accounts: List<TimelineAccount>,
val statuses: List<Status>,

View File

@ -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<Emoji>,
@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<Attachment>,
@Json(name = "media_attachments") val attachments: List<Attachment>,
val mentions: List<Mention>,
val tags: List<HashTag>?,
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<HashTag>? = 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<FilterResult>?
val filtered: List<FilterResult> = 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 {

View File

@ -15,6 +15,9 @@
package com.keylesspalace.tusky.entity
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class StatusContext(
val ancestors: List<Status>,
val descendants: List<Status>

View File

@ -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<Attachment>,
val poll: Poll? = null,
@Json(name = "media_attachments") val mediaAttachments: List<Attachment>,
val emojis: List<Emoji>
)

View File

@ -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
)

View File

@ -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
)

View File

@ -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<Emoji>? = emptyList()
// optional for backward compatibility
val emojis: List<Emoji> = emptyList()
) {
val name: String

View File

@ -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,14 +14,25 @@ data class MediaTranslation(
*
* See [doc](https://docs.joinmastodon.org/entities/Translation/).
*/
@JsonClass(generateAdapter = true)
data class Translation(
val content: String,
@SerializedName("spoiler_warning")
val spoilerWarning: String?,
val poll: List<String>?,
@SerializedName("media_attachments")
@Json(name = "spoiler_text")
val spoilerText: String? = null,
val poll: TranslatedPoll? = null,
@Json(name = "media_attachments")
val mediaAttachments: List<MediaTranslation>,
@SerializedName("detected_source_language")
@Json(name = "detected_source_language")
val detectedSourceLanguage: String,
val provider: String,
)
@JsonClass(generateAdapter = true)
data class TranslatedPoll(
val options: List<TranslatedPollOption>
)
@JsonClass(generateAdapter = true)
data class TranslatedPollOption(
val title: String
)

View File

@ -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<TrendingTagHistory>
@ -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)

View File

@ -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

View File

@ -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 <http://www.gnu.org/licenses>. */
* see <http://www.gnu.org/licenses>.
*/
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<Boolean?> {
@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

View File

@ -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 <http://www.gnu.org/licenses>.
*/
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<T> private constructor(
private val delegate: JsonAdapter<T>
) : JsonAdapter<T>() {
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<Annotation>,
moshi: Moshi
): JsonAdapter<*>? {
val delegateAnnotations =
Types.nextAnnotations(annotations, Guarded::class.java) ?: return null
val delegate = moshi.nextAdapter<Any?>(this, type, delegateAnnotations)
return GuardedAdapter(delegate)
}
}
}
}

View File

@ -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
/*
* Jacksons 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
}

View File

@ -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<Date?>() {
@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
}
}
}
}
}

View File

@ -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 }

View File

@ -89,7 +89,9 @@ interface MastodonApi {
): NetworkResult<InstanceV1>
@GET("api/v2/instance")
suspend fun getInstance(): NetworkResult<Instance>
suspend fun getInstance(
@Header(DOMAIN_HEADER) domain: String? = null
): NetworkResult<Instance>
@GET("api/v1/filters")
suspend fun getFiltersV1(): NetworkResult<List<FilterV1>>

View File

@ -1,55 +0,0 @@
/* 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 <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.network
import java.io.IOException
import java.io.InputStream
import okhttp3.MediaType
import okhttp3.RequestBody
import okio.BufferedSink
class ProgressRequestBody(private val content: InputStream, private val contentLength: Long, private val mediaType: MediaType, private val uploadListener: UploadCallback) : RequestBody() {
fun interface UploadCallback {
fun onProgressUpdate(percentage: Int)
}
override fun contentType(): MediaType {
return mediaType
}
override fun contentLength(): Long {
return contentLength
}
@Throws(IOException::class)
override fun writeTo(sink: BufferedSink) {
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var uploaded: Long = 0
content.use { content ->
var read: Int
while (content.read(buffer).also { read = it } != -1) {
uploadListener.onProgressUpdate((100 * uploaded / contentLength).toInt())
uploaded += read.toLong()
sink.write(buffer, 0, read)
}
uploadListener.onProgressUpdate((100 * uploaded / contentLength).toInt())
}
}
companion object {
private const val DEFAULT_BUFFER_SIZE = 2048
}
}

View File

@ -0,0 +1,57 @@
/* 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 <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.network
import android.content.ContentResolver
import android.net.Uri
import java.io.FileNotFoundException
import okhttp3.MediaType
import okhttp3.RequestBody
import okio.Buffer
import okio.BufferedSink
import okio.source
private const val DEFAULT_CHUNK_SIZE = 8192L
fun interface UploadCallback {
fun onProgressUpdate(percentage: Int)
}
fun Uri.asRequestBody(contentResolver: ContentResolver, contentType: MediaType? = null, contentLength: Long = -1L, uploadListener: UploadCallback? = null): RequestBody {
return object : RequestBody() {
override fun contentType(): MediaType? = contentType
override fun contentLength(): Long = contentLength
override fun writeTo(sink: BufferedSink) {
val buffer = Buffer()
var uploaded: Long = 0
val inputStream = contentResolver.openInputStream(this@asRequestBody) ?: throw FileNotFoundException("Unavailable ContentProvider")
inputStream.source().use { source ->
while (true) {
val read = source.read(buffer, DEFAULT_CHUNK_SIZE)
if (read == -1L) {
break
}
sink.write(buffer, read)
uploaded += read
uploadListener?.let { if (contentLength > 0L) it.onProgressUpdate((100L * uploaded / contentLength).toInt()) }
}
uploadListener?.onProgressUpdate(100)
}
}
}
}

View File

@ -94,7 +94,7 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() {
StatusToSend(
text = text,
warningText = spoiler,
visibility = visibility.serverString(),
visibility = visibility.serverString,
sensitive = false,
media = emptyList(),
scheduledAt = null,

View File

@ -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<Emoji>?, view: View, animate: Boolean): CharSequence {
if (emojis.isNullOrEmpty()) {
fun CharSequence.emojify(emojis: List<Emoji>, view: View, animate: Boolean): CharSequence {
if (emojis.isEmpty()) {
return this
}

View File

@ -21,27 +21,29 @@ package com.keylesspalace.tusky.util
* Class to represent sum type/tagged union/variant/ADT e.t.c.
* It is either Left or Right.
*/
sealed class Either<out L, out R> {
data class Left<out L, out R>(val value: L) : Either<L, R>()
data class Right<out L, out R>(val value: R) : Either<L, R>()
sealed interface Either<out L, out R> {
data class Left<out L, out R>(val value: L) : Either<L, R>
data class Right<out L, out R>(val value: R) : Either<L, R>
fun isRight() = this is Right
fun isRight(): Boolean = this is Right
fun isLeft() = this is Left
fun isLeft(): Boolean = this is Left
fun asLeftOrNull() = (this as? Left<L, R>)?.value
fun asLeftOrNull(): L? = (this as? Left<L, R>)?.value
fun asRightOrNull() = (this as? Right<L, R>)?.value
fun asRightOrNull(): R? = (this as? Right<L, R>)?.value
fun asLeft(): L = (this as Left<L, R>).value
fun asRight(): R = (this as Right<L, R>).value
inline fun <N> map(crossinline mapper: (R) -> N): Either<L, N> {
return if (this.isLeft()) {
Left(this.asLeft())
} else {
Right(mapper(this.asRight()))
companion object {
inline fun <L, R, N> Either<L, R>.map(mapper: (R) -> N): Either<L, N> {
return if (this.isLeft()) {
Left(this.asLeft())
} else {
Right(mapper(this.asRight()))
}
}
}
}

View File

@ -15,52 +15,33 @@
package com.keylesspalace.tusky.util
import android.annotation.SuppressLint
import android.content.ContentResolver
import android.net.Uri
import java.io.Closeable
import java.io.File
import java.io.FileNotFoundException
import java.io.FileOutputStream
import java.io.IOException
import java.io.InputStream
import okio.buffer
import okio.sink
import okio.source
private const val DEFAULT_BLOCKSIZE = 16384
fun Closeable?.closeQuietly() {
fun Closeable.closeQuietly() {
try {
this?.close()
close()
} catch (e: IOException) {
// intentionally unhandled
}
}
@SuppressLint("Recycle") // The linter can't tell that the stream gets closed by a helper method
fun Uri.copyToFile(contentResolver: ContentResolver, file: File): Boolean {
val from: InputStream?
val to: FileOutputStream
try {
from = contentResolver.openInputStream(this)
to = FileOutputStream(file)
} catch (e: FileNotFoundException) {
return false
}
if (from == null) return false
val chunk = ByteArray(DEFAULT_BLOCKSIZE)
try {
while (true) {
val bytes = from.read(chunk, 0, chunk.size)
if (bytes < 0) break
to.write(chunk, 0, bytes)
return try {
val inputStream = contentResolver.openInputStream(this) ?: return false
inputStream.source().use { source ->
file.sink().buffer().use { bufferedSink ->
bufferedSink.writeAll(source)
}
}
true
} catch (e: IOException) {
return false
false
}
from.closeQuietly()
to.closeQuietly()
return true
}

View File

@ -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)

View File

@ -69,7 +69,7 @@ fun getMediaSize(contentResolver: ContentResolver, uri: Uri?): Long {
@Throws(FileNotFoundException::class)
fun getImageSquarePixels(contentResolver: ContentResolver, uri: Uri): Long {
val input = contentResolver.openInputStream(uri)
val input = contentResolver.openInputStream(uri) ?: throw FileNotFoundException("Unavailable ContentProvider")
val options = BitmapFactory.Options()
options.inJustDecodeBounds = true

View File

@ -1,14 +1,16 @@
package com.keylesspalace.tusky.util
sealed class Resource<T>(open val data: T?)
sealed interface Resource<T> {
val data: T?
}
class Loading<T>(override val data: T? = null) : Resource<T>(data)
class Loading<T>(override val data: T? = null) : Resource<T>
class Success<T>(override val data: T? = null) : Resource<T>(data)
class Success<T>(override val data: T? = null) : Resource<T>
class Error<T>(
override val data: T? = null,
val errorMessage: String? = null,
var consumed: Boolean = false,
val cause: Throwable? = null
) : Resource<T>(data)
) : Resource<T>

View File

@ -3,15 +3,14 @@
package com.keylesspalace.tusky.util
import android.text.Spanned
import java.util.Random
import kotlin.random.Random
private const val POSSIBLE_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
fun randomAlphanumericString(count: Int): String {
val chars = CharArray(count)
val random = Random()
for (i in 0 until count) {
chars[i] = POSSIBLE_CHARS[random.nextInt(POSSIBLE_CHARS.length)]
chars[i] = POSSIBLE_CHARS[Random.nextInt(POSSIBLE_CHARS.length)]
}
return String(chars)
}

View File

@ -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
)

View File

@ -22,12 +22,12 @@ import com.keylesspalace.tusky.entity.Translation
import com.keylesspalace.tusky.util.parseAsMastodonHtml
import com.keylesspalace.tusky.util.shouldTrimStatus
sealed class TranslationViewData {
abstract val data: Translation?
sealed interface TranslationViewData {
val data: Translation?
data class Loaded(override val data: Translation) : TranslationViewData()
data class Loaded(override val data: Translation) : TranslationViewData
data object Loading : TranslationViewData() {
data object Loading : TranslationViewData {
override val data: Translation?
get() = null
}
@ -67,10 +67,12 @@ sealed class StatusViewData {
actionable.attachments.translated { translation -> map { it.translated(translation) } }
val spoilerText: String =
actionable.spoilerText.translated { translation -> translation.spoilerWarning ?: this }
actionable.spoilerText.translated { translation -> translation.spoilerText ?: this }
val poll = actionable.poll?.translated { translation ->
val translatedOptionsText = translation.poll ?: return@translated this
val translatedOptionsText = translation.poll?.options?.map { option ->
option.title
} ?: return@translated this
val translatedOptions = options.zip(translatedOptionsText) { option, translatedText ->
option.copy(title = translatedText)
}

View File

@ -17,15 +17,14 @@ package com.keylesspalace.tusky.viewdata
import java.util.Date
sealed class TrendingViewData {
abstract val id: String
sealed interface TrendingViewData {
val id: String
data class Header(
val start: Date,
val end: Date
) : TrendingViewData() {
override val id: String
get() = start.toString() + end.toString()
) : TrendingViewData {
override val id: String = start.toString() + end.toString()
}
data class Tag(
@ -33,8 +32,7 @@ sealed class TrendingViewData {
val usage: List<Long>,
val accounts: List<Long>,
val maxTrendingValue: Long
) : TrendingViewData() {
override val id: String
get() = name
) : TrendingViewData {
override val id: String = name
}
}

View File

@ -23,6 +23,7 @@ import at.connyduck.calladapter.networkresult.fold
import com.keylesspalace.tusky.entity.TimelineAccount
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.Either
import com.keylesspalace.tusky.util.Either.Companion.map
import com.keylesspalace.tusky.util.Either.Left
import com.keylesspalace.tusky.util.Either.Right
import com.keylesspalace.tusky.util.withoutFirstWhich
@ -117,7 +118,7 @@ class AccountsInListViewModel @Inject constructor(private val api: MastodonApi)
}
}
private inline fun updateState(crossinline fn: State.() -> State) {
private inline fun updateState(fn: State.() -> State) {
_state.value = fn(_state.value)
}
}

View File

@ -27,9 +27,12 @@ import java.io.IOException
import java.net.ConnectException
import javax.inject.Inject
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
internal class ListsViewModel @Inject constructor(private val api: MastodonApi) : ViewModel() {
@ -49,15 +52,15 @@ internal class ListsViewModel @Inject constructor(private val api: MastodonApi)
data class State(val lists: List<MastoList>, val loadingState: LoadingState)
val state: Flow<State> get() = _state
val events: Flow<Event> get() = _events
private val _state = MutableStateFlow(State(listOf(), LoadingState.INITIAL))
private val _events =
MutableSharedFlow<Event>(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val state: StateFlow<State> = _state.asStateFlow()
private val _events = MutableSharedFlow<Event>(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val events: SharedFlow<Event> = _events.asSharedFlow()
fun retryLoading() {
loadIfNeeded()
@ -140,7 +143,7 @@ internal class ListsViewModel @Inject constructor(private val api: MastodonApi)
}
}
private inline fun updateState(crossinline fn: State.() -> State) {
private inline fun updateState(fn: State.() -> State) {
_state.value = fn(_state.value)
}

View File

@ -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" />
<com.keylesspalace.tusky.view.LicenseCard
android:layout_width="match_parent"

Some files were not shown because too many files have changed in this diff Show More