mirror of
https://github.com/pachli/pachli-android.git
synced 2025-02-08 07:58:55 +01:00
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:
parent
aaf8cf57f3
commit
42219875e9
@ -184,7 +184,6 @@ dependencies {
|
||||
googleImplementation(libs.app.update)
|
||||
googleImplementation(libs.app.update.ktx)
|
||||
|
||||
implementation(libs.kotlin.result)
|
||||
implementation(libs.semver)
|
||||
|
||||
debugImplementation(libs.leakcanary)
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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) {
|
||||
|
@ -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
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
152
app/src/main/java/app/pachli/network/ServerRepository.kt
Normal file
152
app/src/main/java/app/pachli/network/ServerRepository.kt
Normal 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,
|
||||
)
|
||||
}
|
||||
}
|
@ -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) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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(),
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
61
app/src/test/java/app/pachli/di/FakeNodeInfoApiModule.kt
Normal file
61
app/src/test/java/app/pachli/di/FakeNodeInfoApiModule.kt
Normal 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()
|
||||
}
|
@ -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",
|
||||
|
@ -28,3 +28,8 @@ android {
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(libs.kotlin.result)
|
||||
api(libs.kotlin.result.coroutines)
|
||||
}
|
||||
|
@ -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"))
|
||||
}
|
||||
}
|
@ -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)
|
||||
|
||||
|
@ -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"))
|
||||
}
|
||||
}
|
230
core/network/src/main/kotlin/app/pachli/core/network/Server.kt
Normal file
230
core/network/src/main/kotlin/app/pachli/core/network/Server.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -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>
|
||||
}
|
24
core/network/src/main/res/values/strings.xml
Normal file
24
core/network/src/main/res/values/strings.xml
Normal 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>
|
@ -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))
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -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" }
|
||||
|
Loading…
x
Reference in New Issue
Block a user