fix: Disable filter functionality if unsupported by the server (#366)

The previous code unilaterally enabled filter functionality. Some
Mastodon-like servers -- like GoToSocial -- do not support filters, and
this resulted in user visible error messages when connecting to those
servers.

To fix this:

- Extend the set of supported server capabilities to include client and
server side filtering.

- Disable the filter preferences if the server does not support filters
and show a message explaining why it's disabled.

Extend the capabilities model to support this:

- Fetch server software name and version from the nodeinfo endpoints
(implementing the nodeinfo API and schema)

- Extend the use of kotlin-result to provide hierarchies of Error
classes and demonstrate how to chain errors and display more informative
messages without using exceptions.

Fixes #343
This commit is contained in:
Nik Clayton 2024-01-18 21:44:30 +01:00 committed by GitHub
parent aaf8cf57f3
commit 42219875e9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 1264 additions and 383 deletions

View File

@ -184,7 +184,6 @@ dependencies {
googleImplementation(libs.app.update)
googleImplementation(libs.app.update.ktx)
implementation(libs.kotlin.result)
implementation(libs.semver)
debugImplementation(libs.leakcanary)

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<issues format="6" by="lint 8.2.0" type="baseline" client="gradle" dependencies="false" name="AGP (8.2.0)" variant="all" version="8.2.0">
<issues format="6" by="lint 8.2.1" type="baseline" client="gradle" dependencies="false" name="AGP (8.2.1)" variant="all" version="8.2.1">
<issue
id="InvalidPackage"
@ -718,7 +718,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="312"
line="313"
column="5"/>
</issue>
@ -729,7 +729,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="361"
line="362"
column="5"/>
</issue>
@ -740,7 +740,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="511"
line="512"
column="5"/>
</issue>
@ -751,7 +751,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="697"
line="698"
column="5"/>
</issue>
@ -916,7 +916,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/app/pachli/components/announcements/AnnouncementAdapter.kt"
line="163"
line="169"
column="9"/>
</issue>
@ -1763,7 +1763,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="302"
line="303"
column="13"/>
</issue>
@ -1774,7 +1774,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="372"
line="373"
column="13"/>
</issue>
@ -1785,7 +1785,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="424"
line="425"
column="13"/>
</issue>
@ -1796,7 +1796,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="471"
line="472"
column="13"/>
</issue>
@ -1807,7 +1807,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="475"
line="476"
column="13"/>
</issue>
@ -1818,7 +1818,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="476"
line="477"
column="13"/>
</issue>
@ -1829,7 +1829,7 @@
errorLine2=" ~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="477"
line="478"
column="13"/>
</issue>
@ -1840,7 +1840,7 @@
errorLine2=" ~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="478"
line="479"
column="13"/>
</issue>
@ -1851,7 +1851,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="479"
line="480"
column="13"/>
</issue>
@ -1862,7 +1862,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="480"
line="481"
column="13"/>
</issue>
@ -1873,7 +1873,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="481"
line="482"
column="13"/>
</issue>
@ -1884,7 +1884,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="482"
line="483"
column="13"/>
</issue>
@ -1895,7 +1895,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="483"
line="484"
column="13"/>
</issue>
@ -1906,7 +1906,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="549"
line="550"
column="13"/>
</issue>
@ -1917,7 +1917,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="550"
line="551"
column="13"/>
</issue>
@ -1928,7 +1928,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="563"
line="564"
column="13"/>
</issue>
@ -1939,7 +1939,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="564"
line="565"
column="13"/>
</issue>
@ -1950,7 +1950,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="565"
line="566"
column="13"/>
</issue>
@ -1961,7 +1961,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="567"
line="568"
column="13"/>
</issue>
@ -1972,7 +1972,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="607"
line="608"
column="13"/>
</issue>
@ -1983,7 +1983,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="650"
line="651"
column="13"/>
</issue>
@ -1994,7 +1994,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="656"
line="657"
column="13"/>
</issue>
@ -2005,7 +2005,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="688"
line="689"
column="13"/>
</issue>
@ -2016,7 +2016,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="716"
line="717"
column="13"/>
</issue>
@ -2159,7 +2159,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/app/pachli/components/preference/AccountPreferencesFragment.kt"
line="309"
line="326"
column="29"/>
</issue>
@ -2170,7 +2170,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/app/pachli/components/preference/AccountPreferencesFragment.kt"
line="315"
line="332"
column="25"/>
</issue>
@ -2335,7 +2335,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/app/pachli/components/compose/ComposeActivity.kt"
line="549"
line="559"
column="21"/>
</issue>
@ -2346,7 +2346,7 @@
errorLine2=" ~~~~~~~~~">
<location
file="src/main/java/app/pachli/components/compose/ComposeActivity.kt"
line="558"
line="568"
column="17"/>
</issue>
@ -2357,7 +2357,7 @@
errorLine2=" ~~~~~~~~~">
<location
file="src/main/java/app/pachli/components/compose/ComposeActivity.kt"
line="558"
line="568"
column="17"/>
</issue>
@ -2731,7 +2731,7 @@
errorLine2=" ~~~~~~~~~~~~~~">
<location
file="src/main/java/app/pachli/ListsActivity.kt"
line="264"
line="267"
column="21"/>
</issue>
@ -2742,7 +2742,7 @@
errorLine2=" ~~~~~~">
<location
file="src/main/java/app/pachli/ListsActivity.kt"
line="266"
line="269"
column="21"/>
</issue>
@ -3534,7 +3534,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/app/pachli/fragment/SFragment.kt"
line="224"
line="239"
column="48"/>
</issue>

View File

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

View File

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

View File

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

View File

@ -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 <http://www.gnu.org/licenses>.
*/
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()
}
}

