Move PermalinkBuilder, MatrixToConverter and PermalinkParser content to the impl project in order to remove projects.appconfig dependency from matrix.api module.

This commit is contained in:
Benoit Marty 2024-04-02 12:35:35 +02:00 committed by Benoit Marty
parent a9f326c81c
commit 1e5a61a49c
52 changed files with 771 additions and 318 deletions

View file

@ -17,40 +17,18 @@
package io.element.android.libraries.matrix.api.permalink
import android.net.Uri
import io.element.android.appconfig.MatrixConfiguration
/**
* Mapping of an input URI to a matrix.to compliant URI.
*/
object MatrixToConverter {
interface MatrixToConverter {
/**
* Try to convert a URL from an element web instance or from a client permalink to a matrix.to url.
* To be successfully converted, URL path should contain one of the [SUPPORTED_PATHS].
* To be successfully converted, URL path should contain one of the [DefaultMatrixToConverter.SUPPORTED_PATHS].
* Examples:
* - https://riot.im/develop/#/room/#element-android:matrix.org -> https://matrix.to/#/#element-android:matrix.org
* - https://app.element.io/#/room/#element-android:matrix.org -> https://matrix.to/#/#element-android:matrix.org
* - https://www.example.org/#/room/#element-android:matrix.org -> https://matrix.to/#/#element-android:matrix.org
*/
fun convert(uri: Uri): Uri? {
val uriString = uri.toString()
val baseUrl = MatrixConfiguration.MATRIX_TO_PERMALINK_BASE_URL
return when {
// URL is already a matrix.to
uriString.startsWith(baseUrl) -> uri
// Web or client url
SUPPORTED_PATHS.any { it in uriString } -> {
val path = SUPPORTED_PATHS.first { it in uriString }
Uri.parse(baseUrl + uriString.substringAfter(path))
}
// URL is not supported
else -> null
}
}
private val SUPPORTED_PATHS = listOf(
"/#/room/",
"/#/user/",
"/#/group/"
)
fun convert(uri: Uri): Uri?
}

View file

@ -16,70 +16,13 @@
package io.element.android.libraries.matrix.api.permalink
import io.element.android.appconfig.MatrixConfiguration
import io.element.android.libraries.matrix.api.core.MatrixPatterns
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
object PermalinkBuilder {
private const val ROOM_PATH = "room/"
private const val USER_PATH = "user/"
private val permalinkBaseUrl get() = (MatrixConfiguration.clientPermalinkBaseUrl ?: MatrixConfiguration.MATRIX_TO_PERMALINK_BASE_URL).also {
var baseUrl = it
if (!baseUrl.endsWith("/")) {
baseUrl += "/"
}
if (!baseUrl.endsWith("/#/")) {
baseUrl += "/#/"
}
}
fun permalinkForUser(userId: UserId): Result<String> {
return if (MatrixPatterns.isUserId(userId.value)) {
val url = buildString {
append(permalinkBaseUrl)
if (!isMatrixTo()) {
append(USER_PATH)
}
append(userId.value)
}
Result.success(url)
} else {
Result.failure(PermalinkBuilderError.InvalidUserId)
}
}
fun permalinkForRoomAlias(roomAlias: String): Result<String> {
return if (MatrixPatterns.isRoomAlias(roomAlias)) {
Result.success(permalinkForRoomAliasOrId(roomAlias))
} else {
Result.failure(PermalinkBuilderError.InvalidRoomAlias)
}
}
fun permalinkForRoomId(roomId: RoomId): Result<String> {
return if (MatrixPatterns.isRoomId(roomId.value)) {
Result.success(permalinkForRoomAliasOrId(roomId.value))
} else {
Result.failure(PermalinkBuilderError.InvalidRoomId)
}
}
private fun permalinkForRoomAliasOrId(value: String): String {
val id = escapeId(value)
return buildString {
append(permalinkBaseUrl)
if (!isMatrixTo()) {
append(ROOM_PATH)
}
append(id)
}
}
private fun escapeId(value: String) = value.replace("/", "%2F")
private fun isMatrixTo(): Boolean = permalinkBaseUrl.startsWith(MatrixConfiguration.MATRIX_TO_PERMALINK_BASE_URL)
interface PermalinkBuilder {
fun permalinkForUser(userId: UserId): Result<String>
fun permalinkForRoomAlias(roomAlias: String): Result<String>
fun permalinkForRoomId(roomId: RoomId): Result<String>
}
sealed class PermalinkBuilderError : Throwable() {

View file

@ -17,11 +17,6 @@
package io.element.android.libraries.matrix.api.permalink
import android.net.Uri
import android.net.UrlQuerySanitizer
import io.element.android.libraries.matrix.api.core.MatrixPatterns
import kotlinx.collections.immutable.toImmutableList
import timber.log.Timber
import java.net.URLDecoder
/**
* This class turns a uri to a [PermalinkData].
@ -29,121 +24,15 @@ import java.net.URLDecoder
* or matrix.to permalinks (e.g. https://matrix.to/#/@chagai95:matrix.org)
* or client permalinks (e.g. <clientPermalinkBaseUrl>user/@chagai95:matrix.org)
*/
object PermalinkParser {
interface PermalinkParser {
/**
* Turns a uri string to a [PermalinkData].
*/
fun parse(uriString: String): PermalinkData {
val uri = Uri.parse(uriString)
return parse(uri)
}
fun parse(uriString: String): PermalinkData
/**
* Turns a uri to a [PermalinkData].
* https://github.com/matrix-org/matrix-doc/blob/master/proposals/1704-matrix.to-permalinks.md
*/
fun parse(uri: Uri): PermalinkData {
// the client or element-based domain permalinks (e.g. https://app.element.io/#/user/@chagai95:matrix.org) don't have the
// mxid in the first param (like matrix.to does - https://matrix.to/#/@chagai95:matrix.org) but rather in the second after /user/ so /user/mxid
// so convert URI to matrix.to to simplify parsing process
val matrixToUri = MatrixToConverter.convert(uri) ?: return PermalinkData.FallbackLink(uri)
// We can't use uri.fragment as it is decoding to early and it will break the parsing
// of parameters that represents url (like signurl)
val fragment = matrixToUri.toString().substringAfter("#") // uri.fragment
if (fragment.isEmpty()) {
return PermalinkData.FallbackLink(uri)
}
val safeFragment = fragment.substringBefore('?')
val viaQueryParameters = fragment.getViaParameters()
// we are limiting to 2 params
val params = safeFragment
.split(MatrixPatterns.SEP_REGEX)
.filter { it.isNotEmpty() }
.take(2)
val decodedParams = params
.map { URLDecoder.decode(it, "UTF-8") }
val identifier = params.getOrNull(0)
val decodedIdentifier = decodedParams.getOrNull(0)
val extraParameter = decodedParams.getOrNull(1)
return when {
identifier.isNullOrEmpty() || decodedIdentifier.isNullOrEmpty() -> PermalinkData.FallbackLink(uri)
MatrixPatterns.isUserId(decodedIdentifier) -> PermalinkData.UserLink(userId = decodedIdentifier)
MatrixPatterns.isRoomId(decodedIdentifier) -> {
handleRoomIdCase(fragment, decodedIdentifier, matrixToUri, extraParameter, viaQueryParameters)
}
MatrixPatterns.isRoomAlias(decodedIdentifier) -> {
PermalinkData.RoomLink(
roomIdOrAlias = decodedIdentifier,
isRoomAlias = true,
eventId = extraParameter.takeIf { !it.isNullOrEmpty() && MatrixPatterns.isEventId(it) },
viaParameters = viaQueryParameters.toImmutableList()
)
}
else -> PermalinkData.FallbackLink(uri, MatrixPatterns.isGroupId(identifier))
}
}
private fun handleRoomIdCase(fragment: String, identifier: String, uri: Uri, extraParameter: String?, viaQueryParameters: List<String>): PermalinkData {
// Can't rely on built in parsing because it's messing around the signurl
val paramList = safeExtractParams(fragment)
val signUrl = paramList.firstOrNull { it.first == "signurl" }?.second
val email = paramList.firstOrNull { it.first == "email" }?.second
return if (signUrl.isNullOrEmpty().not() && email.isNullOrEmpty().not()) {
try {
val signValidUri = Uri.parse(signUrl)
val identityServerHost = signValidUri.authority ?: throw IllegalArgumentException("missing `authority`")
val token = signValidUri.getQueryParameter("token") ?: throw IllegalArgumentException("missing `token`")
val privateKey = signValidUri.getQueryParameter("private_key") ?: throw IllegalArgumentException("missing `private_key`")
PermalinkData.RoomEmailInviteLink(
roomId = identifier,
email = email!!,
signUrl = signUrl!!,
roomName = paramList.firstOrNull { it.first == "room_name" }?.second,
inviterName = paramList.firstOrNull { it.first == "inviter_name" }?.second,
roomAvatarUrl = paramList.firstOrNull { it.first == "room_avatar_url" }?.second,
roomType = paramList.firstOrNull { it.first == "room_type" }?.second,
identityServer = identityServerHost,
token = token,
privateKey = privateKey
)
} catch (failure: Throwable) {
Timber.i("## Permalink: Failed to parse permalink $signUrl")
PermalinkData.FallbackLink(uri)
}
} else {
PermalinkData.RoomLink(
roomIdOrAlias = identifier,
isRoomAlias = false,
eventId = extraParameter.takeIf { !it.isNullOrEmpty() && MatrixPatterns.isEventId(it) },
viaParameters = viaQueryParameters.toImmutableList()
)
}
}
private fun safeExtractParams(fragment: String) =
fragment.substringAfter("?").split('&').mapNotNull {
val splitNameValue = it.split("=")
if (splitNameValue.size == 2) {
Pair(splitNameValue[0], URLDecoder.decode(splitNameValue[1], "UTF-8"))
} else {
null
}
}
private fun String.getViaParameters(): List<String> {
return runCatching {
UrlQuerySanitizer(this)
.parameterList
.filter {
it.mParameter == "via"
}
.map {
URLDecoder.decode(it.mValue, "UTF-8")
}
}.getOrDefault(emptyList())
}
fun parse(uri: Uri): PermalinkData
}

View file

@ -1,56 +0,0 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.api.permalink
import android.net.Uri
import com.google.common.truth.Truth.assertThat
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class MatrixToConverterTest {
@Test
fun `converting a matrix-to url does nothing`() {
val url = Uri.parse("https://matrix.to/#/#element-android:matrix.org")
assertThat(MatrixToConverter.convert(url)).isEqualTo(url)
}
@Test
fun `converting a url with a supported room path returns a matrix-to url`() {
val url = Uri.parse("https://riot.im/develop/#/room/#element-android:matrix.org")
assertThat(MatrixToConverter.convert(url)).isEqualTo(Uri.parse("https://matrix.to/#/#element-android:matrix.org"))
}
@Test
fun `converting a url with a supported user path returns a matrix-to url`() {
val url = Uri.parse("https://riot.im/develop/#/user/@test:matrix.org")
assertThat(MatrixToConverter.convert(url)).isEqualTo(Uri.parse("https://matrix.to/#/@test:matrix.org"))
}
@Test
fun `converting a url with a supported group path returns a matrix-to url`() {
val url = Uri.parse("https://riot.im/develop/#/group/+group:matrix.org")
assertThat(MatrixToConverter.convert(url)).isEqualTo(Uri.parse("https://matrix.to/#/+group:matrix.org"))
}
@Test
fun `converting an unsupported url returns null`() {
val url = Uri.parse("https://element.io/")
assertThat(MatrixToConverter.convert(url)).isNull()
}
}

View file

@ -1,80 +0,0 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.api.permalink
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.androidutils.metadata.withReleaseBehavior
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.tests.testutils.assertThrowsInDebug
import org.junit.Test
class PermalinkBuilderTest {
fun `building a permalink for an invalid user id throws when verifying the id`() {
assertThrowsInDebug {
val userId = UserId("some invalid user id")
PermalinkBuilder.permalinkForUser(userId)
}
}
fun `building a permalink for an invalid room id throws when verifying the id`() {
assertThrowsInDebug {
val roomId = RoomId("some invalid room id")
PermalinkBuilder.permalinkForRoomId(roomId)
}
}
@Test
fun `building a permalink for an invalid user id returns failure when not verifying the id`() {
withReleaseBehavior {
val userId = UserId("some invalid user id")
assertThat(PermalinkBuilder.permalinkForUser(userId).isFailure).isTrue()
}
}
@Test
fun `building a permalink for an invalid room id returns failure when not verifying the id`() {
withReleaseBehavior {
val roomId = RoomId("some invalid room id")
assertThat(PermalinkBuilder.permalinkForRoomId(roomId).isFailure).isTrue()
}
}
@Test
fun `building a permalink for an invalid room alias returns failure`() {
val roomAlias = "an invalid room alias"
assertThat(PermalinkBuilder.permalinkForRoomAlias(roomAlias).isFailure).isTrue()
}
@Test
fun `building a permalink for a valid user id returns a matrix-to url`() {
val userId = UserId("@user:matrix.org")
assertThat(PermalinkBuilder.permalinkForUser(userId).getOrNull()).isEqualTo("https://matrix.to/#/@user:matrix.org")
}
@Test
fun `building a permalink for a valid room id returns a matrix-to url`() {
val roomId = RoomId("!aBCdEFG1234:matrix.org")
assertThat(PermalinkBuilder.permalinkForRoomId(roomId).getOrNull()).isEqualTo("https://matrix.to/#/!aBCdEFG1234:matrix.org")
}
@Test
fun `building a permalink for a valid room alias returns a matrix-to url`() {
val roomAlias = "#room:matrix.org"
assertThat(PermalinkBuilder.permalinkForRoomAlias(roomAlias).getOrNull()).isEqualTo("https://matrix.to/#/#room:matrix.org")
}
}

View file

@ -1,167 +0,0 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.api.permalink
import com.google.common.truth.Truth.assertThat
import kotlinx.collections.immutable.persistentListOf
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class PermalinkParserTest {
@Test
fun `parsing an invalid url returns a fallback link`() {
val url = "https://element.io"
assertThat(PermalinkParser.parse(url)).isInstanceOf(PermalinkData.FallbackLink::class.java)
}
@Test
fun `parsing an invalid url with the right path but no content returns a fallback link`() {
val url = "https://app.element.io/#/user"
assertThat(PermalinkParser.parse(url)).isInstanceOf(PermalinkData.FallbackLink::class.java)
}
@Test
fun `parsing an invalid url with the right path but empty content returns a fallback link`() {
val url = "https://app.element.io/#/user/"
assertThat(PermalinkParser.parse(url)).isInstanceOf(PermalinkData.FallbackLink::class.java)
}
@Test
fun `parsing an invalid url with the right path but invalid content returns a fallback link`() {
val url = "https://app.element.io/#/user/some%20user!"
assertThat(PermalinkParser.parse(url)).isInstanceOf(PermalinkData.FallbackLink::class.java)
}
@Test
fun `parsing a valid user url returns a user link`() {
val url = "https://app.element.io/#/user/@test:matrix.org"
assertThat(PermalinkParser.parse(url)).isEqualTo(
PermalinkData.UserLink(
userId = "@test:matrix.org"
)
)
}
@Test
fun `parsing a valid room id url returns a room link`() {
val url = "https://app.element.io/#/room/!aBCD1234:matrix.org"
assertThat(PermalinkParser.parse(url)).isEqualTo(
PermalinkData.RoomLink(
roomIdOrAlias = "!aBCD1234:matrix.org",
isRoomAlias = false,
eventId = null,
viaParameters = persistentListOf(),
)
)
}
@Test
fun `parsing a valid room id with event id url returns a room link`() {
val url = "https://app.element.io/#/room/!aBCD1234:matrix.org/$1234567890abcdef:matrix.org"
assertThat(PermalinkParser.parse(url)).isEqualTo(
PermalinkData.RoomLink(
roomIdOrAlias = "!aBCD1234:matrix.org",
isRoomAlias = false,
eventId = "\$1234567890abcdef:matrix.org",
viaParameters = persistentListOf(),
)
)
}
@Test
fun `parsing a valid room id with and invalid event id url returns a room link with no event id`() {
val url = "https://app.element.io/#/room/!aBCD1234:matrix.org/1234567890abcdef:matrix.org"
assertThat(PermalinkParser.parse(url)).isEqualTo(
PermalinkData.RoomLink(
roomIdOrAlias = "!aBCD1234:matrix.org",
isRoomAlias = false,
eventId = null,
viaParameters = persistentListOf(),
)
)
}
@Test
fun `parsing a valid room id with event id and via parameters url returns a room link`() {
val url = "https://app.element.io/#/room/!aBCD1234:matrix.org/$1234567890abcdef:matrix.org?via=matrix.org&via=matrix.com"
assertThat(PermalinkParser.parse(url)).isEqualTo(
PermalinkData.RoomLink(
roomIdOrAlias = "!aBCD1234:matrix.org",
isRoomAlias = false,
eventId = "\$1234567890abcdef:matrix.org",
viaParameters = persistentListOf("matrix.org", "matrix.com"),
)
)
}
@Test
fun `parsing a valid room alias url returns a room link`() {
val url = "https://app.element.io/#/room/#element-android:matrix.org"
assertThat(PermalinkParser.parse(url)).isEqualTo(
PermalinkData.RoomLink(
roomIdOrAlias = "#element-android:matrix.org",
isRoomAlias = true,
eventId = null,
viaParameters = persistentListOf(),
)
)
}
@Test
fun `parsing a url with an invalid signurl returns a fallback link`() {
// This url has no private key
val url = "https://app.element.io/#/room/%21aBCDEF12345%3Amatrix.org" +
"?email=testuser%40element.io" +
"&signurl=https%3A%2F%2Fvector.im%2F_matrix%2Fidentity%2Fapi%2Fv1%2Fsign-ed25519%3Ftoken%3Da_token" +
"&room_name=TestRoom" +
"&room_avatar_url=" +
"&inviter_name=User" +
"&guest_access_token=" +
"&guest_user_id=" +
"&room_type="
assertThat(PermalinkParser.parse(url)).isInstanceOf(PermalinkData.FallbackLink::class.java)
}
@Test
fun `parsing a url with signurl returns a room email invite link`() {
val url = "https://app.element.io/#/room/%21aBCDEF12345%3Amatrix.org" +
"?email=testuser%40element.io" +
"&signurl=https%3A%2F%2Fvector.im%2F_matrix%2Fidentity%2Fapi%2Fv1%2Fsign-ed25519%3Ftoken%3Da_token%26private_key%3Da_private_key" +
"&room_name=TestRoom" +
"&room_avatar_url=" +
"&inviter_name=User" +
"&guest_access_token=" +
"&guest_user_id=" +
"&room_type="
assertThat(PermalinkParser.parse(url)).isEqualTo(
PermalinkData.RoomEmailInviteLink(
roomId = "!aBCDEF12345:matrix.org",
email = "testuser@element.io",
signUrl = "https://vector.im/_matrix/identity/api/v1/sign-ed25519?token=a_token&private_key=a_private_key",
roomName = "TestRoom",
roomAvatarUrl = "",
inviterName = "User",
identityServer = "vector.im",
token = "a_token",
privateKey = "a_private_key",
roomType = "",
)
)
}
}