refactor: Convert from Gson to Moshi (#428)

Moshi is faster to decode JSON at runtime, is actively maintained, has a
smaller memory and method footprint, and a slightly smaller APK size.
Moshi also correctly creates default constructor arguments instead of
leaving them null, which was a source of `NullPointerExceptions` when
using Gson.

The conversion broadly consisted of:

- Adding `@JsonClass(generateAdapter = true)` to data classes that
marshall to/from JSON.

- Replacing `@SerializedName(value = ...)` with `@Json(name = ...)`.

- Replacing Gson instances with Moshi in Retrofit, Hilt, and tests.

- Using Moshi adapters to marshall to/from JSON instead of Gson `toJson`
/ `fromJson`.

- Deleting `Rfc3339DateJsonAdapter` and related code, and using the
equivalent adapter bundled with Moshi.

- Rewriting `GuardedBooleanAdapter` as a more generic `GuardedAdapter`.

- Deleting unused ProGuard rules; Moshi generates adapters using code
generation, not runtime reflection.

The conversion surfaced some bugs which have been fixed.

- Not all audio attachments have attachment size metadata. Don't show
the attachment preview if the metadata is missing.

- Some `throwable` were not being logged correctly.

- The wrong type was being used when parsing the response when sending a
scheduled status.

- Exceptions other than `HttpException` or `IoException` would also
cause a status to be resent. If there's a JSON error parsing a response
the status would be repeatedly sent.

- In tests strings containing error responses were not valid JSON.

- Workaround Mastodon a bug and ensure `filter.keywords` is populated,
https://github.com/mastodon/mastodon/issues/29142
This commit is contained in:
Nik Clayton 2024-02-09 12:41:13 +01:00 committed by GitHub
parent d01c72b7d7
commit a3d45ca9ec
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
87 changed files with 731 additions and 888 deletions

View File

@ -143,7 +143,9 @@ dependencies {
implementation(libs.android.material) implementation(libs.android.material)
implementation(libs.gson) implementation(libs.moshi)
implementation(libs.moshi.adapters)
ksp(libs.moshi.codegen)
implementation(libs.bundles.retrofit) implementation(libs.bundles.retrofit)

View File

@ -57,19 +57,6 @@
# error reports # error reports
-keepnames class * -keepnames class *
# https://github.com/google/gson/blob/master/examples/android-proguard-example/proguard.cfg
# Prevent proguard from stripping interface information from TypeAdapter, TypeAdapterFactory,
# JsonSerializer, JsonDeserializer instances (so they can be used in @JsonAdapter)
-keep class * extends com.google.gson.TypeAdapter
-keep class * implements com.google.gson.TypeAdapterFactory
-keep class * implements com.google.gson.JsonSerializer
-keep class * implements com.google.gson.JsonDeserializer
# Retain generic signatures of TypeToken and its subclasses with R8 version 3.0 and higher.
-keep,allowobfuscation,allowshrinking class com.google.gson.reflect.TypeToken
-keep,allowobfuscation,allowshrinking class * extends com.google.gson.reflect.TypeToken
# Retain generic signatures of classes used in MastodonApi so Retrofit works # Retain generic signatures of classes used in MastodonApi so Retrofit works
-keep,allowobfuscation,allowshrinking class io.reactivex.rxjava3.core.Single -keep,allowobfuscation,allowshrinking class io.reactivex.rxjava3.core.Single
-keep,allowobfuscation,allowshrinking class retrofit2.Response -keep,allowobfuscation,allowshrinking class retrofit2.Response

View File

@ -19,7 +19,6 @@ package app.pachli.di
import app.pachli.updatecheck.FdroidService import app.pachli.updatecheck.FdroidService
import at.connyduck.calladapter.networkresult.NetworkResultCallAdapterFactory import at.connyduck.calladapter.networkresult.NetworkResultCallAdapterFactory
import com.google.gson.Gson
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@ -27,7 +26,7 @@ import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton import javax.inject.Singleton
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.create import retrofit2.create
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
@ -37,11 +36,10 @@ object UpdateCheckModule {
@Singleton @Singleton
fun providesFdroidService( fun providesFdroidService(
httpClient: OkHttpClient, httpClient: OkHttpClient,
gson: Gson,
): FdroidService = Retrofit.Builder() ): FdroidService = Retrofit.Builder()
.baseUrl("https://f-droid.org") .baseUrl("https://f-droid.org")
.client(httpClient) .client(httpClient)
.addConverterFactory(GsonConverterFactory.create(gson)) .addConverterFactory(MoshiConverterFactory.create())
.addCallAdapterFactory(NetworkResultCallAdapterFactory.create()) .addCallAdapterFactory(NetworkResultCallAdapterFactory.create())
.build() .build()
.create() .create()

View File

@ -19,16 +19,19 @@ package app.pachli.updatecheck
import androidx.annotation.Keep import androidx.annotation.Keep
import at.connyduck.calladapter.networkresult.NetworkResult import at.connyduck.calladapter.networkresult.NetworkResult
import com.squareup.moshi.JsonClass
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.Path import retrofit2.http.Path
@Keep @Keep
@JsonClass(generateAdapter = true)
data class FdroidPackageVersion( data class FdroidPackageVersion(
val versionName: String, val versionName: String,
val versionCode: Int, val versionCode: Int,
) )
@Keep @Keep
@JsonClass(generateAdapter = true)
data class FdroidPackage( data class FdroidPackage(
val packageName: String, val packageName: String,
val suggestedVersionCode: Int, val suggestedVersionCode: Int,

View File

@ -19,7 +19,6 @@ package app.pachli.di
import app.pachli.updatecheck.GitHubService import app.pachli.updatecheck.GitHubService
import at.connyduck.calladapter.networkresult.NetworkResultCallAdapterFactory import at.connyduck.calladapter.networkresult.NetworkResultCallAdapterFactory
import com.google.gson.Gson
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@ -27,7 +26,6 @@ import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton import javax.inject.Singleton
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.create import retrofit2.create
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
@ -37,11 +35,10 @@ object UpdateCheckModule {
@Singleton @Singleton
fun providesGitHubService( fun providesGitHubService(
httpClient: OkHttpClient, httpClient: OkHttpClient,
gson: Gson,
): GitHubService = Retrofit.Builder() ): GitHubService = Retrofit.Builder()
.baseUrl("https://api.github.com") .baseUrl("https://api.github.com")
.client(httpClient) .client(httpClient)
.addConverterFactory(GsonConverterFactory.create(gson)) .addConverterFactory(MoshiConverterFactory.create())
.addCallAdapterFactory(NetworkResultCallAdapterFactory.create()) .addCallAdapterFactory(NetworkResultCallAdapterFactory.create())
.build() .build()
.create() .create()

View File

@ -19,23 +19,26 @@ package app.pachli.updatecheck
import androidx.annotation.Keep import androidx.annotation.Keep
import at.connyduck.calladapter.networkresult.NetworkResult import at.connyduck.calladapter.networkresult.NetworkResult
import com.google.gson.annotations.SerializedName import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.Path import retrofit2.http.Path
@Keep @Keep
@JsonClass(generateAdapter = true)
data class GitHubReleaseAsset( data class GitHubReleaseAsset(
/** File name for the asset, e.g., "113.apk" */ /** File name for the asset, e.g., "113.apk" */
val name: String, val name: String,
/** MIME content type for the asset, e.g., "application/vnd.android.package-archive" */ /** MIME content type for the asset, e.g., "application/vnd.android.package-archive" */
@SerializedName("content_type") val contentType: String, @Json(name = "content_type") val contentType: String,
) )
@Keep @Keep
@JsonClass(generateAdapter = true)
data class GitHubRelease( data class GitHubRelease(
/** URL for the release's web page */ /** URL for the release's web page */
@SerializedName("html_url") val htmlUrl: String, @Json(name = "html_url") val htmlUrl: String,
val assets: List<GitHubReleaseAsset>, val assets: List<GitHubReleaseAsset>,
) )

View File

@ -37,10 +37,7 @@ import android.view.MenuItem
import android.view.View import android.view.View
import android.webkit.MimeTypeMap import android.webkit.MimeTypeMap
import android.widget.Toast import android.widget.Toast
import androidx.core.app.ActivityCompat.invalidateOptionsMenu
import androidx.core.app.ActivityCompat.requestPermissions
import androidx.core.app.ShareCompat import androidx.core.app.ShareCompat
import androidx.core.content.ContextCompat.getSystemService
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
@ -89,7 +86,7 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener
var isToolbarVisible = true var isToolbarVisible = true
private set private set
private var attachments: ArrayList<AttachmentViewData>? = null private var attachments: List<AttachmentViewData>? = null
private val toolbarVisibilityListeners = mutableListOf<ToolbarVisibilityListener>() private val toolbarVisibilityListeners = mutableListOf<ToolbarVisibilityListener>()
private var imageUrl: String? = null private var imageUrl: String? = null

View File

@ -91,7 +91,7 @@ class ReportNotificationViewHolder(
binding.notificationSummary.text = itemView.context.getString( binding.notificationSummary.text = itemView.context.getString(
R.string.notification_summary_report_format, R.string.notification_summary_report_format,
getRelativeTimeSpanString(itemView.context, report.createdAt.time, Date().time), getRelativeTimeSpanString(itemView.context, report.createdAt.time, Date().time),
report.status_ids?.size ?: 0, report.statusIds?.size ?: 0,
) )
binding.notificationCategory.text = getTranslatedCategory(itemView.context, report.category) binding.notificationCategory.text = getTranslatedCategory(itemView.context, report.category)

View File

@ -901,6 +901,8 @@ abstract class StatusBaseViewHolder<T : IStatusViewData> protected constructor(i
protected fun hasPreviewableAttachment(attachments: List<Attachment>): Boolean { protected fun hasPreviewableAttachment(attachments: List<Attachment>): Boolean {
for (attachment in attachments) { for (attachment in attachments) {
if (attachment.type == Attachment.Type.UNKNOWN) return false if (attachment.type == Attachment.Type.UNKNOWN) return false
if (attachment.meta?.original?.width == null && attachment.meta?.small?.width == null) return false
} }
return true return true
} }

View File

@ -2,7 +2,9 @@ package app.pachli.appstore
import app.pachli.core.accounts.AccountManager import app.pachli.core.accounts.AccountManager
import app.pachli.core.database.dao.TimelineDao import app.pachli.core.database.dao.TimelineDao
import com.google.gson.Gson import app.pachli.core.network.model.Poll
import com.squareup.moshi.Moshi
import com.squareup.moshi.adapter
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -10,11 +12,12 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@OptIn(ExperimentalStdlibApi::class)
class CacheUpdater @Inject constructor( class CacheUpdater @Inject constructor(
eventHub: EventHub, eventHub: EventHub,
accountManager: AccountManager, accountManager: AccountManager,
timelineDao: TimelineDao, timelineDao: TimelineDao,
gson: Gson, moshi: Moshi,
) { ) {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@ -34,7 +37,7 @@ class CacheUpdater @Inject constructor(
is StatusDeletedEvent -> is StatusDeletedEvent ->
timelineDao.delete(accountId, event.statusId) timelineDao.delete(accountId, event.statusId)
is PollVoteEvent -> { is PollVoteEvent -> {
val pollString = gson.toJson(event.poll) val pollString = moshi.adapter<Poll>().toJson(event.poll)
timelineDao.setVoted(accountId, event.statusId, pollString) timelineDao.setVoted(accountId, event.statusId, pollString)
} }
is PinEvent -> is PinEvent ->

View File

@ -18,7 +18,7 @@ data class StatusDeletedEvent(val statusId: String) : Event
/** A status the user wrote was successfully sent */ /** A status the user wrote was successfully sent */
// TODO: Rename, calling it "Composed" does not imply anything about the sent state // TODO: Rename, calling it "Composed" does not imply anything about the sent state
data class StatusComposedEvent(val status: Status) : Event data class StatusComposedEvent(val status: Status) : Event
data class StatusScheduledEvent(val status: Status) : Event data object StatusScheduledEvent : Event
data class StatusEditedEvent(val originalId: String, val status: Status) : Event data class StatusEditedEvent(val originalId: String, val status: Status) : Event
data class ProfileEditedEvent(val newProfileData: Account) : Event data class ProfileEditedEvent(val newProfileData: Account) : Event
data class FilterChangedEvent(val filterKind: Filter.Kind) : Event data class FilterChangedEvent(val filterKind: Filter.Kind) : Event

View File

@ -24,7 +24,8 @@ import app.pachli.core.network.model.Error
import app.pachli.core.network.model.Links import app.pachli.core.network.model.Links
import app.pachli.core.network.model.Notification import app.pachli.core.network.model.Notification
import app.pachli.core.network.retrofit.MastodonApi import app.pachli.core.network.retrofit.MastodonApi
import com.google.gson.Gson import com.squareup.moshi.Moshi
import com.squareup.moshi.adapter
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
@ -37,9 +38,10 @@ private val INVALID = LoadResult.Invalid<String, Notification>()
/** [PagingSource] for Mastodon Notifications, identified by the Notification ID */ /** [PagingSource] for Mastodon Notifications, identified by the Notification ID */
class NotificationsPagingSource @Inject constructor( class NotificationsPagingSource @Inject constructor(
private val mastodonApi: MastodonApi, private val mastodonApi: MastodonApi,
private val gson: Gson, private val moshi: Moshi,
private val notificationFilter: Set<Notification.Type>, private val notificationFilter: Set<Notification.Type>,
) : PagingSource<String, Notification>() { ) : PagingSource<String, Notification>() {
@OptIn(ExperimentalStdlibApi::class)
override suspend fun load(params: LoadParams<String>): LoadResult<String, Notification> { override suspend fun load(params: LoadParams<String>): LoadResult<String, Notification> {
Timber.d("load() with ${params.javaClass.simpleName} for key: ${params.key}") Timber.d("load() with ${params.javaClass.simpleName} for key: ${params.key}")
@ -67,12 +69,12 @@ class NotificationsPagingSource @Inject constructor(
if (errorBody.isBlank()) return@let "no reason given" if (errorBody.isBlank()) return@let "no reason given"
val error = try { val error = try {
gson.fromJson(errorBody, Error::class.java) moshi.adapter<Error>().fromJson(errorBody)!!
} catch (e: Exception) { } catch (e: Exception) {
return@let "$errorBody ($e)" return@let "$errorBody ($e)"
} }
when (val desc = error.error_description) { when (val desc = error.errorDescription) {
null -> error.error null -> error.error
else -> "${error.error}: $desc" else -> "${error.error}: $desc"
} }

View File

@ -25,7 +25,7 @@ import androidx.paging.PagingSource
import app.pachli.core.common.di.ApplicationScope import app.pachli.core.common.di.ApplicationScope
import app.pachli.core.network.model.Notification import app.pachli.core.network.model.Notification
import app.pachli.core.network.retrofit.MastodonApi import app.pachli.core.network.retrofit.MastodonApi
import com.google.gson.Gson import com.squareup.moshi.Moshi
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.async import kotlinx.coroutines.async
@ -36,7 +36,7 @@ import timber.log.Timber
class NotificationsRepository @Inject constructor( class NotificationsRepository @Inject constructor(
private val mastodonApi: MastodonApi, private val mastodonApi: MastodonApi,
private val gson: Gson, private val moshi: Moshi,
@ApplicationScope private val externalScope: CoroutineScope, @ApplicationScope private val externalScope: CoroutineScope,
) { ) {
private var factory: InvalidatingPagingSourceFactory<String, Notification>? = null private var factory: InvalidatingPagingSourceFactory<String, Notification>? = null
@ -53,7 +53,7 @@ class NotificationsRepository @Inject constructor(
Timber.d("getNotificationsStream(), filtering: $filter") Timber.d("getNotificationsStream(), filtering: $filter")
factory = InvalidatingPagingSourceFactory { factory = InvalidatingPagingSourceFactory {
NotificationsPagingSource(mastodonApi, gson, filter) NotificationsPagingSource(mastodonApi, moshi, filter)
} }
return Pager( return Pager(

View File

@ -51,7 +51,7 @@ class ScheduledStatusViewModel @Inject constructor(
pagingSourceFactory.remove(status) pagingSourceFactory.remove(status)
}, },
{ throwable -> { throwable ->
Timber.w("Error deleting scheduled status", throwable) Timber.w("Error deleting scheduled status: $throwable")
}, },
) )
} }

View File

@ -22,6 +22,7 @@ import app.pachli.components.search.SearchType
import app.pachli.core.network.model.SearchResult import app.pachli.core.network.model.SearchResult
import app.pachli.core.network.retrofit.MastodonApi import app.pachli.core.network.retrofit.MastodonApi
import at.connyduck.calladapter.networkresult.getOrElse import at.connyduck.calladapter.networkresult.getOrElse
import timber.log.Timber
class SearchPagingSource<T : Any>( class SearchPagingSource<T : Any>(
private val mastodonApi: MastodonApi, private val mastodonApi: MastodonApi,
@ -61,7 +62,10 @@ class SearchPagingSource<T : Any>(
limit = params.loadSize, limit = params.loadSize,
offset = currentKey, offset = currentKey,
following = false, following = false,
).getOrElse { return LoadResult.Error(it) } ).getOrElse {
Timber.w(it)
return LoadResult.Error(it)
}
val res = parser(data) val res = parser(data)

View File

@ -79,7 +79,6 @@ abstract class SearchFragment<T : Any> :
} }
adapter.addLoadStateListener { loadState -> adapter.addLoadStateListener { loadState ->
if (loadState.refresh is LoadState.Error) { if (loadState.refresh is LoadState.Error) {
showError() showError()
} }

View File

@ -40,7 +40,7 @@ import app.pachli.util.EmptyPagingSource
import app.pachli.viewdata.StatusViewData import app.pachli.viewdata.StatusViewData
import at.connyduck.calladapter.networkresult.NetworkResult import at.connyduck.calladapter.networkresult.NetworkResult
import at.connyduck.calladapter.networkresult.fold import at.connyduck.calladapter.networkresult.fold
import com.google.gson.Gson import com.squareup.moshi.Moshi
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -66,7 +66,7 @@ class CachedTimelineRepository @Inject constructor(
val timelineDao: TimelineDao, val timelineDao: TimelineDao,
private val remoteKeyDao: RemoteKeyDao, private val remoteKeyDao: RemoteKeyDao,
private val translatedStatusDao: TranslatedStatusDao, private val translatedStatusDao: TranslatedStatusDao,
private val gson: Gson, private val moshi: Moshi,
@ApplicationScope private val externalScope: CoroutineScope, @ApplicationScope private val externalScope: CoroutineScope,
) { ) {
private var factory: InvalidatingPagingSourceFactory<Int, TimelineStatusWithAccount>? = null private var factory: InvalidatingPagingSourceFactory<Int, TimelineStatusWithAccount>? = null
@ -118,7 +118,7 @@ class CachedTimelineRepository @Inject constructor(
transactionProvider, transactionProvider,
timelineDao, timelineDao,
remoteKeyDao, remoteKeyDao,
gson, moshi,
), ),
pagingSourceFactory = factory!!, pagingSourceFactory = factory!!,
).flow ).flow

View File

@ -36,7 +36,7 @@ import app.pachli.core.database.model.TimelineStatusWithAccount
import app.pachli.core.network.model.Links import app.pachli.core.network.model.Links
import app.pachli.core.network.model.Status import app.pachli.core.network.model.Status
import app.pachli.core.network.retrofit.MastodonApi import app.pachli.core.network.retrofit.MastodonApi
import com.google.gson.Gson import com.squareup.moshi.Moshi
import java.io.IOException import java.io.IOException
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
@ -54,7 +54,7 @@ class CachedTimelineRemoteMediator(
private val transactionProvider: TransactionProvider, private val transactionProvider: TransactionProvider,
private val timelineDao: TimelineDao, private val timelineDao: TimelineDao,
private val remoteKeyDao: RemoteKeyDao, private val remoteKeyDao: RemoteKeyDao,
private val gson: Gson, private val moshi: Moshi,
) : RemoteMediator<Int, TimelineStatusWithAccount>() { ) : RemoteMediator<Int, TimelineStatusWithAccount>() {
private val activeAccount = accountManager.activeAccount!! private val activeAccount = accountManager.activeAccount!!
@ -254,9 +254,9 @@ class CachedTimelineRemoteMediator(
@Transaction @Transaction
private suspend fun insertStatuses(statuses: List<Status>) { private suspend fun insertStatuses(statuses: List<Status>) {
for (status in statuses) { for (status in statuses) {
timelineDao.insertAccount(TimelineAccountEntity.from(status.account, activeAccount.id, gson)) timelineDao.insertAccount(TimelineAccountEntity.from(status.account, activeAccount.id, moshi))
status.reblog?.account?.let { status.reblog?.account?.let {
val account = TimelineAccountEntity.from(it, activeAccount.id, gson) val account = TimelineAccountEntity.from(it, activeAccount.id, moshi)
timelineDao.insertAccount(account) timelineDao.insertAccount(account)
} }
@ -264,7 +264,7 @@ class CachedTimelineRemoteMediator(
TimelineStatusEntity.from( TimelineStatusEntity.from(
status, status,
timelineUserId = activeAccount.id, timelineUserId = activeAccount.id,
gson = gson, moshi = moshi,
), ),
) )
} }

View File

@ -37,7 +37,7 @@ import app.pachli.core.preferences.SharedPreferencesRepository
import app.pachli.usecase.TimelineCases import app.pachli.usecase.TimelineCases
import app.pachli.util.StatusDisplayOptionsRepository import app.pachli.util.StatusDisplayOptionsRepository
import app.pachli.viewdata.StatusViewData import app.pachli.viewdata.StatusViewData
import com.google.gson.Gson import com.squareup.moshi.Moshi
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -61,7 +61,7 @@ class CachedTimelineViewModel @Inject constructor(
accountManager: AccountManager, accountManager: AccountManager,
statusDisplayOptionsRepository: StatusDisplayOptionsRepository, statusDisplayOptionsRepository: StatusDisplayOptionsRepository,
sharedPreferencesRepository: SharedPreferencesRepository, sharedPreferencesRepository: SharedPreferencesRepository,
private val gson: Gson, private val moshi: Moshi,
) : TimelineViewModel( ) : TimelineViewModel(
savedStateHandle, savedStateHandle,
timelineCases, timelineCases,
@ -93,7 +93,7 @@ class CachedTimelineViewModel @Inject constructor(
.map { .map {
StatusViewData.from( StatusViewData.from(
it, it,
gson, moshi,
isExpanded = activeAccount.alwaysOpenSpoiler, isExpanded = activeAccount.alwaysOpenSpoiler,
isShowingContent = activeAccount.alwaysShowSensitiveMedia, isShowingContent = activeAccount.alwaysShowSensitiveMedia,
) )

View File

@ -48,7 +48,7 @@ import app.pachli.viewdata.StatusViewData
import at.connyduck.calladapter.networkresult.fold import at.connyduck.calladapter.networkresult.fold
import at.connyduck.calladapter.networkresult.getOrElse import at.connyduck.calladapter.networkresult.getOrElse
import at.connyduck.calladapter.networkresult.getOrThrow import at.connyduck.calladapter.networkresult.getOrThrow
import com.google.gson.Gson import com.squareup.moshi.Moshi
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@ -69,7 +69,7 @@ class ViewThreadViewModel @Inject constructor(
eventHub: EventHub, eventHub: EventHub,
accountManager: AccountManager, accountManager: AccountManager,
private val timelineDao: TimelineDao, private val timelineDao: TimelineDao,
private val gson: Gson, private val moshi: Moshi,
private val repository: CachedTimelineRepository, private val repository: CachedTimelineRepository,
statusDisplayOptionsRepository: StatusDisplayOptionsRepository, statusDisplayOptionsRepository: StatusDisplayOptionsRepository,
private val filtersRepository: FiltersRepository, private val filtersRepository: FiltersRepository,
@ -133,7 +133,7 @@ class ViewThreadViewModel @Inject constructor(
var detailedStatus = if (timelineStatusWithAccount != null) { var detailedStatus = if (timelineStatusWithAccount != null) {
Timber.d("Loaded status from local timeline") Timber.d("Loaded status from local timeline")
val status = timelineStatusWithAccount.toStatus(gson) val status = timelineStatusWithAccount.toStatus(moshi)
// Return the correct status, depending on which one matched. If you do not do // Return the correct status, depending on which one matched. If you do not do
// this the status IDs will be different between the status that's displayed with // this the status IDs will be different between the status that's displayed with
@ -152,7 +152,7 @@ class ViewThreadViewModel @Inject constructor(
} else { } else {
StatusViewData.from( StatusViewData.from(
timelineStatusWithAccount, timelineStatusWithAccount,
gson, moshi,
isExpanded = alwaysOpenSpoiler, isExpanded = alwaysOpenSpoiler,
isShowingContent = (alwaysShowSensitiveMedia || !status.actionableStatus.sensitive), isShowingContent = (alwaysShowSensitiveMedia || !status.actionableStatus.sensitive),
isDetailed = true, isDetailed = true,

View File

@ -39,6 +39,7 @@ import app.pachli.core.network.retrofit.MastodonApi
import app.pachli.util.unsafeLazy import app.pachli.util.unsafeLazy
import at.connyduck.calladapter.networkresult.fold import at.connyduck.calladapter.networkresult.fold
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import java.io.IOException
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import javax.inject.Inject import javax.inject.Inject
@ -222,12 +223,21 @@ class SendStatusService : Service() {
) )
val sendResult = if (isNew) { val sendResult = if (isNew) {
if (newStatus.scheduledAt == null) {
mastodonApi.createStatus( mastodonApi.createStatus(
"Bearer " + account.accessToken, "Bearer " + account.accessToken,
account.domain, account.domain,
statusToSend.idempotencyKey, statusToSend.idempotencyKey,
newStatus, newStatus,
) )
} else {
mastodonApi.createScheduledStatus(
"Bearer " + account.accessToken,
account.domain,
statusToSend.idempotencyKey,
newStatus,
)
}
} else { } else {
mastodonApi.editStatus( mastodonApi.editStatus(
statusToSend.statusId!!, statusToSend.statusId!!,
@ -250,16 +260,16 @@ class SendStatusService : Service() {
val scheduled = !statusToSend.scheduledAt.isNullOrEmpty() val scheduled = !statusToSend.scheduledAt.isNullOrEmpty()
if (scheduled) { if (scheduled) {
eventHub.dispatch(StatusScheduledEvent(sentStatus)) eventHub.dispatch(StatusScheduledEvent)
} else if (!isNew) { } else if (!isNew) {
eventHub.dispatch(StatusEditedEvent(statusToSend.statusId!!, sentStatus)) eventHub.dispatch(StatusEditedEvent(statusToSend.statusId!!, sentStatus as Status))
} else { } else {
eventHub.dispatch(StatusComposedEvent(sentStatus)) eventHub.dispatch(StatusComposedEvent(sentStatus as Status))
} }
notificationManager.cancel(statusId) notificationManager.cancel(statusId)
}, { throwable -> }, { throwable ->
Timber.w("failed sending status", throwable) Timber.w("failed sending status: $throwable")
failOrRetry(throwable, statusId) failOrRetry(throwable, statusId)
}) })
stopSelfWhenDone() stopSelfWhenDone()
@ -267,12 +277,13 @@ class SendStatusService : Service() {
} }
private suspend fun failOrRetry(throwable: Throwable, statusId: Int) { private suspend fun failOrRetry(throwable: Throwable, statusId: Int) {
if (throwable is HttpException) { when (throwable) {
// the server refused to accept, save status & show error message // the server refused to accept, save status & show error message
failSending(statusId) is HttpException -> failSending(statusId)
} else {
// a network problem occurred, let's retry sending the status // a network problem occurred, let's retry sending the status
retrySending(statusId) is IOException -> retrySending(statusId)
// Some other problem, fail
else -> failSending(statusId)
} }
} }

View File

@ -27,8 +27,11 @@ private fun formatDuration(durationInSeconds: Double): String {
fun List<Attachment>.aspectRatios(): List<Double> { fun List<Attachment>.aspectRatios(): List<Double> {
return map { attachment -> return map { attachment ->
// clamp ratio between 2:1 & 1:2, defaulting to 16:9 // clamp ratio between 2:1 & 1:2, defaulting to 16:9
val size = (attachment.meta?.small ?: attachment.meta?.original) ?: return@map 1.7778 val (width, height, aspect) = (attachment.meta?.small ?: attachment.meta?.original) ?: return@map 1.7778
val aspect = if (size.aspect > 0) size.aspect else size.width.toDouble() / size.height width ?: return@map 1.778
aspect.coerceIn(0.5, 2.0) height ?: return@map 1.778
aspect ?: return@map 1.778
val adjustedAspect = if (aspect > 0) aspect else width.toDouble() / height
adjustedAspect.coerceIn(0.5, 2.0)
} }
} }

View File

@ -28,7 +28,7 @@ import app.pachli.core.network.model.Status
import app.pachli.core.network.parseAsMastodonHtml import app.pachli.core.network.parseAsMastodonHtml
import app.pachli.core.network.replaceCrashingCharacters import app.pachli.core.network.replaceCrashingCharacters
import app.pachli.util.shouldTrimStatus import app.pachli.util.shouldTrimStatus
import com.google.gson.Gson import com.squareup.moshi.Moshi
/** /**
* Interface for the data shown when viewing a status, or something that wraps * Interface for the data shown when viewing a status, or something that wraps
@ -273,13 +273,13 @@ data class StatusViewData(
fun from( fun from(
timelineStatusWithAccount: TimelineStatusWithAccount, timelineStatusWithAccount: TimelineStatusWithAccount,
gson: Gson, moshi: Moshi,
isExpanded: Boolean, isExpanded: Boolean,
isShowingContent: Boolean, isShowingContent: Boolean,
isDetailed: Boolean = false, isDetailed: Boolean = false,
translationState: TranslationState = TranslationState.SHOW_ORIGINAL, translationState: TranslationState = TranslationState.SHOW_ORIGINAL,
): StatusViewData { ): StatusViewData {
val status = timelineStatusWithAccount.toStatus(gson) val status = timelineStatusWithAccount.toStatus(moshi)
return StatusViewData( return StatusViewData(
status = status, status = status,
translation = timelineStatusWithAccount.translatedStatus, translation = timelineStatusWithAccount.translatedStatus,

View File

@ -2,9 +2,13 @@ package app.pachli
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import app.pachli.core.database.model.TranslationState import app.pachli.core.database.model.TranslationState
import app.pachli.core.network.json.GuardedAdapter.Companion.GuardedAdapterFactory
import app.pachli.core.network.model.Status import app.pachli.core.network.model.Status
import app.pachli.viewdata.StatusViewData import app.pachli.viewdata.StatusViewData
import com.google.gson.Gson import com.squareup.moshi.Moshi
import com.squareup.moshi.adapter
import com.squareup.moshi.adapters.Rfc3339DateJsonAdapter
import java.util.Date
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals import org.junit.Assert.assertNotEquals
import org.junit.Test import org.junit.Test
@ -12,6 +16,9 @@ import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class StatusComparisonTest { class StatusComparisonTest {
private val moshi = Moshi.Builder()
.add(Date::class.java, Rfc3339DateJsonAdapter())
.add(GuardedAdapterFactory()).build()
@Test @Test
fun `two equal statuses - should be equal`() { fun `two equal statuses - should be equal`() {
@ -36,8 +43,6 @@ class StatusComparisonTest {
assertNotEquals(createStatus(note = "Test"), createStatus(note = "Test 123456")) assertNotEquals(createStatus(note = "Test"), createStatus(note = "Test 123456"))
} }
private val gson = Gson()
@Test @Test
fun `two equal status view data - should be equal`() { fun `two equal status view data - should be equal`() {
val viewdata1 = StatusViewData( val viewdata1 = StatusViewData(
@ -95,6 +100,7 @@ class StatusComparisonTest {
assertNotEquals(viewdata1, viewdata2) assertNotEquals(viewdata1, viewdata2)
} }
@OptIn(ExperimentalStdlibApi::class)
private fun createStatus( private fun createStatus(
id: String = "123456", id: String = "123456",
content: String = """ content: String = """
@ -206,6 +212,6 @@ class StatusComparisonTest {
"poll": null "poll": null
} }
""".trimIndent() """.trimIndent()
return gson.fromJson(statusJson, Status::class.java) return moshi.adapter<Status>().fromJson(statusJson)!!
} }
} }

View File

@ -21,7 +21,7 @@ import androidx.paging.PagingSource
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import app.pachli.core.network.model.Notification import app.pachli.core.network.model.Notification
import app.pachli.core.network.retrofit.MastodonApi import app.pachli.core.network.retrofit.MastodonApi
import com.google.gson.Gson import com.squareup.moshi.Moshi
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import okhttp3.ResponseBody.Companion.toResponseBody import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
@ -39,15 +39,15 @@ class NotificationsPagingSourceTest {
@Test @Test
fun `load() returns error message on HTTP error`() = runTest { fun `load() returns error message on HTTP error`() = runTest {
// Given // Given
val jsonError = "{error: 'This is an error'}".toResponseBody() val jsonError = """{"error": "This is an error"}""".toResponseBody()
val mockApi: MastodonApi = mock { val mockApi: MastodonApi = mock {
onBlocking { notifications(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } doReturn Response.error(429, jsonError) onBlocking { notifications(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } doReturn Response.error(429, jsonError)
onBlocking { notification(any()) } doReturn Response.error(429, jsonError) onBlocking { notification(any()) } doReturn Response.error(429, jsonError)
} }
val filter = emptySet<Notification.Type>() val filter = emptySet<Notification.Type>()
val gson = Gson() val moshi = Moshi.Builder().build()
val pagingSource = NotificationsPagingSource(mockApi, gson, filter) val pagingSource = NotificationsPagingSource(mockApi, moshi, filter)
val loadingParams = PagingSource.LoadParams.Refresh("0", 5, false) val loadingParams = PagingSource.LoadParams.Refresh("0", 5, false)
// When // When
@ -65,15 +65,15 @@ class NotificationsPagingSourceTest {
@Test @Test
fun `load() returns extended error message on HTTP error`() = runTest { fun `load() returns extended error message on HTTP error`() = runTest {
// Given // Given
val jsonError = "{error: 'This is an error', error_description: 'Description of the error'}".toResponseBody() val jsonError = """{"error": "This is an error", "error_description": "Description of the error"}""".toResponseBody()
val mockApi: MastodonApi = mock { val mockApi: MastodonApi = mock {
onBlocking { notifications(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } doReturn Response.error(429, jsonError) onBlocking { notifications(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } doReturn Response.error(429, jsonError)
onBlocking { notification(any()) } doReturn Response.error(429, jsonError) onBlocking { notification(any()) } doReturn Response.error(429, jsonError)
} }
val filter = emptySet<Notification.Type>() val filter = emptySet<Notification.Type>()
val gson = Gson() val moshi = Moshi.Builder().build()
val pagingSource = NotificationsPagingSource(mockApi, gson, filter) val pagingSource = NotificationsPagingSource(mockApi, moshi, filter)
val loadingParams = PagingSource.LoadParams.Refresh("0", 5, false) val loadingParams = PagingSource.LoadParams.Refresh("0", 5, false)
// When // When
@ -91,15 +91,15 @@ class NotificationsPagingSourceTest {
@Test @Test
fun `load() returns default error message on empty HTTP error`() = runTest { fun `load() returns default error message on empty HTTP error`() = runTest {
// Given // Given
val jsonError = "{}".toResponseBody() val jsonError = "".toResponseBody()
val mockApi: MastodonApi = mock { val mockApi: MastodonApi = mock {
onBlocking { notifications(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } doReturn Response.error(429, jsonError) onBlocking { notifications(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } doReturn Response.error(429, jsonError)
onBlocking { notification(any()) } doReturn Response.error(429, jsonError) onBlocking { notification(any()) } doReturn Response.error(429, jsonError)
} }
val filter = emptySet<Notification.Type>() val filter = emptySet<Notification.Type>()
val gson = Gson() val moshi = Moshi.Builder().build()
val pagingSource = NotificationsPagingSource(mockApi, gson, filter) val pagingSource = NotificationsPagingSource(mockApi, moshi, filter)
val loadingParams = PagingSource.LoadParams.Refresh("0", 5, false) val loadingParams = PagingSource.LoadParams.Refresh("0", 5, false)
// When // When
@ -124,8 +124,8 @@ class NotificationsPagingSourceTest {
} }
val filter = emptySet<Notification.Type>() val filter = emptySet<Notification.Type>()
val gson = Gson() val moshi = Moshi.Builder().build()
val pagingSource = NotificationsPagingSource(mockApi, gson, filter) val pagingSource = NotificationsPagingSource(mockApi, moshi, filter)
val loadingParams = PagingSource.LoadParams.Refresh("0", 5, false) val loadingParams = PagingSource.LoadParams.Refresh("0", 5, false)
// When // When
@ -134,7 +134,7 @@ class NotificationsPagingSourceTest {
// Then // Then
assertTrue(loadResult is PagingSource.LoadResult.Error) assertTrue(loadResult is PagingSource.LoadResult.Error)
assertEquals( assertEquals(
"HTTP 429: {'malformedjson} (com.google.gson.JsonSyntaxException: com.google.gson.stream.MalformedJsonException: Unterminated string at line 1 column 17 path \$.)", "HTTP 429: {'malformedjson} (com.squareup.moshi.JsonEncodingException: Use JsonReader.setLenient(true) to accept malformed JSON at path \$.)",
(loadResult as PagingSource.LoadResult.Error).throwable.message, (loadResult as PagingSource.LoadResult.Error).throwable.message,
) )
} }

View File

@ -20,9 +20,12 @@ import app.pachli.core.database.model.AccountEntity
import app.pachli.core.database.model.RemoteKeyEntity import app.pachli.core.database.model.RemoteKeyEntity
import app.pachli.core.database.model.RemoteKeyKind import app.pachli.core.database.model.RemoteKeyKind
import app.pachli.core.database.model.TimelineStatusWithAccount import app.pachli.core.database.model.TimelineStatusWithAccount
import app.pachli.core.network.json.GuardedAdapter.Companion.GuardedAdapterFactory
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import com.google.gson.Gson import com.squareup.moshi.Moshi
import com.squareup.moshi.adapters.Rfc3339DateJsonAdapter
import java.io.IOException import java.io.IOException
import java.util.Date
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@ -60,12 +63,17 @@ class CachedTimelineRemoteMediatorTest {
private lateinit var pagingSourceFactory: InvalidatingPagingSourceFactory<Int, TimelineStatusWithAccount> private lateinit var pagingSourceFactory: InvalidatingPagingSourceFactory<Int, TimelineStatusWithAccount>
private val moshi: Moshi = Moshi.Builder()
.add(Date::class.java, Rfc3339DateJsonAdapter())
.add(GuardedAdapterFactory())
.build()
@Before @Before
@ExperimentalCoroutinesApi @ExperimentalCoroutinesApi
fun setup() { fun setup() {
val context = InstrumentationRegistry.getInstrumentation().targetContext val context = InstrumentationRegistry.getInstrumentation().targetContext
db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java) db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java)
.addTypeConverter(Converters(Gson())) .addTypeConverter(Converters(moshi))
.build() .build()
transactionProvider = TransactionProvider(db) transactionProvider = TransactionProvider(db)
@ -91,7 +99,7 @@ class CachedTimelineRemoteMediatorTest {
transactionProvider = transactionProvider, transactionProvider = transactionProvider,
timelineDao = db.timelineDao(), timelineDao = db.timelineDao(),
remoteKeyDao = db.remoteKeyDao(), remoteKeyDao = db.remoteKeyDao(),
gson = Gson(), moshi = moshi,
) )
val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state()) } val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state()) }
@ -114,7 +122,7 @@ class CachedTimelineRemoteMediatorTest {
transactionProvider = transactionProvider, transactionProvider = transactionProvider,
timelineDao = db.timelineDao(), timelineDao = db.timelineDao(),
remoteKeyDao = db.remoteKeyDao(), remoteKeyDao = db.remoteKeyDao(),
gson = Gson(), moshi = moshi,
) )
val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state()) } val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state()) }
@ -134,7 +142,7 @@ class CachedTimelineRemoteMediatorTest {
transactionProvider = transactionProvider, transactionProvider = transactionProvider,
timelineDao = db.timelineDao(), timelineDao = db.timelineDao(),
remoteKeyDao = db.remoteKeyDao(), remoteKeyDao = db.remoteKeyDao(),
gson = Gson(), moshi = moshi,
) )
val state = state( val state = state(
@ -174,7 +182,7 @@ class CachedTimelineRemoteMediatorTest {
transactionProvider = transactionProvider, transactionProvider = transactionProvider,
timelineDao = db.timelineDao(), timelineDao = db.timelineDao(),
remoteKeyDao = db.remoteKeyDao(), remoteKeyDao = db.remoteKeyDao(),
gson = Gson(), moshi = moshi,
) )
val state = state( val state = state(
@ -228,7 +236,7 @@ class CachedTimelineRemoteMediatorTest {
transactionProvider = transactionProvider, transactionProvider = transactionProvider,
timelineDao = db.timelineDao(), timelineDao = db.timelineDao(),
remoteKeyDao = db.remoteKeyDao(), remoteKeyDao = db.remoteKeyDao(),
gson = Gson(), moshi = moshi,
) )
val state = state( val state = state(
@ -289,7 +297,7 @@ class CachedTimelineRemoteMediatorTest {
transactionProvider = transactionProvider, transactionProvider = transactionProvider,
timelineDao = db.timelineDao(), timelineDao = db.timelineDao(),
remoteKeyDao = db.remoteKeyDao(), remoteKeyDao = db.remoteKeyDao(),
gson = Gson(), moshi = moshi,
) )
val state = state( val state = state(

View File

@ -35,7 +35,7 @@ import app.pachli.core.testing.rules.MainCoroutineRule
import app.pachli.usecase.TimelineCases import app.pachli.usecase.TimelineCases
import app.pachli.util.StatusDisplayOptionsRepository import app.pachli.util.StatusDisplayOptionsRepository
import at.connyduck.calladapter.networkresult.NetworkResult import at.connyduck.calladapter.networkresult.NetworkResult
import com.google.gson.Gson import com.squareup.moshi.Moshi
import dagger.hilt.android.testing.CustomTestApplication import dagger.hilt.android.testing.CustomTestApplication
import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.HiltAndroidTest
@ -92,6 +92,9 @@ abstract class CachedTimelineViewModelTestBase {
@Inject @Inject
lateinit var statusDisplayOptionsRepository: StatusDisplayOptionsRepository lateinit var statusDisplayOptionsRepository: StatusDisplayOptionsRepository
@Inject
lateinit var moshi: Moshi
protected lateinit var timelineCases: TimelineCases protected lateinit var timelineCases: TimelineCases
protected lateinit var viewModel: TimelineViewModel protected lateinit var viewModel: TimelineViewModel
@ -160,7 +163,7 @@ abstract class CachedTimelineViewModelTestBase {
accountManager, accountManager,
statusDisplayOptionsRepository, statusDisplayOptionsRepository,
sharedPreferencesRepository, sharedPreferencesRepository,
Gson(), moshi,
) )
} }
} }

View File

@ -5,10 +5,12 @@ import app.pachli.core.database.model.TimelineAccountEntity
import app.pachli.core.database.model.TimelineStatusEntity import app.pachli.core.database.model.TimelineStatusEntity
import app.pachli.core.database.model.TimelineStatusWithAccount import app.pachli.core.database.model.TimelineStatusWithAccount
import app.pachli.core.database.model.TranslationState import app.pachli.core.database.model.TranslationState
import app.pachli.core.network.json.GuardedAdapter.Companion.GuardedAdapterFactory
import app.pachli.core.network.model.Status import app.pachli.core.network.model.Status
import app.pachli.core.network.model.TimelineAccount import app.pachli.core.network.model.TimelineAccount
import app.pachli.viewdata.StatusViewData import app.pachli.viewdata.StatusViewData
import com.google.gson.Gson import com.squareup.moshi.Moshi
import com.squareup.moshi.adapters.Rfc3339DateJsonAdapter
import java.util.Date import java.util.Date
private val fixedDate = Date(1638889052000) private val fixedDate = Date(1638889052000)
@ -96,18 +98,21 @@ fun mockStatusEntityWithAccount(
expanded: Boolean = false, expanded: Boolean = false,
): TimelineStatusWithAccount { ): TimelineStatusWithAccount {
val mockedStatus = mockStatus(id) val mockedStatus = mockStatus(id)
val gson = Gson() val moshi = Moshi.Builder()
.add(Date::class.java, Rfc3339DateJsonAdapter())
.add(GuardedAdapterFactory())
.build()
return TimelineStatusWithAccount( return TimelineStatusWithAccount(
status = TimelineStatusEntity.from( status = TimelineStatusEntity.from(
mockedStatus, mockedStatus,
timelineUserId = userId, timelineUserId = userId,
gson = gson, moshi = moshi,
), ),
account = TimelineAccountEntity.from( account = TimelineAccountEntity.from(
mockedStatus.account, mockedStatus.account,
accountId = userId, accountId = userId,
gson = gson, moshi = moshi,
), ),
viewData = StatusViewDataEntity( viewData = StatusViewDataEntity(
serverId = id, serverId = id,

View File

@ -26,7 +26,7 @@ import app.pachli.core.preferences.SharedPreferencesRepository
import app.pachli.usecase.TimelineCases import app.pachli.usecase.TimelineCases
import app.pachli.util.StatusDisplayOptionsRepository import app.pachli.util.StatusDisplayOptionsRepository
import at.connyduck.calladapter.networkresult.NetworkResult import at.connyduck.calladapter.networkresult.NetworkResult
import com.google.gson.Gson import com.squareup.moshi.Moshi
import dagger.hilt.android.testing.BindValue import dagger.hilt.android.testing.BindValue
import dagger.hilt.android.testing.CustomTestApplication import dagger.hilt.android.testing.CustomTestApplication
import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidRule
@ -111,7 +111,7 @@ class ViewThreadViewModelTest {
lateinit var timelineDao: TimelineDao lateinit var timelineDao: TimelineDao
@Inject @Inject
lateinit var gson: Gson lateinit var moshi: Moshi
@BindValue @JvmField @BindValue @JvmField
val filtersRepository: FiltersRepository = mock() val filtersRepository: FiltersRepository = mock()
@ -188,7 +188,7 @@ class ViewThreadViewModelTest {
eventHub, eventHub,
accountManager, accountManager,
timelineDao, timelineDao,
gson, moshi,
cachedTimelineRepository, cachedTimelineRepository,
statusDisplayOptionsRepository, statusDisplayOptionsRepository,
filtersRepository, filtersRepository,

View File

@ -19,9 +19,9 @@ package app.pachli.di
import app.pachli.components.compose.MediaUploader import app.pachli.components.compose.MediaUploader
import app.pachli.core.network.di.NetworkModule import app.pachli.core.network.di.NetworkModule
import app.pachli.core.network.json.Rfc3339DateJsonAdapter import app.pachli.core.network.json.GuardedAdapter
import com.google.gson.Gson import com.squareup.moshi.Moshi
import com.google.gson.GsonBuilder import com.squareup.moshi.adapters.Rfc3339DateJsonAdapter
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
@ -39,9 +39,10 @@ import org.mockito.kotlin.mock
object FakeNetworkModule { object FakeNetworkModule {
@Provides @Provides
@Singleton @Singleton
fun providesGson(): Gson = GsonBuilder() fun providesMoshi(): Moshi = Moshi.Builder()
.registerTypeAdapter(Date::class.java, Rfc3339DateJsonAdapter()) .add(Date::class.java, Rfc3339DateJsonAdapter())
.create() .add(GuardedAdapter.Companion.GuardedAdapterFactory())
.build()
@Provides @Provides
@Singleton @Singleton

View File

@ -35,6 +35,8 @@ dependencies {
implementation(projects.core.network) implementation(projects.core.network)
implementation(projects.core.preferences) implementation(projects.core.preferences)
// Because of the use of @SerializedName in DraftEntity // Because of the use of @Json in DraftEntity
implementation(libs.gson) implementation(libs.moshi)
implementation(libs.moshi.adapters)
ksp(libs.moshi.codegen)
} }

View File

@ -30,28 +30,29 @@ import app.pachli.core.network.model.Poll
import app.pachli.core.network.model.Status import app.pachli.core.network.model.Status
import app.pachli.core.network.model.TranslatedAttachment import app.pachli.core.network.model.TranslatedAttachment
import app.pachli.core.network.model.TranslatedPoll import app.pachli.core.network.model.TranslatedPoll
import com.google.gson.Gson import com.squareup.moshi.Moshi
import com.google.gson.reflect.TypeToken import com.squareup.moshi.adapter
import java.net.URLDecoder import java.net.URLDecoder
import java.net.URLEncoder import java.net.URLEncoder
import java.util.Date import java.util.Date
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@OptIn(ExperimentalStdlibApi::class)
@ProvidedTypeConverter @ProvidedTypeConverter
@Singleton @Singleton
class Converters @Inject constructor( class Converters @Inject constructor(
private val gson: Gson, private val moshi: Moshi,
) { ) {
@TypeConverter @TypeConverter
fun jsonToEmojiList(emojiListJson: String?): List<Emoji>? { fun jsonToEmojiList(json: String?): List<Emoji>? {
return gson.fromJson(emojiListJson, object : TypeToken<List<Emoji>>() {}.type) return json?.let { moshi.adapter<List<Emoji>>().fromJson(it) }
} }
@TypeConverter @TypeConverter
fun emojiListToJson(emojiList: List<Emoji>?): String { fun emojiListToJson(emojiList: List<Emoji>?): String {
return gson.toJson(emojiList) return moshi.adapter<List<Emoji>?>().toJson(emojiList)
} }
@TypeConverter @TypeConverter
@ -81,52 +82,52 @@ class Converters @Inject constructor(
@TypeConverter @TypeConverter
fun accountToJson(account: ConversationAccountEntity?): String { fun accountToJson(account: ConversationAccountEntity?): String {
return gson.toJson(account) return moshi.adapter<ConversationAccountEntity>().toJson(account)
} }
@TypeConverter @TypeConverter
fun jsonToAccount(accountJson: String?): ConversationAccountEntity? { fun jsonToAccount(accountJson: String?): ConversationAccountEntity? {
return gson.fromJson(accountJson, ConversationAccountEntity::class.java) return accountJson?.let { moshi.adapter<ConversationAccountEntity>().fromJson(it) }
} }
@TypeConverter @TypeConverter
fun accountListToJson(accountList: List<ConversationAccountEntity>?): String { fun accountListToJson(accountList: List<ConversationAccountEntity>?): String {
return gson.toJson(accountList) return moshi.adapter<List<ConversationAccountEntity>>().toJson(accountList)
} }
@TypeConverter @TypeConverter
fun jsonToAccountList(accountListJson: String?): List<ConversationAccountEntity>? { fun jsonToAccountList(accountListJson: String?): List<ConversationAccountEntity>? {
return gson.fromJson(accountListJson, object : TypeToken<List<ConversationAccountEntity>>() {}.type) return accountListJson?.let { moshi.adapter<List<ConversationAccountEntity>?>().fromJson(it) }
} }
@TypeConverter @TypeConverter
fun attachmentListToJson(attachmentList: List<Attachment>?): String { fun attachmentListToJson(attachmentList: List<Attachment>?): String {
return gson.toJson(attachmentList) return moshi.adapter<List<Attachment>?>().toJson(attachmentList)
} }
@TypeConverter @TypeConverter
fun jsonToAttachmentList(attachmentListJson: String?): List<Attachment>? { fun jsonToAttachmentList(attachmentListJson: String?): List<Attachment>? {
return gson.fromJson(attachmentListJson, object : TypeToken<List<Attachment>>() {}.type) return attachmentListJson?.let { moshi.adapter<List<Attachment>?>().fromJson(it) }
} }
@TypeConverter @TypeConverter
fun mentionListToJson(mentionArray: List<Status.Mention>?): String? { fun mentionListToJson(mentionArray: List<Status.Mention>?): String? {
return gson.toJson(mentionArray) return moshi.adapter<List<Status.Mention>?>().toJson(mentionArray)
} }
@TypeConverter @TypeConverter
fun jsonToMentionArray(mentionListJson: String?): List<Status.Mention>? { fun jsonToMentionArray(mentionListJson: String?): List<Status.Mention>? {
return gson.fromJson(mentionListJson, object : TypeToken<List<Status.Mention>>() {}.type) return mentionListJson?.let { moshi.adapter<List<Status.Mention>?>().fromJson(it) }
} }
@TypeConverter @TypeConverter
fun tagListToJson(tagArray: List<HashTag>?): String? { fun tagListToJson(tagArray: List<HashTag>?): String? {
return gson.toJson(tagArray) return moshi.adapter<List<HashTag>?>().toJson(tagArray)
} }
@TypeConverter @TypeConverter
fun jsonToTagArray(tagListJson: String?): List<HashTag>? { 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 @TypeConverter
@ -141,61 +142,61 @@ class Converters @Inject constructor(
@TypeConverter @TypeConverter
fun pollToJson(poll: Poll?): String? { fun pollToJson(poll: Poll?): String? {
return gson.toJson(poll) return moshi.adapter<Poll?>().toJson(poll)
} }
@TypeConverter @TypeConverter
fun jsonToPoll(pollJson: String?): Poll? { fun jsonToPoll(pollJson: String?): Poll? {
return gson.fromJson(pollJson, Poll::class.java) return pollJson?.let { moshi.adapter<Poll?>().fromJson(it) }
} }
@TypeConverter @TypeConverter
fun newPollToJson(newPoll: NewPoll?): String? { fun newPollToJson(newPoll: NewPoll?): String? {
return gson.toJson(newPoll) return moshi.adapter<NewPoll?>().toJson(newPoll)
} }
@TypeConverter @TypeConverter
fun jsonToNewPoll(newPollJson: String?): NewPoll? { fun jsonToNewPoll(newPollJson: String?): NewPoll? {
return gson.fromJson(newPollJson, NewPoll::class.java) return newPollJson?.let { moshi.adapter<NewPoll?>().fromJson(it) }
} }
@TypeConverter @TypeConverter
fun draftAttachmentListToJson(draftAttachments: List<DraftAttachment>?): String? { fun draftAttachmentListToJson(draftAttachments: List<DraftAttachment>?): String? {
return gson.toJson(draftAttachments) return moshi.adapter<List<DraftAttachment>?>().toJson(draftAttachments)
} }
@TypeConverter @TypeConverter
fun jsonToDraftAttachmentList(draftAttachmentListJson: String?): List<DraftAttachment>? { fun jsonToDraftAttachmentList(draftAttachmentListJson: String?): List<DraftAttachment>? {
return gson.fromJson(draftAttachmentListJson, object : TypeToken<List<DraftAttachment>>() {}.type) return draftAttachmentListJson?.let { moshi.adapter<List<DraftAttachment>?>().fromJson(it) }
} }
@TypeConverter @TypeConverter
fun filterResultListToJson(filterResults: List<FilterResult>?): String? { fun filterResultListToJson(filterResults: List<FilterResult>?): String? {
return gson.toJson(filterResults) return moshi.adapter<List<FilterResult>?>().toJson(filterResults)
} }
@TypeConverter @TypeConverter
fun jsonToFilterResultList(filterResultListJson: String?): List<FilterResult>? { 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 @TypeConverter
fun translatedPolltoJson(translatedPoll: TranslatedPoll?): String? { fun translatedPolltoJson(translatedPoll: TranslatedPoll?): String? {
return gson.toJson(translatedPoll) return moshi.adapter<TranslatedPoll?>().toJson(translatedPoll)
} }
@TypeConverter @TypeConverter
fun jsonToTranslatedPoll(translatedPollJson: String?): TranslatedPoll? { fun jsonToTranslatedPoll(translatedPollJson: String?): TranslatedPoll? {
return gson.fromJson(translatedPollJson, TranslatedPoll::class.java) return translatedPollJson?.let { moshi.adapter<TranslatedPoll?>().fromJson(it) }
} }
@TypeConverter @TypeConverter
fun translatedAttachmentToJson(translatedAttachment: List<TranslatedAttachment>?): String { fun translatedAttachmentToJson(translatedAttachment: List<TranslatedAttachment>?): String {
return gson.toJson(translatedAttachment) return moshi.adapter<List<TranslatedAttachment>?>().toJson(translatedAttachment)
} }
@TypeConverter @TypeConverter
fun jsonToTranslatedAttachment(translatedAttachmentJson: String): List<TranslatedAttachment>? { fun jsonToTranslatedAttachment(translatedAttachmentJson: String): List<TranslatedAttachment>? {
return gson.fromJson(translatedAttachmentJson, object : TypeToken<List<TranslatedAttachment>>() {}.type) return moshi.adapter<List<TranslatedAttachment>?>().fromJson(translatedAttachmentJson)
} }
} }

View File

@ -28,6 +28,7 @@ import app.pachli.core.network.model.HashTag
import app.pachli.core.network.model.Poll import app.pachli.core.network.model.Poll
import app.pachli.core.network.model.Status import app.pachli.core.network.model.Status
import app.pachli.core.network.model.TimelineAccount import app.pachli.core.network.model.TimelineAccount
import com.squareup.moshi.JsonClass
import java.util.Date import java.util.Date
@Entity(primaryKeys = ["id", "accountId"]) @Entity(primaryKeys = ["id", "accountId"])
@ -65,6 +66,7 @@ data class ConversationEntity(
} }
} }
@JsonClass(generateAdapter = true)
data class ConversationAccountEntity( data class ConversationAccountEntity(
val id: String, val id: String,
val localUsername: String, val localUsername: String,

View File

@ -27,7 +27,8 @@ import app.pachli.core.database.Converters
import app.pachli.core.network.model.Attachment import app.pachli.core.network.model.Attachment
import app.pachli.core.network.model.NewPoll import app.pachli.core.network.model.NewPoll
import app.pachli.core.network.model.Status import app.pachli.core.network.model.Status
import com.google.gson.annotations.SerializedName import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@Entity @Entity
@ -49,17 +50,13 @@ data class DraftEntity(
val statusId: String?, val statusId: String?,
) )
/**
* The alternate names are here because we accidentally published versions were DraftAttachment was minified
* Tusky 15: uriString = e, description = f, type = g
* Tusky 16 beta: uriString = i, description = j, type = k
*/
@Parcelize @Parcelize
@JsonClass(generateAdapter = true)
data class DraftAttachment( data class DraftAttachment(
@SerializedName(value = "uriString", alternate = ["e", "i"]) val uriString: String, @Json(name = "uriString") val uriString: String,
@SerializedName(value = "description", alternate = ["f", "j"]) val description: String?, @Json(name = "description") val description: String?,
@SerializedName(value = "focus") val focus: Attachment.Focus?, @Json(name = "focus") val focus: Attachment.Focus?,
@SerializedName(value = "type", alternate = ["g", "k"]) val type: Type, @Json(name = "type") val type: Type,
) : Parcelable { ) : Parcelable {
val uri: Uri val uri: Uri
get() = uriString.toUri() get() = uriString.toUri()

View File

@ -32,9 +32,8 @@ import app.pachli.core.network.model.HashTag
import app.pachli.core.network.model.Poll import app.pachli.core.network.model.Poll
import app.pachli.core.network.model.Status import app.pachli.core.network.model.Status
import app.pachli.core.network.model.TimelineAccount import app.pachli.core.network.model.TimelineAccount
import com.google.gson.Gson import com.squareup.moshi.Moshi
import com.google.gson.reflect.TypeToken import com.squareup.moshi.adapter
import java.lang.reflect.Type
import java.util.Date import java.util.Date
/** /**
@ -100,7 +99,8 @@ data class TimelineStatusEntity(
val filtered: List<FilterResult>?, val filtered: List<FilterResult>?,
) { ) {
companion object { companion object {
fun from(status: Status, timelineUserId: Long, gson: Gson) = TimelineStatusEntity( @OptIn(ExperimentalStdlibApi::class)
fun from(status: Status, timelineUserId: Long, moshi: Moshi) = TimelineStatusEntity(
serverId = status.id, serverId = status.id,
url = status.actionableStatus.url, url = status.actionableStatus.url,
timelineUserId = timelineUserId, timelineUserId = timelineUserId,
@ -110,7 +110,7 @@ data class TimelineStatusEntity(
content = status.actionableStatus.content, content = status.actionableStatus.content,
createdAt = status.actionableStatus.createdAt.time, createdAt = status.actionableStatus.createdAt.time,
editedAt = status.actionableStatus.editedAt?.time, editedAt = status.actionableStatus.editedAt?.time,
emojis = status.actionableStatus.emojis.let(gson::toJson), emojis = moshi.adapter<List<Emoji>>().toJson(status.actionableStatus.emojis),
reblogsCount = status.actionableStatus.reblogsCount, reblogsCount = status.actionableStatus.reblogsCount,
favouritesCount = status.actionableStatus.favouritesCount, favouritesCount = status.actionableStatus.favouritesCount,
reblogged = status.actionableStatus.reblogged, reblogged = status.actionableStatus.reblogged,
@ -119,16 +119,16 @@ data class TimelineStatusEntity(
sensitive = status.actionableStatus.sensitive, sensitive = status.actionableStatus.sensitive,
spoilerText = status.actionableStatus.spoilerText, spoilerText = status.actionableStatus.spoilerText,
visibility = status.actionableStatus.visibility, visibility = status.actionableStatus.visibility,
attachments = status.actionableStatus.attachments.let(gson::toJson), attachments = moshi.adapter<List<Attachment>>().toJson(status.actionableStatus.attachments),
mentions = status.actionableStatus.mentions.let(gson::toJson), mentions = moshi.adapter<List<Status.Mention>>().toJson(status.actionableStatus.mentions),
tags = status.actionableStatus.tags.let(gson::toJson), tags = moshi.adapter<List<HashTag>>().toJson(status.actionableStatus.tags),
application = status.actionableStatus.application.let(gson::toJson), application = moshi.adapter<Status.Application?>().toJson(status.actionableStatus.application),
reblogServerId = status.reblog?.id, reblogServerId = status.reblog?.id,
reblogAccountId = status.reblog?.let { status.account.id }, reblogAccountId = status.reblog?.let { status.account.id },
poll = status.actionableStatus.poll.let(gson::toJson), poll = moshi.adapter<Poll>().toJson(status.actionableStatus.poll),
muted = status.actionableStatus.muted, muted = status.actionableStatus.muted,
pinned = status.actionableStatus.pinned == true, pinned = status.actionableStatus.pinned == true,
card = status.actionableStatus.card?.let(gson::toJson), card = moshi.adapter<Card?>().toJson(status.actionableStatus.card),
repliesCount = status.actionableStatus.repliesCount, repliesCount = status.actionableStatus.repliesCount,
language = status.actionableStatus.language, language = status.actionableStatus.language,
filtered = status.actionableStatus.filtered, filtered = status.actionableStatus.filtered,
@ -150,7 +150,8 @@ data class TimelineAccountEntity(
val emojis: String, val emojis: String,
val bot: Boolean, val bot: Boolean,
) { ) {
fun toTimelineAccount(gson: Gson): TimelineAccount { @OptIn(ExperimentalStdlibApi::class)
fun toTimelineAccount(moshi: Moshi): TimelineAccount {
return TimelineAccount( return TimelineAccount(
id = serverId, id = serverId,
localUsername = localUsername, localUsername = localUsername,
@ -160,12 +161,13 @@ data class TimelineAccountEntity(
url = url, url = url,
avatar = avatar, avatar = avatar,
bot = bot, bot = bot,
emojis = gson.fromJson(emojis, emojisListType), emojis = moshi.adapter<List<Emoji>>().fromJson(emojis),
) )
} }
companion object { companion object {
fun from(timelineAccount: TimelineAccount, accountId: Long, gson: Gson) = TimelineAccountEntity( @OptIn(ExperimentalStdlibApi::class)
fun from(timelineAccount: TimelineAccount, accountId: Long, moshi: Moshi) = TimelineAccountEntity(
serverId = timelineAccount.id, serverId = timelineAccount.id,
timelineUserId = accountId, timelineUserId = accountId,
localUsername = timelineAccount.localUsername, localUsername = timelineAccount.localUsername,
@ -173,7 +175,7 @@ data class TimelineAccountEntity(
displayName = timelineAccount.name, displayName = timelineAccount.name,
url = timelineAccount.url, url = timelineAccount.url,
avatar = timelineAccount.avatar, avatar = timelineAccount.avatar,
emojis = gson.toJson(timelineAccount.emojis), emojis = moshi.adapter<List<Emoji>>().toJson(timelineAccount.emojis),
bot = timelineAccount.bot, bot = timelineAccount.bot,
) )
} }
@ -214,11 +216,6 @@ data class StatusViewDataEntity(
val translationState: TranslationState, val translationState: TranslationState,
) )
val attachmentArrayListType: Type = object : TypeToken<ArrayList<Attachment>>() {}.type
val emojisListType: Type = object : TypeToken<List<Emoji>>() {}.type
val mentionListType: Type = object : TypeToken<List<Status.Mention>>() {}.type
val tagListType: Type = object : TypeToken<List<HashTag>>() {}.type
data class TimelineStatusWithAccount( data class TimelineStatusWithAccount(
@Embedded @Embedded
val status: TimelineStatusEntity, val status: TimelineStatusEntity,
@ -232,32 +229,28 @@ data class TimelineStatusWithAccount(
@Embedded(prefix = "t_") @Embedded(prefix = "t_")
val translatedStatus: TranslatedStatusEntity? = null, val translatedStatus: TranslatedStatusEntity? = null,
) { ) {
fun toStatus(gson: Gson): Status { @OptIn(ExperimentalStdlibApi::class)
val attachments: ArrayList<Attachment> = gson.fromJson( fun toStatus(moshi: Moshi): Status {
status.attachments, val attachments: List<Attachment> = status.attachments?.let {
attachmentArrayListType, moshi.adapter<List<Attachment>?>().fromJson(it)
) ?: arrayListOf() } ?: emptyList()
val mentions: List<Status.Mention> = gson.fromJson( val mentions: List<Status.Mention> = status.mentions?.let {
status.mentions, moshi.adapter<List<Status.Mention>?>().fromJson(it)
mentionListType, } ?: emptyList()
) ?: emptyList() val tags: List<HashTag>? = status.tags?.let {
val tags: List<HashTag>? = gson.fromJson( moshi.adapter<List<HashTag>?>().fromJson(it)
status.tags, }
tagListType, val application = status.application?.let { moshi.adapter<Status.Application>().fromJson(it) }
) val emojis: List<Emoji> = status.emojis?.let { moshi.adapter<List<Emoji>?>().fromJson(it) }
val application = gson.fromJson(status.application, Status.Application::class.java) ?: emptyList()
val emojis: List<Emoji> = gson.fromJson( val poll: Poll? = status.poll?.let { moshi.adapter<Poll?>().fromJson(it) }
status.emojis, val card: Card? = status.card?.let { moshi.adapter<Card?>().fromJson(it) }
emojisListType,
) ?: emptyList()
val poll: Poll? = gson.fromJson(status.poll, Poll::class.java)
val card: Card? = gson.fromJson(status.card, Card::class.java)
val reblog = status.reblogServerId?.let { id -> val reblog = status.reblogServerId?.let { id ->
Status( Status(
id = id, id = id,
url = status.url, url = status.url,
account = account.toTimelineAccount(gson), account = account.toTimelineAccount(moshi),
inReplyToId = status.inReplyToId, inReplyToId = status.inReplyToId,
inReplyToAccountId = status.inReplyToAccountId, inReplyToAccountId = status.inReplyToAccountId,
reblog = null, reblog = null,
@ -291,7 +284,7 @@ data class TimelineStatusWithAccount(
id = status.serverId, id = status.serverId,
// no url for reblogs // no url for reblogs
url = null, url = null,
account = reblogAccount!!.toTimelineAccount(gson), account = reblogAccount!!.toTimelineAccount(moshi),
inReplyToId = null, inReplyToId = null,
inReplyToAccountId = null, inReplyToAccountId = null,
reblog = reblog, reblog = reblog,
@ -308,7 +301,7 @@ data class TimelineStatusWithAccount(
sensitive = false, sensitive = false,
spoilerText = "", spoilerText = "",
visibility = status.visibility, visibility = status.visibility,
attachments = ArrayList(), attachments = listOf(),
mentions = listOf(), mentions = listOf(),
tags = listOf(), tags = listOf(),
application = null, application = null,
@ -324,7 +317,7 @@ data class TimelineStatusWithAccount(
Status( Status(
id = status.serverId, id = status.serverId,
url = status.url, url = status.url,
account = account.toTimelineAccount(gson), account = account.toTimelineAccount(moshi),
inReplyToId = status.inReplyToId, inReplyToId = status.inReplyToId,
inReplyToAccountId = status.inReplyToAccountId, inReplyToAccountId = status.inReplyToAccountId,
reblog = null, reblog = null,

View File

@ -21,7 +21,7 @@ import androidx.room.Room
import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry
import app.pachli.core.database.AppDatabase import app.pachli.core.database.AppDatabase
import app.pachli.core.database.Converters import app.pachli.core.database.Converters
import com.google.gson.Gson import com.squareup.moshi.Moshi
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
@ -36,10 +36,10 @@ import javax.inject.Singleton
object FakeDatabaseModule { object FakeDatabaseModule {
@Provides @Provides
@Singleton @Singleton
fun providesDatabase(gson: Gson): AppDatabase { fun providesDatabase(moshi: Moshi): AppDatabase {
val context = InstrumentationRegistry.getInstrumentation().targetContext val context = InstrumentationRegistry.getInstrumentation().targetContext
return Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java) return Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java)
.addTypeConverter(Converters(gson)) .addTypeConverter(Converters(moshi))
.allowMainThreadQueries() .allowMainThreadQueries()
.build() .build()
} }

View File

@ -148,7 +148,7 @@ class ComposeActivityIntent(context: Context) : Intent() {
/** Editing a status started as an existing draft */ /** Editing a status started as an existing draft */
EDIT_DRAFT, EDIT_DRAFT,
/** Editing an an existing scheduled status */ /** Editing an existing scheduled status */
EDIT_SCHEDULED, EDIT_SCHEDULED,
} }
@ -210,7 +210,7 @@ class EditFilterActivityIntent(context: Context, filter: Filter? = null) : Inten
/** /**
* @param context * @param context
* @param loginMode See [LoginMode] * @param loginMode See [LoginMode]
* @see [app.pachli.components.login.LoginActivity] * @see [app.pachli.feature.login.LoginActivity]
*/ */
class LoginActivityIntent(context: Context, loginMode: LoginMode = LoginMode.DEFAULT) : Intent() { class LoginActivityIntent(context: Context, loginMode: LoginMode = LoginMode.DEFAULT) : Intent() {
/** How to log in */ /** How to log in */
@ -472,7 +472,7 @@ class ViewMediaActivityIntent private constructor(context: Context) : Intent() {
private const val EXTRA_SINGLE_IMAGE_URL = "singleImage" private const val EXTRA_SINGLE_IMAGE_URL = "singleImage"
/** @return the list of [AttachmentViewData] passed in this intent, or null */ /** @return the list of [AttachmentViewData] passed in this intent, or null */
fun getAttachments(intent: Intent): ArrayList<AttachmentViewData>? = IntentCompat.getParcelableArrayListExtra(intent, EXTRA_ATTACHMENTS, AttachmentViewData::class.java) fun getAttachments(intent: Intent): List<AttachmentViewData>? = IntentCompat.getParcelableArrayListExtra(intent, EXTRA_ATTACHMENTS, AttachmentViewData::class.java)
/** @return the index of the attachment to show, or 0 */ /** @return the index of the attachment to show, or 0 */
fun getAttachmentIndex(intent: Intent) = intent.getIntExtra(EXTRA_ATTACHMENT_INDEX, 0) fun getAttachmentIndex(intent: Intent) = intent.getIntExtra(EXTRA_ATTACHMENT_INDEX, 0)

View File

@ -33,7 +33,10 @@ dependencies {
implementation(projects.core.common) implementation(projects.core.common)
implementation(projects.core.preferences) implementation(projects.core.preferences)
implementation(libs.gson) implementation(libs.moshi)
implementation(libs.moshi.adapters)
ksp(libs.moshi.codegen)
implementation(libs.bundles.retrofit) implementation(libs.bundles.retrofit)
implementation(libs.bundles.okhttp) implementation(libs.bundles.okhttp)
api(libs.networkresult.calladapter) api(libs.networkresult.calladapter)

View File

@ -22,7 +22,7 @@ import android.os.Build
import app.pachli.core.common.util.versionName import app.pachli.core.common.util.versionName
import app.pachli.core.mastodon.model.MediaUploadApi import app.pachli.core.mastodon.model.MediaUploadApi
import app.pachli.core.network.BuildConfig import app.pachli.core.network.BuildConfig
import app.pachli.core.network.json.Rfc3339DateJsonAdapter import app.pachli.core.network.json.GuardedAdapter.Companion.GuardedAdapterFactory
import app.pachli.core.network.retrofit.InstanceSwitchAuthInterceptor import app.pachli.core.network.retrofit.InstanceSwitchAuthInterceptor
import app.pachli.core.network.retrofit.MastodonApi import app.pachli.core.network.retrofit.MastodonApi
import app.pachli.core.preferences.PrefKeys.HTTP_PROXY_ENABLED import app.pachli.core.preferences.PrefKeys.HTTP_PROXY_ENABLED
@ -32,8 +32,8 @@ import app.pachli.core.preferences.ProxyConfiguration
import app.pachli.core.preferences.SharedPreferencesRepository import app.pachli.core.preferences.SharedPreferencesRepository
import app.pachli.core.preferences.getNonNullString import app.pachli.core.preferences.getNonNullString
import at.connyduck.calladapter.networkresult.NetworkResultCallAdapterFactory import at.connyduck.calladapter.networkresult.NetworkResultCallAdapterFactory
import com.google.gson.Gson import com.squareup.moshi.Moshi
import com.google.gson.GsonBuilder import com.squareup.moshi.adapters.Rfc3339DateJsonAdapter
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@ -50,7 +50,7 @@ import okhttp3.OkHttp
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.create import retrofit2.create
import timber.log.Timber import timber.log.Timber
@ -60,9 +60,10 @@ object NetworkModule {
@Provides @Provides
@Singleton @Singleton
fun providesGson(): Gson = GsonBuilder() fun providesMoshi(): Moshi = Moshi.Builder()
.registerTypeAdapter(Date::class.java, Rfc3339DateJsonAdapter()) .add(Date::class.java, Rfc3339DateJsonAdapter())
.create() .add(GuardedAdapterFactory())
.build()
@Provides @Provides
@Singleton @Singleton
@ -116,11 +117,11 @@ object NetworkModule {
@Singleton @Singleton
fun providesRetrofit( fun providesRetrofit(
httpClient: OkHttpClient, httpClient: OkHttpClient,
gson: Gson, moshi: Moshi,
): Retrofit { ): Retrofit {
return Retrofit.Builder().baseUrl("https://" + MastodonApi.PLACEHOLDER_DOMAIN) return Retrofit.Builder().baseUrl("https://" + MastodonApi.PLACEHOLDER_DOMAIN)
.client(httpClient) .client(httpClient)
.addConverterFactory(GsonConverterFactory.create(gson)) .addConverterFactory(MoshiConverterFactory.create(moshi))
.addCallAdapterFactory(NetworkResultCallAdapterFactory.create()) .addCallAdapterFactory(NetworkResultCallAdapterFactory.create())
.build() .build()
} }

View File

@ -0,0 +1,79 @@
/*
* Copyright 2024 Pachli Association
*
* This file is a part of Pachli.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Pachli; if not,
* see <http://www.gnu.org/licenses>.
*/
package app.pachli.core.network.json
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.JsonDataException
import com.squareup.moshi.JsonQualifier
import com.squareup.moshi.JsonReader
import com.squareup.moshi.JsonWriter
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import java.lang.reflect.Type
/**
* Deserialize this field as the given type, or null if the field value is not
* this type.
*/
@Retention(AnnotationRetention.RUNTIME)
@JsonQualifier
annotation class Guarded
/**
* Parse the given field as either the delegate type, or null if it does not parse
* as that type.
*/
class GuardedAdapter(private val delegate: JsonAdapter<*>) : JsonAdapter<Any>() {
override fun fromJson(reader: JsonReader): Any? {
val peeked = reader.peekJson()
val result = try {
delegate.fromJson(peeked)
} catch (_: JsonDataException) {
null
} finally {
peeked.close()
}
reader.skipValue()
return result
}
override fun toJson(writer: JsonWriter, value: Any?) {
throw UnsupportedOperationException("@Guarded is only used to desererialize objects")
}
companion object {
class GuardedAdapterFactory : Factory {
override fun create(
type: Type,
annotations: MutableSet<out 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,34 +0,0 @@
/* Copyright 2022 Tusky Contributors
*
* This file is a part of Pachli.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Pachli; if not,
* see <http://www.gnu.org/licenses>.
*/
package app.pachli.core.network.json
import com.google.gson.JsonDeserializationContext
import com.google.gson.JsonDeserializer
import com.google.gson.JsonElement
import com.google.gson.JsonParseException
import java.lang.reflect.Type
class GuardedBooleanAdapter : JsonDeserializer<Boolean?> {
@Throws(JsonParseException::class)
override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): Boolean? {
return if (json.isJsonObject) {
null
} else {
json.asBoolean
}
}
}

View File

@ -1,327 +0,0 @@
/*
* Copyright 2023 Pachli Association
*
* This file is a part of Pachli.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Pachli; if not,
* see <http://www.gnu.org/licenses>.
*/
package app.pachli.core.network.json
/*
* Copyright (C) 2011 FasterXML, LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import com.google.gson.JsonParseException
import java.util.Calendar
import java.util.Date
import java.util.GregorianCalendar
import java.util.Locale
import java.util.TimeZone
import kotlin.math.min
import kotlin.math.pow
/*
* 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 app.pachli.core.network.json
import com.google.gson.JsonParseException
import com.google.gson.TypeAdapter
import com.google.gson.stream.JsonReader
import com.google.gson.stream.JsonToken
import com.google.gson.stream.JsonWriter
import java.io.IOException
import java.util.Date
import timber.log.Timber
class Rfc3339DateJsonAdapter : TypeAdapter<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) {
Timber.w(jpe)
null
}
}
}
}
}

View File

@ -17,8 +17,10 @@
package app.pachli.core.network.model package app.pachli.core.network.model
import com.google.gson.annotations.SerializedName import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class AccessToken( data class AccessToken(
@SerializedName("access_token") val accessToken: String, @Json(name = "access_token") val accessToken: String,
) )

View File

@ -16,25 +16,27 @@
package app.pachli.core.network.model package app.pachli.core.network.model
import com.google.gson.annotations.SerializedName import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import java.util.Date import java.util.Date
@JsonClass(generateAdapter = true)
data class Account( data class Account(
val id: String, val id: String,
@SerializedName("username") val localUsername: String, @Json(name = "username") val localUsername: String,
@SerializedName("acct") val username: String, @Json(name = "acct") val username: String,
// should never be null per API definition, but some servers break the contract // should never be null per API definition, but some servers break the contract
@SerializedName("display_name") val displayName: String?, @Json(name = "display_name") val displayName: String?,
// should never be null per API definition, but some servers break the contract // should never be null per API definition, but some servers break the contract
@SerializedName("created_at") val createdAt: Date?, @Json(name = "created_at") val createdAt: Date?,
val note: String, val note: String,
val url: String, val url: String,
val avatar: String, val avatar: String,
val header: String, val header: String,
val locked: Boolean = false, val locked: Boolean = false,
@SerializedName("followers_count") val followersCount: Int = 0, @Json(name = "followers_count") val followersCount: Int = 0,
@SerializedName("following_count") val followingCount: Int = 0, @Json(name = "following_count") val followingCount: Int = 0,
@SerializedName("statuses_count") val statusesCount: Int = 0, @Json(name = "statuses_count") val statusesCount: Int = 0,
val source: AccountSource? = null, val source: AccountSource? = null,
val bot: Boolean = false, val bot: Boolean = false,
// nullable for backward compatibility // nullable for backward compatibility
@ -55,6 +57,7 @@ data class Account(
fun isRemote(): Boolean = this.username != this.localUsername fun isRemote(): Boolean = this.username != this.localUsername
} }
@JsonClass(generateAdapter = true)
data class AccountSource( data class AccountSource(
val privacy: Status.Visibility?, val privacy: Status.Visibility?,
val sensitive: Boolean?, val sensitive: Boolean?,
@ -63,18 +66,21 @@ data class AccountSource(
val language: String?, val language: String?,
) )
@JsonClass(generateAdapter = true)
data class Field( data class Field(
val name: String, val name: String,
val value: String, val value: String,
@SerializedName("verified_at") val verifiedAt: Date?, @Json(name = "verified_at") val verifiedAt: Date?,
) )
@JsonClass(generateAdapter = true)
data class StringField( data class StringField(
val name: String, val name: String,
val value: String, val value: String,
) )
/** [Mastodon Entities: Role](https://docs.joinmastodon.org/entities/Role) */ /** [Mastodon Entities: Role](https://docs.joinmastodon.org/entities/Role) */
@JsonClass(generateAdapter = true)
data class Role( data class Role(
/** Displayable name of the role */ /** Displayable name of the role */
val name: String, val name: String,

View File

@ -16,17 +16,19 @@
package app.pachli.core.network.model package app.pachli.core.network.model
import com.google.gson.annotations.SerializedName import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import java.util.Date import java.util.Date
@JsonClass(generateAdapter = true)
data class Announcement( data class Announcement(
val id: String, val id: String,
val content: String, val content: String,
@SerializedName("starts_at") val startsAt: Date?, @Json(name = "starts_at") val startsAt: Date?,
@SerializedName("ends_at") val endsAt: Date?, @Json(name = "ends_at") val endsAt: Date?,
@SerializedName("all_day") val allDay: Boolean, @Json(name = "all_day") val allDay: Boolean,
@SerializedName("published_at") val publishedAt: Date, @Json(name = "published_at") val publishedAt: Date,
@SerializedName("updated_at") val updatedAt: Date, @Json(name = "updated_at") val updatedAt: Date,
val read: Boolean, val read: Boolean,
val mentions: List<Status.Mention>, val mentions: List<Status.Mention>,
val statuses: List<Status>, val statuses: List<Status>,
@ -47,11 +49,12 @@ data class Announcement(
return id.hashCode() return id.hashCode()
} }
@JsonClass(generateAdapter = true)
data class Reaction( data class Reaction(
val name: String, val name: String,
val count: Int, val count: Int,
val me: Boolean, val me: Boolean,
val url: String?, val url: String?,
@SerializedName("static_url") val staticUrl: String?, @Json(name = "static_url") val staticUrl: String?,
) )
} }

View File

@ -16,9 +16,11 @@
package app.pachli.core.network.model package app.pachli.core.network.model
import com.google.gson.annotations.SerializedName import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class AppCredentials( data class AppCredentials(
@SerializedName("client_id") val clientId: String, @Json(name = "client_id") val clientId: String,
@SerializedName("client_secret") val clientSecret: String, @Json(name = "client_secret") val clientSecret: String,
) )

View File

@ -18,61 +18,45 @@
package app.pachli.core.network.model package app.pachli.core.network.model
import android.os.Parcelable import android.os.Parcelable
import com.google.gson.JsonDeserializationContext import com.squareup.moshi.Json
import com.google.gson.JsonDeserializer import com.squareup.moshi.JsonClass
import com.google.gson.JsonElement
import com.google.gson.JsonParseException
import com.google.gson.annotations.JsonAdapter
import com.google.gson.annotations.SerializedName
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@Parcelize @Parcelize
@JsonClass(generateAdapter = true)
data class Attachment( data class Attachment(
val id: String, val id: String,
val url: String, val url: String,
// can be null for e.g. audio attachments // can be null for e.g. audio attachments
@SerializedName("preview_url") val previewUrl: String?, @Json(name = "preview_url") val previewUrl: String?,
val meta: MetaData?, val meta: MetaData?,
val type: Type, val type: Type,
val description: String?, val description: String?,
val blurhash: String?, val blurhash: String?,
) : Parcelable { ) : Parcelable {
@JsonAdapter(MediaTypeDeserializer::class)
enum class Type { enum class Type {
@SerializedName("image") @Json(name = "image")
IMAGE, IMAGE,
@SerializedName("gifv") @Json(name = "gifv")
GIFV, GIFV,
@SerializedName("video") @Json(name = "video")
VIDEO, VIDEO,
@SerializedName("audio") @Json(name = "audio")
AUDIO, AUDIO,
@SerializedName("unknown") @Json(name = "unknown")
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]. * The meta data of an [Attachment].
*/ */
@Parcelize @Parcelize
@JsonClass(generateAdapter = true)
data class MetaData( data class MetaData(
val focus: Focus?, val focus: Focus?,
val duration: Float?, val duration: Float?,
@ -87,6 +71,7 @@ data class Attachment(
* https://github.com/jonom/jquery-focuspoint#1-calculate-your-images-focus-point * https://github.com/jonom/jquery-focuspoint#1-calculate-your-images-focus-point
*/ */
@Parcelize @Parcelize
@JsonClass(generateAdapter = true)
data class Focus( data class Focus(
val x: Float, val x: Float,
val y: Float, val y: Float,
@ -98,9 +83,21 @@ data class Attachment(
* The size of an image, used to specify the width/height. * The size of an image, used to specify the width/height.
*/ */
@Parcelize @Parcelize
@JsonClass(generateAdapter = true)
data class Size( data class Size(
val width: Int, val width: Int?,
val height: Int, val height: Int?,
val aspect: Double, // Not always present, see https://github.com/mastodon/mastodon/issues/29125
) : Parcelable @Json(name = "aspect")
val _aspect: Double?,
) : Parcelable {
val aspect: Double
get() {
if (_aspect != null) return _aspect
width ?: return 1.778
height ?: return 1.778
return (width / height).toDouble()
}
}
} }

View File

@ -17,22 +17,24 @@
package app.pachli.core.network.model package app.pachli.core.network.model
import com.google.gson.annotations.SerializedName import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class Card( data class Card(
override val url: String, override val url: String,
override val title: String, override val title: String,
override val description: String, override val description: String,
@SerializedName("type") override val kind: PreviewCardKind, @Json(name = "type") override val kind: PreviewCardKind,
@SerializedName("author_name") override val authorName: String, @Json(name = "author_name") override val authorName: String,
@SerializedName("author_url") override val authorUrl: String, @Json(name = "author_url") override val authorUrl: String,
@SerializedName("provider_name") override val providerName: String, @Json(name = "provider_name") override val providerName: String,
@SerializedName("provider_url") override val providerUrl: String, @Json(name = "provider_url") override val providerUrl: String,
override val html: String, override val html: String,
override val width: Int, override val width: Int,
override val height: Int, override val height: Int,
override val image: String? = null, override val image: String? = null,
@SerializedName("embed_url") override val embedUrl: String, @Json(name = "embed_url") override val embedUrl: String,
override val blurhash: String? = null, override val blurhash: String? = null,
) : PreviewCard { ) : PreviewCard {

View File

@ -16,12 +16,14 @@
package app.pachli.core.network.model package app.pachli.core.network.model
import com.google.gson.annotations.SerializedName import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class Conversation( data class Conversation(
val id: String, val id: String,
val accounts: List<TimelineAccount>, val accounts: List<TimelineAccount>,
// should never be null, but apparently its possible https://github.com/tuskyapp/Tusky/issues/1038 // should never be null, but apparently its possible https://github.com/tuskyapp/Tusky/issues/1038
@SerializedName("last_status") val lastStatus: Status?, @Json(name = "last_status") val lastStatus: Status?,
val unread: Boolean, val unread: Boolean,
) )

View File

@ -16,18 +16,20 @@
package app.pachli.core.network.model package app.pachli.core.network.model
import com.google.gson.annotations.SerializedName import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import java.util.Date import java.util.Date
@JsonClass(generateAdapter = true)
data class DeletedStatus( data class DeletedStatus(
val text: String?, val text: String?,
@SerializedName("in_reply_to_id") val inReplyToId: String?, @Json(name = "in_reply_to_id") val inReplyToId: String?,
@SerializedName("spoiler_text") val spoilerText: String, @Json(name = "spoiler_text") val spoilerText: String,
val visibility: Status.Visibility, val visibility: Status.Visibility,
val sensitive: Boolean, val sensitive: Boolean,
@SerializedName("media_attachments") val attachments: List<Attachment>?, @Json(name = "media_attachments") val attachments: List<Attachment>?,
val poll: Poll?, val poll: Poll?,
@SerializedName("created_at") val createdAt: Date, @Json(name = "created_at") val createdAt: Date,
val language: String?, val language: String?,
) { ) {
fun isEmpty(): Boolean { fun isEmpty(): Boolean {

View File

@ -17,13 +17,15 @@
package app.pachli.core.network.model package app.pachli.core.network.model
import android.os.Parcelable import android.os.Parcelable
import com.google.gson.annotations.SerializedName import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@Parcelize @Parcelize
@JsonClass(generateAdapter = true)
data class Emoji( data class Emoji(
val shortcode: String, val shortcode: String,
val url: String, val url: String,
@SerializedName("static_url") val staticUrl: String, @Json(name = "static_url") val staticUrl: String,
@SerializedName("visible_in_picker") val visibleInPicker: Boolean?, @Json(name = "visible_in_picker") val visibleInPicker: Boolean?,
) : Parcelable ) : Parcelable

View File

@ -17,8 +17,13 @@
package app.pachli.core.network.model package app.pachli.core.network.model
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/** @see [Error](https://docs.joinmastodon.org/entities/Error/) */ /** @see [Error](https://docs.joinmastodon.org/entities/Error/) */
@JsonClass(generateAdapter = true)
data class Error( data class Error(
val error: String, val error: String,
val error_description: String?, @Json(name = "error_description")
val errorDescription: String?,
) )

View File

@ -1,18 +1,25 @@
package app.pachli.core.network.model package app.pachli.core.network.model
import android.os.Parcelable 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 java.util.Date
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@Parcelize @Parcelize
@JsonClass(generateAdapter = true)
data class Filter( data class Filter(
val id: String, val id: String,
val title: String, val title: String,
val context: List<String>, val context: List<String>,
@SerializedName("expires_at") val expiresAt: Date?, @Json(name = "expires_at") val expiresAt: Date?,
@SerializedName("filter_action") private val filterAction: String, @Json(name = "filter_action") val filterAction: String,
val keywords: List<FilterKeyword>, // This should not normally be empty. However, Mastodon does not include
// this in a status' `filtered.filter` property (it's not null or empty,
// it's missing) which breaks deserialisation. Patch this by ensuring it's
// always initialised to an empty list.
// TODO: https://github.com/mastodon/mastodon/issues/29142
val keywords: List<FilterKeyword> = emptyList(),
// val statuses: List<FilterStatus>, // val statuses: List<FilterStatus>,
) : Parcelable { ) : Parcelable {
enum class Action(val action: String) { enum class Action(val action: String) {

View File

@ -1,12 +1,14 @@
package app.pachli.core.network.model package app.pachli.core.network.model
import android.os.Parcelable import android.os.Parcelable
import com.google.gson.annotations.SerializedName import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@Parcelize @Parcelize
@JsonClass(generateAdapter = true)
data class FilterKeyword( data class FilterKeyword(
val id: String, val id: String,
val keyword: String, val keyword: String,
@SerializedName("whole_word") val wholeWord: Boolean, @Json(name = "whole_word") val wholeWord: Boolean,
) : Parcelable ) : Parcelable

View File

@ -1,9 +1,11 @@
package app.pachli.core.network.model package app.pachli.core.network.model
import com.google.gson.annotations.SerializedName import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class FilterResult( data class FilterResult(
val filter: Filter, val filter: Filter,
@SerializedName("keyword_matches") val keywordMatches: List<String>?, @Json(name = "keyword_matches") val keywordMatches: List<String>?,
@SerializedName("status_matches") val statusMatches: List<String>?, @Json(name = "status_matches") val statusMatches: List<String>?,
) )

View File

@ -16,16 +16,18 @@
package app.pachli.core.network.model package app.pachli.core.network.model
import com.google.gson.annotations.SerializedName import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import java.util.Date import java.util.Date
@JsonClass(generateAdapter = true)
data class FilterV1( data class FilterV1(
val id: String, val id: String,
val phrase: String, val phrase: String,
val context: List<String>, val context: List<String>,
@SerializedName("expires_at") val expiresAt: Date?, @Json(name = "expires_at") val expiresAt: Date?,
val irreversible: Boolean, val irreversible: Boolean,
@SerializedName("whole_word") val wholeWord: Boolean, @Json(name = "whole_word") val wholeWord: Boolean,
) { ) {
companion object { companion object {
const val HOME = "home" const val HOME = "home"

View File

@ -1,3 +1,6 @@
package app.pachli.core.network.model package app.pachli.core.network.model
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class HashTag(val name: String, val url: String, val following: Boolean? = null) data class HashTag(val name: String, val url: String, val following: Boolean? = null)

View File

@ -16,9 +16,11 @@
package app.pachli.core.network.model package app.pachli.core.network.model
import com.google.gson.annotations.SerializedName import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/** https://docs.joinmastodon.org/entities/V1_Instance/ */ /** https://docs.joinmastodon.org/entities/V1_Instance/ */
@JsonClass(generateAdapter = true)
data class InstanceV1( data class InstanceV1(
val uri: String, val uri: String,
// val title: String, // val title: String,
@ -29,13 +31,13 @@ data class InstanceV1(
// val stats: Map<String, Int>?, // val stats: Map<String, Int>?,
// val thumbnail: String?, // val thumbnail: String?,
// val languages: List<String>, // val languages: List<String>,
// @SerializedName("contact_account") val contactAccount: Account, // @Json(name = "contact_account") val contactAccount: Account,
@SerializedName("max_toot_chars") val maxTootChars: Int?, @Json(name = "max_toot_chars") val maxTootChars: Int?,
@SerializedName("poll_limits") val pollConfiguration: PollConfiguration?, @Json(name = "poll_limits") val pollConfiguration: PollConfiguration?,
val configuration: InstanceConfiguration?, val configuration: InstanceConfiguration?,
@SerializedName("max_media_attachments") val maxMediaAttachments: Int?, @Json(name = "max_media_attachments") val maxMediaAttachments: Int?,
val pleroma: PleromaConfiguration?, val pleroma: PleromaConfiguration?,
@SerializedName("upload_limit") val uploadLimit: Int?, @Json(name = "upload_limit") val uploadLimit: Int?,
val rules: List<InstanceRules>?, val rules: List<InstanceRules>?,
) { ) {
override fun hashCode(): Int { override fun hashCode(): Int {
@ -51,49 +53,57 @@ data class InstanceV1(
} }
} }
@JsonClass(generateAdapter = true)
data class PollConfiguration( data class PollConfiguration(
@SerializedName("max_options") val maxOptions: Int?, @Json(name = "max_options") val maxOptions: Int?,
@SerializedName("max_option_chars") val maxOptionChars: Int?, @Json(name = "max_option_chars") val maxOptionChars: Int?,
@SerializedName("max_characters_per_option") val maxCharactersPerOption: Int?, @Json(name = "max_characters_per_option") val maxCharactersPerOption: Int?,
@SerializedName("min_expiration") val minExpiration: Int?, @Json(name = "min_expiration") val minExpiration: Int?,
@SerializedName("max_expiration") val maxExpiration: Int?, @Json(name = "max_expiration") val maxExpiration: Int?,
) )
@JsonClass(generateAdapter = true)
data class InstanceConfiguration( data class InstanceConfiguration(
val statuses: StatusConfiguration?, val statuses: StatusConfiguration?,
@SerializedName("media_attachments") val mediaAttachments: MediaAttachmentConfiguration?, @Json(name = "media_attachments") val mediaAttachments: MediaAttachmentConfiguration?,
val polls: PollConfiguration?, val polls: PollConfiguration?,
) )
@JsonClass(generateAdapter = true)
data class StatusConfiguration( data class StatusConfiguration(
@SerializedName("max_characters") val maxCharacters: Int?, @Json(name = "max_characters") val maxCharacters: Int?,
@SerializedName("max_media_attachments") val maxMediaAttachments: Int?, @Json(name = "max_media_attachments") val maxMediaAttachments: Int?,
@SerializedName("characters_reserved_per_url") val charactersReservedPerUrl: Int?, @Json(name = "characters_reserved_per_url") val charactersReservedPerUrl: Int?,
) )
@JsonClass(generateAdapter = true)
data class MediaAttachmentConfiguration( data class MediaAttachmentConfiguration(
@SerializedName("supported_mime_types") val supportedMimeTypes: List<String>?, @Json(name = "supported_mime_types") val supportedMimeTypes: List<String>?,
@SerializedName("image_size_limit") val imageSizeLimit: Int?, @Json(name = "image_size_limit") val imageSizeLimit: Int?,
@SerializedName("image_matrix_limit") val imageMatrixLimit: Int?, @Json(name = "image_matrix_limit") val imageMatrixLimit: Int?,
@SerializedName("video_size_limit") val videoSizeLimit: Int?, @Json(name = "video_size_limit") val videoSizeLimit: Int?,
@SerializedName("video_frame_rate_limit") val videoFrameRateLimit: Int?, @Json(name = "video_frame_rate_limit") val videoFrameRateLimit: Int?,
@SerializedName("video_matrix_limit") val videoMatrixLimit: Int?, @Json(name = "video_matrix_limit") val videoMatrixLimit: Int?,
) )
@JsonClass(generateAdapter = true)
data class PleromaConfiguration( data class PleromaConfiguration(
val metadata: PleromaMetadata?, val metadata: PleromaMetadata?,
) )
@JsonClass(generateAdapter = true)
data class PleromaMetadata( data class PleromaMetadata(
@SerializedName("fields_limits") val fieldLimits: PleromaFieldLimits, @Json(name = "fields_limits") val fieldLimits: PleromaFieldLimits,
) )
@JsonClass(generateAdapter = true)
data class PleromaFieldLimits( data class PleromaFieldLimits(
@SerializedName("max_fields") val maxFields: Int?, @Json(name = "max_fields") val maxFields: Int?,
@SerializedName("name_length") val nameLength: Int?, @Json(name = "name_length") val nameLength: Int?,
@SerializedName("value_length") val valueLength: Int?, @Json(name = "value_length") val valueLength: Int?,
) )
@JsonClass(generateAdapter = true)
data class InstanceRules( data class InstanceRules(
val id: String, val id: String,
val text: String, val text: String,

View File

@ -17,9 +17,11 @@
package app.pachli.core.network.model package app.pachli.core.network.model
import com.google.gson.annotations.SerializedName import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/** https://docs.joinmastodon.org/entities/Instance/ */ /** https://docs.joinmastodon.org/entities/Instance/ */
@JsonClass(generateAdapter = true)
data class InstanceV2( data class InstanceV2(
/** The domain name of the instance */ /** The domain name of the instance */
val domain: String, val domain: String,
@ -34,7 +36,7 @@ data class InstanceV2(
* The URL for the source code of the software running on this instance, in keeping with AGPL * The URL for the source code of the software running on this instance, in keeping with AGPL
* license requirements. * license requirements.
*/ */
@SerializedName("source_url") val sourceUrl: String, @Json(name = "source_url") val sourceUrl: String,
/** A short, plain-text description defined by the admin. */ /** A short, plain-text description defined by the admin. */
val description: String, val description: String,
@ -61,16 +63,19 @@ data class InstanceV2(
val rules: List<Rule>, val rules: List<Rule>,
) )
@JsonClass(generateAdapter = true)
data class Usage( data class Usage(
/** Usage data related to users on this instance. */ /** Usage data related to users on this instance. */
val users: Users, val users: Users,
) )
@JsonClass(generateAdapter = true)
data class Users( data class Users(
/** The number of active users in the past 4 weeks. */ /** The number of active users in the past 4 weeks. */
val activeMonth: Int = 0, val activeMonth: Int = 0,
) )
@JsonClass(generateAdapter = true)
data class Thumbnail( data class Thumbnail(
/** The URL for the thumbnail image. */ /** The URL for the thumbnail image. */
val url: String, val url: String,
@ -85,14 +90,16 @@ data class Thumbnail(
val versions: ThumbnailVersions?, val versions: ThumbnailVersions?,
) )
@JsonClass(generateAdapter = true)
data class ThumbnailVersions( data class ThumbnailVersions(
/** The URL for the thumbnail image at 1x resolution. */ /** The URL for the thumbnail image at 1x resolution. */
@SerializedName("@1x") val oneX: String?, @Json(name = "@1x") val oneX: String?,
/** The URL for the thumbnail image at 2x resolution. */ /** The URL for the thumbnail image at 2x resolution. */
@SerializedName("@2x") val twoX: String?, @Json(name = "@2x") val twoX: String?,
) )
@JsonClass(generateAdapter = true)
data class Configuration( data class Configuration(
/** URLs of interest for clients apps. */ /** URLs of interest for clients apps. */
val urls: InstanceV2Urls, val urls: InstanceV2Urls,
@ -104,7 +111,7 @@ data class Configuration(
val statuses: InstanceV2Statuses, val statuses: InstanceV2Statuses,
/** Hints for which attachments will be accepted. */ /** Hints for which attachments will be accepted. */
@SerializedName("media_attachments") val mediaAttachments: MediaAttachments, @Json(name = "media_attachments") val mediaAttachments: MediaAttachments,
/** Limits related to polls. */ /** Limits related to polls. */
val polls: InstanceV2Polls, val polls: InstanceV2Polls,
@ -113,77 +120,94 @@ data class Configuration(
val translation: InstanceV2Translation, val translation: InstanceV2Translation,
) )
@JsonClass(generateAdapter = true)
data class InstanceV2Urls( data class InstanceV2Urls(
/** The Websockets URL for connecting to the streaming API. */ /**
@SerializedName("streaming_api") val streamingApi: String, * The Websockets URL for connecting to the streaming API. This is the
* documented property name
*/
@Json(name = "streaming_api") val streamingApi: String? = null,
/**
* The Websockets URL for connecting to the streaming API. This is the
* undocumented property name, see https://github.com/mastodon/mastodon/pull/29124
*/
@Json(name = "streaming") val streaming: String? = null,
) )
@JsonClass(generateAdapter = true)
data class InstanceV2Accounts( data class InstanceV2Accounts(
/** The maximum number of featured tags allowed for each account. */ /** The maximum number of featured tags allowed for each account. */
@SerializedName("max_featured_tags") val maxFeaturedTags: Int, @Json(name = "max_featured_tags") val maxFeaturedTags: Int,
) )
@JsonClass(generateAdapter = true)
data class InstanceV2Statuses( data class InstanceV2Statuses(
/** The maximum number of allowed characters per status. */ /** The maximum number of allowed characters per status. */
@SerializedName("max_characters") val maxCharacters: Int, @Json(name = "max_characters") val maxCharacters: Int,
/** The maximum number of media attachments that can be added to a status. */ /** The maximum number of media attachments that can be added to a status. */
@SerializedName("max_media_attachments") val maxMediaAttachments: Int, @Json(name = "max_media_attachments") val maxMediaAttachments: Int,
/** Each URL in a status will be assumed to be exactly this many characters. */ /** Each URL in a status will be assumed to be exactly this many characters. */
@SerializedName("characters_reserved_per_url") val charactersReservedPerUrl: Int, @Json(name = "characters_reserved_per_url") val charactersReservedPerUrl: Int,
) )
@JsonClass(generateAdapter = true)
data class MediaAttachments( data class MediaAttachments(
/** Contains MIME types that can be uploaded. */ /** Contains MIME types that can be uploaded. */
@SerializedName("supported_mime_types") val supportedMimeTypes: List<String>, @Json(name = "supported_mime_types") val supportedMimeTypes: List<String>,
/** The maximum size of any uploaded image, in bytes. */ /** The maximum size of any uploaded image, in bytes. */
@SerializedName("image_size_limit") val imageSizeLimit: Int, @Json(name = "image_size_limit") val imageSizeLimit: Int,
/** The maximum number of pixels (width times height) for image uploads. */ /** The maximum number of pixels (width times height) for image uploads. */
@SerializedName("image_matrix_limit") val imageMatrixLimit: Int, @Json(name = "image_matrix_limit") val imageMatrixLimit: Int,
/** The maximum size of any uploaded video, in bytes. */ /** The maximum size of any uploaded video, in bytes. */
@SerializedName("video_size_limit") val videoSizeLimit: Int, @Json(name = "video_size_limit") val videoSizeLimit: Int,
/** The maximum frame rate for any uploaded video. */ /** The maximum frame rate for any uploaded video. */
@SerializedName("video_frame_rate_limit") val videoFrameRateLimit: Int, @Json(name = "video_frame_rate_limit") val videoFrameRateLimit: Int,
/** The maximum number of pixels (width times height) for video uploads. */ /** The maximum number of pixels (width times height) for video uploads. */
@SerializedName("video_matrix_limit") val videoMatrixLimit: Int, @Json(name = "video_matrix_limit") val videoMatrixLimit: Int,
) )
@JsonClass(generateAdapter = true)
data class InstanceV2Polls( data class InstanceV2Polls(
/** Each poll is allowed to have up to this many options. */ /** Each poll is allowed to have up to this many options. */
@SerializedName("max_options") val maxOptions: Int, @Json(name = "max_options") val maxOptions: Int,
/** Each poll option is allowed to have this many characters. */ /** Each poll option is allowed to have this many characters. */
@SerializedName("max_characters_per_option") val maxCharactersPerOption: Int, @Json(name = "max_characters_per_option") val maxCharactersPerOption: Int,
/** The shortest allowed poll duration, in seconds. */ /** The shortest allowed poll duration, in seconds. */
@SerializedName("min_expiration") val minExpiration: Int, @Json(name = "min_expiration") val minExpiration: Int,
/** The longest allowed poll duration, in seconds. */ /** The longest allowed poll duration, in seconds. */
@SerializedName("max_expiration") val maxExpiration: Int, @Json(name = "max_expiration") val maxExpiration: Int,
) )
@JsonClass(generateAdapter = true)
data class InstanceV2Translation( data class InstanceV2Translation(
/** Whether the Translations API is available on this instance. */ /** Whether the Translations API is available on this instance. */
val enabled: Boolean, val enabled: Boolean,
) )
@JsonClass(generateAdapter = true)
data class Registrations( data class Registrations(
/** Whether registrations are enabled. */ /** Whether registrations are enabled. */
val enabled: Boolean, val enabled: Boolean,
/** Whether registrations require moderator approval. */ /** Whether registrations require moderator approval. */
@SerializedName("approval_required") val approvalRequired: Boolean, @Json(name = "approval_required") val approvalRequired: Boolean,
/** A custom message to be shown when registrations are closed. */ /** A custom message to be shown when registrations are closed. */
val message: String?, val message: String?,
) )
@JsonClass(generateAdapter = true)
data class Contact( data class Contact(
/** An email address that can be messaged regarding inquiries or issues. */ /** An email address that can be messaged regarding inquiries or issues. */
val email: String, val email: String,
@ -193,6 +217,7 @@ data class Contact(
) )
/** https://docs.joinmastodon.org/entities/Rule/ */ /** https://docs.joinmastodon.org/entities/Rule/ */
@JsonClass(generateAdapter = true)
data class Rule( data class Rule(
/** An identifier for the rule. */ /** An identifier for the rule. */
val id: String, val id: String,

View File

@ -1,15 +1,17 @@
package app.pachli.core.network.model package app.pachli.core.network.model
import com.google.gson.annotations.SerializedName import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import java.util.Date import java.util.Date
/** /**
* API type for saving the scroll position of a timeline. * API type for saving the scroll position of a timeline.
*/ */
@JsonClass(generateAdapter = true)
data class Marker( data class Marker(
@SerializedName("last_read_id") @Json(name = "last_read_id")
val lastReadId: String, val lastReadId: String,
val version: Int, val version: Int,
@SerializedName("updated_at") @Json(name = "updated_at")
val updatedAt: Date, val updatedAt: Date,
) )

View File

@ -16,6 +16,9 @@
package app.pachli.core.network.model package app.pachli.core.network.model
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class MastoList( data class MastoList(
val id: String, val id: String,
val title: String, val title: String,

View File

@ -1,9 +1,12 @@
package app.pachli.core.network.model package app.pachli.core.network.model
import com.squareup.moshi.JsonClass
/** /**
* The same as Attachment, except the url is null - see https://docs.joinmastodon.org/methods/statuses/media/ * 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 * We are only interested in the id, so other attributes are omitted
*/ */
@JsonClass(generateAdapter = true)
data class MediaUploadResult( data class MediaUploadResult(
val id: String, val id: String,
) )

View File

@ -17,32 +17,36 @@
package app.pachli.core.network.model package app.pachli.core.network.model
import android.os.Parcelable import android.os.Parcelable
import com.google.gson.annotations.SerializedName import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@JsonClass(generateAdapter = true)
data class NewStatus( data class NewStatus(
val status: String, val status: String,
@SerializedName("spoiler_text") val warningText: String, @Json(name = "spoiler_text") val warningText: String,
@SerializedName("in_reply_to_id") val inReplyToId: String?, @Json(name = "in_reply_to_id") val inReplyToId: String?,
val visibility: String, val visibility: String,
val sensitive: Boolean, val sensitive: Boolean,
@SerializedName("media_ids") val mediaIds: List<String>?, @Json(name = "media_ids") val mediaIds: List<String>?,
@SerializedName("media_attributes") val mediaAttributes: List<MediaAttribute>?, @Json(name = "media_attributes") val mediaAttributes: List<MediaAttribute>?,
@SerializedName("scheduled_at") val scheduledAt: String?, @Json(name = "scheduled_at") val scheduledAt: String?,
val poll: NewPoll?, val poll: NewPoll?,
val language: String?, val language: String?,
) )
@Parcelize @Parcelize
@JsonClass(generateAdapter = true)
data class NewPoll( data class NewPoll(
val options: List<String>, val options: List<String>,
@SerializedName("expires_in") val expiresIn: Int, @Json(name = "expires_in") val expiresIn: Int,
val multiple: Boolean, val multiple: Boolean,
) : Parcelable ) : Parcelable
// It would be nice if we could reuse MediaToSend, // It would be nice if we could reuse MediaToSend,
// but the server requires a different format for focus // but the server requires a different format for focus
@Parcelize @Parcelize
@JsonClass(generateAdapter = true)
data class MediaAttribute( data class MediaAttribute(
val id: String, val id: String,
val description: String?, val description: String?,

View File

@ -17,14 +17,12 @@
package app.pachli.core.network.model package app.pachli.core.network.model
import com.google.gson.JsonDeserializationContext import com.squareup.moshi.Json
import com.google.gson.JsonDeserializer import com.squareup.moshi.JsonClass
import com.google.gson.JsonElement
import com.google.gson.JsonParseException
import com.google.gson.annotations.JsonAdapter
// TODO: These should be different subclasses per type, so that each subclass can // TODO: These should be different subclasses per type, so that each subclass can
// carry the non-null data that it needs. // carry the non-null data that it needs.
@JsonClass(generateAdapter = true)
data class Notification( data class Notification(
val type: Type, val type: Type,
val id: String, val id: String,
@ -34,38 +32,48 @@ data class Notification(
) { ) {
/** From https://docs.joinmastodon.org/entities/Notification/#type */ /** From https://docs.joinmastodon.org/entities/Notification/#type */
@JsonAdapter(NotificationTypeAdapter::class)
enum class Type(val presentation: String) { enum class Type(val presentation: String) {
@Json(name = "unknown")
UNKNOWN("unknown"), UNKNOWN("unknown"),
/** Someone mentioned you */ /** Someone mentioned you */
@Json(name = "mention")
MENTION("mention"), MENTION("mention"),
/** Someone boosted one of your statuses */ /** Someone boosted one of your statuses */
@Json(name = "reblog")
REBLOG("reblog"), REBLOG("reblog"),
/** Someone favourited one of your statuses */ /** Someone favourited one of your statuses */
@Json(name = "favourite")
FAVOURITE("favourite"), FAVOURITE("favourite"),
/** Someone followed you */ /** Someone followed you */
@Json(name = "follow")
FOLLOW("follow"), FOLLOW("follow"),
/** Someone requested to follow you */ /** Someone requested to follow you */
@Json(name = "follow_request")
FOLLOW_REQUEST("follow_request"), FOLLOW_REQUEST("follow_request"),
/** A poll you have voted in or created has ended */ /** A poll you have voted in or created has ended */
@Json(name = "poll")
POLL("poll"), POLL("poll"),
/** Someone you enabled notifications for has posted a status */ /** Someone you enabled notifications for has posted a status */
@Json(name = "status")
STATUS("status"), STATUS("status"),
/** Someone signed up (optionally sent to admins) */ /** Someone signed up (optionally sent to admins) */
@Json(name = "admin.sign_up")
SIGN_UP("admin.sign_up"), SIGN_UP("admin.sign_up"),
/** A status you interacted with has been updated */ /** A status you interacted with has been updated */
@Json(name = "update")
UPDATE("update"), UPDATE("update"),
/** A new report has been filed */ /** A new report has been filed */
@Json(name = "admin.report")
REPORT("admin.report"), REPORT("admin.report"),
; ;
@ -101,18 +109,6 @@ data class Notification(
return notification?.id == this.id 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)
}
}
// for Pleroma compatibility that uses Mention type // for Pleroma compatibility that uses Mention type
fun rewriteToStatusTypeIfNeeded(accountId: String): Notification { fun rewriteToStatusTypeIfNeeded(accountId: String): Notification {
if (type == Type.MENTION && status != null) { if (type == Type.MENTION && status != null) {

View File

@ -16,10 +16,12 @@
package app.pachli.core.network.model package app.pachli.core.network.model
import com.google.gson.annotations.SerializedName import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class NotificationSubscribeResult( data class NotificationSubscribeResult(
val id: Int, val id: Int,
val endpoint: String, val endpoint: String,
@SerializedName("server_key") val serverKey: String, @Json(name = "server_key") val serverKey: String,
) )

View File

@ -1,19 +1,21 @@
package app.pachli.core.network.model package app.pachli.core.network.model
import com.google.gson.annotations.SerializedName import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import java.util.Date import java.util.Date
@JsonClass(generateAdapter = true)
data class Poll( data class Poll(
val id: String, val id: String,
@SerializedName("expires_at") val expiresAt: Date?, @Json(name = "expires_at") val expiresAt: Date?,
val expired: Boolean, val expired: Boolean,
val multiple: Boolean, val multiple: Boolean,
@SerializedName("votes_count") val votesCount: Int, @Json(name = "votes_count") val votesCount: Int,
// nullable for compatibility with Pleroma // nullable for compatibility with Pleroma
@SerializedName("voters_count") val votersCount: Int?, @Json(name = "voters_count") val votersCount: Int?,
val options: List<PollOption>, val options: List<PollOption>,
val voted: Boolean, val voted: Boolean,
@SerializedName("own_votes") val ownVotes: List<Int>?, @Json(name = "own_votes") val ownVotes: List<Int>?,
) { ) {
fun votedCopy(choices: List<Int>): Poll { fun votedCopy(choices: List<Int>): Poll {
@ -42,7 +44,8 @@ data class Poll(
) )
} }
@JsonClass(generateAdapter = true)
data class PollOption( data class PollOption(
val title: String, val title: String,
@SerializedName("votes_count") val votesCount: Int, @Json(name = "votes_count") val votesCount: Int,
) )

View File

@ -17,25 +17,30 @@
package app.pachli.core.network.model package app.pachli.core.network.model
import app.pachli.core.network.json.GuardedBooleanAdapter import app.pachli.core.network.json.Guarded
import com.google.gson.annotations.JsonAdapter import com.squareup.moshi.Json
import com.google.gson.annotations.SerializedName import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class Relationship( data class Relationship(
val id: String, val id: String,
val following: Boolean, val following: Boolean,
@SerializedName("followed_by") val followedBy: Boolean, @Json(name = "followed_by") val followedBy: Boolean,
val blocking: Boolean, val blocking: Boolean,
val muting: Boolean, val muting: Boolean,
@SerializedName("muting_notifications") val mutingNotifications: Boolean, @Json(name = "muting_notifications") val mutingNotifications: Boolean,
val requested: 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, * Pleroma extension, same as 'notifying' on Mastodon.
* so we use the custom GuardedBooleanAdapter to ignore the field if it is not a boolean. *
* Some instances like qoto.org have a custom subscription feature where
* 'subscribing' is a json object, so we use the custom `@Guarded` annotation
* to ignore the field if it is not a boolean.
*/ */
@JsonAdapter(GuardedBooleanAdapter::class) val subscribing: Boolean? = null, @Guarded
@SerializedName("domain_blocking") val blockingDomain: Boolean, val subscribing: Boolean? = null,
@Json(name = "domain_blocking") val blockingDomain: Boolean,
// nullable for backward compatibility / feature detection // nullable for backward compatibility / feature detection
val note: String?, val note: String?,
// since 3.3.0rc // since 3.3.0rc

View File

@ -17,13 +17,15 @@
package app.pachli.core.network.model package app.pachli.core.network.model
import com.google.gson.annotations.SerializedName import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import java.util.Date import java.util.Date
@JsonClass(generateAdapter = true)
data class Report( data class Report(
val id: String, val id: String,
val category: String, val category: String,
val status_ids: List<String>?, @Json(name = "status_ids") val statusIds: List<String>?,
@SerializedName("created_at") val createdAt: Date, @Json(name = "created_at") val createdAt: Date,
@SerializedName("target_account") val targetAccount: TimelineAccount, @Json(name = "target_account") val targetAccount: TimelineAccount,
) )

View File

@ -16,11 +16,13 @@
package app.pachli.core.network.model package app.pachli.core.network.model
import com.google.gson.annotations.SerializedName import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class ScheduledStatus( data class ScheduledStatus(
val id: String, val id: String,
@SerializedName("scheduled_at") val scheduledAt: String, @Json(name = "scheduled_at") val scheduledAt: String,
val params: StatusParams, val params: StatusParams,
@SerializedName("media_attachments") val mediaAttachments: ArrayList<Attachment>, @Json(name = "media_attachments") val mediaAttachments: List<Attachment>,
) )

View File

@ -16,6 +16,9 @@
package app.pachli.core.network.model package app.pachli.core.network.model
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class SearchResult( data class SearchResult(
val accounts: List<TimelineAccount>, val accounts: List<TimelineAccount>,
val statuses: List<Status>, val statuses: List<Status>,

View File

@ -20,31 +20,33 @@ package app.pachli.core.network.model
import android.text.SpannableStringBuilder import android.text.SpannableStringBuilder
import android.text.style.URLSpan import android.text.style.URLSpan
import app.pachli.core.network.parseAsMastodonHtml import app.pachli.core.network.parseAsMastodonHtml
import com.google.gson.annotations.SerializedName import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import java.util.Date import java.util.Date
@JsonClass(generateAdapter = true)
data class Status( data class Status(
val id: String, val id: String,
// not present if it's reblog // not present if it's reblog
val url: String?, val url: String?,
val account: TimelineAccount, val account: TimelineAccount,
@SerializedName("in_reply_to_id") val inReplyToId: String?, @Json(name = "in_reply_to_id") val inReplyToId: String?,
@SerializedName("in_reply_to_account_id") val inReplyToAccountId: String?, @Json(name = "in_reply_to_account_id") val inReplyToAccountId: String?,
val reblog: Status?, val reblog: Status?,
val content: String, val content: String,
@SerializedName("created_at") val createdAt: Date, @Json(name = "created_at") val createdAt: Date,
@SerializedName("edited_at") val editedAt: Date?, @Json(name = "edited_at") val editedAt: Date?,
val emojis: List<Emoji>, val emojis: List<Emoji>,
@SerializedName("reblogs_count") val reblogsCount: Int, @Json(name = "reblogs_count") val reblogsCount: Int,
@SerializedName("favourites_count") val favouritesCount: Int, @Json(name = "favourites_count") val favouritesCount: Int,
@SerializedName("replies_count") val repliesCount: Int, @Json(name = "replies_count") val repliesCount: Int,
val reblogged: Boolean, val reblogged: Boolean,
val favourited: Boolean, val favourited: Boolean,
val bookmarked: Boolean, val bookmarked: Boolean,
val sensitive: Boolean, val sensitive: Boolean,
@SerializedName("spoiler_text") val spoilerText: String, @Json(name = "spoiler_text") val spoilerText: String,
val visibility: Visibility, val visibility: Visibility,
@SerializedName("media_attachments") val attachments: List<Attachment>, @Json(name = "media_attachments") val attachments: List<Attachment>,
val mentions: List<Mention>, val mentions: List<Mention>,
val tags: List<HashTag>?, val tags: List<HashTag>?,
val application: Application?, val application: Application?,
@ -65,16 +67,16 @@ data class Status(
enum class Visibility(val num: Int) { enum class Visibility(val num: Int) {
UNKNOWN(0), UNKNOWN(0),
@SerializedName("public") @Json(name = "public")
PUBLIC(1), PUBLIC(1),
@SerializedName("unlisted") @Json(name = "unlisted")
UNLISTED(2), UNLISTED(2),
@SerializedName("private") @Json(name = "private")
PRIVATE(3), PRIVATE(3),
@SerializedName("direct") @Json(name = "direct")
DIRECT(4), DIRECT(4),
; ;
@ -156,13 +158,15 @@ data class Status(
return builder.toString() return builder.toString()
} }
@JsonClass(generateAdapter = true)
data class Mention( data class Mention(
val id: String, val id: String,
val url: String, val url: String,
@SerializedName("acct") val username: String, @Json(name = "acct") val username: String,
@SerializedName("username") val localUsername: String, @Json(name = "username") val localUsername: String,
) )
@JsonClass(generateAdapter = true)
data class Application( data class Application(
val name: String, val name: String,
val website: String?, val website: String?,

View File

@ -16,6 +16,9 @@
package app.pachli.core.network.model package app.pachli.core.network.model
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class StatusContext( data class StatusContext(
val ancestors: List<Status>, val ancestors: List<Status>,
val descendants: List<Status>, val descendants: List<Status>,

View File

@ -1,15 +1,17 @@
package app.pachli.core.network.model package app.pachli.core.network.model
import com.google.gson.annotations.SerializedName import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import java.util.Date import java.util.Date
@JsonClass(generateAdapter = true)
data class StatusEdit( data class StatusEdit(
val content: String, val content: String,
@SerializedName("spoiler_text") val spoilerText: String, @Json(name = "spoiler_text") val spoilerText: String,
val sensitive: Boolean, val sensitive: Boolean,
@SerializedName("created_at") val createdAt: Date, @Json(name = "created_at") val createdAt: Date,
val account: TimelineAccount, val account: TimelineAccount,
val poll: Poll?, val poll: Poll?,
@SerializedName("media_attachments") val mediaAttachments: List<Attachment>, @Json(name = "media_attachments") val mediaAttachments: List<Attachment>,
val emojis: List<Emoji>, val emojis: List<Emoji>,
) )

View File

@ -17,12 +17,14 @@
package app.pachli.core.network.model package app.pachli.core.network.model
import com.google.gson.annotations.SerializedName import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class StatusParams( data class StatusParams(
val text: String, val text: String,
val sensitive: Boolean, val sensitive: Boolean,
val visibility: Status.Visibility, val visibility: Status.Visibility,
@SerializedName("spoiler_text") val spoilerText: String, @Json(name = "spoiler_text") val spoilerText: String,
@SerializedName("in_reply_to_id") val inReplyToId: String?, @Json(name = "in_reply_to_id") val inReplyToId: String?,
) )

View File

@ -17,10 +17,12 @@
package app.pachli.core.network.model package app.pachli.core.network.model
import com.google.gson.annotations.SerializedName import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class StatusSource( data class StatusSource(
val id: String, val id: String,
val text: String, val text: String,
@SerializedName("spoiler_text") val spoilerText: String, @Json(name = "spoiler_text") val spoilerText: String,
) )

View File

@ -17,19 +17,21 @@
package app.pachli.core.network.model package app.pachli.core.network.model
import com.google.gson.annotations.SerializedName import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/** /**
* Same as [Account], but only with the attributes required in timelines. * 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. * Prefer this class over [Account] because it uses way less memory & deserializes faster from json.
*/ */
@JsonClass(generateAdapter = true)
data class TimelineAccount( data class TimelineAccount(
val id: String, val id: String,
@SerializedName("username") val localUsername: String, @Json(name = "username") val localUsername: String,
@SerializedName("acct") val username: String, @Json(name = "acct") val username: String,
// should never be null per Api definition, but some servers break the contract // should never be null per Api definition, but some servers break the contract
@Deprecated("prefer the `name` property, which is not-null and not-empty") @Deprecated("prefer the `name` property, which is not-null and not-empty")
@SerializedName("display_name") val displayName: String?, @Json(name = "display_name") val displayName: String?,
val url: String, val url: String,
val avatar: String, val avatar: String,
val note: String, val note: String,

View File

@ -17,9 +17,11 @@
package app.pachli.core.network.model package app.pachli.core.network.model
import com.google.gson.annotations.SerializedName import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/** https://docs.joinmastodon.org/entities/Translation/ */ /** https://docs.joinmastodon.org/entities/Translation/ */
@JsonClass(generateAdapter = true)
data class Translation( data class Translation(
/** The translated text of the status (HTML), equivalent to [Status.content] */ /** The translated text of the status (HTML), equivalent to [Status.content] */
val content: String, val content: String,
@ -28,14 +30,14 @@ data class Translation(
* The language of the source text, as auto-detected by the machine translation * The language of the source text, as auto-detected by the machine translation
* (ISO 639 language code) * (ISO 639 language code)
*/ */
@SerializedName("detected_source_language") val detectedSourceLanguage: String, @Json(name = "detected_source_language") val detectedSourceLanguage: String,
// Not documented, see https://github.com/mastodon/documentation/issues/1248 // Not documented, see https://github.com/mastodon/documentation/issues/1248
/** /**
* The translated spoiler text of the status (text), if it exists, equivalent to * The translated spoiler text of the status (text), if it exists, equivalent to
* [Status.spoilerText] * [Status.spoilerText]
*/ */
@SerializedName("spoiler_text") val spoilerText: String, @Json(name = "spoiler_text") val spoilerText: String,
// Not documented, see https://github.com/mastodon/documentation/issues/1248 // Not documented, see https://github.com/mastodon/documentation/issues/1248
/** The translated poll (if it exists) */ /** The translated poll (if it exists) */
@ -46,7 +48,7 @@ data class Translation(
* Translated descriptions for media attachments, if any were attached. Other metadata has * Translated descriptions for media attachments, if any were attached. Other metadata has
* to be determined from the original attachment. * to be determined from the original attachment.
*/ */
@SerializedName("media_attachments") val attachments: List<TranslatedAttachment>, @Json(name = "media_attachments") val attachments: List<TranslatedAttachment>,
/** The service that provided the machine translation */ /** The service that provided the machine translation */
val provider: String, val provider: String,
@ -56,17 +58,20 @@ data class Translation(
* A translated poll. Does not contain all the poll data, only the translated text. * A translated poll. Does not contain all the poll data, only the translated text.
* Vote counts and other metadata has to be determined from the original poll object. * Vote counts and other metadata has to be determined from the original poll object.
*/ */
@JsonClass(generateAdapter = true)
data class TranslatedPoll( data class TranslatedPoll(
val id: String, val id: String,
val options: List<TranslatedPollOption>, val options: List<TranslatedPollOption>,
) )
/** A translated poll option. */ /** A translated poll option. */
@JsonClass(generateAdapter = true)
data class TranslatedPollOption( data class TranslatedPollOption(
val title: String, val title: String,
) )
/** A translated attachment. Only the description is translated */ /** A translated attachment. Only the description is translated */
@JsonClass(generateAdapter = true)
data class TranslatedAttachment( data class TranslatedAttachment(
val id: String, val id: String,
val description: String, val description: String,

View File

@ -17,6 +17,7 @@
package app.pachli.core.network.model package app.pachli.core.network.model
import com.squareup.moshi.JsonClass
import java.util.Date import java.util.Date
/** /**
@ -27,6 +28,7 @@ import java.util.Date
* @param history A list of [TrendingTagHistory]. Each element contains metrics per day for this hashtag. * @param 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.) * (@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( data class TrendingTag(
val name: String, val name: String,
val history: List<TrendingTagHistory>, val history: List<TrendingTagHistory>,
@ -39,6 +41,7 @@ data class TrendingTag(
* @param accounts The number of accounts that have posted with this hashtag. * @param accounts The number of accounts that have posted with this hashtag.
* @param uses The number of posts with this hashtag. * @param uses The number of posts with this hashtag.
*/ */
@JsonClass(generateAdapter = true)
data class TrendingTagHistory( data class TrendingTagHistory(
val day: String, val day: String,
val accounts: String, val accounts: String,

View File

@ -17,19 +17,20 @@
package app.pachli.core.network.model package app.pachli.core.network.model
import com.google.gson.annotations.SerializedName import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
enum class PreviewCardKind { enum class PreviewCardKind {
@SerializedName("link") @Json(name = "link")
LINK, LINK,
@SerializedName("photo") @Json(name = "photo")
PHOTO, PHOTO,
@SerializedName("video") @Json(name = "video")
VIDEO, VIDEO,
@SerializedName("rich") @Json(name = "rich")
RICH, RICH,
} }
@ -53,6 +54,7 @@ interface PreviewCard {
val blurhash: String? val blurhash: String?
} }
@JsonClass(generateAdapter = true)
data class LinkHistory( data class LinkHistory(
val day: String, val day: String,
val accounts: Int, val accounts: Int,
@ -60,20 +62,21 @@ data class LinkHistory(
) )
/** Represents a https://docs.joinmastodon.org/entities/PreviewCard/#trends-link */ /** Represents a https://docs.joinmastodon.org/entities/PreviewCard/#trends-link */
@JsonClass(generateAdapter = true)
data class TrendsLink( data class TrendsLink(
override val url: String, override val url: String,
override val title: String, override val title: String,
override val description: String, override val description: String,
@SerializedName("type") override val kind: PreviewCardKind, @Json(name = "type") override val kind: PreviewCardKind,
@SerializedName("author_name") override val authorName: String, @Json(name = "author_name") override val authorName: String,
@SerializedName("author_url") override val authorUrl: String, @Json(name = "author_url") override val authorUrl: String,
@SerializedName("provider_name") override val providerName: String, @Json(name = "provider_name") override val providerName: String,
@SerializedName("provider_url") override val providerUrl: String, @Json(name = "provider_url") override val providerUrl: String,
override val html: String, override val html: String,
override val width: Int, override val width: Int,
override val height: Int, override val height: Int,
override val image: String? = null, override val image: String? = null,
@SerializedName("embed_url") override val embedUrl: String, @Json(name = "embed_url") override val embedUrl: String,
override val blurhash: String? = null, override val blurhash: String? = null,
val history: List<LinkHistory>, val history: List<LinkHistory>,
) : PreviewCard ) : PreviewCard

View File

@ -26,13 +26,16 @@ import app.pachli.core.network.model.nodeinfo.NodeInfo.Error.NoSoftwareVersion
import com.github.michaelbull.result.Err import com.github.michaelbull.result.Err
import com.github.michaelbull.result.Ok import com.github.michaelbull.result.Ok
import com.github.michaelbull.result.Result import com.github.michaelbull.result.Result
import com.squareup.moshi.JsonClass
/** /**
* The JRD document that links to one or more URLs that contain the schema. * The JRD document that links to one or more URLs that contain the schema.
* *
* See https://nodeinfo.diaspora.software/protocol.html. * See https://nodeinfo.diaspora.software/protocol.html.
*/ */
@JsonClass(generateAdapter = true)
data class UnvalidatedJrd(val links: List<Link>) { data class UnvalidatedJrd(val links: List<Link>) {
@JsonClass(generateAdapter = true)
data class Link(val rel: String, val href: String) data class Link(val rel: String, val href: String)
} }
@ -42,7 +45,9 @@ data class UnvalidatedJrd(val links: List<Link>) {
* See https://nodeinfo.diaspora.software/protocol.html and * See https://nodeinfo.diaspora.software/protocol.html and
* https://nodeinfo.diaspora.software/schema.html. * https://nodeinfo.diaspora.software/schema.html.
*/ */
@JsonClass(generateAdapter = true)
data class UnvalidatedNodeInfo(val software: Software?) { data class UnvalidatedNodeInfo(val software: Software?) {
@JsonClass(generateAdapter = true)
data class Software(val name: String?, val version: String?) data class Software(val name: String?, val version: String?)
} }

View File

@ -202,6 +202,14 @@ interface MastodonApi {
@Body status: NewStatus, @Body status: NewStatus,
): NetworkResult<Status> ): NetworkResult<Status>
@POST("api/v1/statuses")
suspend fun createScheduledStatus(
@Header("Authorization") auth: String,
@Header(DOMAIN_HEADER) domain: String,
@Header("Idempotency-Key") idempotencyKey: String,
@Body status: NewStatus,
): NetworkResult<ScheduledStatus>
@GET("api/v1/statuses/{id}") @GET("api/v1/statuses/{id}")
suspend fun status( suspend fun status(
@Path("id") statusId: String, @Path("id") statusId: String,

View File

@ -44,9 +44,10 @@ import app.pachli.core.network.model.Users
import app.pachli.core.network.model.nodeinfo.NodeInfo import app.pachli.core.network.model.nodeinfo.NodeInfo
import com.github.michaelbull.result.Ok import com.github.michaelbull.result.Ok
import com.github.michaelbull.result.Result import com.github.michaelbull.result.Result
import com.google.common.reflect.TypeToken
import com.google.common.truth.Truth.assertWithMessage import com.google.common.truth.Truth.assertWithMessage
import com.google.gson.Gson import com.squareup.moshi.Moshi
import com.google.gson.reflect.TypeToken import com.squareup.moshi.adapter
import io.github.z4kn4fein.semver.toVersion import io.github.z4kn4fein.semver.toVersion
import java.io.BufferedReader import java.io.BufferedReader
import org.junit.Test import org.junit.Test
@ -274,8 +275,8 @@ class ServerTest(
} }
} }
class ServerVersionTest() { class ServerVersionTest {
private val gson = Gson() private val moshi = Moshi.Builder().build()
private fun loadJsonAsString(fileName: String): String { private fun loadJsonAsString(fileName: String): String {
return javaClass.getResourceAsStream("/$fileName")!! return javaClass.getResourceAsStream("/$fileName")!!
@ -290,15 +291,15 @@ class ServerVersionTest() {
* that have been seen by Fediverse Observer. These version strings * that have been seen by Fediverse Observer. These version strings
* are then parsed and are all expected to parse correctly. * are then parsed and are all expected to parse correctly.
*/ */
@OptIn(ExperimentalStdlibApi::class)
@Test @Test
fun parseVersionString() { fun parseVersionString() {
val mapType: TypeToken<Map<String, Set<String>>> = val mapType: TypeToken<Map<String, Set<String>>> =
object : TypeToken<Map<String, Set<String>>>() {} object : TypeToken<Map<String, Set<String>>>() {}
val serverVersions = gson.fromJson( val serverVersions = moshi.adapter<Map<String, Set<String>>>()
loadJsonAsString("server-versions.json5"), .lenient()
mapType, .fromJson(loadJsonAsString("server-versions.json5"))!!
)
// Sanity check that data was loaded correctly. Expect at least 5 distinct keys // Sanity check that data was loaded correctly. Expect at least 5 distinct keys
assertWithMessage("number of server types in server-versions.json5 is too low") assertWithMessage("number of server types in server-versions.json5 is too low")

View File

@ -1,13 +1,18 @@
package app.pachli.core.network.json package app.pachli.core.network.json
import app.pachli.core.network.json.GuardedAdapter.Companion.GuardedAdapterFactory
import app.pachli.core.network.model.Relationship import app.pachli.core.network.model.Relationship
import com.google.gson.Gson import com.google.common.truth.Truth.assertThat
import org.junit.Assert.assertEquals import com.squareup.moshi.Moshi
import com.squareup.moshi.adapter
import org.junit.Test import org.junit.Test
class GuardedBooleanAdapterTest { @OptIn(ExperimentalStdlibApi::class)
class GuardedAdapterTest {
private val gson = Gson() private val moshi = Moshi.Builder()
.add(GuardedAdapterFactory())
.build()
@Test @Test
fun `should deserialize Relationship when attribute 'subscribing' is a boolean`() { fun `should deserialize Relationship when attribute 'subscribing' is a boolean`() {
@ -30,7 +35,7 @@ class GuardedBooleanAdapterTest {
} }
""".trimIndent() """.trimIndent()
assertEquals( assertThat(moshi.adapter<Relationship>().fromJson(jsonInput)).isEqualTo(
Relationship( Relationship(
id = "1", id = "1",
following = true, following = true,
@ -45,7 +50,6 @@ class GuardedBooleanAdapterTest {
note = "Hi", note = "Hi",
notifying = false, notifying = false,
), ),
gson.fromJson(jsonInput, Relationship::class.java),
) )
} }
@ -70,7 +74,7 @@ class GuardedBooleanAdapterTest {
} }
""".trimIndent() """.trimIndent()
assertEquals( assertThat(moshi.adapter<Relationship>().fromJson(jsonInput)).isEqualTo(
Relationship( Relationship(
id = "2", id = "2",
following = true, following = true,
@ -85,7 +89,6 @@ class GuardedBooleanAdapterTest {
note = "Hi", note = "Hi",
notifying = false, notifying = false,
), ),
gson.fromJson(jsonInput, Relationship::class.java),
) )
} }
@ -109,7 +112,7 @@ class GuardedBooleanAdapterTest {
} }
""".trimIndent() """.trimIndent()
assertEquals( assertThat(moshi.adapter<Relationship>().fromJson(jsonInput)).isEqualTo(
Relationship( Relationship(
id = "3", id = "3",
following = true, following = true,
@ -124,7 +127,6 @@ class GuardedBooleanAdapterTest {
note = "Hi", note = "Hi",
notifying = false, notifying = false,
), ),
gson.fromJson(jsonInput, Relationship::class.java),
) )
} }
} }

View File

@ -39,7 +39,6 @@ filemoji-compat = "3.2.7"
glide = "4.16.0" glide = "4.16.0"
# Deliberate downgrade, https://github.com/tuskyapp/Tusky/issues/3631 # Deliberate downgrade, https://github.com/tuskyapp/Tusky/issues/3631
glide-animation-plugin = "2.23.0" glide-animation-plugin = "2.23.0"
gson = "2.10.1"
hilt = "2.50" hilt = "2.50"
junit = "4.13.2" junit = "4.13.2"
kotlin = "1.9.22" kotlin = "1.9.22"
@ -53,6 +52,7 @@ material-drawer = "9.0.2"
material-typeface = "4.0.0.2-kotlin" material-typeface = "4.0.0.2-kotlin"
mockito-inline = "5.2.0" mockito-inline = "5.2.0"
mockito-kotlin = "5.2.1" mockito-kotlin = "5.2.1"
moshi = "1.15.1"
networkresult-calladapter = "1.0.0" networkresult-calladapter = "1.0.0"
okhttp = "4.12.0" okhttp = "4.12.0"
quadrant = "1.9.1" quadrant = "1.9.1"
@ -152,7 +152,6 @@ glide-animation-plugin = { module = "com.github.penfeizhou.android.animation:gli
glide-compiler = { module = "com.github.bumptech.glide:ksp", version.ref = "glide" } glide-compiler = { module = "com.github.bumptech.glide:ksp", version.ref = "glide" }
glide-core = { module = "com.github.bumptech.glide:glide", version.ref = "glide" } glide-core = { module = "com.github.bumptech.glide:glide", version.ref = "glide" }
glide-okhttp3-integration = { module = "com.github.bumptech.glide:okhttp3-integration", version.ref = "glide" } glide-okhttp3-integration = { module = "com.github.bumptech.glide:okhttp3-integration", version.ref = "glide" }
gson = { module = "com.google.code.gson:gson", version.ref = "gson" }
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" } hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" }
hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hilt" } hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hilt" }
@ -175,10 +174,13 @@ material-typeface = { module = "com.mikepenz:google-material-typeface", version.
mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version.ref = "mockito-kotlin" } mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version.ref = "mockito-kotlin" }
mockito-inline = { module = "org.mockito:mockito-inline", version.ref = "mockito-inline" } mockito-inline = { module = "org.mockito:mockito-inline", version.ref = "mockito-inline" }
mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" } mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" }
moshi = { module = "com.squareup.moshi:moshi", version.ref = "moshi" }
moshi-adapters = { module = "com.squareup.moshi:moshi-adapters", version.ref = "moshi" }
moshi-codegen = { module = "com.squareup.moshi:moshi-kotlin-codegen", version.ref = "moshi" }
networkresult-calladapter = { module = "at.connyduck:networkresult-calladapter", version.ref = "networkresult-calladapter" } networkresult-calladapter = { module = "at.connyduck:networkresult-calladapter", version.ref = "networkresult-calladapter" }
okhttp-core = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } okhttp-core = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
okhttp-logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" } okhttp-logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" }
retrofit-converter-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "retrofit" } retrofit-converter-moshi = { module = "com.squareup.retrofit2:converter-moshi", version.ref = "retrofit" }
retrofit-core = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } retrofit-core = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" }
rxjava3-android = { module = "io.reactivex.rxjava3:rxandroid", version.ref = "rxandroid3" } rxjava3-android = { module = "io.reactivex.rxjava3:rxandroid", version.ref = "rxandroid3" }
@ -217,7 +219,7 @@ lint-tests = ["junit", "lint-cli", "lint-tests"]
material-drawer = ["material-drawer-core", "material-drawer-iconics"] material-drawer = ["material-drawer-core", "material-drawer-iconics"]
mockito = ["mockito-kotlin", "mockito-inline"] mockito = ["mockito-kotlin", "mockito-inline"]
okhttp = ["okhttp-core", "okhttp-logging-interceptor"] okhttp = ["okhttp-core", "okhttp-logging-interceptor"]
retrofit = ["retrofit-core", "retrofit-converter-gson"] retrofit = ["retrofit-core", "retrofit-converter-moshi"]
room = ["androidx-room-ktx", "androidx-room-paging"] room = ["androidx-room-ktx", "androidx-room-paging"]
rxjava3 = ["rxjava3-core", "rxjava3-android", "rxjava3-kotlin"] rxjava3 = ["rxjava3-core", "rxjava3-android", "rxjava3-kotlin"]
xmldiff = ["diffx", "xmlwriter"] xmldiff = ["diffx", "xmlwriter"]

View File

@ -16,6 +16,7 @@
*/ */
plugins { plugins {
id("com.google.devtools.ksp")
id("com.apollographql.apollo3") version "3.8.2" id("com.apollographql.apollo3") version "3.8.2"
} }
@ -31,7 +32,9 @@ dependencies {
implementation("io.github.oshai:kotlin-logging-jvm:5.1.0") implementation("io.github.oshai:kotlin-logging-jvm:5.1.0")
implementation("ch.qos.logback:logback-classic:1.4.11") implementation("ch.qos.logback:logback-classic:1.4.11")
implementation(libs.gson) // Moshi
implementation(libs.moshi)
ksp(libs.moshi.codegen)
// Testing // Testing
testImplementation(kotlin("test")) testImplementation(kotlin("test"))

View File

@ -25,7 +25,8 @@ import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.core.UsageError import com.github.ajalt.clikt.core.UsageError
import com.github.ajalt.clikt.parameters.options.flag import com.github.ajalt.clikt.parameters.options.flag
import com.github.ajalt.clikt.parameters.options.option import com.github.ajalt.clikt.parameters.options.option
import com.google.gson.GsonBuilder import com.squareup.moshi.Moshi
import com.squareup.moshi.adapter
import io.github.oshai.kotlinlogging.DelegatingKLogger import io.github.oshai.kotlinlogging.DelegatingKLogger
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import java.nio.file.Path import java.nio.file.Path
@ -65,6 +66,7 @@ class App : CliktCommand(help = """Update server-versions.json5""") {
return null return null
} }
@OptIn(ExperimentalStdlibApi::class)
override fun run() = runBlocking { override fun run() = runBlocking {
System.setProperty("file.encoding", "UTF8") System.setProperty("file.encoding", "UTF8")
((log as? DelegatingKLogger<*>)?.underlyingLogger as Logger).level = if (verbose) Level.INFO else Level.WARN ((log as? DelegatingKLogger<*>)?.underlyingLogger as Logger).level = if (verbose) Level.INFO else Level.WARN
@ -102,8 +104,8 @@ class App : CliktCommand(help = """Update server-versions.json5""") {
} }
} }
val gson = GsonBuilder().setPrettyPrinting().create() val moshi = Moshi.Builder().build()
w.print(gson.toJson(m)) w.print(moshi.adapter<Map<String, Set<String>>>().indent(" ").toJson(m))
w.close() w.close()
} }
} }