View File

@ -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 <http://www.gnu.org/licenses>.
*/
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<Result<Server?, Error>>(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<Server, Error> = 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<String, Error> = 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,
)
}
}

View File

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

View File

@ -285,6 +285,7 @@
<string name="pref_title_app_theme">App theme</string>
<string name="pref_title_timelines">Timelines</string>
<string name="pref_title_timeline_filters">Filters</string>
<string name="pref_summary_timeline_filters">Your server does not support filters</string>
<string name="app_them_dark">Dark</string>
<string name="app_theme_light">Light</string>
<string name="app_theme_black">Black</string>
@ -764,4 +765,12 @@ Your description here:\n\n
----\n
</string>
<string name="server_repository_error">Could not fetch server info: %1$s</string>
<string name="server_repository_error_get_well_known_node_info">fetching /.well-known/nodeinfo failed: %1$s</string>
<string name="server_repository_error_unsupported_schema">/.well-known/nodeinfo did not contain understandable schemas</string>
<string name="server_repository_error_get_node_info">fetching nodeinfo %1$s failed: %2$s</string>
<string name="server_repository_error_validate_node_info">validating nodeinfo %1$s failed: %2$s</string>
<string name="server_repository_error_get_instance_info">fetching /api/v1/instance failed: %1$s</string>
<string name="server_repository_error_capabilities">parsing server capabilities failed: %1$s</string>
</resources>

View File

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

View File

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

View File

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

View File

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

View File

@ -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 <http://www.gnu.org/licenses>.
*/
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()
}

View File

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

View File

@ -28,3 +28,8 @@ android {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
}
dependencies {
api(libs.kotlin.result)
api(libs.kotlin.result.coroutines)
}

View File

