Html rendering: fix lots of things and add clickable links
This commit is contained in:
parent
45cf334d6e
commit
1655fe80d6
8 changed files with 710 additions and 67 deletions
|
|
@ -0,0 +1,201 @@
|
|||
/*
|
||||
* Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
*
|
||||
* 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.x.matrix.core
|
||||
|
||||
import io.element.android.x.sdk.matrix.BuildConfig
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* This class contains pattern to match the different Matrix ids
|
||||
* Ref: https://matrix.org/docs/spec/appendices#identifier-grammar
|
||||
*/
|
||||
object MatrixPatterns {
|
||||
|
||||
// Note: TLD is not mandatory (localhost, IP address...)
|
||||
private const val DOMAIN_REGEX = ":[A-Z0-9.-]+(:[0-9]{2,5})?"
|
||||
|
||||
// regex pattern to find matrix user ids in a string.
|
||||
// See https://matrix.org/docs/spec/appendices#historical-user-ids
|
||||
private const val MATRIX_USER_IDENTIFIER_REGEX = "@[A-Z0-9\\x21-\\x39\\x3B-\\x7F]+$DOMAIN_REGEX"
|
||||
val PATTERN_CONTAIN_MATRIX_USER_IDENTIFIER = MATRIX_USER_IDENTIFIER_REGEX.toRegex(RegexOption.IGNORE_CASE)
|
||||
|
||||
// regex pattern to find room ids in a string.
|
||||
private const val MATRIX_ROOM_IDENTIFIER_REGEX = "![A-Z0-9]+$DOMAIN_REGEX"
|
||||
private val PATTERN_CONTAIN_MATRIX_ROOM_IDENTIFIER = MATRIX_ROOM_IDENTIFIER_REGEX.toRegex(RegexOption.IGNORE_CASE)
|
||||
|
||||
// regex pattern to find room aliases in a string.
|
||||
private const val MATRIX_ROOM_ALIAS_REGEX = "#[A-Z0-9._%#@=+-]+$DOMAIN_REGEX"
|
||||
private val PATTERN_CONTAIN_MATRIX_ALIAS = MATRIX_ROOM_ALIAS_REGEX.toRegex(RegexOption.IGNORE_CASE)
|
||||
|
||||
// regex pattern to find message ids in a string.
|
||||
private const val MATRIX_EVENT_IDENTIFIER_REGEX = "\\$[A-Z0-9]+$DOMAIN_REGEX"
|
||||
private val PATTERN_CONTAIN_MATRIX_EVENT_IDENTIFIER = MATRIX_EVENT_IDENTIFIER_REGEX.toRegex(RegexOption.IGNORE_CASE)
|
||||
|
||||
// regex pattern to find message ids in a string.
|
||||
private const val MATRIX_EVENT_IDENTIFIER_V3_REGEX = "\\$[A-Z0-9/+]+"
|
||||
private val PATTERN_CONTAIN_MATRIX_EVENT_IDENTIFIER_V3 = MATRIX_EVENT_IDENTIFIER_V3_REGEX.toRegex(RegexOption.IGNORE_CASE)
|
||||
|
||||
// Ref: https://matrix.org/docs/spec/rooms/v4#event-ids
|
||||
private const val MATRIX_EVENT_IDENTIFIER_V4_REGEX = "\\$[A-Z0-9\\-_]+"
|
||||
private val PATTERN_CONTAIN_MATRIX_EVENT_IDENTIFIER_V4 = MATRIX_EVENT_IDENTIFIER_V4_REGEX.toRegex(RegexOption.IGNORE_CASE)
|
||||
|
||||
// regex pattern to find group ids in a string.
|
||||
private const val MATRIX_GROUP_IDENTIFIER_REGEX = "\\+[A-Z0-9=_\\-./]+$DOMAIN_REGEX"
|
||||
private val PATTERN_CONTAIN_MATRIX_GROUP_IDENTIFIER = MATRIX_GROUP_IDENTIFIER_REGEX.toRegex(RegexOption.IGNORE_CASE)
|
||||
|
||||
// regex pattern to find permalink with message id.
|
||||
// Android does not support in URL so extract it.
|
||||
private const val PERMALINK_BASE_REGEX = "https://matrix\\.to/#/"
|
||||
private const val APP_BASE_REGEX = "https://[A-Z0-9.-]+\\.[A-Z]{2,}/[A-Z]{3,}/#/room/"
|
||||
const val SEP_REGEX = "/"
|
||||
|
||||
private const val LINK_TO_ROOM_ID_REGEXP = PERMALINK_BASE_REGEX + MATRIX_ROOM_IDENTIFIER_REGEX + SEP_REGEX + MATRIX_EVENT_IDENTIFIER_REGEX
|
||||
private val PATTERN_CONTAIN_MATRIX_TO_PERMALINK_ROOM_ID = LINK_TO_ROOM_ID_REGEXP.toRegex(RegexOption.IGNORE_CASE)
|
||||
|
||||
private const val LINK_TO_ROOM_ALIAS_REGEXP = PERMALINK_BASE_REGEX + MATRIX_ROOM_ALIAS_REGEX + SEP_REGEX + MATRIX_EVENT_IDENTIFIER_REGEX
|
||||
private val PATTERN_CONTAIN_MATRIX_TO_PERMALINK_ROOM_ALIAS = LINK_TO_ROOM_ALIAS_REGEXP.toRegex(RegexOption.IGNORE_CASE)
|
||||
|
||||
private const val LINK_TO_APP_ROOM_ID_REGEXP = APP_BASE_REGEX + MATRIX_ROOM_IDENTIFIER_REGEX + SEP_REGEX + MATRIX_EVENT_IDENTIFIER_REGEX
|
||||
private val PATTERN_CONTAIN_APP_LINK_PERMALINK_ROOM_ID = LINK_TO_APP_ROOM_ID_REGEXP.toRegex(RegexOption.IGNORE_CASE)
|
||||
|
||||
private const val LINK_TO_APP_ROOM_ALIAS_REGEXP = APP_BASE_REGEX + MATRIX_ROOM_ALIAS_REGEX + SEP_REGEX + MATRIX_EVENT_IDENTIFIER_REGEX
|
||||
private val PATTERN_CONTAIN_APP_LINK_PERMALINK_ROOM_ALIAS = LINK_TO_APP_ROOM_ALIAS_REGEXP.toRegex(RegexOption.IGNORE_CASE)
|
||||
|
||||
// ascii characters in the range \x20 (space) to \x7E (~)
|
||||
val ORDER_STRING_REGEX = "[ -~]+".toRegex()
|
||||
|
||||
// list of patterns to find some matrix item.
|
||||
val MATRIX_PATTERNS = listOf(
|
||||
PATTERN_CONTAIN_MATRIX_TO_PERMALINK_ROOM_ID,
|
||||
PATTERN_CONTAIN_MATRIX_TO_PERMALINK_ROOM_ALIAS,
|
||||
PATTERN_CONTAIN_APP_LINK_PERMALINK_ROOM_ID,
|
||||
PATTERN_CONTAIN_APP_LINK_PERMALINK_ROOM_ALIAS,
|
||||
PATTERN_CONTAIN_MATRIX_USER_IDENTIFIER,
|
||||
PATTERN_CONTAIN_MATRIX_ALIAS,
|
||||
PATTERN_CONTAIN_MATRIX_ROOM_IDENTIFIER,
|
||||
PATTERN_CONTAIN_MATRIX_EVENT_IDENTIFIER,
|
||||
PATTERN_CONTAIN_MATRIX_GROUP_IDENTIFIER
|
||||
)
|
||||
|
||||
/**
|
||||
* Tells if a string is a valid user Id.
|
||||
*
|
||||
* @param str the string to test
|
||||
* @return true if the string is a valid user id
|
||||
*/
|
||||
fun isUserId(str: String?): Boolean {
|
||||
return str != null && str matches PATTERN_CONTAIN_MATRIX_USER_IDENTIFIER
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells if a string is a valid room id.
|
||||
*
|
||||
* @param str the string to test
|
||||
* @return true if the string is a valid room Id
|
||||
*/
|
||||
fun isRoomId(str: String?): Boolean {
|
||||
return str != null && str matches PATTERN_CONTAIN_MATRIX_ROOM_IDENTIFIER
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells if a string is a valid room alias.
|
||||
*
|
||||
* @param str the string to test
|
||||
* @return true if the string is a valid room alias.
|
||||
*/
|
||||
fun isRoomAlias(str: String?): Boolean {
|
||||
return str != null && str matches PATTERN_CONTAIN_MATRIX_ALIAS
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells if a string is a valid event id.
|
||||
*
|
||||
* @param str the string to test
|
||||
* @return true if the string is a valid event id.
|
||||
*/
|
||||
fun isEventId(str: String?): Boolean {
|
||||
return str != null &&
|
||||
(str matches PATTERN_CONTAIN_MATRIX_EVENT_IDENTIFIER ||
|
||||
str matches PATTERN_CONTAIN_MATRIX_EVENT_IDENTIFIER_V3 ||
|
||||
str matches PATTERN_CONTAIN_MATRIX_EVENT_IDENTIFIER_V4)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells if a string is a valid group id.
|
||||
*
|
||||
* @param str the string to test
|
||||
* @return true if the string is a valid group id.
|
||||
*/
|
||||
fun isGroupId(str: String?): Boolean {
|
||||
return str != null && str matches PATTERN_CONTAIN_MATRIX_GROUP_IDENTIFIER
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract server name from a matrix id.
|
||||
*
|
||||
* @param matrixId
|
||||
* @return null if not found or if matrixId is null
|
||||
*/
|
||||
fun extractServerNameFromId(matrixId: String?): String? {
|
||||
return matrixId?.substringAfter(":", missingDelimiterValue = "")?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract user name from a matrix id.
|
||||
*
|
||||
* @param matrixId
|
||||
* @return null if the input is not a valid matrixId
|
||||
*/
|
||||
fun extractUserNameFromId(matrixId: String): String? {
|
||||
return if (isUserId(matrixId)) {
|
||||
matrixId.removePrefix("@").substringBefore(":", missingDelimiterValue = "")
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Orders which are not strings, or do not consist solely of ascii characters in the range \x20 (space) to \x7E (~),
|
||||
* or consist of more than 50 characters, are forbidden and the field should be ignored if received.
|
||||
*/
|
||||
fun isValidOrderString(order: String?): Boolean {
|
||||
return order != null && order.length < 50 && order matches ORDER_STRING_REGEX
|
||||
}
|
||||
|
||||
/*
|
||||
fun candidateAliasFromRoomName(roomName: String, domain: String): String {
|
||||
return roomName.lowercase()
|
||||
.replaceSpaceChars(replacement = "_")
|
||||
.removeInvalidRoomNameChars()
|
||||
.take(MatrixConstants.maxAliasLocalPartLength(domain))
|
||||
}
|
||||
*/
|
||||
|
||||
/**
|
||||
* Return the domain form a userId.
|
||||
* Examples:
|
||||
* - "@alice:domain.org".getDomain() will return "domain.org"
|
||||
* - "@bob:domain.org:3455".getDomain() will return "domain.org:3455"
|
||||
*/
|
||||
fun String.getServerName(): String {
|
||||
if (BuildConfig.DEBUG && !isUserId(this)) {
|
||||
// They are some invalid userId localpart in the wild, but the domain part should be there anyway
|
||||
Timber.w("Not a valid user ID: $this")
|
||||
}
|
||||
return substringAfter(":")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
package io.element.android.x.matrix.permalink
|
||||
|
||||
import android.net.Uri
|
||||
|
||||
/**
|
||||
* Mapping of an input URI to a matrix.to compliant URI.
|
||||
*/
|
||||
object MatrixToConverter {
|
||||
|
||||
const val MATRIX_TO_URL_BASE = "https://matrix.to/#/"
|
||||
|
||||
/**
|
||||
* 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].
|
||||
* 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()
|
||||
|
||||
return when {
|
||||
// URL is already a matrix.to
|
||||
uriString.startsWith(MATRIX_TO_URL_BASE) -> uri
|
||||
// Web or client url
|
||||
SUPPORTED_PATHS.any { it in uriString } -> {
|
||||
val path = SUPPORTED_PATHS.first { it in uriString }
|
||||
Uri.parse(MATRIX_TO_URL_BASE + uriString.substringAfter(path))
|
||||
}
|
||||
// URL is not supported
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private val SUPPORTED_PATHS = listOf(
|
||||
"/#/room/",
|
||||
"/#/user/",
|
||||
"/#/group/"
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
package io.element.android.x.matrix.permalink
|
||||
|
||||
import android.net.Uri
|
||||
|
||||
/**
|
||||
* This sealed class represents all the permalink cases.
|
||||
* You don't have to instantiate yourself but should use [PermalinkParser] instead.
|
||||
*/
|
||||
sealed class PermalinkData {
|
||||
|
||||
data class RoomLink(
|
||||
val roomIdOrAlias: String,
|
||||
val isRoomAlias: Boolean,
|
||||
val eventId: String?,
|
||||
val viaParameters: List<String>
|
||||
) : PermalinkData()
|
||||
|
||||
/*
|
||||
* &room_name=Team2
|
||||
* &room_avatar_url=mxc:
|
||||
* &inviter_name=bob
|
||||
*/
|
||||
data class RoomEmailInviteLink(
|
||||
val roomId: String,
|
||||
val email: String,
|
||||
val signUrl: String,
|
||||
val roomName: String?,
|
||||
val roomAvatarUrl: String?,
|
||||
val inviterName: String?,
|
||||
val identityServer: String,
|
||||
val token: String,
|
||||
val privateKey: String,
|
||||
val roomType: String?
|
||||
) : PermalinkData()
|
||||
|
||||
data class UserLink(val userId: String) : PermalinkData()
|
||||
|
||||
data class FallbackLink(val uri: Uri, val isLegacyGroupLink: Boolean = false) : PermalinkData()
|
||||
}
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
package io.element.android.x.matrix.permalink
|
||||
|
||||
import android.net.Uri
|
||||
import android.net.UrlQuerySanitizer
|
||||
import io.element.android.x.matrix.core.MatrixPatterns
|
||||
import timber.log.Timber
|
||||
import java.net.URLDecoder
|
||||
|
||||
/**
|
||||
* This class turns a uri to a [PermalinkData].
|
||||
* element-based domains (e.g. https://app.element.io/#/user/@chagai95:matrix.org) permalinks
|
||||
* or matrix.to permalinks (e.g. https://matrix.to/#/@chagai95:matrix.org)
|
||||
* or client permalinks (e.g. <clientPermalinkBaseUrl>user/@chagai95:matrix.org)
|
||||
*/
|
||||
object PermalinkParser {
|
||||
|
||||
/**
|
||||
* Turns a uri string to a [PermalinkData].
|
||||
*/
|
||||
fun parse(uriString: String): PermalinkData {
|
||||
val uri = Uri.parse(uriString)
|
||||
return parse(uri)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
)
|
||||
}
|
||||
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()
|
||||
val token = signValidUri.getQueryParameter("token") ?: throw IllegalArgumentException()
|
||||
val privateKey = signValidUri.getQueryParameter("private_key") ?: throw IllegalArgumentException()
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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 UrlQuerySanitizer(this)
|
||||
.parameterList
|
||||
.filter {
|
||||
it.mParameter == "via"
|
||||
}.map {
|
||||
URLDecoder.decode(it.mValue, "UTF-8")
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue