From 131ebabe8544c75e9aef350470f6b5cc58585250 Mon Sep 17 00:00:00 2001 From: Levi Bard Date: Wed, 25 Oct 2023 12:53:10 +0200 Subject: [PATCH] Add support for v2/instance (#4062) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit …with fallback to v1 --- .../keylesspalace/tusky/StatusListActivity.kt | 6 +- .../tusky/components/drafts/DraftsActivity.kt | 4 +- .../components/filters/EditFilterActivity.kt | 4 +- .../components/filters/EditFilterViewModel.kt | 6 +- .../components/filters/FiltersViewModel.kt | 9 +- .../instanceinfo/InstanceInfoRepository.kt | 64 ++++++-- .../components/login/LoginWebViewViewModel.kt | 25 ++- .../timeline/viewmodel/TimelineViewModel.kt | 4 +- .../viewthread/ViewThreadViewModel.kt | 4 +- .../keylesspalace/tusky/entity/Instance.kt | 147 +++++++----------- .../keylesspalace/tusky/entity/InstanceV1.kt | 98 ++++++++++++ .../tusky/network/MastodonApi.kt | 6 +- .../tusky/util/ThrowableExtensions.kt | 2 + .../ComposeActivity/ComposeActivityTest.kt | 107 +++++++++++-- 14 files changed, 344 insertions(+), 142 deletions(-) create mode 100644 app/src/main/java/com/keylesspalace/tusky/entity/InstanceV1.kt diff --git a/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt b/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt index c3ca4937e..5907e7df1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt @@ -34,11 +34,11 @@ import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel.K import com.keylesspalace.tusky.databinding.ActivityStatuslistBinding import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.FilterV1 +import com.keylesspalace.tusky.util.isHttpNotFound import com.keylesspalace.tusky.util.viewBinding import dagger.android.DispatchingAndroidInjector import dagger.android.HasAndroidInjector import kotlinx.coroutines.launch -import retrofit2.HttpException import javax.inject.Inject class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { @@ -192,7 +192,7 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { updateTagMuteState(mutedFilter != null) }, { throwable -> - if (throwable is HttpException && throwable.code() == 404) { + if (throwable.isHttpNotFound()) { mastodonApi.getFiltersV1().fold( { filters -> mutedFilterV1 = filters.firstOrNull { filter -> @@ -251,7 +251,7 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { } }, { throwable -> - if (throwable is HttpException && throwable.code() == 404) { + if (throwable.isHttpNotFound()) { mastodonApi.createFilterV1( hashedTag, listOf(FilterV1.HOME), diff --git a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsActivity.kt index 6d9a2aa16..14dc21003 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsActivity.kt @@ -35,11 +35,11 @@ import com.keylesspalace.tusky.databinding.ActivityDraftsBinding import com.keylesspalace.tusky.db.DraftEntity import com.keylesspalace.tusky.db.DraftsAlert import com.keylesspalace.tusky.di.ViewModelFactory +import com.keylesspalace.tusky.util.isHttpNotFound import com.keylesspalace.tusky.util.parseAsMastodonHtml import com.keylesspalace.tusky.util.visible import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch -import retrofit2.HttpException import javax.inject.Inject class DraftsActivity : BaseActivity(), DraftActionListener { @@ -131,7 +131,7 @@ class DraftsActivity : BaseActivity(), DraftActionListener { Log.w(TAG, "failed loading reply information", throwable) - if (throwable is HttpException && throwable.code() == 404) { + if (throwable.isHttpNotFound()) { // the original status to which a reply was drafted has been deleted // let's open the ComposeActivity without reply information Toast.makeText(context, getString(R.string.drafts_post_reply_removed), Toast.LENGTH_LONG).show() diff --git a/app/src/main/java/com/keylesspalace/tusky/components/filters/EditFilterActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/filters/EditFilterActivity.kt index 3de2ca5b9..6737acd3b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/filters/EditFilterActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/filters/EditFilterActivity.kt @@ -26,10 +26,10 @@ import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.FilterKeyword import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.isHttpNotFound import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.visible import kotlinx.coroutines.launch -import retrofit2.HttpException import java.util.Date import javax.inject.Inject @@ -282,7 +282,7 @@ class EditFilterActivity : BaseActivity() { finish() }, { throwable -> - if (throwable is HttpException && throwable.code() == 404) { + if (throwable.isHttpNotFound()) { api.deleteFilterV1(filter.id).fold( { finish() diff --git a/app/src/main/java/com/keylesspalace/tusky/components/filters/EditFilterViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/filters/EditFilterViewModel.kt index d33031d65..d055c3177 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/filters/EditFilterViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/filters/EditFilterViewModel.kt @@ -8,9 +8,9 @@ import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.FilterKeyword import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.isHttpNotFound import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.withContext -import retrofit2.HttpException import javax.inject.Inject class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub: EventHub) : ViewModel() { @@ -108,7 +108,7 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub }, { throwable -> return ( - throwable is HttpException && throwable.code() == 404 && + throwable.isHttpNotFound() && // Endpoint not found, fall back to v1 api createFilterV1(contexts, expiresInSeconds) ) @@ -141,7 +141,7 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub return results.none { it.isFailure } }, { throwable -> - if (throwable is HttpException && throwable.code() == 404) { + if (throwable.isHttpNotFound()) { // Endpoint not found, fall back to v1 api if (updateFilterV1(contexts, expiresInSeconds)) { return true diff --git a/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersViewModel.kt index e28d251b8..5baade6b5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersViewModel.kt @@ -9,10 +9,10 @@ import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.PreferenceChangedEvent import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.isHttpNotFound import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch -import retrofit2.HttpException import javax.inject.Inject class FiltersViewModel @Inject constructor( @@ -38,14 +38,13 @@ class FiltersViewModel @Inject constructor( this@FiltersViewModel._state.value = State(filters, LoadingState.LOADED) }, { throwable -> - if (throwable is HttpException && throwable.code() == 404) { + if (throwable.isHttpNotFound()) { api.getFiltersV1().fold( { filters -> this@FiltersViewModel._state.value = State(filters.map { it.toFilter() }, LoadingState.LOADED) }, - { throwable -> + { _ -> // TODO log errors (also below) - this@FiltersViewModel._state.value = _state.value.copy(loadingState = LoadingState.ERROR_OTHER) } ) @@ -68,7 +67,7 @@ class FiltersViewModel @Inject constructor( } }, { throwable -> - if (throwable is HttpException && throwable.code() == 404) { + if (throwable.isHttpNotFound()) { api.deleteFilterV1(filter.id).fold( { this@FiltersViewModel._state.value = State(this@FiltersViewModel._state.value.filters.filter { it.id != filter.id }, LoadingState.LOADED) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfoRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfoRepository.kt index 1045fe480..4031181ed 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfoRepository.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfoRepository.kt @@ -25,6 +25,7 @@ import com.keylesspalace.tusky.db.EmojisEntity import com.keylesspalace.tusky.db.InstanceInfoEntity import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.isHttpNotFound import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import javax.inject.Inject @@ -63,27 +64,31 @@ class InstanceInfoRepository @Inject constructor( { instance -> val instanceEntity = InstanceInfoEntity( instance = instanceName, - maximumTootCharacters = instance.configuration?.statuses?.maxCharacters ?: instance.maxTootChars, - maxPollOptions = instance.configuration?.polls?.maxOptions ?: instance.pollConfiguration?.maxOptions, - maxPollOptionLength = instance.configuration?.polls?.maxCharactersPerOption ?: instance.pollConfiguration?.maxOptionChars, - minPollDuration = instance.configuration?.polls?.minExpiration ?: instance.pollConfiguration?.minExpiration, - maxPollDuration = instance.configuration?.polls?.maxExpiration ?: instance.pollConfiguration?.maxExpiration, - charactersReservedPerUrl = instance.configuration?.statuses?.charactersReservedPerUrl, + maximumTootCharacters = instance.configuration.statuses.maxCharacters, + maxPollOptions = instance.configuration.polls.maxOptions, + maxPollOptionLength = instance.configuration.polls.maxCharactersPerOption, + minPollDuration = instance.configuration.polls.minExpirationSeconds, + maxPollDuration = instance.configuration.polls.maxExpirationSeconds, + charactersReservedPerUrl = instance.configuration.statuses.charactersReservedPerUrl, version = instance.version, - videoSizeLimit = instance.configuration?.mediaAttachments?.videoSizeLimit ?: instance.uploadLimit, - imageSizeLimit = instance.configuration?.mediaAttachments?.imageSizeLimit ?: instance.uploadLimit, - imageMatrixLimit = instance.configuration?.mediaAttachments?.imageMatrixLimit, - maxMediaAttachments = instance.configuration?.statuses?.maxMediaAttachments ?: instance.maxMediaAttachments, + videoSizeLimit = instance.configuration.mediaAttachments.videoSizeLimitBytes.toInt(), + imageSizeLimit = instance.configuration.mediaAttachments.imageSizeLimitBytes.toInt(), + imageMatrixLimit = instance.configuration.mediaAttachments.imagePixelCountLimit.toInt(), + maxMediaAttachments = instance.configuration.statuses.maxMediaAttachments, maxFields = instance.pleroma?.metadata?.fieldLimits?.maxFields, maxFieldNameLength = instance.pleroma?.metadata?.fieldLimits?.nameLength, - maxFieldValueLength = instance.pleroma?.metadata?.fieldLimits?.valueLength + maxFieldValueLength = instance.pleroma?.metadata?.fieldLimits?.valueLength, ) dao.upsert(instanceEntity) instanceEntity }, { throwable -> - Log.w(TAG, "failed to instance, falling back to cache and default values", throwable) - dao.getInstanceInfo(instanceName) + if (throwable.isHttpNotFound()) { + getInstanceInfoV1() + } else { + Log.w(TAG, "failed to instance, falling back to cache and default values", throwable) + dao.getInstanceInfo(instanceName) + } } ).let { instanceInfo: InstanceInfoEntity? -> InstanceInfo( @@ -100,11 +105,42 @@ class InstanceInfoRepository @Inject constructor( maxFields = instanceInfo?.maxFields ?: DEFAULT_MAX_ACCOUNT_FIELDS, maxFieldNameLength = instanceInfo?.maxFieldNameLength, maxFieldValueLength = instanceInfo?.maxFieldValueLength, - version = instanceInfo?.version + version = instanceInfo?.version, ) } } + private suspend fun getInstanceInfoV1(): InstanceInfoEntity? = withContext(Dispatchers.IO) { + api.getInstanceV1() + .fold( + { instance -> + val instanceEntity = InstanceInfoEntity( + instance = instanceName, + maximumTootCharacters = instance.configuration?.statuses?.maxCharacters ?: instance.maxTootChars, + maxPollOptions = instance.configuration?.polls?.maxOptions ?: instance.pollConfiguration?.maxOptions, + maxPollOptionLength = instance.configuration?.polls?.maxCharactersPerOption ?: instance.pollConfiguration?.maxOptionChars, + minPollDuration = instance.configuration?.polls?.minExpiration ?: instance.pollConfiguration?.minExpiration, + maxPollDuration = instance.configuration?.polls?.maxExpiration ?: instance.pollConfiguration?.maxExpiration, + charactersReservedPerUrl = instance.configuration?.statuses?.charactersReservedPerUrl, + version = instance.version, + videoSizeLimit = instance.configuration?.mediaAttachments?.videoSizeLimit ?: instance.uploadLimit, + imageSizeLimit = instance.configuration?.mediaAttachments?.imageSizeLimit ?: instance.uploadLimit, + imageMatrixLimit = instance.configuration?.mediaAttachments?.imageMatrixLimit, + maxMediaAttachments = instance.configuration?.statuses?.maxMediaAttachments ?: instance.maxMediaAttachments, + maxFields = instance.pleroma?.metadata?.fieldLimits?.maxFields, + maxFieldNameLength = instance.pleroma?.metadata?.fieldLimits?.nameLength, + maxFieldValueLength = instance.pleroma?.metadata?.fieldLimits?.valueLength, + ) + dao.upsert(instanceEntity) + instanceEntity + }, + { throwable -> + Log.w(TAG, "failed to instance, falling back to cache and default values", throwable) + dao.getInstanceInfo(instanceName) + } + ) + } + companion object { private const val TAG = "InstanceInfoRepo" diff --git a/app/src/main/java/com/keylesspalace/tusky/components/login/LoginWebViewViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/login/LoginWebViewViewModel.kt index 39dd311aa..cf3c6a6bd 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/login/LoginWebViewViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/login/LoginWebViewViewModel.kt @@ -20,6 +20,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import at.connyduck.calladapter.networkresult.fold import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.isHttpNotFound import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import javax.inject.Inject @@ -36,11 +37,25 @@ class LoginWebViewViewModel @Inject constructor( if (this.domain == null) { this.domain = domain viewModelScope.launch { - api.getInstance(domain).fold({ instance -> - instanceRules.value = instance.rules?.map { rule -> rule.text }.orEmpty() - }, { throwable -> - Log.w("LoginWebViewViewModel", "failed to load instance info", throwable) - }) + api.getInstance().fold( + { instance -> + instanceRules.value = instance.rules.map { rule -> rule.text } + }, + { throwable -> + if (throwable.isHttpNotFound()) { + api.getInstanceV1(domain).fold( + { instance -> + instanceRules.value = instance.rules?.map { rule -> rule.text }.orEmpty() + }, + { throwable -> + Log.w("LoginWebViewViewModel", "failed to load instance info", throwable) + } + ) + } else { + Log.w("LoginWebViewViewModel", "failed to load instance info", throwable) + } + } + ) } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt index 154660473..88c5f1df7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt @@ -45,11 +45,11 @@ import com.keylesspalace.tusky.network.FilterModel import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.usecase.TimelineCases +import com.keylesspalace.tusky.util.isHttpNotFound import com.keylesspalace.tusky.viewdata.StatusViewData import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch -import retrofit2.HttpException abstract class TimelineViewModel( private val timelineCases: TimelineCases, @@ -281,7 +281,7 @@ abstract class TimelineViewModel( invalidate() }, { throwable -> - if (throwable is HttpException && throwable.code() == 404) { + if (throwable.isHttpNotFound()) { // Fallback to client-side filter code val filters = api.getFiltersV1().getOrElse { Log.e(TAG, "Failed to fetch filters", it) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModel.kt index 9ff14d54b..f33bf0792 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModel.kt @@ -37,6 +37,7 @@ import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.FilterModel import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.usecase.TimelineCases +import com.keylesspalace.tusky.util.isHttpNotFound import com.keylesspalace.tusky.util.toViewData import com.keylesspalace.tusky.viewdata.StatusViewData import kotlinx.coroutines.Job @@ -47,7 +48,6 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import retrofit2.HttpException import javax.inject.Inject class ViewThreadViewModel @Inject constructor( @@ -391,7 +391,7 @@ class ViewThreadViewModel @Inject constructor( updateStatuses() }, { throwable -> - if (throwable is HttpException && throwable.code() == 404) { + if (throwable.isHttpNotFound()) { val filters = api.getFiltersV1().getOrElse { Log.w(TAG, "Failed to fetch filters", it) return@launch diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Instance.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Instance.kt index 77864cfeb..bf5aa2804 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Instance.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Instance.kt @@ -1,98 +1,71 @@ -/* Copyright 2018 Levi Bard - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . */ - package com.keylesspalace.tusky.entity import com.google.gson.annotations.SerializedName data class Instance( - val uri: String, - // val title: String, - // val description: String, - // val email: String, + val domain: String, +// val title: String, val version: String, - // val urls: Map, - // val stats: Map?, - // val thumbnail: String?, - // val languages: List, - // @SerializedName("contact_account") val contactAccount: Account, - @SerializedName("max_toot_chars") val maxTootChars: Int?, - @SerializedName("poll_limits") val pollConfiguration: PollConfiguration?, - val configuration: InstanceConfiguration?, - @SerializedName("max_media_attachments") val maxMediaAttachments: Int?, +// @SerializedName("source_url") val sourceUrl: String, +// val description: String, +// val usage: Usage, +// val thumbnail: Thumbnail, +// val languages: List, + val configuration: Configuration, +// val registrations: Registrations, +// val contact: Contact, + val rules: List, val pleroma: PleromaConfiguration?, - @SerializedName("upload_limit") val uploadLimit: Int?, - val rules: List? ) { - override fun hashCode(): Int { - return uri.hashCode() + data class Usage(val users: Users) { + data class Users(@SerializedName("active_month") val activeMonth: Int) } - - override fun equals(other: Any?): Boolean { - if (other !is Instance) { - return false - } - val instance = other as Instance? - return instance?.uri.equals(uri) + data class Thumbnail( + val url: String, + val blurhash: String?, + val versions: Versions?, + ) { + data class Versions( + @SerializedName("@1x") val at1x: String?, + @SerializedName("@2x") val at2x: String?, + ) } + data class Configuration( + val urls: Urls, + val accounts: Accounts, + val statuses: Statuses, + @SerializedName("media_attachments") val mediaAttachments: MediaAttachments, + val polls: Polls, + val translation: Translation, + ) { + data class Urls(@SerializedName("streaming_api") val streamingApi: String) + data class Accounts(@SerializedName("max_featured_tags") val maxFeaturedTags: Int) + data class Statuses( + @SerializedName("max_characters") val maxCharacters: Int, + @SerializedName("max_media_attachments") val maxMediaAttachments: Int, + @SerializedName("characters_reserved_per_url") val charactersReservedPerUrl: Int, + ) + data class MediaAttachments( + @SerializedName("supported_mime_types") val supportedMimeTypes: List, + @SerializedName("image_size_limit") val imageSizeLimitBytes: Long, + @SerializedName("image_matrix_limit") val imagePixelCountLimit: Long, + @SerializedName("video_size_limit") val videoSizeLimitBytes: Long, + @SerializedName("video_matrix_limit") val videoPixelCountLimit: Long, + @SerializedName("video_frame_rate_limit") val videoFrameRateLimit: Int, + ) + data class Polls( + @SerializedName("max_options") val maxOptions: Int, + @SerializedName("max_characters_per_option") val maxCharactersPerOption: Int, + @SerializedName("min_expiration") val minExpirationSeconds: Int, + @SerializedName("max_expiration") val maxExpirationSeconds: Int, + ) + data class Translation(val enabled: Boolean) + } + data class Registrations( + val enabled: Boolean, + @SerializedName("approval_required") val approvalRequired: Boolean, + val message: String?, + ) + data class Contact(val email: String, val account: Account) + data class Rule(val id: String, val text: String) } - -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? -) - -data class InstanceConfiguration( - val statuses: StatusConfiguration?, - @SerializedName("media_attachments") val mediaAttachments: MediaAttachmentConfiguration?, - val polls: PollConfiguration? -) - -data class StatusConfiguration( - @SerializedName("max_characters") val maxCharacters: Int?, - @SerializedName("max_media_attachments") val maxMediaAttachments: Int?, - @SerializedName("characters_reserved_per_url") val charactersReservedPerUrl: Int? -) - -data class MediaAttachmentConfiguration( - @SerializedName("supported_mime_types") val supportedMimeTypes: List?, - @SerializedName("image_size_limit") val imageSizeLimit: Int?, - @SerializedName("image_matrix_limit") val imageMatrixLimit: Int?, - @SerializedName("video_size_limit") val videoSizeLimit: Int?, - @SerializedName("video_frame_rate_limit") val videoFrameRateLimit: Int?, - @SerializedName("video_matrix_limit") val videoMatrixLimit: Int? -) - -data class PleromaConfiguration( - val metadata: PleromaMetadata? -) - -data class PleromaMetadata( - @SerializedName("fields_limits") val fieldLimits: PleromaFieldLimits -) - -data class PleromaFieldLimits( - @SerializedName("max_fields") val maxFields: Int?, - @SerializedName("name_length") val nameLength: Int?, - @SerializedName("value_length") val valueLength: Int? -) - -data class InstanceRules( - val id: String, - val text: String -) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/InstanceV1.kt b/app/src/main/java/com/keylesspalace/tusky/entity/InstanceV1.kt new file mode 100644 index 000000000..d79e247b8 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/entity/InstanceV1.kt @@ -0,0 +1,98 @@ +/* Copyright 2018 Levi Bard + * + * This file is a part of Tusky. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.entity + +import com.google.gson.annotations.SerializedName + +data class InstanceV1( + val uri: String, + // val title: String, + // val description: String, + // val email: String, + val version: String, + // val urls: Map, + // val stats: Map?, + // val thumbnail: String?, + // val languages: List, + // @SerializedName("contact_account") val contactAccount: Account, + @SerializedName("max_toot_chars") val maxTootChars: Int?, + @SerializedName("poll_limits") val pollConfiguration: PollConfiguration?, + val configuration: InstanceConfiguration?, + @SerializedName("max_media_attachments") val maxMediaAttachments: Int?, + val pleroma: PleromaConfiguration?, + @SerializedName("upload_limit") val uploadLimit: Int?, + val rules: List? +) { + override fun hashCode(): Int { + return uri.hashCode() + } + + override fun equals(other: Any?): Boolean { + if (other !is InstanceV1) { + return false + } + val instance = other as InstanceV1? + return instance?.uri.equals(uri) + } +} + +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? +) + +data class InstanceConfiguration( + val statuses: StatusConfiguration?, + @SerializedName("media_attachments") val mediaAttachments: MediaAttachmentConfiguration?, + val polls: PollConfiguration? +) + +data class StatusConfiguration( + @SerializedName("max_characters") val maxCharacters: Int?, + @SerializedName("max_media_attachments") val maxMediaAttachments: Int?, + @SerializedName("characters_reserved_per_url") val charactersReservedPerUrl: Int? +) + +data class MediaAttachmentConfiguration( + @SerializedName("supported_mime_types") val supportedMimeTypes: List?, + @SerializedName("image_size_limit") val imageSizeLimit: Int?, + @SerializedName("image_matrix_limit") val imageMatrixLimit: Int?, + @SerializedName("video_size_limit") val videoSizeLimit: Int?, + @SerializedName("video_frame_rate_limit") val videoFrameRateLimit: Int?, + @SerializedName("video_matrix_limit") val videoMatrixLimit: Int? +) + +data class PleromaConfiguration( + val metadata: PleromaMetadata? +) + +data class PleromaMetadata( + @SerializedName("fields_limits") val fieldLimits: PleromaFieldLimits +) + +data class PleromaFieldLimits( + @SerializedName("max_fields") val maxFields: Int?, + @SerializedName("name_length") val nameLength: Int?, + @SerializedName("value_length") val valueLength: Int? +) + +data class InstanceRules( + val id: String, + val text: String +) diff --git a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt index 46646a4cc..86cb88e3d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt @@ -29,6 +29,7 @@ import com.keylesspalace.tusky.entity.FilterKeyword import com.keylesspalace.tusky.entity.FilterV1 import com.keylesspalace.tusky.entity.HashTag import com.keylesspalace.tusky.entity.Instance +import com.keylesspalace.tusky.entity.InstanceV1 import com.keylesspalace.tusky.entity.Marker import com.keylesspalace.tusky.entity.MastoList import com.keylesspalace.tusky.entity.MediaUploadResult @@ -84,7 +85,10 @@ interface MastodonApi { suspend fun getCustomEmojis(): NetworkResult> @GET("api/v1/instance") - suspend fun getInstance(@Header(DOMAIN_HEADER) domain: String? = null): NetworkResult + suspend fun getInstanceV1(@Header(DOMAIN_HEADER) domain: String? = null): NetworkResult + + @GET("api/v2/instance") + suspend fun getInstance(): NetworkResult @GET("api/v1/filters") suspend fun getFiltersV1(): NetworkResult> diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ThrowableExtensions.kt b/app/src/main/java/com/keylesspalace/tusky/util/ThrowableExtensions.kt index 60633f725..c837fc3ea 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ThrowableExtensions.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ThrowableExtensions.kt @@ -40,3 +40,5 @@ fun Throwable.getErrorString(context: Context): String = getServerErrorMessage() is IOException -> context.getString(R.string.error_network) else -> context.getString(R.string.error_generic) } + +fun Throwable.isHttpNotFound(): Boolean = (this as? HttpException)?.code() == 404 diff --git a/app/src/test/java/com/keylesspalace/tusky/components/compose/ComposeActivity/ComposeActivityTest.kt b/app/src/test/java/com/keylesspalace/tusky/components/compose/ComposeActivity/ComposeActivityTest.kt index f16355b57..ecff6d3be 100644 --- a/app/src/test/java/com/keylesspalace/tusky/components/compose/ComposeActivity/ComposeActivityTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/components/compose/ComposeActivity/ComposeActivityTest.kt @@ -35,8 +35,11 @@ import com.keylesspalace.tusky.db.InstanceInfoEntity import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.entity.Instance import com.keylesspalace.tusky.entity.InstanceConfiguration +import com.keylesspalace.tusky.entity.InstanceV1 import com.keylesspalace.tusky.entity.StatusConfiguration import com.keylesspalace.tusky.network.MastodonApi +import okhttp3.ResponseBody +import okhttp3.ResponseBody.Companion.toResponseBody import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -51,6 +54,8 @@ import org.robolectric.Robolectric import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config import org.robolectric.fakes.RoboMenuItem +import retrofit2.HttpException +import retrofit2.Response import java.util.Locale /** @@ -87,6 +92,7 @@ class ComposeActivityTest { notificationVibration = true, notificationLight = true ) + private var instanceV1ResponseCallback: (() -> InstanceV1)? = null private var instanceResponseCallback: (() -> Instance)? = null private var composeOptions: ComposeActivity.ComposeOptions? = null @@ -102,6 +108,13 @@ class ComposeActivityTest { apiMock = mock { onBlocking { getCustomEmojis() } doReturn NetworkResult.success(emptyList()) onBlocking { getInstance() } doReturn instanceResponseCallback?.invoke().let { instance -> + if (instance == null) { + NetworkResult.failure(HttpException(Response.error(404, "Not found".toResponseBody()))) + } else { + NetworkResult.success(instance) + } + } + onBlocking { getInstanceV1() } doReturn instanceV1ResponseCallback?.invoke().let { instance -> if (instance == null) { NetworkResult.failure(Throwable()) } else { @@ -192,22 +205,13 @@ class ComposeActivityTest { @Test fun whenMaximumTootCharsIsNull_defaultLimitIsUsed() { - instanceResponseCallback = { getInstanceWithCustomConfiguration(null) } + instanceV1ResponseCallback = { getInstanceV1WithCustomConfiguration(null) } setupActivity() assertEquals(InstanceInfoRepository.DEFAULT_CHARACTER_LIMIT, activity.maximumTootCharacters) } @Test fun whenMaximumTootCharsIsPopulated_customLimitIsUsed() { - val customMaximum = 1000 - instanceResponseCallback = { getInstanceWithCustomConfiguration(customMaximum, getCustomInstanceConfiguration(maximumStatusCharacters = customMaximum)) } - setupActivity() - shadowOf(getMainLooper()).idle() - assertEquals(customMaximum, activity.maximumTootCharacters) - } - - @Test - fun whenOnlyLegacyMaximumTootCharsIsPopulated_customLimitIsUsed() { val customMaximum = 1000 instanceResponseCallback = { getInstanceWithCustomConfiguration(customMaximum) } setupActivity() @@ -215,10 +219,19 @@ class ComposeActivityTest { assertEquals(customMaximum, activity.maximumTootCharacters) } + @Test + fun whenOnlyLegacyMaximumTootCharsIsPopulated_customLimitIsUsed() { + val customMaximum = 1000 + instanceV1ResponseCallback = { getInstanceV1WithCustomConfiguration(customMaximum) } + setupActivity() + shadowOf(getMainLooper()).idle() + assertEquals(customMaximum, activity.maximumTootCharacters) + } + @Test fun whenOnlyConfigurationMaximumTootCharsIsPopulated_customLimitIsUsed() { val customMaximum = 1000 - instanceResponseCallback = { getInstanceWithCustomConfiguration(null, getCustomInstanceConfiguration(maximumStatusCharacters = customMaximum)) } + instanceV1ResponseCallback = { getInstanceV1WithCustomConfiguration(null, getCustomInstanceConfiguration(maximumStatusCharacters = customMaximum)) } setupActivity() shadowOf(getMainLooper()).idle() assertEquals(customMaximum, activity.maximumTootCharacters) @@ -227,7 +240,7 @@ class ComposeActivityTest { @Test fun whenDifferentCharLimitsArePopulated_statusConfigurationLimitIsUsed() { val customMaximum = 1000 - instanceResponseCallback = { getInstanceWithCustomConfiguration(customMaximum, getCustomInstanceConfiguration(maximumStatusCharacters = customMaximum * 2)) } + instanceV1ResponseCallback = { getInstanceV1WithCustomConfiguration(customMaximum, getCustomInstanceConfiguration(maximumStatusCharacters = customMaximum * 2)) } setupActivity() shadowOf(getMainLooper()).idle() assertEquals(customMaximum * 2, activity.maximumTootCharacters) @@ -270,7 +283,19 @@ class ComposeActivityTest { val url = "https://www.google.dk/search?biw=1920&bih=990&tbm=isch&sa=1&ei=bmDrWuOoKMv6kwWOkIaoDQ&q=indiana+jones+i+hate+snakes+animated&oq=indiana+jones+i+hate+snakes+animated&gs_l=psy-ab.3...54174.55443.0.55553.9.7.0.0.0.0.255.333.1j0j1.2.0....0...1c.1.64.psy-ab..7.0.0....0.40G-kcDkC6A#imgdii=PSp15hQjN1JqvM:&imgrc=H0hyE2JW5wrpBM:" val additionalContent = "Check out this @image #search result: " val customUrlLength = 16 - instanceResponseCallback = { getInstanceWithCustomConfiguration(configuration = getCustomInstanceConfiguration(charactersReservedPerUrl = customUrlLength)) } + instanceResponseCallback = { getInstanceWithCustomConfiguration(null, customUrlLength) } + setupActivity() + shadowOf(getMainLooper()).idle() + insertSomeTextInContent(additionalContent + url) + assertEquals(activity.calculateTextLength(), additionalContent.length + customUrlLength) + } + + @Test + fun whenTextContainsUrl_onlyEllipsizedURLIsCounted_withCustomConfigurationV1() { + val url = "https://www.google.dk/search?biw=1920&bih=990&tbm=isch&sa=1&ei=bmDrWuOoKMv6kwWOkIaoDQ&q=indiana+jones+i+hate+snakes+animated&oq=indiana+jones+i+hate+snakes+animated&gs_l=psy-ab.3...54174.55443.0.55553.9.7.0.0.0.0.255.333.1j0j1.2.0....0...1c.1.64.psy-ab..7.0.0....0.40G-kcDkC6A#imgdii=PSp15hQjN1JqvM:&imgrc=H0hyE2JW5wrpBM:" + val additionalContent = "Check out this @image #search result: " + val customUrlLength = 16 + instanceV1ResponseCallback = { getInstanceV1WithCustomConfiguration(configuration = getCustomInstanceConfiguration(charactersReservedPerUrl = customUrlLength)) } setupActivity() shadowOf(getMainLooper()).idle() insertSomeTextInContent(additionalContent + url) @@ -283,7 +308,20 @@ class ComposeActivityTest { val url = "https://www.google.dk/search?biw=1920&bih=990&tbm=isch&sa=1&ei=bmDrWuOoKMv6kwWOkIaoDQ&q=indiana+jones+i+hate+snakes+animated&oq=indiana+jones+i+hate+snakes+animated&gs_l=psy-ab.3...54174.55443.0.55553.9.7.0.0.0.0.255.333.1j0j1.2.0....0...1c.1.64.psy-ab..7.0.0....0.40G-kcDkC6A#imgdii=PSp15hQjN1JqvM:&imgrc=H0hyE2JW5wrpBM:" val additionalContent = " Check out this @image #search result: " val customUrlLength = 18 // The intention is that this is longer than shortUrl.length - instanceResponseCallback = { getInstanceWithCustomConfiguration(configuration = getCustomInstanceConfiguration(charactersReservedPerUrl = customUrlLength)) } + instanceResponseCallback = { getInstanceWithCustomConfiguration(null, customUrlLength) } + setupActivity() + shadowOf(getMainLooper()).idle() + insertSomeTextInContent(shortUrl + additionalContent + url) + assertEquals(activity.calculateTextLength(), additionalContent.length + (customUrlLength * 2)) + } + + @Test + fun whenTextContainsShortUrls_allUrlsGetEllipsized_withCustomConfigurationV1() { + val shortUrl = "https://tusky.app" + val url = "https://www.google.dk/search?biw=1920&bih=990&tbm=isch&sa=1&ei=bmDrWuOoKMv6kwWOkIaoDQ&q=indiana+jones+i+hate+snakes+animated&oq=indiana+jones+i+hate+snakes+animated&gs_l=psy-ab.3...54174.55443.0.55553.9.7.0.0.0.0.255.333.1j0j1.2.0....0...1c.1.64.psy-ab..7.0.0....0.40G-kcDkC6A#imgdii=PSp15hQjN1JqvM:&imgrc=H0hyE2JW5wrpBM:" + val additionalContent = " Check out this @image #search result: " + val customUrlLength = 18 // The intention is that this is longer than shortUrl.length + instanceV1ResponseCallback = { getInstanceV1WithCustomConfiguration(configuration = getCustomInstanceConfiguration(charactersReservedPerUrl = customUrlLength)) } setupActivity() shadowOf(getMainLooper()).idle() insertSomeTextInContent(shortUrl + additionalContent + url) @@ -295,7 +333,19 @@ class ComposeActivityTest { val url = "https://www.google.dk/search?biw=1920&bih=990&tbm=isch&sa=1&ei=bmDrWuOoKMv6kwWOkIaoDQ&q=indiana+jones+i+hate+snakes+animated&oq=indiana+jones+i+hate+snakes+animated&gs_l=psy-ab.3...54174.55443.0.55553.9.7.0.0.0.0.255.333.1j0j1.2.0....0...1c.1.64.psy-ab..7.0.0....0.40G-kcDkC6A#imgdii=PSp15hQjN1JqvM:&imgrc=H0hyE2JW5wrpBM:" val additionalContent = " Check out this @image #search result: " val customUrlLength = 16 - instanceResponseCallback = { getInstanceWithCustomConfiguration(configuration = getCustomInstanceConfiguration(charactersReservedPerUrl = customUrlLength)) } + instanceResponseCallback = { getInstanceWithCustomConfiguration(null, customUrlLength) } + setupActivity() + shadowOf(getMainLooper()).idle() + insertSomeTextInContent(url + additionalContent + url) + assertEquals(activity.calculateTextLength(), additionalContent.length + (customUrlLength * 2)) + } + + @Test + fun whenTextContainsMultipleURLs_allURLsGetEllipsized_withCustomConfigurationV1() { + val url = "https://www.google.dk/search?biw=1920&bih=990&tbm=isch&sa=1&ei=bmDrWuOoKMv6kwWOkIaoDQ&q=indiana+jones+i+hate+snakes+animated&oq=indiana+jones+i+hate+snakes+animated&gs_l=psy-ab.3...54174.55443.0.55553.9.7.0.0.0.0.255.333.1j0j1.2.0....0...1c.1.64.psy-ab..7.0.0....0.40G-kcDkC6A#imgdii=PSp15hQjN1JqvM:&imgrc=H0hyE2JW5wrpBM:" + val additionalContent = " Check out this @image #search result: " + val customUrlLength = 16 + instanceV1ResponseCallback = { getInstanceV1WithCustomConfiguration(configuration = getCustomInstanceConfiguration(charactersReservedPerUrl = customUrlLength)) } setupActivity() shadowOf(getMainLooper()).idle() insertSomeTextInContent(url + additionalContent + url) @@ -491,8 +541,33 @@ class ComposeActivityTest { activity.findViewById(R.id.composeEditField).setText(text ?: "Some text") } - private fun getInstanceWithCustomConfiguration(maximumLegacyTootCharacters: Int? = null, configuration: InstanceConfiguration? = null): Instance { + private fun getInstanceWithCustomConfiguration(maximumStatusCharacters: Int? = null, charactersReservedPerUrl: Int? = null): Instance { return Instance( + domain = "https://example.token", + version = "2.6.3", + configuration = getConfiguration(maximumStatusCharacters, charactersReservedPerUrl), + pleroma = null, + rules = emptyList() + ) + } + + private fun getConfiguration(maximumStatusCharacters: Int?, charactersReservedPerUrl: Int?): Instance.Configuration { + return Instance.Configuration( + Instance.Configuration.Urls(streamingApi = ""), + Instance.Configuration.Accounts(1), + Instance.Configuration.Statuses( + maximumStatusCharacters ?: InstanceInfoRepository.DEFAULT_CHARACTER_LIMIT, + InstanceInfoRepository.DEFAULT_MAX_MEDIA_ATTACHMENTS, + charactersReservedPerUrl ?: InstanceInfoRepository.DEFAULT_CHARACTERS_RESERVED_PER_URL + ), + Instance.Configuration.MediaAttachments(emptyList(), 0, 0, 0, 0, 0), + Instance.Configuration.Polls(0, 0, 0, 0), + Instance.Configuration.Translation(false), + ) + } + + private fun getInstanceV1WithCustomConfiguration(maximumLegacyTootCharacters: Int? = null, configuration: InstanceConfiguration? = null): InstanceV1 { + return InstanceV1( uri = "https://example.token", version = "2.6.3", maxTootChars = maximumLegacyTootCharacters,