diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a6e5b269b..64513c16d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -184,7 +184,6 @@ dependencies { googleImplementation(libs.app.update) googleImplementation(libs.app.update.ktx) - implementation(libs.kotlin.result) implementation(libs.semver) debugImplementation(libs.leakcanary) diff --git a/app/lint-baseline.xml b/app/lint-baseline.xml index 46b6ef2b0..f242c57c2 100644 --- a/app/lint-baseline.xml +++ b/app/lint-baseline.xml @@ -1,5 +1,5 @@ - + @@ -729,7 +729,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -740,7 +740,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -751,7 +751,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -916,7 +916,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> @@ -1763,7 +1763,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1774,7 +1774,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1785,7 +1785,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1796,7 +1796,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -1807,7 +1807,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1818,7 +1818,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -1829,7 +1829,7 @@ errorLine2=" ~~~~~~~~~~~~"> @@ -1840,7 +1840,7 @@ errorLine2=" ~~~~~~~~~~~~~~"> @@ -1851,7 +1851,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1862,7 +1862,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1873,7 +1873,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> @@ -1884,7 +1884,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1895,7 +1895,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> @@ -1906,7 +1906,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1917,7 +1917,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1928,7 +1928,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~"> @@ -1939,7 +1939,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1950,7 +1950,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1961,7 +1961,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1972,7 +1972,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -1983,7 +1983,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1994,7 +1994,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -2005,7 +2005,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -2016,7 +2016,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -2159,7 +2159,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~"> @@ -2170,7 +2170,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~"> @@ -2335,7 +2335,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~"> @@ -2346,7 +2346,7 @@ errorLine2=" ~~~~~~~~~"> @@ -2357,7 +2357,7 @@ errorLine2=" ~~~~~~~~~"> @@ -2731,7 +2731,7 @@ errorLine2=" ~~~~~~~~~~~~~~"> @@ -2742,7 +2742,7 @@ errorLine2=" ~~~~~~"> @@ -3534,7 +3534,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~"> diff --git a/app/src/main/java/app/pachli/components/preference/AccountPreferencesFragment.kt b/app/src/main/java/app/pachli/components/preference/AccountPreferencesFragment.kt index bb50c082e..f184ea693 100644 --- a/app/src/main/java/app/pachli/components/preference/AccountPreferencesFragment.kt +++ b/app/src/main/java/app/pachli/components/preference/AccountPreferencesFragment.kt @@ -36,10 +36,13 @@ import app.pachli.core.navigation.LoginActivityIntent.LoginMode import app.pachli.core.navigation.PreferencesActivityIntent import app.pachli.core.navigation.PreferencesActivityIntent.PreferenceScreen import app.pachli.core.navigation.TabPreferenceActivityIntent +import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_FILTERS_CLIENT +import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_FILTERS_SERVER import app.pachli.core.network.model.Account import app.pachli.core.network.model.Status import app.pachli.core.network.retrofit.MastodonApi import app.pachli.core.preferences.PrefKeys +import app.pachli.network.ServerRepository import app.pachli.settings.AccountPreferenceDataStore import app.pachli.settings.listPreference import app.pachli.settings.makePreferenceScreen @@ -51,10 +54,12 @@ import app.pachli.util.getLocaleList import app.pachli.util.getPachliDisplayName import app.pachli.util.makeIcon import app.pachli.util.unsafeLazy +import com.github.michaelbull.result.getOrElse import com.google.android.material.snackbar.Snackbar import com.mikepenz.iconics.IconicsDrawable import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import dagger.hilt.android.AndroidEntryPoint +import io.github.z4kn4fein.semver.constraints.toConstraint import javax.inject.Inject import retrofit2.Call import retrofit2.Callback @@ -69,6 +74,9 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() { @Inject lateinit var mastodonApi: MastodonApi + @Inject + lateinit var serverRepository: ServerRepository + @Inject lateinit var eventHub: EventHub @@ -178,6 +186,15 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() { launchFilterActivity() true } + val server = serverRepository.flow.value.getOrElse { null } + isEnabled = server?.let { + it.can( + ORG_JOINMASTODON_FILTERS_CLIENT, ">1.0.0".toConstraint(), + ) || it.can( + ORG_JOINMASTODON_FILTERS_SERVER, ">1.0.0".toConstraint(), + ) + } ?: false + if (!isEnabled) summary = context.getString(R.string.pref_summary_timeline_filters) } preferenceCategory(R.string.pref_publishing) { diff --git a/app/src/main/java/app/pachli/components/timeline/FiltersRepository.kt b/app/src/main/java/app/pachli/components/timeline/FiltersRepository.kt index fe2484358..6716826a4 100644 --- a/app/src/main/java/app/pachli/components/timeline/FiltersRepository.kt +++ b/app/src/main/java/app/pachli/components/timeline/FiltersRepository.kt @@ -17,11 +17,16 @@ package app.pachli.components.timeline +import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_FILTERS_CLIENT +import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_FILTERS_SERVER import app.pachli.core.network.model.Filter import app.pachli.core.network.model.FilterV1 import app.pachli.core.network.retrofit.MastodonApi +import app.pachli.network.ServerRepository import at.connyduck.calladapter.networkresult.fold import at.connyduck.calladapter.networkresult.getOrThrow +import com.github.michaelbull.result.getOrElse +import io.github.z4kn4fein.semver.constraints.toConstraint import javax.inject.Inject import javax.inject.Singleton import retrofit2.HttpException @@ -38,6 +43,7 @@ sealed interface FilterKind { @Singleton class FiltersRepository @Inject constructor( private val mastodonApi: MastodonApi, + private val serverRepository: ServerRepository, ) { /** * Get the current set of filters. @@ -47,15 +53,27 @@ class FiltersRepository @Inject constructor( * * @throws HttpException if the requests fail */ - suspend fun getFilters(): FilterKind = mastodonApi.getFilters().fold( - { filters -> FilterKind.V2(filters) }, - { throwable -> - if (throwable is HttpException && throwable.code() == 404) { - val filters = mastodonApi.getFiltersV1().getOrThrow() - FilterKind.V1(filters) - } else { - throw throwable - } - }, - ) + suspend fun getFilters(): FilterKind { + // If fetching capabilities failed then assume no filtering + val server = serverRepository.flow.value.getOrElse { null } ?: return FilterKind.V2(emptyList()) + + // If the server doesn't support filtering then return an empty list of filters + if (!server.can(ORG_JOINMASTODON_FILTERS_CLIENT, ">=1.0.0".toConstraint()) && + !server.can(ORG_JOINMASTODON_FILTERS_SERVER, ">=1.0.0".toConstraint()) + ) { + return FilterKind.V2(emptyList()) + } + + return mastodonApi.getFilters().fold( + { filters -> FilterKind.V2(filters) }, + { throwable -> + if (throwable is HttpException && throwable.code() == 404) { + val filters = mastodonApi.getFiltersV1().getOrThrow() + FilterKind.V1(filters) + } else { + throw throwable + } + }, + ) + } } diff --git a/app/src/main/java/app/pachli/fragment/SFragment.kt b/app/src/main/java/app/pachli/fragment/SFragment.kt index 7d1648461..2c4a585d9 100644 --- a/app/src/main/java/app/pachli/fragment/SFragment.kt +++ b/app/src/main/java/app/pachli/fragment/SFragment.kt @@ -51,19 +51,21 @@ import app.pachli.core.navigation.ComposeActivityIntent.ComposeOptions import app.pachli.core.navigation.ReportActivityIntent import app.pachli.core.navigation.StatusListActivityIntent import app.pachli.core.navigation.ViewMediaActivityIntent -import app.pachli.core.network.ServerOperation +import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_STATUSES_TRANSLATE import app.pachli.core.network.model.Attachment import app.pachli.core.network.model.Status import app.pachli.core.network.parseAsMastodonHtml import app.pachli.core.network.retrofit.MastodonApi import app.pachli.interfaces.AccountSelectionListener -import app.pachli.network.ServerCapabilitiesRepository +import app.pachli.network.ServerRepository import app.pachli.usecase.TimelineCases import app.pachli.util.openLink import app.pachli.view.showMuteAccountDialog import app.pachli.viewdata.StatusViewData import at.connyduck.calladapter.networkresult.fold import at.connyduck.calladapter.networkresult.onFailure +import com.github.michaelbull.result.onFailure +import com.github.michaelbull.result.onSuccess import com.google.android.material.snackbar.Snackbar import io.github.z4kn4fein.semver.constraints.toConstraint import javax.inject.Inject @@ -91,7 +93,7 @@ abstract class SFragment : Fragment() { lateinit var timelineCases: TimelineCases @Inject - lateinit var serverCapabilitiesRepository: ServerCapabilitiesRepository + lateinit var serverRepository: ServerRepository private var serverCanTranslate = false @@ -115,11 +117,24 @@ abstract class SFragment : Fragment() { viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { - serverCapabilitiesRepository.flow.collect { - serverCanTranslate = it.can( - ServerOperation.ORG_JOINMASTODON_STATUSES_TRANSLATE, - ">=1.0".toConstraint(), - ) + serverRepository.flow.collect { result -> + result.onSuccess { + serverCanTranslate = it?.can( + operation = ORG_JOINMASTODON_STATUSES_TRANSLATE, + constraint = ">=1.0".toConstraint(), + ) ?: false + } + result.onFailure { + val msg = getString( + R.string.server_repository_error, + it.msg(requireContext()), + ) + Timber.e(msg) + Snackbar.make(requireView(), msg, Snackbar.LENGTH_INDEFINITE) + .setTextMaxLines(5) + .show() + serverCanTranslate = false + } } } } diff --git a/app/src/main/java/app/pachli/network/ServerCapabilitiesRepository.kt b/app/src/main/java/app/pachli/network/ServerCapabilitiesRepository.kt deleted file mode 100644 index 89fb21338..000000000 --- a/app/src/main/java/app/pachli/network/ServerCapabilitiesRepository.kt +++ /dev/null @@ -1,65 +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 . - */ - -package app.pachli.network - -import app.pachli.core.accounts.AccountManager -import app.pachli.core.common.di.ApplicationScope -import app.pachli.core.network.ServerCapabilities -import app.pachli.core.network.retrofit.MastodonApi -import at.connyduck.calladapter.networkresult.fold -import com.github.michaelbull.result.getOr -import javax.inject.Inject -import javax.inject.Singleton -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch - -@Singleton -class ServerCapabilitiesRepository @Inject constructor( - private val mastodonApi: MastodonApi, - private val accountManager: AccountManager, - @ApplicationScope private val externalScope: CoroutineScope, -) { - private val _flow = MutableStateFlow(ServerCapabilities()) - val flow = _flow.asStateFlow() - - init { - externalScope.launch { - accountManager.activeAccountFlow.collect { - _flow.emit(getCapabilities()) - } - } - } - - /** - * Returns the capabilities of the current server. If the capabilties cannot be - * determined then a default set of capabilities that all servers are expected - * to support is returned. - */ - private suspend fun getCapabilities(): ServerCapabilities { - return mastodonApi.getInstanceV2().fold( - { instance -> ServerCapabilities.from(instance).getOr { null } }, - { - mastodonApi.getInstanceV1().fold({ instance -> - ServerCapabilities.from(instance).getOr { null } - }, { null }) - }, - ) ?: ServerCapabilities() - } -} diff --git a/app/src/main/java/app/pachli/network/ServerRepository.kt b/app/src/main/java/app/pachli/network/ServerRepository.kt new file mode 100644 index 000000000..a26148338 --- /dev/null +++ b/app/src/main/java/app/pachli/network/ServerRepository.kt @@ -0,0 +1,152 @@ +/* + * 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 . + */ + +package app.pachli.network + +import androidx.annotation.StringRes +import app.pachli.R +import app.pachli.core.accounts.AccountManager +import app.pachli.core.common.PachliError +import app.pachli.core.common.di.ApplicationScope +import app.pachli.core.network.Server +import app.pachli.core.network.model.nodeinfo.NodeInfo +import app.pachli.core.network.retrofit.MastodonApi +import app.pachli.core.network.retrofit.NodeInfoApi +import app.pachli.network.ServerRepository.Error.Capabilities +import app.pachli.network.ServerRepository.Error.GetInstanceInfo +import app.pachli.network.ServerRepository.Error.GetNodeInfo +import app.pachli.network.ServerRepository.Error.GetWellKnownNodeInfo +import app.pachli.network.ServerRepository.Error.UnsupportedSchema +import app.pachli.network.ServerRepository.Error.ValidateNodeInfo +import at.connyduck.calladapter.networkresult.fold +import com.github.michaelbull.result.Err +import com.github.michaelbull.result.Ok +import com.github.michaelbull.result.Result +import com.github.michaelbull.result.coroutines.binding.binding +import com.github.michaelbull.result.mapError +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import timber.log.Timber + +/** + * NodeInfo schema versions we can parse. + * + * See https://nodeinfo.diaspora.software/schema.html. + */ +private val SCHEMAS = listOf( + "http://nodeinfo.diaspora.software/ns/schema/2.1", + "http://nodeinfo.diaspora.software/ns/schema/2.0", + "http://nodeinfo.diaspora.software/ns/schema/1.1", + "http://nodeinfo.diaspora.software/ns/schema/1.0", +) + +@Singleton +class ServerRepository @Inject constructor( + private val mastodonApi: MastodonApi, + private val nodeInfoApi: NodeInfoApi, + private val accountManager: AccountManager, + @ApplicationScope private val externalScope: CoroutineScope, +) { + private val _flow = MutableStateFlow>(Ok(null)) + val flow = _flow.asStateFlow() + + init { + externalScope.launch { + accountManager.activeAccountFlow.collect { _flow.emit(getServer()) } + } + } + + /** + * @return the current server or a [Server.Error] subclass error if the + * server can not be determined. + */ + private suspend fun getServer(): Result = binding { + // Fetch the /.well-known/nodeinfo document + val nodeInfoJrd = nodeInfoApi.nodeInfoJrd().fold( + { Ok(it) }, + { Err(GetWellKnownNodeInfo(it)) }, + ).bind() + + // Find a link to a schema we can parse, prefering newer schema versions + var nodeInfoUrlResult: Result = Err(UnsupportedSchema) + for (link in nodeInfoJrd.links.sortedByDescending { it.rel }) { + if (SCHEMAS.contains(link.rel)) { + nodeInfoUrlResult = Ok(link.href) + break + } + } + + val nodeInfoUrl = nodeInfoUrlResult.bind() + + Timber.d("Loading node info from $nodeInfoUrl") + val nodeInfo = nodeInfoApi.nodeInfo(nodeInfoUrl).fold( + { NodeInfo.from(it).mapError { ValidateNodeInfo(nodeInfoUrl, it) } }, + { Err(GetNodeInfo(nodeInfoUrl, it)) }, + ).bind() + + mastodonApi.getInstanceV2().fold( + { Server.from(nodeInfo.software, it).mapError(::Capabilities) }, + { + mastodonApi.getInstanceV1().fold( + { Server.from(nodeInfo.software, it).mapError(::Capabilities) }, + { Err(GetInstanceInfo(it)) }, + ) + }, + ).bind() + } + + sealed class Error( + @StringRes resourceId: Int, + vararg formatArgs: String, + source: PachliError? = null, + ) : PachliError(resourceId, *formatArgs, source = source) { + data class GetWellKnownNodeInfo(val throwable: Throwable) : Error( + R.string.server_repository_error_get_well_known_node_info, + throwable.localizedMessage, + ) + + data object UnsupportedSchema : Error( + R.string.server_repository_error_unsupported_schema, + ) + + data class GetNodeInfo(val url: String, val throwable: Throwable) : Error( + R.string.server_repository_error_get_node_info, + url, + throwable.localizedMessage, + ) + + data class ValidateNodeInfo(val url: String, val error: NodeInfo.Error) : Error( + R.string.server_repository_error_validate_node_info, + url, + source = error, + ) + + data class GetInstanceInfo(val throwable: Throwable) : Error( + R.string.server_repository_error_get_instance_info, + throwable.localizedMessage, + ) + + data class Capabilities(val error: Server.Error) : Error( + R.string.server_repository_error_capabilities, + source = error, + ) + } +} diff --git a/app/src/main/java/app/pachli/util/StatusDisplayOptionsRepository.kt b/app/src/main/java/app/pachli/util/StatusDisplayOptionsRepository.kt index 1baa192ad..190fc92c3 100644 --- a/app/src/main/java/app/pachli/util/StatusDisplayOptionsRepository.kt +++ b/app/src/main/java/app/pachli/util/StatusDisplayOptionsRepository.kt @@ -22,11 +22,13 @@ import androidx.annotation.VisibleForTesting.Companion.PRIVATE import app.pachli.core.accounts.AccountManager import app.pachli.core.common.di.ApplicationScope import app.pachli.core.database.model.AccountEntity -import app.pachli.core.network.ServerOperation +import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_STATUSES_TRANSLATE import app.pachli.core.preferences.PrefKeys import app.pachli.core.preferences.SharedPreferencesRepository -import app.pachli.network.ServerCapabilitiesRepository +import app.pachli.network.ServerRepository import app.pachli.settings.AccountPreferenceDataStore +import com.github.michaelbull.result.onFailure +import com.github.michaelbull.result.onSuccess import io.github.z4kn4fein.semver.constraints.toConstraint import javax.inject.Inject import javax.inject.Singleton @@ -47,7 +49,7 @@ import timber.log.Timber @Singleton class StatusDisplayOptionsRepository @Inject constructor( private val sharedPreferencesRepository: SharedPreferencesRepository, - private val serverCapabilitiesRepository: ServerCapabilitiesRepository, + private val serverRepository: ServerRepository, private val accountManager: AccountManager, private val accountPreferenceDataStore: AccountPreferenceDataStore, @ApplicationScope private val externalScope: CoroutineScope, @@ -157,13 +159,16 @@ class StatusDisplayOptionsRepository @Inject constructor( } externalScope.launch { - serverCapabilitiesRepository.flow.collect { serverCapabilities -> + serverRepository.flow.collect { result -> Timber.d("Updating because server capabilities changed") - _flow.update { - it.copy( - canTranslate = serverCapabilities.can(ServerOperation.ORG_JOINMASTODON_STATUSES_TRANSLATE, ">=1.0".toConstraint()), - ) + result.onSuccess { server -> + _flow.update { + it.copy( + canTranslate = server?.can(ORG_JOINMASTODON_STATUSES_TRANSLATE, ">=1.0".toConstraint()) ?: false, + ) + } } + result.onFailure { _flow.update { it.copy(canTranslate = false) } } } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cfb3ac77b..3df9580bb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -285,6 +285,7 @@ App theme Timelines Filters + Your server does not support filters Dark Light Black @@ -764,4 +765,12 @@ Your description here:\n\n ----\n + + Could not fetch server info: %1$s + fetching /.well-known/nodeinfo failed: %1$s + /.well-known/nodeinfo did not contain understandable schemas + fetching nodeinfo %1$s failed: %2$s + validating nodeinfo %1$s failed: %2$s + fetching /api/v1/instance failed: %1$s + parsing server capabilities failed: %1$s diff --git a/app/src/test/java/app/pachli/components/notifications/NotificationsViewModelTestBase.kt b/app/src/test/java/app/pachli/components/notifications/NotificationsViewModelTestBase.kt index 32ffccc32..d438ab999 100644 --- a/app/src/test/java/app/pachli/components/notifications/NotificationsViewModelTestBase.kt +++ b/app/src/test/java/app/pachli/components/notifications/NotificationsViewModelTestBase.kt @@ -23,14 +23,18 @@ import app.pachli.components.timeline.FilterKind import app.pachli.components.timeline.FiltersRepository import app.pachli.core.accounts.AccountManager import app.pachli.core.database.model.AccountEntity +import app.pachli.core.network.model.nodeinfo.UnvalidatedJrd +import app.pachli.core.network.model.nodeinfo.UnvalidatedNodeInfo import app.pachli.core.network.retrofit.MastodonApi +import app.pachli.core.network.retrofit.NodeInfoApi import app.pachli.core.preferences.SharedPreferencesRepository import app.pachli.core.testing.fakes.InMemorySharedPreferences import app.pachli.core.testing.rules.MainCoroutineRule -import app.pachli.network.ServerCapabilitiesRepository +import app.pachli.network.ServerRepository import app.pachli.settings.AccountPreferenceDataStore import app.pachli.usecase.TimelineCases import app.pachli.util.StatusDisplayOptionsRepository +import at.connyduck.calladapter.networkresult.NetworkResult import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.TestScope import okhttp3.ResponseBody @@ -38,6 +42,7 @@ import okhttp3.ResponseBody.Companion.toResponseBody import org.junit.Before import org.junit.Rule import org.junit.runner.RunWith +import org.mockito.kotlin.any import org.mockito.kotlin.doAnswer import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock @@ -112,15 +117,32 @@ abstract class NotificationsViewModelTestBase { onBlocking { getInstanceV1() } doAnswer { null } } - val serverCapabilitiesRepository = ServerCapabilitiesRepository( + val nodeInfoApi: NodeInfoApi = mock { + onBlocking { nodeInfoJrd() } doReturn NetworkResult.success( + UnvalidatedJrd( + listOf( + UnvalidatedJrd.Link( + "http://nodeinfo.diaspora.software/ns/schema/2.1", + "https://example.com", + ), + ), + ), + ) + onBlocking { nodeInfo(any()) } doReturn NetworkResult.success( + UnvalidatedNodeInfo(UnvalidatedNodeInfo.Software("mastodon", "4.2.0")), + ) + } + + val serverRepository = ServerRepository( mastodonApi, + nodeInfoApi, accountManager, TestScope(), ) statusDisplayOptionsRepository = StatusDisplayOptionsRepository( sharedPreferencesRepository, - serverCapabilitiesRepository, + serverRepository, accountManager, accountPreferenceDataStore, TestScope(), diff --git a/app/src/test/java/app/pachli/components/timeline/CachedTimelineViewModelTestBase.kt b/app/src/test/java/app/pachli/components/timeline/CachedTimelineViewModelTestBase.kt index c67cf9f0d..a76c9b238 100644 --- a/app/src/test/java/app/pachli/components/timeline/CachedTimelineViewModelTestBase.kt +++ b/app/src/test/java/app/pachli/components/timeline/CachedTimelineViewModelTestBase.kt @@ -26,7 +26,10 @@ import app.pachli.components.timeline.viewmodel.TimelineViewModel import app.pachli.core.accounts.AccountManager import app.pachli.core.network.model.Account import app.pachli.core.network.model.TimelineKind +import app.pachli.core.network.model.nodeinfo.UnvalidatedJrd +import app.pachli.core.network.model.nodeinfo.UnvalidatedNodeInfo import app.pachli.core.network.retrofit.MastodonApi +import app.pachli.core.network.retrofit.NodeInfoApi import app.pachli.core.preferences.SharedPreferencesRepository import app.pachli.core.testing.rules.MainCoroutineRule import app.pachli.usecase.TimelineCases @@ -44,6 +47,7 @@ import okhttp3.ResponseBody.Companion.toResponseBody import org.junit.Before import org.junit.Rule import org.junit.runner.RunWith +import org.mockito.kotlin.any import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.reset @@ -73,6 +77,9 @@ abstract class CachedTimelineViewModelTestBase { @Inject lateinit var mastodonApi: MastodonApi + @Inject + lateinit var nodeInfoApi: NodeInfoApi + @Inject lateinit var sharedPreferencesRepository: SharedPreferencesRepository @@ -106,6 +113,23 @@ abstract class CachedTimelineViewModelTestBase { onBlocking { getFilters() } doReturn NetworkResult.success(emptyList()) } + reset(nodeInfoApi) + nodeInfoApi.stub { + onBlocking { nodeInfoJrd() } doReturn NetworkResult.success( + UnvalidatedJrd( + listOf( + UnvalidatedJrd.Link( + "http://nodeinfo.diaspora.software/ns/schema/2.1", + "https://example.com", + ), + ), + ), + ) + onBlocking { nodeInfo(any()) } doReturn NetworkResult.success( + UnvalidatedNodeInfo(UnvalidatedNodeInfo.Software("mastodon", "4.2.0")), + ) + } + accountManager.addAccount( accessToken = "token", domain = "domain.example", diff --git a/app/src/test/java/app/pachli/components/timeline/NetworkTimelineViewModelTestBase.kt b/app/src/test/java/app/pachli/components/timeline/NetworkTimelineViewModelTestBase.kt index 63ebbb14b..caee067f2 100644 --- a/app/src/test/java/app/pachli/components/timeline/NetworkTimelineViewModelTestBase.kt +++ b/app/src/test/java/app/pachli/components/timeline/NetworkTimelineViewModelTestBase.kt @@ -25,7 +25,10 @@ import app.pachli.components.timeline.viewmodel.TimelineViewModel import app.pachli.core.accounts.AccountManager import app.pachli.core.network.model.Account import app.pachli.core.network.model.TimelineKind +import app.pachli.core.network.model.nodeinfo.UnvalidatedJrd +import app.pachli.core.network.model.nodeinfo.UnvalidatedNodeInfo import app.pachli.core.network.retrofit.MastodonApi +import app.pachli.core.network.retrofit.NodeInfoApi import app.pachli.core.preferences.SharedPreferencesRepository import app.pachli.core.testing.rules.MainCoroutineRule import app.pachli.usecase.TimelineCases @@ -42,6 +45,7 @@ import okhttp3.ResponseBody.Companion.toResponseBody import org.junit.Before import org.junit.Rule import org.junit.runner.RunWith +import org.mockito.kotlin.any import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.reset @@ -66,6 +70,9 @@ abstract class NetworkTimelineViewModelTestBase { @Inject lateinit var mastodonApi: MastodonApi + @Inject + lateinit var nodeInfoApi: NodeInfoApi + @Inject lateinit var sharedPreferencesRepository: SharedPreferencesRepository @@ -99,6 +106,23 @@ abstract class NetworkTimelineViewModelTestBase { onBlocking { getFilters() } doReturn NetworkResult.success(emptyList()) } + reset(nodeInfoApi) + nodeInfoApi.stub { + onBlocking { nodeInfoJrd() } doReturn NetworkResult.success( + UnvalidatedJrd( + listOf( + UnvalidatedJrd.Link( + "http://nodeinfo.diaspora.software/ns/schema/2.1", + "https://example.com", + ), + ), + ), + ) + onBlocking { nodeInfo(any()) } doReturn NetworkResult.success( + UnvalidatedNodeInfo(UnvalidatedNodeInfo.Software("mastodon", "4.2.0")), + ) + } + accountManager.addAccount( accessToken = "token", domain = "domain.example", diff --git a/app/src/test/java/app/pachli/components/viewthread/ViewThreadViewModelTest.kt b/app/src/test/java/app/pachli/components/viewthread/ViewThreadViewModelTest.kt index 0d3c946b2..2d89258a7 100644 --- a/app/src/test/java/app/pachli/components/viewthread/ViewThreadViewModelTest.kt +++ b/app/src/test/java/app/pachli/components/viewthread/ViewThreadViewModelTest.kt @@ -18,7 +18,10 @@ import app.pachli.core.database.dao.TimelineDao import app.pachli.core.database.model.AccountEntity import app.pachli.core.network.model.Account import app.pachli.core.network.model.StatusContext +import app.pachli.core.network.model.nodeinfo.UnvalidatedJrd +import app.pachli.core.network.model.nodeinfo.UnvalidatedNodeInfo import app.pachli.core.network.retrofit.MastodonApi +import app.pachli.core.network.retrofit.NodeInfoApi import app.pachli.core.preferences.SharedPreferencesRepository import app.pachli.usecase.TimelineCases import app.pachli.util.StatusDisplayOptionsRepository @@ -92,6 +95,9 @@ class ViewThreadViewModelTest { @Inject lateinit var mastodonApi: MastodonApi + @Inject + lateinit var nodeInfoApi: NodeInfoApi + @Inject lateinit var eventHub: EventHub @@ -126,6 +132,23 @@ class ViewThreadViewModelTest { onBlocking { getFilters() } doReturn FilterKind.V2(emptyList()) } + reset(nodeInfoApi) + nodeInfoApi.stub { + onBlocking { nodeInfoJrd() } doReturn NetworkResult.success( + UnvalidatedJrd( + listOf( + UnvalidatedJrd.Link( + "http://nodeinfo.diaspora.software/ns/schema/2.1", + "https://example.com", + ), + ), + ), + ) + onBlocking { nodeInfo(any()) } doReturn NetworkResult.success( + UnvalidatedNodeInfo(UnvalidatedNodeInfo.Software("mastodon", "4.2.0")), + ) + } + val defaultAccount = AccountEntity( id = 1, domain = "mastodon.test", diff --git a/app/src/test/java/app/pachli/di/FakeNodeInfoApiModule.kt b/app/src/test/java/app/pachli/di/FakeNodeInfoApiModule.kt new file mode 100644 index 000000000..b4dda6be7 --- /dev/null +++ b/app/src/test/java/app/pachli/di/FakeNodeInfoApiModule.kt @@ -0,0 +1,61 @@ +/* + * 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 . + */ + +package app.pachli.di + +import app.pachli.core.network.di.NodeInfoApiModule +import app.pachli.core.network.retrofit.NodeInfoApi +import dagger.Module +import dagger.Provides +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn +import javax.inject.Singleton +import org.mockito.kotlin.mock + +/** + * Provides an empty mock. Use like: + * + * ```kotlin + * @Inject + * lateinit var nodeInfoApi: NodeInfoApi + * + * // ... + * + * @Before + * fun setup() { + * hilt.inject() + * + * reset(nodeInfoApi) + * nodeInfoApi.stub { + * onBlocking { someFunction() } doReturn SomeValue + * // ... + * } + * + * // ... + * } + * ``` + */ +@TestInstallIn( + components = [SingletonComponent::class], + replaces = [NodeInfoApiModule::class], +) +@Module +object FakeNodeInfoApiModule { + @Provides + @Singleton + fun providesApi(): NodeInfoApi = mock() +} diff --git a/app/src/test/java/app/pachli/util/StatusDisplayOptionsRepositoryTest.kt b/app/src/test/java/app/pachli/util/StatusDisplayOptionsRepositoryTest.kt index dbfb4050d..08a20d362 100644 --- a/app/src/test/java/app/pachli/util/StatusDisplayOptionsRepositoryTest.kt +++ b/app/src/test/java/app/pachli/util/StatusDisplayOptionsRepositoryTest.kt @@ -23,11 +23,15 @@ import app.cash.turbine.test import app.pachli.PachliApplication import app.pachli.core.accounts.AccountManager import app.pachli.core.network.model.Account +import app.pachli.core.network.model.nodeinfo.UnvalidatedJrd +import app.pachli.core.network.model.nodeinfo.UnvalidatedNodeInfo import app.pachli.core.network.retrofit.MastodonApi +import app.pachli.core.network.retrofit.NodeInfoApi import app.pachli.core.preferences.PrefKeys import app.pachli.core.preferences.SharedPreferencesRepository import app.pachli.core.testing.rules.MainCoroutineRule import app.pachli.settings.AccountPreferenceDataStore +import at.connyduck.calladapter.networkresult.NetworkResult import com.google.common.truth.Truth.assertThat import dagger.hilt.android.testing.CustomTestApplication import dagger.hilt.android.testing.HiltAndroidRule @@ -42,6 +46,10 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.reset +import org.mockito.kotlin.stub import org.robolectric.annotation.Config open class PachliHiltApplication : PachliApplication() @@ -66,6 +74,9 @@ class StatusDisplayOptionsRepositoryTest { @Inject lateinit var mastodonApi: MastodonApi + @Inject + lateinit var nodeInfoApi: NodeInfoApi + @Inject lateinit var sharedPreferencesRepository: SharedPreferencesRepository @@ -81,6 +92,23 @@ class StatusDisplayOptionsRepositoryTest { fun setup() { hilt.inject() + reset(nodeInfoApi) + nodeInfoApi.stub { + onBlocking { nodeInfoJrd() } doReturn NetworkResult.success( + UnvalidatedJrd( + listOf( + UnvalidatedJrd.Link( + "http://nodeinfo.diaspora.software/ns/schema/2.1", + "https://example.com", + ), + ), + ), + ) + onBlocking { nodeInfo(any()) } doReturn NetworkResult.success( + UnvalidatedNodeInfo(UnvalidatedNodeInfo.Software("mastodon", "4.2.0")), + ) + } + accountManager.addAccount( accessToken = "token", domain = "domain.example", diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts index e4e89b219..1bb76c302 100644 --- a/core/common/build.gradle.kts +++ b/core/common/build.gradle.kts @@ -28,3 +28,8 @@ android { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } } + +dependencies { + api(libs.kotlin.result) + api(libs.kotlin.result.coroutines) +} diff --git a/core/common/src/main/kotlin/app/pachli/core/common/PachliError.kt b/core/common/src/main/kotlin/app/pachli/core/common/PachliError.kt new file mode 100644 index 000000000..616288e4e --- /dev/null +++ b/core/common/src/main/kotlin/app/pachli/core/common/PachliError.kt @@ -0,0 +1,128 @@ +/* + * 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 . + */ + +package app.pachli.core.common + +import android.content.Context +import androidx.annotation.StringRes +import com.github.michaelbull.result.Err +import com.github.michaelbull.result.Ok +import com.github.michaelbull.result.Result +import com.github.michaelbull.result.getError +import com.github.michaelbull.result.getOr +import kotlin.coroutines.cancellation.CancellationException + +/** + * Base class for errors throughout the app. + * + * Derive new error class hierarchies for different components using a sealed + * class hierarchy like so: + * + * ```kotlin + * sealed class Error( + * @StringRes resourceId: Int, + * vararg formatArgs: String, + * source: PachliError? = null, + * ) : PachliError(resourceId, *formatArgs, source = source) { + * data object SomeProblem : Error(R.string.error_some_problem) + * data class OutOfRange(val input: Int) : Error( + * R.string.error_out_of_range + * input, + * ) + * data class Fetch(val url: String, val e: PachliError) : Error( + * R.string.error_fetch, + * url, + * source = e, + * ) + * } + * ``` + * + * In this example `SomeProblem` represents an error with no additional context, + * `OtherProblem` is an error relating to a URL and the URL will be included in + * the error message, and `WrappedError` represents an error that wraps another + * error that was the actual cause. + * + * Possible string resources for those errors would be: + * + * ```xml + * Operation failed + * Value %1$d is out of range + * Could not fetch %1$s: %2$s + * ``` + * + * In that last example the `url` parameter will be interpolated as the first + * placeholder and the error message from the error passed as the `source` + * parameter will be interpolated as the second placeholder. + * + * @property resourceId String resource ID for the error message + * @property formatArgs 0 or more arguments to interpolate in to the string resource + * @property source (optional) The underlying error that caused this error + */ +open class PachliError( + @StringRes private val resourceId: Int, + private vararg val formatArgs: String, + val source: PachliError? = null, +) { + fun msg(context: Context): String { + val args = mutableListOf(*formatArgs) + source?.let { args.add(it.msg(context)) } + return context.getString(resourceId, *args.toTypedArray()) + } +} + +// See https://www.jacobras.nl/2022/04/resilient-use-cases-with-kotlin-result-coroutines-and-annotations/ + +/** + * Like [runCatching], but with proper coroutines cancellation handling. Also only catches [Exception] instead of [Throwable]. + * + * Cancellation exceptions need to be rethrown. See https://github.com/Kotlin/kotlinx.coroutines/issues/1814. + */ +inline fun resultOf(block: () -> R): Result { + return try { + Ok(block()) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Err(e) + } +} + +/** + * Like [runCatching], but with proper coroutines cancellation handling. Also only catches [Exception] instead of [Throwable]. + * + * Cancellation exceptions need to be rethrown. See https://github.com/Kotlin/kotlinx.coroutines/issues/1814. + */ +inline fun T.resultOf(block: T.() -> R): Result { + return try { + Ok(block()) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Err(e) + } +} + +/** + * Like [mapCatching], but uses [resultOf] instead of [runCatching]. + */ +inline fun Result.mapResult(transform: (value: T) -> R): Result { + val successResult = getOr { null } // getOrNull() + return when { + successResult != null -> resultOf { transform(successResult) } + else -> Err(getError() ?: error("Unreachable state")) + } +} diff --git a/core/network/build.gradle.kts b/core/network/build.gradle.kts index f14908ca2..e60f4f469 100644 --- a/core/network/build.gradle.kts +++ b/core/network/build.gradle.kts @@ -36,7 +36,6 @@ dependencies { implementation(libs.gson) implementation(libs.bundles.retrofit) implementation(libs.bundles.okhttp) - implementation(libs.kotlin.result) implementation(libs.networkresult.calladapter) implementation(libs.semver) diff --git a/core/network/src/main/kotlin/app/pachli/core/network/Operations.kt b/core/network/src/main/kotlin/app/pachli/core/network/Operations.kt deleted file mode 100644 index 0479a0208..000000000 --- a/core/network/src/main/kotlin/app/pachli/core/network/Operations.kt +++ /dev/null @@ -1,181 +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 . - */ - -package app.pachli.core.network - -import app.pachli.core.network.ServerKind.MASTODON -import app.pachli.core.network.model.InstanceV1 -import app.pachli.core.network.model.InstanceV2 -import com.github.michaelbull.result.Err -import com.github.michaelbull.result.Ok -import com.github.michaelbull.result.Result -import com.github.michaelbull.result.binding -import com.github.michaelbull.result.getError -import com.github.michaelbull.result.getOr -import com.github.michaelbull.result.mapError -import io.github.z4kn4fein.semver.Version -import io.github.z4kn4fein.semver.constraints.Constraint -import io.github.z4kn4fein.semver.constraints.satisfiedByAny -import kotlin.collections.set -import kotlin.coroutines.cancellation.CancellationException - -/** - * Identifiers for operations that the server may or may not support. - */ -enum class ServerOperation(id: String) { - // Translate a status, introduced in Mastodon 4.0.0 - ORG_JOINMASTODON_STATUSES_TRANSLATE("org.joinmastodon.statuses.translate"), -} - -enum class ServerKind { - MASTODON, - AKKOMA, - PLEROMA, - UNKNOWN, - - ; - - companion object { - private val rxVersion = """\(compatible; ([^ ]+) ([^)]+)\)""".toRegex() - - fun parse(vs: String): Result, ServerCapabilitiesError> = binding { - // Parse instance version, which looks like "4.2.1 (compatible; Iceshrimp 2023.11)" - // or it's a regular version number. - val matchResult = rxVersion.find(vs) - if (matchResult == null) { - val version = resultOf { - Version.parse(vs, strict = false) - }.mapError { ServerCapabilitiesError.VersionParse(it) }.bind() - return@binding Pair(MASTODON, version) - } - - val (software, unparsedVersion) = matchResult.destructured - val version = resultOf { - Version.parse(unparsedVersion, strict = false) - }.mapError { ServerCapabilitiesError.VersionParse(it) }.bind() - - val s = when (software.lowercase()) { - "akkoma" -> AKKOMA - "mastodon" -> MASTODON - "pleroma" -> PLEROMA - else -> UNKNOWN - } - - return@binding Pair(s, version) - } - } -} - -/** Errors that can occur when processing server capabilities */ -sealed interface ServerCapabilitiesError { - val throwable: Throwable - - /** Could not parse the server's version string */ - data class VersionParse(override val throwable: Throwable) : ServerCapabilitiesError -} - -/** Represents operations that can be performed on the given server. */ -class ServerCapabilities( - val serverKind: ServerKind = MASTODON, - private val capabilities: Map> = emptyMap(), -) { - /** - * Returns true if the server supports the given operation at the given minimum version - * level, false otherwise. - */ - fun can(operation: ServerOperation, constraint: Constraint) = capabilities[operation]?.let { - versions -> - constraint satisfiedByAny versions - } ?: false - - companion object { - /** - * Generates [ServerCapabilities] from the instance's configuration report. - */ - fun from(instance: InstanceV1): Result = binding { - val (serverKind, _) = ServerKind.parse(instance.version).bind() - val capabilities = mutableMapOf>() - - // Create a default set of capabilities (empty). Mastodon servers support InstanceV2 from - // v4.0.0 onwards, and there's no information about capabilities for other server kinds. - - ServerCapabilities(serverKind, capabilities) - } - - /** - * Generates [ServerCapabilities] from the instance's configuration report. - */ - fun from(instance: InstanceV2): Result = binding { - val (serverKind, _) = ServerKind.parse(instance.version).bind() - val capabilities = mutableMapOf>() - - when (serverKind) { - MASTODON -> { - if (instance.configuration.translation.enabled) { - capabilities[ServerOperation.ORG_JOINMASTODON_STATUSES_TRANSLATE] = listOf(Version(major = 1)) - } - } - else -> { /* nothing to do yet */ } - } - - ServerCapabilities(serverKind, capabilities) - } - } -} - -// See https://www.jacobras.nl/2022/04/resilient-use-cases-with-kotlin-result-coroutines-and-annotations/ - -/** - * Like [runCatching], but with proper coroutines cancellation handling. Also only catches [Exception] instead of [Throwable]. - * - * Cancellation exceptions need to be rethrown. See https://github.com/Kotlin/kotlinx.coroutines/issues/1814. - */ -inline fun resultOf(block: () -> R): Result { - return try { - Ok(block()) - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Err(e) - } -} - -/** - * Like [runCatching], but with proper coroutines cancellation handling. Also only catches [Exception] instead of [Throwable]. - * - * Cancellation exceptions need to be rethrown. See https://github.com/Kotlin/kotlinx.coroutines/issues/1814. - */ -inline fun T.resultOf(block: T.() -> R): Result { - return try { - Ok(block()) - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Err(e) - } -} - -/** - * Like [mapCatching], but uses [resultOf] instead of [runCatching]. - */ -inline fun Result.mapResult(transform: (value: T) -> R): Result { - val successResult = getOr { null } // getOrNull() - return when { - successResult != null -> resultOf { transform(successResult) } - else -> Err(getError() ?: error("Unreachable state")) - } -} diff --git a/core/network/src/main/kotlin/app/pachli/core/network/Server.kt b/core/network/src/main/kotlin/app/pachli/core/network/Server.kt new file mode 100644 index 000000000..e72af1267 --- /dev/null +++ b/core/network/src/main/kotlin/app/pachli/core/network/Server.kt @@ -0,0 +1,230 @@ +/* + * 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 . + */ + +package app.pachli.core.network + +import androidx.annotation.StringRes +import app.pachli.core.common.PachliError +import app.pachli.core.common.resultOf +import app.pachli.core.network.Server.Error.UnparseableVersion +import app.pachli.core.network.ServerKind.AKKOMA +import app.pachli.core.network.ServerKind.GOTOSOCIAL +import app.pachli.core.network.ServerKind.MASTODON +import app.pachli.core.network.ServerKind.PLEROMA +import app.pachli.core.network.ServerKind.UNKNOWN +import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_FILTERS_CLIENT +import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_FILTERS_SERVER +import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_STATUSES_TRANSLATE +import app.pachli.core.network.model.InstanceV1 +import app.pachli.core.network.model.InstanceV2 +import app.pachli.core.network.model.nodeinfo.NodeInfo +import com.github.michaelbull.result.Result +import com.github.michaelbull.result.binding +import com.github.michaelbull.result.getOrElse +import com.github.michaelbull.result.mapError +import io.github.z4kn4fein.semver.Version +import io.github.z4kn4fein.semver.constraints.Constraint +import io.github.z4kn4fein.semver.satisfies +import io.github.z4kn4fein.semver.toVersion +import kotlin.collections.set + +/** + * Identifiers for operations that the server may or may not support. + */ +enum class ServerOperation(id: String, versions: List) { + /** Client-side filters */ + ORG_JOINMASTODON_FILTERS_CLIENT( + "org.joinmastodon.filters.client", + listOf( + // Initial introduction in Mastodon 2.4.3 + Version(major = 1), + // "account" context available in filter views in Mastodon 3.1.0 + Version(major = 1, minor = 1), + ), + ), + + /** Server-side filters */ + ORG_JOINMASTODON_FILTERS_SERVER( + "org.joinmastodon.filters.server", + listOf( + // Intitial introduction in Mastodon 4.0.0 + Version(major = 1), + ), + ), + + /** Translate a status */ + ORG_JOINMASTODON_STATUSES_TRANSLATE( + "org.joinmastodon.statuses.translate", + listOf( + // Initial introduction in Mastodon 4.0.0 + Version(major = 1), + // Spoiler warnings, polls, and media descriptions are also translated in Mastodon 4.2.0 + Version(major = 1, minor = 1), + ), + ), +} + +data class Server( + val kind: ServerKind, + val version: Version, + private val capabilities: Map = emptyMap(), +) { + /** + * @return true if the server supports the given operation at the given minimum version + * level, false otherwise. + */ + fun can(operation: ServerOperation, constraint: Constraint) = capabilities[operation]?.let { + version -> + version satisfies constraint + } ?: false + + companion object { + /** + * Constructs a server from its [NodeInfo] and [InstanceV2] details. + */ + fun from(software: NodeInfo.Software, instanceV2: InstanceV2): Result = binding { + val serverKind = ServerKind.from(software.name) + val version = parseVersionString(serverKind, software.version).bind() + val capabilities = capabilitiesFromServerVersion(serverKind, version) + + when (serverKind) { + MASTODON -> { + if (instanceV2.configuration.translation.enabled) { + capabilities[ORG_JOINMASTODON_STATUSES_TRANSLATE] = when { + version >= "4.2.0".toVersion() -> "1.1.0".toVersion() + else -> "1.0.0".toVersion() + } + } + } + else -> { /* Nothing to do */ } + } + + Server(serverKind, version, capabilities) + } + + /** + * Constructs a server from its [NodeInfo] and [InstanceV1] details. + */ + fun from(software: NodeInfo.Software, instanceV1: InstanceV1): Result = binding { + val serverKind = ServerKind.from(software.name) + val version = parseVersionString(serverKind, software.version).bind() + val capabilities = capabilitiesFromServerVersion(serverKind, version) + + Server(serverKind, version, capabilities) + } + + /** + * Parse a [version] string from the given [serverKind] in to a [Version]. + */ + private fun parseVersionString(serverKind: ServerKind, version: String): Result = binding { + // Real world examples of version strings from nodeinfo + // pleroma - 2.6.50-875-g2eb5c453.service-origin+soapbox + // akkoma - 3.9.3-0-gd83f5f66f-blob + // firefish - 1.1.0-dev29-hf1 + // hometown - 4.0.10+hometown-1.1.1 + // cherrypick - 4.6.0+cs-8f0ba0f + // gotosocial - 0.13.1-SNAPSHOT git-dfc7656 + + val semver = when (serverKind) { + // These servers have semver compatible versions + AKKOMA, MASTODON, PLEROMA, UNKNOWN -> { + resultOf { Version.parse(version, strict = false) } + .mapError { UnparseableVersion(version, it) }.bind() + } + // GoToSocial does not report a semver compatible version, expect something + // where the possible version number is space-separated, like "0.13.1 git-ccecf5a" + // https://github.com/superseriousbusiness/gotosocial/issues/1953 + GOTOSOCIAL -> { + // Try and parse as semver, just in case + resultOf { Version.parse(version, strict = false) } + .getOrElse { + // Didn't parse, use the first component, fall back to 0.0.0 + val components = version.split(" ") + resultOf { Version.parse(components[0], strict = false) } + .getOrElse { "0.0.0".toVersion() } + } + } + } + + semver + } + + /** + * Capabilities that can be determined directly from the server's version, without checking + * the instanceInfo response. + * + * Modifies `capabilities` by potentially adding new capabilities to the map. + */ + private fun capabilitiesFromServerVersion(kind: ServerKind, v: Version): MutableMap { + val c = mutableMapOf() + when (kind) { + MASTODON -> { + when { + v >= "3.1.0".toVersion() -> c[ORG_JOINMASTODON_FILTERS_CLIENT] = "1.1.0".toVersion() + v >= "2.4.3".toVersion() -> c[ORG_JOINMASTODON_FILTERS_CLIENT] = "1.0.0".toVersion() + } + + when { + v >= "4.0.0".toVersion() -> c[ORG_JOINMASTODON_FILTERS_SERVER] = "1.0.0".toVersion() + } + } + + // GoToSocial can't filter, https://github.com/superseriousbusiness/gotosocial/issues/1472 + GOTOSOCIAL -> { } + + // Everything else. Assume server side filtering and no translation. This may be an + // incorrect assumption. + AKKOMA, PLEROMA, UNKNOWN -> { + c[ORG_JOINMASTODON_FILTERS_SERVER] = "1.0.0".toVersion() + } + } + return c + } + } + + /** Errors that can occur when processing server capabilities */ + sealed class Error( + @StringRes resourceId: Int, + vararg formatArgs: String, + ) : PachliError(resourceId, *formatArgs) { + /** Could not parse the server's version string */ + data class UnparseableVersion(val version: String, val throwable: Throwable) : Error( + R.string.server_error_unparseable_version, + version, + throwable.localizedMessage, + ) + } +} + +enum class ServerKind { + AKKOMA, + GOTOSOCIAL, + MASTODON, + PLEROMA, + UNKNOWN, + ; + + companion object { + fun from(s: String) = when (s.lowercase()) { + "akkoma" -> AKKOMA + "gotosocial" -> GOTOSOCIAL + "mastodon" -> MASTODON + "pleroma" -> PLEROMA + else -> UNKNOWN + } + } +} diff --git a/core/network/src/main/kotlin/app/pachli/core/network/di/NodeInfoApiModule.kt b/core/network/src/main/kotlin/app/pachli/core/network/di/NodeInfoApiModule.kt new file mode 100644 index 000000000..05a10159d --- /dev/null +++ b/core/network/src/main/kotlin/app/pachli/core/network/di/NodeInfoApiModule.kt @@ -0,0 +1,35 @@ +/* + * 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 . + */ + +package app.pachli.core.network.di + +import app.pachli.core.network.retrofit.NodeInfoApi +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton +import retrofit2.Retrofit +import retrofit2.create + +@InstallIn(SingletonComponent::class) +@Module +class NodeInfoApiModule { + @Provides + @Singleton + fun providesNodeInfoApi(retrofit: Retrofit): NodeInfoApi = retrofit.create() +} diff --git a/core/network/src/main/kotlin/app/pachli/core/network/model/nodeinfo/NodeInfo.kt b/core/network/src/main/kotlin/app/pachli/core/network/model/nodeinfo/NodeInfo.kt new file mode 100644 index 000000000..99d75d121 --- /dev/null +++ b/core/network/src/main/kotlin/app/pachli/core/network/model/nodeinfo/NodeInfo.kt @@ -0,0 +1,83 @@ +/* + * 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 . + */ + +package app.pachli.core.network.model.nodeinfo + +import androidx.annotation.StringRes +import app.pachli.core.common.PachliError +import app.pachli.core.network.R +import app.pachli.core.network.model.nodeinfo.NodeInfo.Error.NoSoftwareBlock +import app.pachli.core.network.model.nodeinfo.NodeInfo.Error.NoSoftwareName +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 + +/** + * The JRD document that links to one or more URLs that contain the schema. + * + * See https://nodeinfo.diaspora.software/protocol.html. + */ +data class UnvalidatedJrd(val links: List) { + data class Link(val rel: String, val href: String) +} + +/** + * An unvalidated schema document. May not conform to the schema. + * + * See https://nodeinfo.diaspora.software/protocol.html and + * https://nodeinfo.diaspora.software/schema.html. + */ +data class UnvalidatedNodeInfo(val software: Software?) { + data class Software(val name: String?, val version: String?) +} + +/** + * A validated NodeInfo. + * + * See https://nodeinfo.diaspora.software/protocol.html and + * https://nodeinfo.diaspora.software/schema.html. + */ +data class NodeInfo(val software: Software) { + data class Software( + /** Software name, won't be null, empty, or blank */ + val name: String, + /** Software version, won't be null, empty, or blank */ + val version: String, + ) + + companion object { + fun from(nodeInfo: UnvalidatedNodeInfo): Result { + return when { + nodeInfo.software == null -> Err(NoSoftwareBlock) + nodeInfo.software.name.isNullOrBlank() -> Err(NoSoftwareName) + nodeInfo.software.version.isNullOrBlank() -> Err(NoSoftwareVersion) + else -> Ok(NodeInfo(Software(nodeInfo.software.name, nodeInfo.software.version))) + } + } + } + + sealed class Error( + @StringRes resourceId: Int, + vararg formatArgs: String, + source: PachliError? = null, + ) : PachliError(resourceId, *formatArgs, source = source) { + data object NoSoftwareBlock : Error(R.string.node_info_error_no_software) + data object NoSoftwareName : Error(R.string.node_info_error_no_software_name) + data object NoSoftwareVersion : Error(R.string.node_info_error_no_software_version) + } +} diff --git a/core/network/src/main/kotlin/app/pachli/core/network/retrofit/NodeInfoApi.kt b/core/network/src/main/kotlin/app/pachli/core/network/retrofit/NodeInfoApi.kt new file mode 100644 index 000000000..a2e11e85f --- /dev/null +++ b/core/network/src/main/kotlin/app/pachli/core/network/retrofit/NodeInfoApi.kt @@ -0,0 +1,38 @@ +/* + * 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 . + */ + +package app.pachli.core.network.retrofit + +import app.pachli.core.network.model.nodeinfo.UnvalidatedJrd +import app.pachli.core.network.model.nodeinfo.UnvalidatedNodeInfo +import at.connyduck.calladapter.networkresult.NetworkResult +import retrofit2.http.GET +import retrofit2.http.Url + +interface NodeInfoApi { + /** + * Instance info from the Nodeinfo .well_known (https://nodeinfo.diaspora.software/protocol.html) endpoint + */ + @GET("/.well-known/nodeinfo") + suspend fun nodeInfoJrd(): NetworkResult + + /** + * Instance info from NodeInfo (https://nodeinfo.diaspora.software/schema.html) endpoint + */ + @GET + suspend fun nodeInfo(@Url nodeInfoUrl: String): NetworkResult +} diff --git a/core/network/src/main/res/values/strings.xml b/core/network/src/main/res/values/strings.xml new file mode 100644 index 000000000..7329ad856 --- /dev/null +++ b/core/network/src/main/res/values/strings.xml @@ -0,0 +1,24 @@ + + + + did not contain a software block + software name is missing, empty, or blank + software version is missing, empty, or blank + + could not parse \"%1$s\" as a version: %2$s + diff --git a/core/network/src/test/kotlin/app/pachli/core/network/ServerKindTest.kt b/core/network/src/test/kotlin/app/pachli/core/network/ServerKindTest.kt deleted file mode 100644 index 506001ab9..000000000 --- a/core/network/src/test/kotlin/app/pachli/core/network/ServerKindTest.kt +++ /dev/null @@ -1,67 +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 . - */ - -package app.pachli.core.network - -import app.pachli.core.network.ServerKind.AKKOMA -import app.pachli.core.network.ServerKind.MASTODON -import app.pachli.core.network.ServerKind.PLEROMA -import app.pachli.core.network.ServerKind.UNKNOWN -import com.github.michaelbull.result.Ok -import com.github.michaelbull.result.Result -import io.github.z4kn4fein.semver.Version -import org.junit.Assert.assertEquals -import org.junit.Test -import org.junit.runner.RunWith -import org.junit.runners.Parameterized -import org.junit.runners.Parameterized.Parameters - -@RunWith(Parameterized::class) -class ServerKindTest( - private val input: String, - private val want: Result, ServerCapabilitiesError>, -) { - companion object { - @Parameters(name = "{0}") - @JvmStatic - fun data(): Iterable { - return listOf( - arrayOf( - "4.0.0", - Ok(Pair(MASTODON, Version.parse("4.0.0", strict = false))), - ), - arrayOf( - "4.2.1 (compatible; Iceshrimp 2023.11)", - Ok(Pair(UNKNOWN, Version.parse("2023.11", strict = false))), - ), - arrayOf( - "2.7.2 (compatible; Akkoma 3.10.3-202-g1b838627-1-CI-COMMIT-TAG---)", - Ok(Pair(AKKOMA, Version.parse("3.10.3-202-g1b838627-1-CI-COMMIT-TAG---", strict = false))), - ), - arrayOf( - "2.7.2 (compatible; Pleroma 2.5.54-640-gacbec640.develop+soapbox)", - Ok(Pair(PLEROMA, Version.parse("2.5.54-640-gacbec640.develop+soapbox", strict = false))), - ), - ) - } - } - - @Test - fun `ServerKind parse works`() { - assertEquals(want, ServerKind.parse(input)) - } -} diff --git a/core/network/src/test/kotlin/app/pachli/core/network/ServerTest.kt b/core/network/src/test/kotlin/app/pachli/core/network/ServerTest.kt new file mode 100644 index 000000000..9e55ca2be --- /dev/null +++ b/core/network/src/test/kotlin/app/pachli/core/network/ServerTest.kt @@ -0,0 +1,254 @@ +/* + * 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 . + */ + +package app.pachli.core.network + +import app.pachli.core.network.ServerKind.AKKOMA +import app.pachli.core.network.ServerKind.GOTOSOCIAL +import app.pachli.core.network.ServerKind.MASTODON +import app.pachli.core.network.ServerKind.PLEROMA +import app.pachli.core.network.ServerKind.UNKNOWN +import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_FILTERS_CLIENT +import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_FILTERS_SERVER +import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_STATUSES_TRANSLATE +import app.pachli.core.network.model.Account +import app.pachli.core.network.model.Configuration +import app.pachli.core.network.model.Contact +import app.pachli.core.network.model.InstanceV2 +import app.pachli.core.network.model.InstanceV2Accounts +import app.pachli.core.network.model.InstanceV2Polls +import app.pachli.core.network.model.InstanceV2Statuses +import app.pachli.core.network.model.InstanceV2Translation +import app.pachli.core.network.model.InstanceV2Urls +import app.pachli.core.network.model.MediaAttachments +import app.pachli.core.network.model.Registrations +import app.pachli.core.network.model.Thumbnail +import app.pachli.core.network.model.Usage +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.truth.Truth.assertWithMessage +import io.github.z4kn4fein.semver.toVersion +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import org.junit.runners.Parameterized.Parameters + +@RunWith(Parameterized::class) +class ServerTest( + private val input: Triple, + private val want: Result, +) { + companion object { + private val defaultInstance = InstanceV2( + domain = "", + title = "", + version = "", + sourceUrl = "", + description = "", + usage = Usage(users = Users(activeMonth = 1)), + thumbnail = Thumbnail(url = "", blurhash = null, versions = null), + languages = emptyList(), + configuration = Configuration( + urls = InstanceV2Urls(streamingApi = ""), + accounts = InstanceV2Accounts(maxFeaturedTags = 1), + statuses = InstanceV2Statuses( + maxCharacters = 500, + maxMediaAttachments = 4, + charactersReservedPerUrl = 23, + ), + mediaAttachments = MediaAttachments( + supportedMimeTypes = emptyList(), + imageSizeLimit = 1, + imageMatrixLimit = 1, + videoSizeLimit = 1, + videoFrameRateLimit = 1, + videoMatrixLimit = 1, + ), + polls = InstanceV2Polls( + maxOptions = 4, + maxCharactersPerOption = 200, + minExpiration = 1, + maxExpiration = 2, + ), + translation = InstanceV2Translation(enabled = false), + ), + registrations = Registrations( + enabled = false, + approvalRequired = false, + message = null, + ), + contact = Contact( + email = "", + account = Account( + id = "1", + localUsername = "", + username = "", + displayName = null, + createdAt = null, + note = "", + url = "", + avatar = "", + header = "", + locked = false, + ), + ), + rules = emptyList(), + ) + + @Parameters(name = "{0}") + @JvmStatic + fun data(): Iterable { + return listOf( + arrayOf( + Triple( + "Mastodon 4.0.0 has expected capabilities", + NodeInfo.Software("mastodon", "4.0.0"), + defaultInstance, + ), + Ok( + Server( + kind = MASTODON, + version = "4.0.0".toVersion(), + capabilities = mapOf( + ORG_JOINMASTODON_FILTERS_CLIENT to "1.1.0".toVersion(), + ORG_JOINMASTODON_FILTERS_SERVER to "1.0.0".toVersion(), + ), + ), + ), + ), + arrayOf( + Triple( + "Mastodon 4.0.0 has translate 1.0.0", + NodeInfo.Software("mastodon", "4.0.0"), + defaultInstance.copy( + configuration = defaultInstance.configuration.copy( + translation = InstanceV2Translation(enabled = true), + ), + ), + ), + Ok( + Server( + kind = MASTODON, + version = "4.0.0".toVersion(), + capabilities = mapOf( + ORG_JOINMASTODON_FILTERS_CLIENT to "1.1.0".toVersion(), + ORG_JOINMASTODON_FILTERS_SERVER to "1.0.0".toVersion(), + ORG_JOINMASTODON_STATUSES_TRANSLATE to "1.0.0".toVersion(), + ), + ), + ), + ), + arrayOf( + Triple( + "Mastodon 4.2.0 has has translate 1.1.0", + NodeInfo.Software("mastodon", "4.2.0"), + defaultInstance.copy( + configuration = defaultInstance.configuration.copy( + translation = InstanceV2Translation(enabled = true), + ), + ), + ), + Ok( + Server( + kind = MASTODON, + version = "4.2.0".toVersion(), + capabilities = mapOf( + ORG_JOINMASTODON_FILTERS_CLIENT to "1.1.0".toVersion(), + ORG_JOINMASTODON_FILTERS_SERVER to "1.0.0".toVersion(), + ORG_JOINMASTODON_STATUSES_TRANSLATE to "1.1.0".toVersion(), + ), + ), + ), + ), + arrayOf( + Triple( + "GoToSocial has no translation or filtering", + NodeInfo.Software("gotosocial", "0.13.1 git-ccecf5a"), + defaultInstance, + ), + Ok( + Server( + kind = GOTOSOCIAL, + version = "0.13.1".toVersion(), + capabilities = emptyMap(), + ), + ), + ), + arrayOf( + Triple( + "Pleroma can filter", + NodeInfo.Software("pleroma", "2.6.50-875-g2eb5c453.service-origin+soapbox"), + defaultInstance, + ), + Ok( + Server( + kind = PLEROMA, + version = "2.6.50-875-g2eb5c453.service-origin+soapbox".toVersion(), + capabilities = mapOf( + ORG_JOINMASTODON_FILTERS_SERVER to "1.0.0".toVersion(), + ), + ), + ), + ), + arrayOf( + Triple( + "Akkoma can filter", + NodeInfo.Software("akkoma", "3.9.3-0-gd83f5f66f-blob"), + defaultInstance, + ), + Ok( + Server( + kind = AKKOMA, + version = "3.9.3-0-gd83f5f66f-blob".toVersion(), + capabilities = mapOf( + ORG_JOINMASTODON_FILTERS_SERVER to "1.0.0".toVersion(), + ), + ), + ), + ), + arrayOf( + Triple( + "Firefish can filter", + NodeInfo.Software("firefish", "1.1.0-dev29-hf1"), + defaultInstance, + ), + Ok( + Server( + kind = UNKNOWN, + version = "1.1.0-dev29-hf1".toVersion(), + capabilities = mapOf( + ORG_JOINMASTODON_FILTERS_SERVER to "1.0.0".toVersion(), + ), + ), + ), + ), + ) + } + } + + @Test + fun `Server from() with V2 works`() { + val msg = input.first + val software = input.second + val instanceV2 = input.third + assertWithMessage(msg) + .that(Server.from(software, instanceV2)) + .isEqualTo(want) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fd754dd97..fb1b4fe46 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -152,6 +152,7 @@ hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hil hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hilt" } junit = { module = "junit:junit", version.ref = "junit" } kotlin-result = { module = "com.michael-bull.kotlin-result:kotlin-result", version.ref = "kotlin-result" } +kotlin-result-coroutines = { module = "com.michael-bull.kotlin-result:kotlin-result-coroutines", version.ref = "kotlin-result" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } kotlinx-coroutines-rx3 = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-rx3", version.ref = "coroutines" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }