diff --git a/.editorconfig b/.editorconfig
index 9f545e498..2ede3f2b9 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -25,3 +25,8 @@ max_line_length = off
[*.{yml,yaml}]
indent_size = 2
+
+# Disable ktlint on generated source code, see
+# https://github.com/JLLeitschuh/ktlint-gradle/issues/746
+[**/build/generated/source/**]
+ktlint = disabled
diff --git a/app/lint-baseline.xml b/app/lint-baseline.xml
index f242c57c2..8c79fbe87 100644
--- a/app/lint-baseline.xml
+++ b/app/lint-baseline.xml
@@ -3534,7 +3534,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~">
diff --git a/app/src/main/java/app/pachli/fragment/SFragment.kt b/app/src/main/java/app/pachli/fragment/SFragment.kt
index 2c4a585d9..214891543 100644
--- a/app/src/main/java/app/pachli/fragment/SFragment.kt
+++ b/app/src/main/java/app/pachli/fragment/SFragment.kt
@@ -127,6 +127,7 @@ abstract class SFragment : Fragment() {
result.onFailure {
val msg = getString(
R.string.server_repository_error,
+ accountManager.activeAccount!!.domain,
it.msg(requireContext()),
)
Timber.e(msg)
diff --git a/app/src/main/java/app/pachli/network/ServerRepository.kt b/app/src/main/java/app/pachli/network/ServerRepository.kt
index a26148338..523fcb257 100644
--- a/app/src/main/java/app/pachli/network/ServerRepository.kt
+++ b/app/src/main/java/app/pachli/network/ServerRepository.kt
@@ -27,7 +27,7 @@ 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.GetInstanceInfoV1
import app.pachli.network.ServerRepository.Error.GetNodeInfo
import app.pachli.network.ServerRepository.Error.GetWellKnownNodeInfo
import app.pachli.network.ServerRepository.Error.UnsupportedSchema
@@ -75,8 +75,8 @@ class ServerRepository @Inject constructor(
}
/**
- * @return the current server or a [Server.Error] subclass error if the
- * server can not be determined.
+ * @return the server info or a [Server.Error] if the server info can not
+ * be determined.
*/
private suspend fun getServer(): Result = binding {
// Fetch the /.well-known/nodeinfo document
@@ -107,7 +107,7 @@ class ServerRepository @Inject constructor(
{
mastodonApi.getInstanceV1().fold(
{ Server.from(nodeInfo.software, it).mapError(::Capabilities) },
- { Err(GetInstanceInfo(it)) },
+ { Err(GetInstanceInfoV1(it)) },
)
},
).bind()
@@ -139,7 +139,7 @@ class ServerRepository @Inject constructor(
source = error,
)
- data class GetInstanceInfo(val throwable: Throwable) : Error(
+ data class GetInstanceInfoV1(val throwable: Throwable) : Error(
R.string.server_repository_error_get_instance_info,
throwable.localizedMessage,
)
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 3df9580bb..73a615640 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -766,7 +766,7 @@ Your description here:\n\n
----\n
- Could not fetch server info: %1$s
+ Could not fetch server info for %1$s: %2$s
fetching /.well-known/nodeinfo failed: %1$s
/.well-known/nodeinfo did not contain understandable schemas
fetching nodeinfo %1$s failed: %2$s
diff --git a/core/common/src/main/kotlin/app/pachli/core/common/PachliError.kt b/core/common/src/main/kotlin/app/pachli/core/common/PachliError.kt
index 616288e4e..ae1ecb2f5 100644
--- a/core/common/src/main/kotlin/app/pachli/core/common/PachliError.kt
+++ b/core/common/src/main/kotlin/app/pachli/core/common/PachliError.kt
@@ -19,12 +19,6 @@ 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.
@@ -83,46 +77,3 @@ open class PachliError(
return context.getString(resourceId, *args.toTypedArray())
}
}
-
-// See https://www.jacobras.nl/2022/04/resilient-use-cases-with-kotlin-result-coroutines-and-annotations/
-
-/**
- * Like [runCatching], but with proper coroutines cancellation handling. Also only catches [Exception] instead of [Throwable].
- *
- * Cancellation exceptions need to be rethrown. See https://github.com/Kotlin/kotlinx.coroutines/issues/1814.
- */
-inline fun resultOf(block: () -> R): Result {
- return try {
- Ok(block())
- } catch (e: CancellationException) {
- throw e
- } catch (e: Exception) {
- Err(e)
- }
-}
-
-/**
- * Like [runCatching], but with proper coroutines cancellation handling. Also only catches [Exception] instead of [Throwable].
- *
- * Cancellation exceptions need to be rethrown. See https://github.com/Kotlin/kotlinx.coroutines/issues/1814.
- */
-inline fun T.resultOf(block: T.() -> R): Result {
- return try {
- Ok(block())
- } catch (e: CancellationException) {
- throw e
- } catch (e: Exception) {
- Err(e)
- }
-}
-
-/**
- * Like [mapCatching], but uses [resultOf] instead of [runCatching].
- */
-inline fun Result.mapResult(transform: (value: T) -> R): Result {
- val successResult = getOr { null } // getOrNull()
- return when {
- successResult != null -> resultOf { transform(successResult) }
- else -> Err(getError() ?: error("Unreachable state"))
- }
-}
diff --git a/core/network/src/main/kotlin/app/pachli/core/network/Server.kt b/core/network/src/main/kotlin/app/pachli/core/network/Server.kt
index e72af1267..78ed4525f 100644
--- a/core/network/src/main/kotlin/app/pachli/core/network/Server.kt
+++ b/core/network/src/main/kotlin/app/pachli/core/network/Server.kt
@@ -18,13 +18,21 @@
package app.pachli.core.network
import androidx.annotation.StringRes
+import androidx.annotation.VisibleForTesting
+import androidx.annotation.VisibleForTesting.Companion.PRIVATE
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.FEDIBIRD
+import app.pachli.core.network.ServerKind.FRIENDICA
+import app.pachli.core.network.ServerKind.GLITCH
import app.pachli.core.network.ServerKind.GOTOSOCIAL
+import app.pachli.core.network.ServerKind.HOMETOWN
+import app.pachli.core.network.ServerKind.ICESHRIMP
import app.pachli.core.network.ServerKind.MASTODON
+import app.pachli.core.network.ServerKind.PIXELFED
import app.pachli.core.network.ServerKind.PLEROMA
+import app.pachli.core.network.ServerKind.SHARKEY
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
@@ -32,14 +40,19 @@ import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_STATUSES_TRANSLA
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.Ok
import com.github.michaelbull.result.Result
+import com.github.michaelbull.result.andThen
import com.github.michaelbull.result.binding
-import com.github.michaelbull.result.getOrElse
+import com.github.michaelbull.result.coroutines.runSuspendCatching
import com.github.michaelbull.result.mapError
+import com.github.michaelbull.result.recover
+import com.github.michaelbull.result.toResultOr
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 java.text.ParseException
import kotlin.collections.set
/**
@@ -97,7 +110,7 @@ data class Server(
* Constructs a server from its [NodeInfo] and [InstanceV2] details.
*/
fun from(software: NodeInfo.Software, instanceV2: InstanceV2): Result = binding {
- val serverKind = ServerKind.from(software.name)
+ val serverKind = ServerKind.from(software)
val version = parseVersionString(serverKind, software.version).bind()
val capabilities = capabilitiesFromServerVersion(serverKind, version)
@@ -120,7 +133,7 @@ data class Server(
* Constructs a server from its [NodeInfo] and [InstanceV1] details.
*/
fun from(software: NodeInfo.Software, instanceV1: InstanceV1): Result = binding {
- val serverKind = ServerKind.from(software.name)
+ val serverKind = ServerKind.from(software)
val version = parseVersionString(serverKind, software.version).bind()
val capabilities = capabilitiesFromServerVersion(serverKind, version)
@@ -130,37 +143,108 @@ data class Server(
/**
* Parse a [version] string from the given [serverKind] in to a [Version].
*/
- private fun parseVersionString(serverKind: ServerKind, version: String): Result = binding {
- // Real world examples of version strings from nodeinfo
- // pleroma - 2.6.50-875-g2eb5c453.service-origin+soapbox
- // akkoma - 3.9.3-0-gd83f5f66f-blob
- // firefish - 1.1.0-dev29-hf1
- // hometown - 4.0.10+hometown-1.1.1
- // cherrypick - 4.6.0+cs-8f0ba0f
- // gotosocial - 0.13.1-SNAPSHOT git-dfc7656
+ @VisibleForTesting(otherwise = PRIVATE)
+ fun parseVersionString(serverKind: ServerKind, version: String): Result {
+ val result = runSuspendCatching {
+ Version.parse(version, strict = false)
+ }.mapError { UnparseableVersion(version, it) }
- 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()
+ if (result is Ok) return result
+
+ return when (serverKind) {
+ // These servers should have semver compatible versions, but perhaps
+ // the server operator has changed them. Try looking for a matching
+ // .. somewhere in the version string and hope
+ // it's correct
+ AKKOMA, FEDIBIRD, GLITCH, HOMETOWN, MASTODON, PIXELFED, UNKNOWN -> {
+ val rx = """(?\d+)\.(?\d+).(?\d+)""".toRegex()
+ rx.find(version)
+ .toResultOr { UnparseableVersion(version, ParseException("unexpected null", 0)) }
+ .andThen {
+ val adjusted = "${it.groups["major"]?.value}.${it.groups["minor"]?.value}.${it.groups["patch"]?.value}"
+ runSuspendCatching { Version.parse(adjusted, strict = false) }
+ .mapError { UnparseableVersion(version, it) }
+ }
}
- // GoToSocial does not report a semver compatible version, expect something
- // where the possible version number is space-separated, like "0.13.1 git-ccecf5a"
+
+ // Friendica does not report a semver compatible version, expect something
+ // where the version looks like "yyyy.mm", with an optional suffix that
+ // starts with a "-". The date-like parts of the string may have leading
+ // zeros.
+ //
+ // Try to extract the "yyyy.mm", without any leading zeros, append ".0".
+ // https://github.com/friendica/friendica/issues/11264
+ FRIENDICA -> {
+ val rx = """^0*(?\d+)\.0*(?\d+)""".toRegex()
+ rx.find(version)
+ .toResultOr { UnparseableVersion(version, ParseException("unexpected null", 0)) }
+ .andThen {
+ val adjusted = "${it.groups["major"]?.value}.${it.groups["minor"]?.value}.0"
+ runSuspendCatching { Version.parse(adjusted, strict = false) }
+ .mapError { UnparseableVersion(version, it) }
+ }
+ }
+
+ // GoToSocial does not always report a semver compatible version, and is all
+ // over the place, including:
+ //
+ // - "" (empty)
+ // - "git-8ab30d0"
+ // - "kalaclista git-212fecf"
+ // - "f4fcffc8b56ef73c184ae17892b69181961c15c7"
+ //
+ // as well as instances where the version number is semver compatible, but is
+ // separated by whitespace or a "_".
+ //
// https://github.com/superseriousbusiness/gotosocial/issues/1953
+ //
+ // Since GoToSocial has comparatively few features at the moment just fall
+ // back to "0.0.0" if there are problems.
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() }
+ // Failed, split on spaces and parse the first component
+ val components = version.split(" ", "_")
+ runSuspendCatching { Version.parse(components[0], strict = false) }
+ .recover { "0.0.0".toVersion() }
+ }
+
+ // IceShrimp uses "yyyy.mm.dd" with leading zeros in the month and day
+ // components, similar to Friendica.
+ // https://iceshrimp.dev/iceshrimp/iceshrimp/issues/502 and
+ // https://iceshrimp.dev/iceshrimp/iceshrimp-rewrite/issues/1
+ ICESHRIMP -> {
+ val rx = """^0*(?\d+)\.0*(?\d+)\.0*(?\d+)""".toRegex()
+ rx.find(version).toResultOr { UnparseableVersion(version, ParseException("unexpected null", 0)) }
+ .andThen {
+ val adjusted = "${it.groups["major"]?.value}.${it.groups["minor"]?.value ?: 0}.${it.groups["patch"]?.value ?: 0}"
+ runSuspendCatching { Version.parse(adjusted, strict = false) }
+ .mapError { UnparseableVersion(adjusted, it) }
+ }
+ }
+
+ // Seen "Pleroma 0.9.0 d93789dfde3c44c76a56732088a897ddddfe9716" in
+ // the wild
+ PLEROMA -> {
+ val rx = """Pleroma (?\d+)\.(?\d+)\.(?\d+)""".toRegex()
+ rx.find(version).toResultOr { UnparseableVersion(version, ParseException("unexpected null", 0)) }
+ .andThen {
+ val adjusted = "${it.groups["major"]?.value}.${it.groups["minor"]?.value}.${it.groups["patch"]?.value}"
+ runSuspendCatching { Version.parse(adjusted, strict = false) }
+ .mapError { UnparseableVersion(adjusted, it) }
+ }
+ }
+
+ // Uses format "yyyy.mm.dd" with an optional ".beta..." suffix.
+ // https://git.joinsharkey.org/Sharkey/Sharkey/issues/371
+ SHARKEY -> {
+ val rx = """^(?\d+)\.(?\d+)\.(?\d+)""".toRegex()
+ rx.find(version).toResultOr { UnparseableVersion(version, ParseException("unexpected null", 0)) }
+ .andThen {
+ val adjusted = "${it.groups["major"]?.value}.${it.groups["minor"]?.value}.${it.groups["patch"]?.value}"
+ runSuspendCatching { Version.parse(adjusted, strict = false) }
+ .mapError { UnparseableVersion(adjusted, it) }
}
}
}
-
- semver
}
/**
@@ -188,7 +272,7 @@ data class Server(
// Everything else. Assume server side filtering and no translation. This may be an
// incorrect assumption.
- AKKOMA, PLEROMA, UNKNOWN -> {
+ AKKOMA, FEDIBIRD, FRIENDICA, GLITCH, HOMETOWN, ICESHRIMP, PIXELFED, PLEROMA, SHARKEY, UNKNOWN -> {
c[ORG_JOINMASTODON_FILTERS_SERVER] = "1.0.0".toVersion()
}
}
@@ -210,20 +294,45 @@ data class Server(
}
}
+/**
+ * Servers that are known to implement the Mastodon client API
+ */
enum class ServerKind {
AKKOMA,
+ FEDIBIRD,
+ FRIENDICA,
+ GLITCH,
GOTOSOCIAL,
+ HOMETOWN,
+ ICESHRIMP,
MASTODON,
PLEROMA,
+ PIXELFED,
+ SHARKEY,
+
+ /**
+ * Catch-all for servers we don't recognise but that responded to either
+ * /api/v1/instance or /api/v2/instance
+ */
UNKNOWN,
;
companion object {
- fun from(s: String) = when (s.lowercase()) {
+ fun from(s: NodeInfo.Software) = when (s.name.lowercase()) {
"akkoma" -> AKKOMA
+ "fedibird" -> FEDIBIRD
+ "friendica" -> FRIENDICA
"gotosocial" -> GOTOSOCIAL
- "mastodon" -> MASTODON
+ "hometown" -> HOMETOWN
+ "iceshrimp" -> ICESHRIMP
+ "mastodon" -> {
+ // Glitch doesn't report a different software name it stuffs it
+ // in the version (https://github.com/glitch-soc/mastodon/issues/2582).
+ if (s.version.contains("+glitch")) GLITCH else MASTODON
+ }
+ "pixelfed" -> PIXELFED
"pleroma" -> PLEROMA
+ "sharkey" -> SHARKEY
else -> UNKNOWN
}
}
diff --git a/core/network/src/test/kotlin/app/pachli/core/network/ServerTest.kt b/core/network/src/test/kotlin/app/pachli/core/network/ServerTest.kt
index 9e55ca2be..09a0874e7 100644
--- a/core/network/src/test/kotlin/app/pachli/core/network/ServerTest.kt
+++ b/core/network/src/test/kotlin/app/pachli/core/network/ServerTest.kt
@@ -18,6 +18,7 @@
package app.pachli.core.network
import app.pachli.core.network.ServerKind.AKKOMA
+import app.pachli.core.network.ServerKind.FRIENDICA
import app.pachli.core.network.ServerKind.GOTOSOCIAL
import app.pachli.core.network.ServerKind.MASTODON
import app.pachli.core.network.ServerKind.PLEROMA
@@ -43,7 +44,10 @@ 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 com.google.gson.Gson
+import com.google.gson.reflect.TypeToken
import io.github.z4kn4fein.semver.toVersion
+import java.io.BufferedReader
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized
@@ -238,6 +242,22 @@ class ServerTest(
),
),
),
+ arrayOf(
+ Triple(
+ "Friendica can filter",
+ NodeInfo.Software("friendica", "2023.05-1542"),
+ defaultInstance,
+ ),
+ Ok(
+ Server(
+ kind = FRIENDICA,
+ version = "2023.5.0".toVersion(),
+ capabilities = mapOf(
+ ORG_JOINMASTODON_FILTERS_SERVER to "1.0.0".toVersion(),
+ ),
+ ),
+ ),
+ ),
)
}
}
@@ -252,3 +272,60 @@ class ServerTest(
.isEqualTo(want)
}
}
+
+class ServerVersionTest() {
+ private val gson = Gson()
+
+ private fun loadJsonAsString(fileName: String): String {
+ return javaClass.getResourceAsStream("/$fileName")!!
+ .bufferedReader().use(BufferedReader::readText)
+ }
+
+ /**
+ * Test that parsing all possible versions succeeds.
+ *
+ * To do this tools/mkserverversions generates a JSON file that
+ * contains a map of server names to a list of server version strings
+ * that have been seen by Fediverse Observer. These version strings
+ * are then parsed and are all expected to parse correctly.
+ */
+ @Test
+ fun parseVersionString() {
+ val mapType: TypeToken