Html rendering: fix lots of things and add clickable links

This commit is contained in:
ganfra 2022-12-01 20:41:19 +01:00
parent d2ab0872ec
commit e7697a7585
8 changed files with 710 additions and 67 deletions

View file

@ -3,6 +3,7 @@
package io.element.android.x.features.messages package io.element.android.x.features.messages
import Avatar import Avatar
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
@ -334,6 +335,7 @@ fun MessageEventRow(
onLongClick: () -> Unit, onLongClick: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val interactionSource = remember { MutableInteractionSource() }
val (parentAlignment, contentAlignment) = if (messageEvent.isMine) { val (parentAlignment, contentAlignment) = if (messageEvent.isMine) {
Pair(Alignment.CenterEnd, End) Pair(Alignment.CenterEnd, End)
} else { } else {
@ -360,6 +362,7 @@ fun MessageEventRow(
MessageEventBubble( MessageEventBubble(
groupPosition = messageEvent.groupPosition, groupPosition = messageEvent.groupPosition,
isMine = messageEvent.isMine, isMine = messageEvent.isMine,
interactionSource = interactionSource,
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
modifier = Modifier modifier = Modifier
@ -378,7 +381,9 @@ fun MessageEventRow(
) )
is MessagesTimelineItemTextBasedContent -> MessagesTimelineItemTextView( is MessagesTimelineItemTextBasedContent -> MessagesTimelineItemTextView(
content = messageEvent.content, content = messageEvent.content,
modifier = contentModifier interactionSource = interactionSource,
modifier = contentModifier,
onTextClicked = onClick
) )
is MessagesTimelineItemUnknownContent -> MessagesTimelineItemUnknownView( is MessagesTimelineItemUnknownContent -> MessagesTimelineItemUnknownView(
content = messageEvent.content, content = messageEvent.content,

View file

@ -24,6 +24,7 @@ private val BUBBLE_RADIUS = 16.dp
fun MessageEventBubble( fun MessageEventBubble(
groupPosition: MessagesItemGroupPosition, groupPosition: MessagesItemGroupPosition,
isMine: Boolean, isMine: Boolean,
interactionSource: MutableInteractionSource,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onClick: () -> Unit, onClick: () -> Unit,
onLongClick: () -> Unit, onLongClick: () -> Unit,
@ -87,7 +88,7 @@ fun MessageEventBubble(
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
indication = rememberRipple(), indication = rememberRipple(),
interactionSource = remember { MutableInteractionSource() } interactionSource = interactionSource
), ),
color = backgroundBubbleColor, color = backgroundBubbleColor,
shape = bubbleShape, shape = bubbleShape,

View file

@ -1,5 +1,6 @@
package io.element.android.x.features.messages.components package io.element.android.x.features.messages.components
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -10,11 +11,18 @@ import io.element.android.x.features.messages.model.content.MessagesTimelineItem
@Composable @Composable
fun MessagesTimelineItemTextView( fun MessagesTimelineItemTextView(
content: MessagesTimelineItemTextBasedContent, content: MessagesTimelineItemTextBasedContent,
modifier: Modifier = Modifier interactionSource: MutableInteractionSource,
modifier: Modifier = Modifier,
onTextClicked: () -> Unit,
) { ) {
val htmlDocument = content.htmlDocument val htmlDocument = content.htmlDocument
if (htmlDocument != null) { if (htmlDocument != null) {
HtmlDocument(document = htmlDocument, modifier) HtmlDocument(
document = htmlDocument,
modifier = modifier,
onTextClicked = onTextClicked,
interactionSource = interactionSource
)
} else { } else {
Box(modifier) { Box(modifier) {
Text(text = content.body) Text(text = content.body)

View file

@ -1,20 +1,27 @@
@file:OptIn(ExperimentalMaterialApi::class)
package io.element.android.x.features.messages.components.html package io.element.android.x.features.messages.components.html
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.offset import androidx.compose.foundation.interaction.PressInteraction
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ColorScheme import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.material3.MaterialTheme import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.material3.Surface import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material3.Text import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.* import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
@ -22,63 +29,161 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.google.accompanist.flowlayout.FlowRow
import io.element.android.x.matrix.permalink.PermalinkData
import io.element.android.x.matrix.permalink.PermalinkParser
import kotlinx.coroutines.launch
import org.jsoup.nodes.Document import org.jsoup.nodes.Document
import org.jsoup.nodes.Element import org.jsoup.nodes.Element
import org.jsoup.nodes.Node import org.jsoup.nodes.Node
import org.jsoup.nodes.TextNode import org.jsoup.nodes.TextNode
private const val chipId = "chip"
@Composable @Composable
fun HtmlDocument(document: Document, modifier: Modifier = Modifier) { fun HtmlDocument(
HtmlBody(body = document.body(), modifier = modifier) document: Document,
interactionSource: MutableInteractionSource,
onTextClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
HtmlBody(
body = document.body(),
modifier = modifier,
onTextClicked = onTextClicked,
interactionSource = interactionSource
)
} }
@Composable @Composable
private fun HtmlBody(body: Element, modifier: Modifier = Modifier) { private fun HtmlBody(
Column( body: Element,
modifier = modifier modifier: Modifier = Modifier,
onTextClicked: () -> Unit,
interactionSource: MutableInteractionSource,
) {
@Composable
fun NodesFlowRode(
nodes: Iterator<Node>,
onTextClicked: () -> Unit,
interactionSource: MutableInteractionSource,
) = FlowRow(
mainAxisSpacing = 2.dp,
crossAxisSpacing = 8.dp,
) { ) {
for (node in body.childNodes()) { var sameRow = true
when (node) { while (sameRow && nodes.hasNext()) {
when (val node = nodes.next()) {
is TextNode -> { is TextNode -> {
if (!node.isBlank) { if (!node.isBlank) {
Text(text = node.text()) Text(text = node.text())
} }
} }
is Element -> { is Element -> {
HtmlBlock(element = node) if (node.isInline()) {
} HtmlInline(
else -> { node,
continue onTextClicked = onTextClicked,
interactionSource = interactionSource
)
} else {
HtmlBlock(
element = node,
onTextClicked = onTextClicked,
interactionSource = interactionSource
)
sameRow = false
}
} }
else -> continue
} }
} }
} }
Column(modifier = modifier) {
val nodesIterator = body.childNodes().iterator()
while (nodesIterator.hasNext()) {
NodesFlowRode(
nodes = nodesIterator,
onTextClicked = onTextClicked,
interactionSource = interactionSource
)
}
}
} }
@Composable private fun Element.isInline(): Boolean {
private fun HtmlBlock(element: Element, modifier: Modifier = Modifier) { return when (normalName()) {
val blockModifier = modifier "del" -> true
.padding(top = 4.dp) "mx-reply" -> false
when (element.normalName()) { else -> !isBlock
"p" -> HtmlParagraph(element, blockModifier)
"h1", "h2", "h3", "h4", "h5", "h6" -> HtmlHeading(element, blockModifier)
"ol" -> HtmlOrderedList(element, blockModifier)
"ul" -> HtmlUnorderedList(element, blockModifier)
"blockquote" -> HtmlBlockquote(element, blockModifier)
"pre" -> HtmlPreformatted(element, blockModifier)
"mx-reply" -> HtmlMxReply(element, blockModifier)
// fallback to html inline
else -> HtmlInline(element, modifier)
} }
} }
@Composable @Composable
private fun HtmlInline(element: Element, modifier: Modifier = Modifier) { private fun HtmlBlock(
Box(modifier.padding(start = 8.dp)) { element: Element,
modifier: Modifier = Modifier,
onTextClicked: () -> Unit,
interactionSource: MutableInteractionSource,
) {
val blockModifier = modifier
.padding(top = 4.dp)
when (element.normalName()) {
"p" -> HtmlParagraph(
paragraph = element,
modifier = blockModifier,
onTextClicked = onTextClicked,
interactionSource = interactionSource
)
"h1", "h2", "h3", "h4", "h5", "h6" -> HtmlHeading(
heading = element,
modifier = blockModifier,
onTextClicked = onTextClicked,
interactionSource = interactionSource
)
"ol" -> HtmlOrderedList(
orderedList = element,
modifier = blockModifier,
onTextClicked = onTextClicked,
interactionSource = interactionSource
)
"ul" -> HtmlUnorderedList(
unorderedList = element,
modifier = blockModifier,
onTextClicked = onTextClicked,
interactionSource = interactionSource
)
"blockquote" -> HtmlBlockquote(
blockquote = element,
modifier = blockModifier,
onTextClicked = onTextClicked,
interactionSource = interactionSource
)
"pre" -> HtmlPreformatted(element, blockModifier)
"mx-reply" -> HtmlMxReply(
mxReply = element,
modifier = blockModifier,
onTextClicked = onTextClicked,
interactionSource = interactionSource
)
else -> return
}
}
@Composable
private fun HtmlInline(
element: Element,
modifier: Modifier = Modifier,
onTextClicked: () -> Unit,
interactionSource: MutableInteractionSource,
) {
Box(modifier) {
val styledText = buildAnnotatedString { val styledText = buildAnnotatedString {
appendInlineElement(element, MaterialTheme.colorScheme) appendInlineElement(element, MaterialTheme.colorScheme)
} }
Text(styledText) HtmlText(text = styledText, onClick = onTextClicked, interactionSource = interactionSource)
} }
} }
@ -100,17 +205,27 @@ private fun HtmlPreformatted(pre: Element, modifier: Modifier = Modifier) {
} }
@Composable @Composable
private fun HtmlParagraph(paragraph: Element, modifier: Modifier = Modifier) { private fun HtmlParagraph(
paragraph: Element,
modifier: Modifier = Modifier,
onTextClicked: () -> Unit,
interactionSource: MutableInteractionSource,
) {
Box(modifier) { Box(modifier) {
val styledText = buildAnnotatedString { val styledText = buildAnnotatedString {
appendInlineChildrenElements(paragraph.childNodes(), MaterialTheme.colorScheme) appendInlineChildrenElements(paragraph.childNodes(), MaterialTheme.colorScheme)
} }
Text(styledText) HtmlText(text = styledText, onClick = onTextClicked, interactionSource = interactionSource)
} }
} }
@Composable @Composable
private fun HtmlBlockquote(blockquote: Element, modifier: Modifier = Modifier) { private fun HtmlBlockquote(
blockquote: Element,
modifier: Modifier = Modifier,
onTextClicked: () -> Unit,
interactionSource: MutableInteractionSource,
) {
val color = MaterialTheme.colorScheme.onBackground val color = MaterialTheme.colorScheme.onBackground
Box( Box(
modifier = modifier modifier = modifier
@ -129,13 +244,18 @@ private fun HtmlBlockquote(blockquote: Element, modifier: Modifier = Modifier) {
appendInlineChildrenElements(blockquote.childNodes(), MaterialTheme.colorScheme) appendInlineChildrenElements(blockquote.childNodes(), MaterialTheme.colorScheme)
} }
} }
Text(text) HtmlText(text = text, onClick = onTextClicked, interactionSource = interactionSource)
} }
} }
@Composable @Composable
private fun HtmlHeading(heading: Element, modifier: Modifier = Modifier) { private fun HtmlHeading(
heading: Element,
modifier: Modifier = Modifier,
onTextClicked: () -> Unit,
interactionSource: MutableInteractionSource,
) {
val style = when (heading.normalName()) { val style = when (heading.normalName()) {
"h1" -> MaterialTheme.typography.headlineLarge.copy(fontSize = 30.sp) "h1" -> MaterialTheme.typography.headlineLarge.copy(fontSize = 30.sp)
"h2" -> MaterialTheme.typography.headlineLarge.copy(fontSize = 26.sp) "h2" -> MaterialTheme.typography.headlineLarge.copy(fontSize = 26.sp)
@ -151,16 +271,28 @@ private fun HtmlHeading(heading: Element, modifier: Modifier = Modifier) {
val text = buildAnnotatedString { val text = buildAnnotatedString {
appendInlineChildrenElements(heading.childNodes(), MaterialTheme.colorScheme) appendInlineChildrenElements(heading.childNodes(), MaterialTheme.colorScheme)
} }
Text(text, style = style) HtmlText(
text = text,
style = style,
onClick = onTextClicked,
interactionSource = interactionSource
)
} }
} }
@Composable @Composable
private fun HtmlMxReply(mxReply: Element, modifier: Modifier = Modifier) { private fun HtmlMxReply(
mxReply: Element,
modifier: Modifier = Modifier,
onTextClicked: () -> Unit,
interactionSource: MutableInteractionSource,
) {
val blockquote = mxReply.childNodes().firstOrNull() ?: return val blockquote = mxReply.childNodes().firstOrNull() ?: return
val shape = RoundedCornerShape(12.dp) val shape = RoundedCornerShape(12.dp)
Surface( Surface(
modifier = modifier.offset(x = -(8.dp)), modifier = modifier
.padding(bottom = 4.dp)
.offset(x = -(8.dp)),
color = MaterialTheme.colorScheme.background, color = MaterialTheme.colorScheme.background,
shape = shape, shape = shape,
) { ) {
@ -190,30 +322,55 @@ private fun HtmlMxReply(mxReply: Element, modifier: Modifier = Modifier) {
} }
} }
} }
Text(text, modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)) HtmlText(
text = text,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
onClick = onTextClicked,
interactionSource = interactionSource
)
} }
} }
@Composable @Composable
private fun HtmlOrderedList(unorderedList: Element, modifier: Modifier = Modifier) { private fun HtmlOrderedList(
orderedList: Element,
modifier: Modifier = Modifier,
onTextClicked: () -> Unit,
interactionSource: MutableInteractionSource,
) {
var number = 1 var number = 1
val delimiter = "." val delimiter = "."
HtmlListItems(unorderedList, modifier = modifier) { HtmlListItems(
list = orderedList,
modifier = modifier,
onTextClicked = onTextClicked,
interactionSource = interactionSource
) {
val text = buildAnnotatedString { val text = buildAnnotatedString {
append("${number++}$delimiter ${it.text()}") append("${number++}$delimiter ${it.text()}")
} }
Text(text) HtmlText(text = text, onClick = onTextClicked, interactionSource = interactionSource)
} }
} }
@Composable @Composable
private fun HtmlUnorderedList(unorderedList: Element, modifier: Modifier = Modifier) { private fun HtmlUnorderedList(
unorderedList: Element,
modifier: Modifier = Modifier,
onTextClicked: () -> Unit,
interactionSource: MutableInteractionSource,
) {
val marker = "" val marker = ""
HtmlListItems(unorderedList, modifier = modifier) { HtmlListItems(
list = unorderedList,
modifier = modifier,
onTextClicked = onTextClicked,
interactionSource = interactionSource
) {
val text = buildAnnotatedString { val text = buildAnnotatedString {
append("$marker ${it.text()}") append("$marker ${it.text()}")
} }
Text(text) HtmlText(text = text, onClick = onTextClicked, interactionSource = interactionSource)
} }
} }
@ -222,6 +379,8 @@ private fun HtmlUnorderedList(unorderedList: Element, modifier: Modifier = Modif
private fun HtmlListItems( private fun HtmlListItems(
list: Element, list: Element,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onTextClicked: () -> Unit,
interactionSource: MutableInteractionSource,
content: @Composable (node: TextNode) -> Unit content: @Composable (node: TextNode) -> Unit
) { ) {
Column(modifier = modifier) { Column(modifier = modifier) {
@ -233,7 +392,9 @@ private fun HtmlListItems(
} }
is Element -> HtmlBlock( is Element -> HtmlBlock(
element = innerNode, element = innerNode,
modifier = modifier.padding(start = 4.dp) modifier = modifier.padding(start = 4.dp),
onTextClicked = onTextClicked,
interactionSource = interactionSource
) )
} }
} }
@ -295,20 +456,79 @@ private fun AnnotatedString.Builder.appendInlineElement(element: Element, colors
} }
} }
"a" -> { "a" -> {
val href = element.attr("href") appendLink(element)
pushStringAnnotation(tag = "url", annotation = href)
withStyle(
style = SpanStyle(
color = Color.Blue,
textDecoration = TextDecoration.Underline
)
) {
append(element.ownText())
}
pop()
} }
else -> { else -> {
appendInlineChildrenElements(element.childNodes(), colors) appendInlineChildrenElements(element.childNodes(), colors)
} }
} }
} }
private fun AnnotatedString.Builder.appendLink(link: Element) {
val uriString = link.attr("href")
val permalinkData = PermalinkParser.parse(uriString)
when (permalinkData) {
is PermalinkData.FallbackLink -> {
pushStringAnnotation(tag = "link", annotation = link.ownText())
withStyle(
style = SpanStyle(color = Color.Blue)
) {
append(link.ownText())
}
pop()
}
is PermalinkData.RoomEmailInviteLink -> {
appendInlineContent(chipId, link.ownText())
}
is PermalinkData.RoomLink -> {
appendInlineContent(chipId, link.ownText())
}
is PermalinkData.UserLink -> {
appendInlineContent(chipId, link.ownText())
}
}
}
@Composable
private fun HtmlText(
text: AnnotatedString,
modifier: Modifier = Modifier,
style: TextStyle = LocalTextStyle.current,
onClick: () -> Unit,
interactionSource: MutableInteractionSource,
) {
val coroutineScope = rememberCoroutineScope()
val uriHandler = LocalUriHandler.current
val layoutResult = remember { mutableStateOf<TextLayoutResult?>(null) }
val pressIndicator = Modifier.pointerInput(onClick) {
detectTapGestures { offset ->
layoutResult.value?.let { layoutResult ->
val position = layoutResult.getOffsetForPosition(offset)
val linkAnnotations = text.getStringAnnotations("link", position, position)
if (linkAnnotations.isEmpty()) {
onClick()
coroutineScope.launch {
val pressInteraction = PressInteraction.Press(offset)
interactionSource.emit(pressInteraction)
interactionSource.emit(PressInteraction.Release(pressInteraction))
}
} else {
uriHandler.openUri(linkAnnotations.first().item)
}
}
}
}
val inlineContentMap = emptyMap<String, InlineTextContent>()
Text(
text = text,
modifier = modifier.then(pressIndicator),
style = style,
onTextLayout = {
layoutResult.value = it
},
inlineContent = inlineContentMap
)
}

View file

@ -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(":")
}
}

View file

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

View file

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

View file

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