@ -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 <http://www.gnu.org/licenses>.
*/
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
* <string name="error_some_problem">Operation failed</string>
* <string name="error_out_of_range">Value %1$d is out of range</string>
* <string name="error_fetch">Could not fetch %1$s: %2$s</string>
* ```
*
* 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 <R> resultOf(block: () -> R): Result<R, Exception> {
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, R> T.resultOf(block: T.() -> R): Result<R, Exception> {
return try {
Ok(block())
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Err(e)
}
}
/**
* Like [mapCatching], but uses [resultOf] instead of [runCatching].
*/
inline fun <R, T> Result<T, Exception>.mapResult(transform: (value: T) -> R): Result<R, Exception> {
val successResult = getOr { null } // getOrNull()
return when {
successResult != null -> resultOf { transform(successResult) }
else -> Err(getError() ?: error("Unreachable state"))
}
}

View File

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

View File

@ -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 <http://www.gnu.org/licenses>.
*/
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<Pair<ServerKind, Version>, 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<ServerOperation, List<Version>> = 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<ServerCapabilities, ServerCapabilitiesError> = binding {
val (serverKind, _) = ServerKind.parse(instance.version).bind()
val capabilities = mutableMapOf<ServerOperation, List<Version>>()
// 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<ServerCapabilities, ServerCapabilitiesError> = binding {
val (serverKind, _) = ServerKind.parse(instance.version).bind()
val capabilities = mutableMapOf<ServerOperation, List<Version>>()
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 <R> resultOf(block: () -> R): Result<R, Exception> {
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, R> T.resultOf(block: T.() -> R): Result<R, Exception> {
return try {
Ok(block())
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Err(e)
}
}
/**
* Like [mapCatching], but uses [resultOf] instead of [runCatching].
*/
inline fun <R, T> Result<T, Exception>.mapResult(transform: (value: T) -> R): Result<R, Exception> {
val successResult = getOr { null } // getOrNull()
return when {
successResult != null -> resultOf { transform(successResult) }
else -> Err(getError() ?: error("Unreachable state"))
}
}

View File

@ -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 <http://www.gnu.org/licenses>.
*/
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<Version>) {
/** 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<ServerOperation, Version> = 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<Server, Error> = 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<Server, Error> = 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<Version, UnparseableVersion> = 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<ServerOperation, Version> {
val c = mutableMapOf<ServerOperation, Version>()
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
}
}
}

View File

@ -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 <http://www.gnu.org/licenses>.
*/
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()
}

View File

@ -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 <http://www.gnu.org/licenses>.
*/
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<Link>) {
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<NodeInfo, Error> {
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)
}
}

View File

@ -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 <http://www.gnu.org/licenses>.
*/
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<UnvalidatedJrd>
/**
* Instance info from NodeInfo (https://nodeinfo.diaspora.software/schema.html) endpoint
*/
@GET
suspend fun nodeInfo(@Url nodeInfoUrl: String): NetworkResult<UnvalidatedNodeInfo>
}

View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright 2024 Pachli Association
~
~ This file is a part of Pachli.
~
~ This program is free software; you can redistribute it and/or modify it under the terms of the
~ GNU General Public License as published by the Free Software Foundation; either version 3 of the
~ License, or (at your option) any later version.
~
~ Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
~ the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
~ Public License for more details.
~
~ You should have received a copy of the GNU General Public License along with Pachli; if not,
~ see <http://www.gnu.org/licenses>.
-->
<resources>
<string name="node_info_error_no_software">did not contain a software block</string>
<string name="node_info_error_no_software_name">software name is missing, empty, or blank</string>
<string name="node_info_error_no_software_version">software version is missing, empty, or blank</string>
<string name="server_error_unparseable_version">could not parse \"%1$s\" as a version: %2$s</string>
</resources>

View File

@ -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 <http://www.gnu.org/licenses>.
*/
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<Pair<ServerKind, Version>, ServerCapabilitiesError>,
) {
companion object {
@Parameters(name = "{0}")
@JvmStatic
fun data(): Iterable<Any> {
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))
}
}

View File

@ -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 <http://www.gnu.org/licenses>.
*/
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<String, NodeInfo.Software, InstanceV2>,
private val want: Result<Server, Server.Error>,
) {
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<Any> {
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)
}
}

View File

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