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.gson)
implementation(libs.moshi)
implementation(libs.moshi.adapters)
ksp(libs.moshi.codegen)
implementation(libs.bundles.retrofit)

View File

@ -57,19 +57,6 @@
# error reports
-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
-keep,allowobfuscation,allowshrinking class io.reactivex.rxjava3.core.Single
-keep,allowobfuscation,allowshrinking class retrofit2.Response

View File

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

View File

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

View File

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

View File

@ -19,23 +19,26 @@ package app.pachli.updatecheck
import androidx.annotation.Keep
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.Path
@Keep
@JsonClass(generateAdapter = true)
data class GitHubReleaseAsset(
/** File name for the asset, e.g., "113.apk" */
val name: String,
/** 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
@JsonClass(generateAdapter = true)
data class GitHubRelease(
/** URL for the release's web page */
@SerializedName("html_url") val htmlUrl: String,
@Json(name = "html_url") val htmlUrl: String,
val assets: List<GitHubReleaseAsset>,
)

View File

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

View File

@ -91,7 +91,7 @@ class ReportNotificationViewHolder(
binding.notificationSummary.text = itemView.context.getString(
R.string.notification_summary_report_format,
getRelativeTimeSpanString(itemView.context, report.createdAt.time, Date().time),
report.status_ids?.size ?: 0,
report.statusIds?.size ?: 0,
)
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 {
for (attachment in attachments) {
if (attachment.type == Attachment.Type.UNKNOWN) return false
if (attachment.meta?.original?.width == null && attachment.meta?.small?.width == null) return false
}
return true
}

View File

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

View File

@ -18,7 +18,7 @@ data class StatusDeletedEvent(val statusId: String) : Event
/** A status the user wrote was successfully sent */
// TODO: Rename, calling it "Composed" does not imply anything about the sent state
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 ProfileEditedEvent(val newProfileData: Account) : 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.Notification
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 kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
@ -37,9 +38,10 @@ private val INVALID = LoadResult.Invalid<String, Notification>()
/** [PagingSource] for Mastodon Notifications, identified by the Notification ID */
class NotificationsPagingSource @Inject constructor(
private val mastodonApi: MastodonApi,
private val gson: Gson,
private val moshi: Moshi,
private val notificationFilter: Set<Notification.Type>,
) : PagingSource<String, Notification>() {
@OptIn(ExperimentalStdlibApi::class)
override suspend fun load(params: LoadParams<String>): LoadResult<String, Notification> {
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"
val error = try {
gson.fromJson(errorBody, Error::class.java)
moshi.adapter<Error>().fromJson(errorBody)!!
} catch (e: Exception) {
return@let "$errorBody ($e)"
}
when (val desc = error.error_description) {
when (val desc = error.errorDescription) {
null -> error.error
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.network.model.Notification
import app.pachli.core.network.retrofit.MastodonApi
import com.google.gson.Gson
import com.squareup.moshi.Moshi
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.async
@ -36,7 +36,7 @@ import timber.log.Timber
class NotificationsRepository @Inject constructor(
private val mastodonApi: MastodonApi,
private val gson: Gson,
private val moshi: Moshi,
@ApplicationScope private val externalScope: CoroutineScope,
) {
private var factory: InvalidatingPagingSourceFactory<String, Notification>? = null
@ -53,7 +53,7 @@ class NotificationsRepository @Inject constructor(
Timber.d("getNotificationsStream(), filtering: $filter")
factory = InvalidatingPagingSourceFactory {
NotificationsPagingSource(mastodonApi, gson, filter)
NotificationsPagingSource(mastodonApi, moshi, filter)
}
return Pager(

View File

@ -51,7 +51,7 @@ class ScheduledStatusViewModel @Inject constructor(
pagingSourceFactory.remove(status)
},
{ 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.retrofit.MastodonApi
import at.connyduck.calladapter.networkresult.getOrElse
import timber.log.Timber
class SearchPagingSource<T : Any>(
private val mastodonApi: MastodonApi,
@ -61,7 +62,10 @@ class SearchPagingSource<T : Any>(
limit = params.loadSize,
offset = currentKey,
following = false,
).getOrElse { return LoadResult.Error(it) }
).getOrElse {
Timber.w(it)
return LoadResult.Error(it)
}
val res = parser(data)

View File

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

View File

@ -40,7 +40,7 @@ import app.pachli.util.EmptyPagingSource
import app.pachli.viewdata.StatusViewData
import at.connyduck.calladapter.networkresult.NetworkResult
import at.connyduck.calladapter.networkresult.fold
import com.google.gson.Gson
import com.squareup.moshi.Moshi
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.CoroutineScope
@ -66,7 +66,7 @@ class CachedTimelineRepository @Inject constructor(
val timelineDao: TimelineDao,
private val remoteKeyDao: RemoteKeyDao,
private val translatedStatusDao: TranslatedStatusDao,
private val gson: Gson,
private val moshi: Moshi,
@ApplicationScope private val externalScope: CoroutineScope,
) {
private var factory: InvalidatingPagingSourceFactory<Int, TimelineStatusWithAccount>? = null
@ -118,7 +118,7 @@ class CachedTimelineRepository @Inject constructor(
transactionProvider,
timelineDao,
remoteKeyDao,
gson,
moshi,
),
pagingSourceFactory = factory!!,
).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.Status
import app.pachli.core.network.retrofit.MastodonApi
import com.google.gson.Gson
import com.squareup.moshi.Moshi
import java.io.IOException
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
@ -54,7 +54,7 @@ class CachedTimelineRemoteMediator(
private val transactionProvider: TransactionProvider,
private val timelineDao: TimelineDao,
private val remoteKeyDao: RemoteKeyDao,
private val gson: Gson,
private val moshi: Moshi,
) : RemoteMediator<Int, TimelineStatusWithAccount>() {
private val activeAccount = accountManager.activeAccount!!
@ -254,9 +254,9 @@ class CachedTimelineRemoteMediator(
@Transaction
private suspend fun insertStatuses(statuses: List<Status>) {
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 {
val account = TimelineAccountEntity.from(it, activeAccount.id, gson)
val account = TimelineAccountEntity.from(it, activeAccount.id, moshi)
timelineDao.insertAccount(account)
}
@ -264,7 +264,7 @@ class CachedTimelineRemoteMediator(
TimelineStatusEntity.from(
status,
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.util.StatusDisplayOptionsRepository
import app.pachli.viewdata.StatusViewData
import com.google.gson.Gson
import com.squareup.moshi.Moshi
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -61,7 +61,7 @@ class CachedTimelineViewModel @Inject constructor(
accountManager: AccountManager,
statusDisplayOptionsRepository: StatusDisplayOptionsRepository,
sharedPreferencesRepository: SharedPreferencesRepository,
private val gson: Gson,
private val moshi: Moshi,
) : TimelineViewModel(
savedStateHandle,
timelineCases,
@ -93,7 +93,7 @@ class CachedTimelineViewModel @Inject constructor(
.map {
StatusViewData.from(
it,
gson,
moshi,
isExpanded = activeAccount.alwaysOpenSpoiler,
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.getOrElse
import at.connyduck.calladapter.networkresult.getOrThrow
import com.google.gson.Gson
import com.squareup.moshi.Moshi
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.Job
@ -69,7 +69,7 @@ class ViewThreadViewModel @Inject constructor(
eventHub: EventHub,
accountManager: AccountManager,
private val timelineDao: TimelineDao,
private val gson: Gson,
private val moshi: Moshi,
private val repository: CachedTimelineRepository,
statusDisplayOptionsRepository: StatusDisplayOptionsRepository,
private val filtersRepository: FiltersRepository,
@ -133,7 +133,7 @@ class ViewThreadViewModel @Inject constructor(
var detailedStatus = if (timelineStatusWithAccount != null) {
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
// this the status IDs will be different between the status that's displayed with
@ -152,7 +152,7 @@ class ViewThreadViewModel @Inject constructor(
} else {
StatusViewData.from(
timelineStatusWithAccount,
gson,
moshi,
isExpanded = alwaysOpenSpoiler,
isShowingContent = (alwaysShowSensitiveMedia || !status.actionableStatus.sensitive),
isDetailed = true,

View File

@ -39,6 +39,7 @@ import app.pachli.core.network.retrofit.MastodonApi
import app.pachli.util.unsafeLazy
import at.connyduck.calladapter.networkresult.fold
import dagger.hilt.android.AndroidEntryPoint
import java.io.IOException
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
import javax.inject.Inject
@ -222,12 +223,21 @@ class SendStatusService : Service() {
)
val sendResult = if (isNew) {
if (newStatus.scheduledAt == null) {
mastodonApi.createStatus(
"Bearer " + account.accessToken,
account.domain,
statusToSend.idempotencyKey,
newStatus,
)
} else {
mastodonApi.createScheduledStatus(
"Bearer " + account.accessToken,
account.domain,
statusToSend.idempotencyKey,
newStatus,
)
}
} else {
mastodonApi.editStatus(
statusToSend.statusId!!,
@ -250,16 +260,16 @@ class SendStatusService : Service() {
val scheduled = !statusToSend.scheduledAt.isNullOrEmpty()
if (scheduled) {
eventHub.dispatch(StatusScheduledEvent(sentStatus))
eventHub.dispatch(StatusScheduledEvent)
} else if (!isNew) {
eventHub.dispatch(StatusEditedEvent(statusToSend.statusId!!, sentStatus))
eventHub.dispatch(StatusEditedEvent(statusToSend.statusId!!, sentStatus as Status))
} else {
eventHub.dispatch(StatusComposedEvent(sentStatus))
eventHub.dispatch(StatusComposedEvent(sentStatus as Status))
}
notificationManager.cancel(statusId)
}, { throwable ->
Timber.w("failed sending status", throwable)
Timber.w("failed sending status: $throwable")
failOrRetry(throwable, statusId)
})
stopSelfWhenDone()
@ -267,12 +277,13 @@ class SendStatusService : Service() {
}
private suspend fun failOrRetry(throwable: Throwable, statusId: Int) {
if (throwable is HttpException) {
when (throwable) {
// the server refused to accept, save status & show error message
failSending(statusId)
} else {
is HttpException -> failSending(statusId)
// 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> {
return map { attachment ->
// clamp ratio between 2:1 & 1:2, defaulting to 16:9
val size = (attachment.meta?.small ?: attachment.meta?.original) ?: return@map 1.7778
val aspect = if (size.aspect > 0) size.aspect else size.width.toDouble() / size.height
aspect.coerceIn(0.5, 2.0)
val (width, height, aspect) = (attachment.meta?.small ?: attachment.meta?.original) ?: return@map 1.7778
width ?: return@map 1.778
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.replaceCrashingCharacters
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
@ -273,13 +273,13 @@ data class StatusViewData(
fun from(
timelineStatusWithAccount: TimelineStatusWithAccount,
gson: Gson,
moshi: Moshi,
isExpanded: Boolean,
isShowingContent: Boolean,
isDetailed: Boolean = false,
translationState: TranslationState = TranslationState.SHOW_ORIGINAL,
): StatusViewData {
val status = timelineStatusWithAccount.toStatus(gson)
val status = timelineStatusWithAccount.toStatus(moshi)
return StatusViewData(
status = status,
translation = timelineStatusWithAccount.translatedStatus,

View File

@ -2,9 +2,13 @@ package app.pachli
import androidx.test.ext.junit.runners.AndroidJUnit4
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.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.assertNotEquals
import org.junit.Test
@ -12,6 +16,9 @@ import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class StatusComparisonTest {
private val moshi = Moshi.Builder()
.add(Date::class.java, Rfc3339DateJsonAdapter())
.add(GuardedAdapterFactory()).build()
@Test
fun `two equal statuses - should be equal`() {
@ -36,8 +43,6 @@ class StatusComparisonTest {
assertNotEquals(createStatus(note = "Test"), createStatus(note = "Test 123456"))
}
private val gson = Gson()
@Test
fun `two equal status view data - should be equal`() {
val viewdata1 = StatusViewData(
@ -95,6 +100,7 @@ class StatusComparisonTest {
assertNotEquals(viewdata1, viewdata2)
}
@OptIn(ExperimentalStdlibApi::class)
private fun createStatus(
id: String = "123456",
content: String = """
@ -206,6 +212,6 @@ class StatusComparisonTest {
"poll": null
}
""".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 app.pachli.core.network.model.Notification
import app.pachli.core.network.retrofit.MastodonApi
import com.google.gson.Gson
import com.squareup.moshi.Moshi
import kotlinx.coroutines.test.runTest
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.assertEquals
@ -39,15 +39,15 @@ class NotificationsPagingSourceTest {
@Test
fun `load() returns error message on HTTP error`() = runTest {
// Given
val jsonError = "{error: 'This is an error'}".toResponseBody()
val jsonError = """{"error": "This is an error"}""".toResponseBody()
val mockApi: MastodonApi = mock {
onBlocking { notifications(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } doReturn Response.error(429, jsonError)
onBlocking { notification(any()) } doReturn Response.error(429, jsonError)
}
val filter = emptySet<Notification.Type>()
val gson = Gson()
val pagingSource = NotificationsPagingSource(mockApi, gson, filter)
val moshi = Moshi.Builder().build()
val pagingSource = NotificationsPagingSource(mockApi, moshi, filter)
val loadingParams = PagingSource.LoadParams.Refresh("0", 5, false)
// When
@ -65,15 +65,15 @@ class NotificationsPagingSourceTest {
@Test
fun `load() returns extended error message on HTTP error`() = runTest {
// 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 {
onBlocking { notifications(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } doReturn Response.error(429, jsonError)
onBlocking { notification(any()) } doReturn Response.error(429, jsonError)
}
val filter = emptySet<Notification.Type>()
val gson = Gson()
val pagingSource = NotificationsPagingSource(mockApi, gson, filter)
val moshi = Moshi.Builder().build()
val pagingSource = NotificationsPagingSource(mockApi, moshi, filter)
val loadingParams = PagingSource.LoadParams.Refresh("0", 5, false)
// When
@ -91,15 +91,15 @@ class NotificationsPagingSourceTest {
@Test
fun `load() returns default error message on empty HTTP error`() = runTest {
// Given
val jsonError = "{}".toResponseBody()
val jsonError = "".toResponseBody()
val mockApi: MastodonApi = mock {
onBlocking { notifications(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } doReturn Response.error(429, jsonError)
onBlocking { notification(any()) } doReturn Response.error(429, jsonError)
}
val filter = emptySet<Notification.Type>()
val gson = Gson()
val pagingSource = NotificationsPagingSource(mockApi, gson, filter)
val moshi = Moshi.Builder().build()
val pagingSource = NotificationsPagingSource(mockApi, moshi, filter)
val loadingParams = PagingSource.LoadParams.Refresh("0", 5, false)
// When
@ -124,8 +124,8 @@ class NotificationsPagingSourceTest {
}
val filter = emptySet<Notification.Type>()
val gson = Gson()
val pagingSource = NotificationsPagingSource(mockApi, gson, filter)
val moshi = Moshi.Builder().build()
val pagingSource = NotificationsPagingSource(mockApi, moshi, filter)
val loadingParams = PagingSource.LoadParams.Refresh("0", 5, false)
// When
@ -134,7 +134,7 @@ class NotificationsPagingSourceTest {
// Then
assertTrue(loadResult is PagingSource.LoadResult.Error)
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,
)
}

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

View File

@ -35,7 +35,7 @@ import app.pachli.core.testing.rules.MainCoroutineRule
import app.pachli.usecase.TimelineCases
import app.pachli.util.StatusDisplayOptionsRepository
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.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
@ -92,6 +92,9 @@ abstract class CachedTimelineViewModelTestBase {
@Inject
lateinit var statusDisplayOptionsRepository: StatusDisplayOptionsRepository
@Inject
lateinit var moshi: Moshi
protected lateinit var timelineCases: TimelineCases
protected lateinit var viewModel: TimelineViewModel
@ -160,7 +163,7 @@ abstract class CachedTimelineViewModelTestBase {
accountManager,
statusDisplayOptionsRepository,
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.TimelineStatusWithAccount
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.TimelineAccount
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
private val fixedDate = Date(1638889052000)
@ -96,18 +98,21 @@ fun mockStatusEntityWithAccount(
expanded: Boolean = false,
): TimelineStatusWithAccount {
val mockedStatus = mockStatus(id)
val gson = Gson()
val moshi = Moshi.Builder()
.add(Date::class.java, Rfc3339DateJsonAdapter())
.add(GuardedAdapterFactory())
.build()
return TimelineStatusWithAccount(
status = TimelineStatusEntity.from(
mockedStatus,
timelineUserId = userId,
gson = gson,
moshi = moshi,
),
account = TimelineAccountEntity.from(
mockedStatus.account,
accountId = userId,
gson = gson,
moshi = moshi,
),
viewData = StatusViewDataEntity(
serverId = id,

View File

@ -26,7 +26,7 @@ import app.pachli.core.preferences.SharedPreferencesRepository
import app.pachli.usecase.TimelineCases
import app.pachli.util.StatusDisplayOptionsRepository
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.CustomTestApplication
import dagger.hilt.android.testing.HiltAndroidRule
@ -111,7 +111,7 @@ class ViewThreadViewModelTest {
lateinit var timelineDao: TimelineDao
@Inject
lateinit var gson: Gson
lateinit var moshi: Moshi
@BindValue @JvmField
val filtersRepository: FiltersRepository = mock()
@ -188,7 +188,7 @@ class ViewThreadViewModelTest {
eventHub,
accountManager,
timelineDao,
gson,
moshi,
cachedTimelineRepository,
statusDisplayOptionsRepository,
filtersRepository,

View File

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

View File

@ -35,6 +35,8 @@ dependencies {
implementation(projects.core.network)
implementation(projects.core.preferences)
// Because of the use of @SerializedName in DraftEntity
implementation(libs.gson)
// Because of the use of @Json in DraftEntity
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.TranslatedAttachment
import app.pachli.core.network.model.TranslatedPoll
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import com.squareup.moshi.Moshi
import com.squareup.moshi.adapter
import java.net.URLDecoder
import java.net.URLEncoder
import java.util.Date
import javax.inject.Inject
import javax.inject.Singleton
@OptIn(ExperimentalStdlibApi::class)
@ProvidedTypeConverter
@Singleton
class Converters @Inject constructor(
private val gson: Gson,
private val moshi: Moshi,
) {
@TypeConverter
fun jsonToEmojiList(emojiListJson: String?): List<Emoji>? {
return gson.fromJson(emojiListJson, object : TypeToken<List<Emoji>>() {}.type)
fun jsonToEmojiList(json: String?): List<Emoji>? {
return json?.let { moshi.adapter<List<Emoji>>().fromJson(it) }
}
@TypeConverter
fun emojiListToJson(emojiList: List<Emoji>?): String {
return gson.toJson(emojiList)
return moshi.adapter<List<Emoji>?>().toJson(emojiList)
}
@TypeConverter
@ -81,52 +82,52 @@ class Converters @Inject constructor(
@TypeConverter
fun accountToJson(account: ConversationAccountEntity?): String {
return gson.toJson(account)
return moshi.adapter<ConversationAccountEntity>().toJson(account)
}
@TypeConverter
fun jsonToAccount(accountJson: String?): ConversationAccountEntity? {
return gson.fromJson(accountJson, ConversationAccountEntity::class.java)
return accountJson?.let { moshi.adapter<ConversationAccountEntity>().fromJson(it) }
}
@TypeConverter
fun accountListToJson(accountList: List<ConversationAccountEntity>?): String {
return gson.toJson(accountList)
return moshi.adapter<List<ConversationAccountEntity>>().toJson(accountList)
}
@TypeConverter
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
fun attachmentListToJson(attachmentList: List<Attachment>?): String {
return gson.toJson(attachmentList)
return moshi.adapter<List<Attachment>?>().toJson(attachmentList)
}
@TypeConverter
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
fun mentionListToJson(mentionArray: List<Status.Mention>?): String? {
return gson.toJson(mentionArray)
return moshi.adapter<List<Status.Mention>?>().toJson(mentionArray)
}
@TypeConverter
fun jsonToMentionArray(mentionListJson: String?): List<Status.Mention>? {
return gson.fromJson(mentionListJson, object : TypeToken<List<Status.Mention>>() {}.type)
return mentionListJson?.let { moshi.adapter<List<Status.Mention>?>().fromJson(it) }
}
@TypeConverter
fun tagListToJson(tagArray: List<HashTag>?): String? {
return gson.toJson(tagArray)
return moshi.adapter<List<HashTag>?>().toJson(tagArray)
}
@TypeConverter
fun jsonToTagArray(tagListJson: String?): List<HashTag>? {
return gson.fromJson(tagListJson, object : TypeToken<List<HashTag>>() {}.type)
return tagListJson?.let { moshi.adapter<List<HashTag>?>().fromJson(it) }
}
@TypeConverter
@ -141,61 +142,61 @@ class Converters @Inject constructor(
@TypeConverter
fun pollToJson(poll: Poll?): String? {
return gson.toJson(poll)
return moshi.adapter<Poll?>().toJson(poll)
}
@TypeConverter
fun jsonToPoll(pollJson: String?): Poll? {
return gson.fromJson(pollJson, Poll::class.java)
return pollJson?.let { moshi.adapter<Poll?>().fromJson(it) }
}
@TypeConverter
fun newPollToJson(newPoll: NewPoll?): String? {
return gson.toJson(newPoll)
return moshi.adapter<NewPoll?>().toJson(newPoll)
}
@TypeConverter
fun jsonToNewPoll(newPollJson: String?): NewPoll? {
return gson.fromJson(newPollJson, NewPoll::class.java)
return newPollJson?.let { moshi.adapter<NewPoll?>().fromJson(it) }
}
@TypeConverter
fun draftAttachmentListToJson(draftAttachments: List<DraftAttachment>?): String? {
return gson.toJson(draftAttachments)
return moshi.adapter<List<DraftAttachment>?>().toJson(draftAttachments)
}
@TypeConverter
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
fun filterResultListToJson(filterResults: List<FilterResult>?): String? {
return gson.toJson(filterResults)
return moshi.adapter<List<FilterResult>?>().toJson(filterResults)
}
@TypeConverter
fun jsonToFilterResultList(filterResultListJson: String?): List<FilterResult>? {
return gson.fromJson(filterResultListJson, object : TypeToken<List<FilterResult>>() {}.type)
return filterResultListJson?.let { moshi.adapter<List<FilterResult>>().fromJson(it) }
}
@TypeConverter
fun translatedPolltoJson(translatedPoll: TranslatedPoll?): String? {
return gson.toJson(translatedPoll)
return moshi.adapter<TranslatedPoll?>().toJson(translatedPoll)
}
@TypeConverter
fun jsonToTranslatedPoll(translatedPollJson: String?): TranslatedPoll? {
return gson.fromJson(translatedPollJson, TranslatedPoll::class.java)
return translatedPollJson?.let { moshi.adapter<TranslatedPoll?>().fromJson(it) }
}
@TypeConverter
fun translatedAttachmentToJson(translatedAttachment: List<TranslatedAttachment>?): String {
return gson.toJson(translatedAttachment)
return moshi.adapter<List<TranslatedAttachment>?>().toJson(translatedAttachment)
}
@TypeConverter
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.Status
import app.pachli.core.network.model.TimelineAccount
import com.squareup.moshi.JsonClass
import java.util.Date
@Entity(primaryKeys = ["id", "accountId"])
@ -65,6 +66,7 @@ data class ConversationEntity(
}
}
@JsonClass(generateAdapter = true)
data class ConversationAccountEntity(
val id: 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.NewPoll
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
@Entity
@ -49,17 +50,13 @@ data class DraftEntity(
val statusId: String?,
)
/**
* The alternate names are here because we accidentally published versions were DraftAttachment was minified
* Tusky 15: uriString = e, description = f, type = g
* Tusky 16 beta: uriString = i, description = j, type = k
*/
@Parcelize
@JsonClass(generateAdapter = true)
data class DraftAttachment(
@SerializedName(value = "uriString", alternate = ["e", "i"]) val uriString: String,
@SerializedName(value = "description", alternate = ["f", "j"]) val description: String?,
@SerializedName(value = "focus") val focus: Attachment.Focus?,
@SerializedName(value = "type", alternate = ["g", "k"]) val type: Type,
@Json(name = "uriString") val uriString: String,
@Json(name = "description") val description: String?,
@Json(name = "focus") val focus: Attachment.Focus?,
@Json(name = "type") val type: Type,
) : Parcelable {
val uri: Uri
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.Status
import app.pachli.core.network.model.TimelineAccount
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import java.lang.reflect.Type
import com.squareup.moshi.Moshi
import com.squareup.moshi.adapter
import java.util.Date
/**
@ -100,7 +99,8 @@ data class TimelineStatusEntity(
val filtered: List<FilterResult>?,
) {
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,
url = status.actionableStatus.url,
timelineUserId = timelineUserId,
@ -110,7 +110,7 @@ data class TimelineStatusEntity(
content = status.actionableStatus.content,
createdAt = status.actionableStatus.createdAt.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,
favouritesCount = status.actionableStatus.favouritesCount,
reblogged = status.actionableStatus.reblogged,
@ -119,16 +119,16 @@ data class TimelineStatusEntity(
sensitive = status.actionableStatus.sensitive,
spoilerText = status.actionableStatus.spoilerText,
visibility = status.actionableStatus.visibility,
attachments = status.actionableStatus.attachments.let(gson::toJson),
mentions = status.actionableStatus.mentions.let(gson::toJson),
tags = status.actionableStatus.tags.let(gson::toJson),
application = status.actionableStatus.application.let(gson::toJson),
attachments = moshi.adapter<List<Attachment>>().toJson(status.actionableStatus.attachments),
mentions = moshi.adapter<List<Status.Mention>>().toJson(status.actionableStatus.mentions),
tags = moshi.adapter<List<HashTag>>().toJson(status.actionableStatus.tags),
application = moshi.adapter<Status.Application?>().toJson(status.actionableStatus.application),
reblogServerId = status.reblog?.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,
pinned = status.actionableStatus.pinned == true,
card = status.actionableStatus.card?.let(gson::toJson),
card = moshi.adapter<Card?>().toJson(status.actionableStatus.card),
repliesCount = status.actionableStatus.repliesCount,
language = status.actionableStatus.language,
filtered = status.actionableStatus.filtered,
@ -150,7 +150,8 @@ data class TimelineAccountEntity(
val emojis: String,
val bot: Boolean,
) {
fun toTimelineAccount(gson: Gson): TimelineAccount {
@OptIn(ExperimentalStdlibApi::class)
fun toTimelineAccount(moshi: Moshi): TimelineAccount {
return TimelineAccount(
id = serverId,
localUsername = localUsername,
@ -160,12 +161,13 @@ data class TimelineAccountEntity(
url = url,
avatar = avatar,
bot = bot,
emojis = gson.fromJson(emojis, emojisListType),
emojis = moshi.adapter<List<Emoji>>().fromJson(emojis),
)
}
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,
timelineUserId = accountId,
localUsername = timelineAccount.localUsername,
@ -173,7 +175,7 @@ data class TimelineAccountEntity(
displayName = timelineAccount.name,
url = timelineAccount.url,
avatar = timelineAccount.avatar,
emojis = gson.toJson(timelineAccount.emojis),
emojis = moshi.adapter<List<Emoji>>().toJson(timelineAccount.emojis),
bot = timelineAccount.bot,
)
}
@ -214,11 +216,6 @@ data class StatusViewDataEntity(
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(
@Embedded
val status: TimelineStatusEntity,
@ -232,32 +229,28 @@ data class TimelineStatusWithAccount(
@Embedded(prefix = "t_")
val translatedStatus: TranslatedStatusEntity? = null,
) {
fun toStatus(gson: Gson): Status {
val attachments: ArrayList<Attachment> = gson.fromJson(
status.attachments,
attachmentArrayListType,
) ?: arrayListOf()
val mentions: List<Status.Mention> = gson.fromJson(
status.mentions,
mentionListType,
) ?: emptyList()
val tags: List<HashTag>? = gson.fromJson(
status.tags,
tagListType,
)
val application = gson.fromJson(status.application, Status.Application::class.java)
val emojis: List<Emoji> = gson.fromJson(
status.emojis,
emojisListType,
) ?: emptyList()
val poll: Poll? = gson.fromJson(status.poll, Poll::class.java)
val card: Card? = gson.fromJson(status.card, Card::class.java)
@OptIn(ExperimentalStdlibApi::class)
fun toStatus(moshi: Moshi): Status {
val attachments: List<Attachment> = status.attachments?.let {
moshi.adapter<List<Attachment>?>().fromJson(it)
} ?: emptyList()
val mentions: List<Status.Mention> = status.mentions?.let {
moshi.adapter<List<Status.Mention>?>().fromJson(it)
} ?: emptyList()
val tags: List<HashTag>? = status.tags?.let {
moshi.adapter<List<HashTag>?>().fromJson(it)
}
val application = status.application?.let { moshi.adapter<Status.Application>().fromJson(it) }
val emojis: List<Emoji> = status.emojis?.let { moshi.adapter<List<Emoji>?>().fromJson(it) }
?: emptyList()
val poll: Poll? = status.poll?.let { moshi.adapter<Poll?>().fromJson(it) }
val card: Card? = status.card?.let { moshi.adapter<Card?>().fromJson(it) }
val reblog = status.reblogServerId?.let { id ->
Status(
id = id,
url = status.url,
account = account.toTimelineAccount(gson),
account = account.toTimelineAccount(moshi),
inReplyToId = status.inReplyToId,
inReplyToAccountId = status.inReplyToAccountId,
reblog = null,
@ -291,7 +284,7 @@ data class TimelineStatusWithAccount(
id = status.serverId,
// no url for reblogs
url = null,
account = reblogAccount!!.toTimelineAccount(gson),
account = reblogAccount!!.toTimelineAccount(moshi),
inReplyToId = null,
inReplyToAccountId = null,
reblog = reblog,
@ -308,7 +301,7 @@ data class TimelineStatusWithAccount(
sensitive = false,
spoilerText = "",
visibility = status.visibility,
attachments = ArrayList(),
attachments = listOf(),
mentions = listOf(),
tags = listOf(),
application = null,
@ -324,7 +317,7 @@ data class TimelineStatusWithAccount(
Status(
id = status.serverId,
url = status.url,
account = account.toTimelineAccount(gson),
account = account.toTimelineAccount(moshi),
inReplyToId = status.inReplyToId,
inReplyToAccountId = status.inReplyToAccountId,
reblog = null,

View File

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

View File

@ -148,7 +148,7 @@ class ComposeActivityIntent(context: Context) : Intent() {
/** Editing a status started as an existing draft */
EDIT_DRAFT,
/** Editing an an existing scheduled status */
/** Editing an existing scheduled status */
EDIT_SCHEDULED,
}
@ -210,7 +210,7 @@ class EditFilterActivityIntent(context: Context, filter: Filter? = null) : Inten
/**
* @param context
* @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() {
/** How to log in */
@ -472,7 +472,7 @@ class ViewMediaActivityIntent private constructor(context: Context) : Intent() {
private const val EXTRA_SINGLE_IMAGE_URL = "singleImage"
/** @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 */
fun getAttachmentIndex(intent: Intent) = intent.getIntExtra(EXTRA_ATTACHMENT_INDEX, 0)

View File

@ -33,7 +33,10 @@ dependencies {
implementation(projects.core.common)
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.okhttp)
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.mastodon.model.MediaUploadApi
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.MastodonApi
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.getNonNullString
import at.connyduck.calladapter.networkresult.NetworkResultCallAdapterFactory
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.squareup.moshi.Moshi
import com.squareup.moshi.adapters.Rfc3339DateJsonAdapter
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -50,7 +50,7 @@ import okhttp3.OkHttp
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.create
import timber.log.Timber
@ -60,9 +60,10 @@ object NetworkModule {
@Provides
@Singleton
fun providesGson(): Gson = GsonBuilder()
.registerTypeAdapter(Date::class.java, Rfc3339DateJsonAdapter())
.create()
fun providesMoshi(): Moshi = Moshi.Builder()
.add(Date::class.java, Rfc3339DateJsonAdapter())
.add(GuardedAdapterFactory())
.build()
@Provides
@Singleton
@ -116,11 +117,11 @@ object NetworkModule {
@Singleton
fun providesRetrofit(
httpClient: OkHttpClient,
gson: Gson,
moshi: Moshi,
): Retrofit {
return Retrofit.Builder().baseUrl("https://" + MastodonApi.PLACEHOLDER_DOMAIN)
.client(httpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.addConverterFactory(MoshiConverterFactory.create(moshi))
.addCallAdapterFactory(NetworkResultCallAdapterFactory.create())
.build()
}

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
import com.google.gson.annotations.SerializedName
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class AccessToken(
@SerializedName("access_token") val accessToken: String,
@Json(name = "access_token") val accessToken: String,
)

View File

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

View File

@ -16,17 +16,19 @@
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
@JsonClass(generateAdapter = true)
data class Announcement(
val id: String,
val content: String,
@SerializedName("starts_at") val startsAt: Date?,
@SerializedName("ends_at") val endsAt: Date?,
@SerializedName("all_day") val allDay: Boolean,
@SerializedName("published_at") val publishedAt: Date,
@SerializedName("updated_at") val updatedAt: Date,
@Json(name = "starts_at") val startsAt: Date?,
@Json(name = "ends_at") val endsAt: Date?,
@Json(name = "all_day") val allDay: Boolean,
@Json(name = "published_at") val publishedAt: Date,
@Json(name = "updated_at") val updatedAt: Date,
val read: Boolean,
val mentions: List<Status.Mention>,
val statuses: List<Status>,
@ -47,11 +49,12 @@ data class Announcement(
return id.hashCode()
}
@JsonClass(generateAdapter = true)
data class Reaction(
val name: String,
val count: Int,
val me: Boolean,
val url: String?,
@SerializedName("static_url") val staticUrl: String?,
@Json(name = "static_url") val staticUrl: String?,
)
}

View File

@ -16,9 +16,11 @@
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(
@SerializedName("client_id") val clientId: String,
@SerializedName("client_secret") val clientSecret: String,
@Json(name = "client_id") val clientId: String,
@Json(name = "client_secret") val clientSecret: String,
)

View File

@ -18,61 +18,45 @@
package app.pachli.core.network.model
import android.os.Parcelable
import com.google.gson.JsonDeserializationContext
import com.google.gson.JsonDeserializer
import com.google.gson.JsonElement
import com.google.gson.JsonParseException
import com.google.gson.annotations.JsonAdapter
import com.google.gson.annotations.SerializedName
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import kotlinx.parcelize.Parcelize
@Parcelize
@JsonClass(generateAdapter = true)
data class Attachment(
val id: String,
val url: String,
// 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 type: Type,
val description: String?,
val blurhash: String?,
) : Parcelable {
@JsonAdapter(MediaTypeDeserializer::class)
enum class Type {
@SerializedName("image")
@Json(name = "image")
IMAGE,
@SerializedName("gifv")
@Json(name = "gifv")
GIFV,
@SerializedName("video")
@Json(name = "video")
VIDEO,
@SerializedName("audio")
@Json(name = "audio")
AUDIO,
@SerializedName("unknown")
@Json(name = "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].
*/
@Parcelize
@JsonClass(generateAdapter = true)
data class MetaData(
val focus: Focus?,
val duration: Float?,
@ -87,6 +71,7 @@ data class Attachment(
* https://github.com/jonom/jquery-focuspoint#1-calculate-your-images-focus-point
*/
@Parcelize
@JsonClass(generateAdapter = true)
data class Focus(
val x: Float,
val y: Float,
@ -98,9 +83,21 @@ data class Attachment(
* The size of an image, used to specify the width/height.
*/
@Parcelize
@JsonClass(generateAdapter = true)
data class Size(
val width: Int,
val height: Int,
val aspect: Double,
) : Parcelable
val width: Int?,
val height: Int?,
// Not always present, see https://github.com/mastodon/mastodon/issues/29125
@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
import com.google.gson.annotations.SerializedName
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class Card(
override val url: String,
override val title: String,
override val description: String,
@SerializedName("type") override val kind: PreviewCardKind,
@SerializedName("author_name") override val authorName: String,
@SerializedName("author_url") override val authorUrl: String,
@SerializedName("provider_name") override val providerName: String,
@SerializedName("provider_url") override val providerUrl: String,
@Json(name = "type") override val kind: PreviewCardKind,
@Json(name = "author_name") override val authorName: String,
@Json(name = "author_url") override val authorUrl: String,
@Json(name = "provider_name") override val providerName: String,
@Json(name = "provider_url") override val providerUrl: String,
override val html: String,
override val width: Int,
override val height: Int,
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,
) : PreviewCard {

View File

@ -16,12 +16,14 @@
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(
val id: String,
val accounts: List<TimelineAccount>,
// should never be null, but apparently its possible https://github.com/tuskyapp/Tusky/issues/1038
@SerializedName("last_status") val lastStatus: Status?,
@Json(name = "last_status") val lastStatus: Status?,
val unread: Boolean,
)

View File

@ -16,18 +16,20 @@
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
@JsonClass(generateAdapter = true)
data class DeletedStatus(
val text: String?,
@SerializedName("in_reply_to_id") val inReplyToId: String?,
@SerializedName("spoiler_text") val spoilerText: String,
@Json(name = "in_reply_to_id") val inReplyToId: String?,
@Json(name = "spoiler_text") val spoilerText: String,
val visibility: Status.Visibility,
val sensitive: Boolean,
@SerializedName("media_attachments") val attachments: List<Attachment>?,
@Json(name = "media_attachments") val attachments: List<Attachment>?,
val poll: Poll?,
@SerializedName("created_at") val createdAt: Date,
@Json(name = "created_at") val createdAt: Date,
val language: String?,
) {
fun isEmpty(): Boolean {

View File

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

View File

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

View File

@ -1,18 +1,25 @@
package app.pachli.core.network.model
import android.os.Parcelable
import com.google.gson.annotations.SerializedName
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import java.util.Date
import kotlinx.parcelize.Parcelize
@Parcelize
@JsonClass(generateAdapter = true)
data class Filter(
val id: String,
val title: String,
val context: List<String>,
@SerializedName("expires_at") val expiresAt: Date?,
@SerializedName("filter_action") private val filterAction: String,
val keywords: List<FilterKeyword>,
@Json(name = "expires_at") val expiresAt: Date?,
@Json(name = "filter_action") val filterAction: String,
// 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>,
) : Parcelable {
enum class Action(val action: String) {

View File

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

View File

@ -1,9 +1,11 @@
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(
val filter: Filter,
@SerializedName("keyword_matches") val keywordMatches: List<String>?,
@SerializedName("status_matches") val statusMatches: List<String>?,
@Json(name = "keyword_matches") val keywordMatches: List<String>?,
@Json(name = "status_matches") val statusMatches: List<String>?,
)

View File

@ -16,16 +16,18 @@
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
@JsonClass(generateAdapter = true)
data class FilterV1(
val id: String,
val phrase: String,
val context: List<String>,
@SerializedName("expires_at") val expiresAt: Date?,
@Json(name = "expires_at") val expiresAt: Date?,
val irreversible: Boolean,
@SerializedName("whole_word") val wholeWord: Boolean,
@Json(name = "whole_word") val wholeWord: Boolean,
) {
companion object {
const val HOME = "home"

View File

@ -1,3 +1,6 @@
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)

View File

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

View File

@ -17,9 +17,11 @@
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/ */
@JsonClass(generateAdapter = true)
data class InstanceV2(
/** The domain name of the instance */
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
* license requirements.
*/
@SerializedName("source_url") val sourceUrl: String,
@Json(name = "source_url") val sourceUrl: String,
/** A short, plain-text description defined by the admin. */
val description: String,
@ -61,16 +63,19 @@ data class InstanceV2(
val rules: List<Rule>,
)
@JsonClass(generateAdapter = true)
data class Usage(
/** Usage data related to users on this instance. */
val users: Users,
)
@JsonClass(generateAdapter = true)
data class Users(
/** The number of active users in the past 4 weeks. */
val activeMonth: Int = 0,
)
@JsonClass(generateAdapter = true)
data class Thumbnail(
/** The URL for the thumbnail image. */
val url: String,
@ -85,14 +90,16 @@ data class Thumbnail(
val versions: ThumbnailVersions?,
)
@JsonClass(generateAdapter = true)
data class ThumbnailVersions(
/** 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. */
@SerializedName("@2x") val twoX: String?,
@Json(name = "@2x") val twoX: String?,
)
@JsonClass(generateAdapter = true)
data class Configuration(
/** URLs of interest for clients apps. */
val urls: InstanceV2Urls,
@ -104,7 +111,7 @@ data class Configuration(
val statuses: InstanceV2Statuses,
/** Hints for which attachments will be accepted. */
@SerializedName("media_attachments") val mediaAttachments: MediaAttachments,
@Json(name = "media_attachments") val mediaAttachments: MediaAttachments,
/** Limits related to polls. */
val polls: InstanceV2Polls,
@ -113,77 +120,94 @@ data class Configuration(
val translation: InstanceV2Translation,
)
@JsonClass(generateAdapter = true)
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(
/** 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(
/** 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. */
@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. */
@SerializedName("characters_reserved_per_url") val charactersReservedPerUrl: Int,
@Json(name = "characters_reserved_per_url") val charactersReservedPerUrl: Int,
)
@JsonClass(generateAdapter = true)
data class MediaAttachments(
/** 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. */
@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. */
@SerializedName("image_matrix_limit") val imageMatrixLimit: Int,
@Json(name = "image_matrix_limit") val imageMatrixLimit: Int,
/** 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. */
@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. */
@SerializedName("video_matrix_limit") val videoMatrixLimit: Int,
@Json(name = "video_matrix_limit") val videoMatrixLimit: Int,
)
@JsonClass(generateAdapter = true)
data class InstanceV2Polls(
/** 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. */
@SerializedName("max_characters_per_option") val maxCharactersPerOption: Int,
@Json(name = "max_characters_per_option") val maxCharactersPerOption: Int,
/** 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. */
@SerializedName("max_expiration") val maxExpiration: Int,
@Json(name = "max_expiration") val maxExpiration: Int,
)
@JsonClass(generateAdapter = true)
data class InstanceV2Translation(
/** Whether the Translations API is available on this instance. */
val enabled: Boolean,
)
@JsonClass(generateAdapter = true)
data class Registrations(
/** Whether registrations are enabled. */
val enabled: Boolean,
/** 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. */
val message: String?,
)
@JsonClass(generateAdapter = true)
data class Contact(
/** An email address that can be messaged regarding inquiries or issues. */
val email: String,
@ -193,6 +217,7 @@ data class Contact(
)
/** https://docs.joinmastodon.org/entities/Rule/ */
@JsonClass(generateAdapter = true)
data class Rule(
/** An identifier for the rule. */
val id: String,

View File

@ -1,15 +1,17 @@
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
/**
* API type for saving the scroll position of a timeline.
*/
@JsonClass(generateAdapter = true)
data class Marker(
@SerializedName("last_read_id")
@Json(name = "last_read_id")
val lastReadId: String,
val version: Int,
@SerializedName("updated_at")
@Json(name = "updated_at")
val updatedAt: Date,
)

View File

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

View File

@ -1,9 +1,12 @@
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/
* We are only interested in the id, so other attributes are omitted
*/
@JsonClass(generateAdapter = true)
data class MediaUploadResult(
val id: String,
)

View File

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

View File

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

View File

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

View File

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

View File

@ -17,13 +17,15 @@
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
@JsonClass(generateAdapter = true)
data class Report(
val id: String,
val category: String,
val status_ids: List<String>?,
@SerializedName("created_at") val createdAt: Date,
@SerializedName("target_account") val targetAccount: TimelineAccount,
@Json(name = "status_ids") val statusIds: List<String>?,
@Json(name = "created_at") val createdAt: Date,
@Json(name = "target_account") val targetAccount: TimelineAccount,
)

View File

@ -16,11 +16,13 @@
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(
val id: String,
@SerializedName("scheduled_at") val scheduledAt: String,
@Json(name = "scheduled_at") val scheduledAt: String,
val params: StatusParams,
@SerializedName("media_attachments") val mediaAttachments: ArrayList<Attachment>,
@Json(name = "media_attachments") val mediaAttachments: List<Attachment>,
)

View File

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

View File

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

View File

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

View File

@ -1,15 +1,17 @@
package 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
@JsonClass(generateAdapter = true)
data class StatusEdit(
val content: String,
@SerializedName("spoiler_text") val spoilerText: String,
@Json(name = "spoiler_text") val spoilerText: String,
val sensitive: Boolean,
@SerializedName("created_at") val createdAt: Date,
@Json(name = "created_at") val createdAt: Date,
val account: TimelineAccount,
val poll: Poll?,
@SerializedName("media_attachments") val mediaAttachments: List<Attachment>,
@Json(name = "media_attachments") val mediaAttachments: List<Attachment>,
val emojis: List<Emoji>,
)

View File

@ -17,12 +17,14 @@
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(
val text: String,
val sensitive: Boolean,
val visibility: Status.Visibility,
@SerializedName("spoiler_text") val spoilerText: String,
@SerializedName("in_reply_to_id") val inReplyToId: String?,
@Json(name = "spoiler_text") val spoilerText: String,
@Json(name = "in_reply_to_id") val inReplyToId: String?,
)

View File

@ -17,10 +17,12 @@
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(
val id: 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
import com.google.gson.annotations.SerializedName
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* Same as [Account], but only with the attributes required in timelines.
* Prefer this class over [Account] because it uses way less memory & deserializes faster from json.
*/
@JsonClass(generateAdapter = true)
data class TimelineAccount(
val id: String,
@SerializedName("username") val localUsername: String,
@SerializedName("acct") val username: String,
@Json(name = "username") val localUsername: String,
@Json(name = "acct") val username: String,
// should never be null per Api definition, but some servers break the contract
@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 avatar: String,
val note: String,

View File

@ -17,9 +17,11 @@
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/ */
@JsonClass(generateAdapter = true)
data class Translation(
/** The translated text of the status (HTML), equivalent to [Status.content] */
val content: String,
@ -28,14 +30,14 @@ data class Translation(
* The language of the source text, as auto-detected by the machine translation
* (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
/**
* The translated spoiler text of the status (text), if it exists, equivalent to
* [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
/** 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
* 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 */
val provider: String,
@ -56,17 +58,20 @@ data class Translation(
* 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.
*/
@JsonClass(generateAdapter = true)
data class TranslatedPoll(
val id: String,
val options: List<TranslatedPollOption>,
)
/** A translated poll option. */
@JsonClass(generateAdapter = true)
data class TranslatedPollOption(
val title: String,
)
/** A translated attachment. Only the description is translated */
@JsonClass(generateAdapter = true)
data class TranslatedAttachment(
val id: String,
val description: String,

View File

@ -17,6 +17,7 @@
package app.pachli.core.network.model
import com.squareup.moshi.JsonClass
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 following This is not listed in the APIs at the time of writing, but an instance is delivering it.)
*/
@JsonClass(generateAdapter = true)
data class TrendingTag(
val name: String,
val history: List<TrendingTagHistory>,
@ -39,6 +41,7 @@ data class TrendingTag(
* @param accounts The number of accounts that have posted with this hashtag.
* @param uses The number of posts with this hashtag.
*/
@JsonClass(generateAdapter = true)
data class TrendingTagHistory(
val day: String,
val accounts: String,

View File

@ -17,19 +17,20 @@
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 {
@SerializedName("link")
@Json(name = "link")
LINK,
@SerializedName("photo")
@Json(name = "photo")
PHOTO,
@SerializedName("video")
@Json(name = "video")
VIDEO,
@SerializedName("rich")
@Json(name = "rich")
RICH,
}
@ -53,6 +54,7 @@ interface PreviewCard {
val blurhash: String?
}
@JsonClass(generateAdapter = true)
data class LinkHistory(
val day: String,
val accounts: Int,
@ -60,20 +62,21 @@ data class LinkHistory(
)
/** Represents a https://docs.joinmastodon.org/entities/PreviewCard/#trends-link */
@JsonClass(generateAdapter = true)
data class TrendsLink(
override val url: String,
override val title: String,
override val description: String,
@SerializedName("type") override val kind: PreviewCardKind,
@SerializedName("author_name") override val authorName: String,
@SerializedName("author_url") override val authorUrl: String,
@SerializedName("provider_name") override val providerName: String,
@SerializedName("provider_url") override val providerUrl: String,
@Json(name = "type") override val kind: PreviewCardKind,
@Json(name = "author_name") override val authorName: String,
@Json(name = "author_url") override val authorUrl: String,
@Json(name = "provider_name") override val providerName: String,
@Json(name = "provider_url") override val providerUrl: String,
override val html: String,
override val width: Int,
override val height: Int,
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,
val history: List<LinkHistory>,
) : 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.Ok
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.
*
* See https://nodeinfo.diaspora.software/protocol.html.
*/
@JsonClass(generateAdapter = true)
data class UnvalidatedJrd(val links: List<Link>) {
@JsonClass(generateAdapter = true)
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
* https://nodeinfo.diaspora.software/schema.html.
*/
@JsonClass(generateAdapter = true)
data class UnvalidatedNodeInfo(val software: Software?) {
@JsonClass(generateAdapter = true)
data class Software(val name: String?, val version: String?)
}

View File

@ -202,6 +202,14 @@ interface MastodonApi {
@Body status: NewStatus,
): 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}")
suspend fun status(
@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 com.github.michaelbull.result.Ok
import com.github.michaelbull.result.Result
import com.google.common.reflect.TypeToken
import com.google.common.truth.Truth.assertWithMessage
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import com.squareup.moshi.Moshi
import com.squareup.moshi.adapter
import io.github.z4kn4fein.semver.toVersion
import java.io.BufferedReader
import org.junit.Test
@ -274,8 +275,8 @@ class ServerTest(
}
}
class ServerVersionTest() {
private val gson = Gson()
class ServerVersionTest {
private val moshi = Moshi.Builder().build()
private fun loadJsonAsString(fileName: String): String {
return javaClass.getResourceAsStream("/$fileName")!!
@ -290,15 +291,15 @@ class ServerVersionTest() {
* that have been seen by Fediverse Observer. These version strings
* are then parsed and are all expected to parse correctly.
*/
@OptIn(ExperimentalStdlibApi::class)
@Test
fun parseVersionString() {
val mapType: TypeToken<Map<String, Set<String>>> =
object : TypeToken<Map<String, Set<String>>>() {}
val serverVersions = gson.fromJson(
loadJsonAsString("server-versions.json5"),
mapType,
)
val serverVersions = moshi.adapter<Map<String, Set<String>>>()
.lenient()
.fromJson(loadJsonAsString("server-versions.json5"))!!
// 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")

View File

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

View File

@ -39,7 +39,6 @@ filemoji-compat = "3.2.7"
glide = "4.16.0"
# Deliberate downgrade, https://github.com/tuskyapp/Tusky/issues/3631
glide-animation-plugin = "2.23.0"
gson = "2.10.1"
hilt = "2.50"
junit = "4.13.2"
kotlin = "1.9.22"
@ -53,6 +52,7 @@ material-drawer = "9.0.2"
material-typeface = "4.0.0.2-kotlin"
mockito-inline = "5.2.0"
mockito-kotlin = "5.2.1"
moshi = "1.15.1"
networkresult-calladapter = "1.0.0"
okhttp = "4.12.0"
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-core = { module = "com.github.bumptech.glide:glide", version.ref = "glide" }
glide-okhttp3-integration = { module = "com.github.bumptech.glide:okhttp3-integration", version.ref = "glide" }
gson = { module = "com.google.code.gson:gson", version.ref = "gson" }
hilt-android = { module = "com.google.dagger:hilt-android", 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" }
@ -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-inline = { module = "org.mockito:mockito-inline", version.ref = "mockito-inline" }
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" }
okhttp-core = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
okhttp-logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" }
retrofit-converter-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "retrofit" }
retrofit-converter-moshi = { module = "com.squareup.retrofit2:converter-moshi", version.ref = "retrofit" }
retrofit-core = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" }
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"]
mockito = ["mockito-kotlin", "mockito-inline"]
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"]
rxjava3 = ["rxjava3-core", "rxjava3-android", "rxjava3-kotlin"]
xmldiff = ["diffx", "xmlwriter"]

View File

@ -16,6 +16,7 @@
*/
plugins {
id("com.google.devtools.ksp")
id("com.apollographql.apollo3") version "3.8.2"
}
@ -31,7 +32,9 @@ dependencies {
implementation("io.github.oshai:kotlin-logging-jvm:5.1.0")
implementation("ch.qos.logback:logback-classic:1.4.11")
implementation(libs.gson)
// Moshi
implementation(libs.moshi)
ksp(libs.moshi.codegen)
// Testing
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.parameters.options.flag
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.KotlinLogging
import java.nio.file.Path
@ -65,6 +66,7 @@ class App : CliktCommand(help = """Update server-versions.json5""") {
return null
}
@OptIn(ExperimentalStdlibApi::class)
override fun run() = runBlocking {
System.setProperty("file.encoding", "UTF8")
((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()
w.print(gson.toJson(m))
val moshi = Moshi.Builder().build()
w.print(moshi.adapter<Map<String, Set<String>>>().indent(" ").toJson(m))
w.close()
}
}