diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/text/LinkifyHelper.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/text/LinkifyHelper.kt index 03dde18006..28d856ce1f 100644 --- a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/text/LinkifyHelper.kt +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/text/LinkifyHelper.kt @@ -16,6 +16,7 @@ import androidx.core.text.toSpannable import androidx.core.text.util.LinkifyCompat import io.element.android.libraries.core.extensions.runCatchingExceptions import timber.log.Timber +import java.net.URI /** * Helper class to linkify text while preserving existing URL spans. @@ -23,11 +24,7 @@ import timber.log.Timber * It also checks the linkified results to make sure URLs spans are not including trailing punctuation. */ object LinkifyHelper { - /** - * Matches a URL whose authority is a bracketed IPv6 address, i.e. `http://[2001:db8::1]:8008/path`. - * A scheme and the brackets are both required, so array syntax such as `values[0]:1` is not matched. - */ - private val IPV6_URL_REGEX = Regex("""(?i)\b(?:https?|ftp)://\[[0-9a-f:.]+(?:%25[0-9a-z._-]+)?](?::\d{1,5})?(?:/\S*)?""") + private val BRACKETED_AUTHORITY_URL_REGEX = Regex("""(? and pill - // spans still win. See https://github.com/element-hq/element-x-android/issues/2614 - for (match in IPV6_URL_REGEX.findAll(spannable)) { + for (match in BRACKETED_AUTHORITY_URL_REGEX.findAll(spannable)) { val start = match.range.first val end = match.range.last + 1 val newEnd = runCatchingExceptions { adjustLinkifiedUrlSpanEndIndex(spannable, start, end) }.onFailure { - Timber.e(it, "Failed to adjust end index for IPv6 link span") + Timber.e(it, "Failed to adjust end index for link span") }.getOrNull() ?: end - // Evict whatever Linkify put inside the address, typically a tel: span. + val url = spannable.substring(start, newEnd) + if (!url.hasBracketedHost()) continue + for (span in spannable.getSpans(start, newEnd)) { spannable.removeSpan(span) } - spannable.setSpan(URLSpan(spannable.substring(start, newEnd)), start, newEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + spannable.setSpan(URLSpan(url), start, newEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) } // Restore old spans, remove new ones if there is a conflict @@ -109,6 +103,11 @@ object LinkifyHelper { return spannable } + private fun String.hasBracketedHost(): Boolean { + val host = runCatchingExceptions { URI(this) }.getOrNull()?.host ?: return false + return host.startsWith('[') && host.endsWith(']') + } + private fun adjustLinkifiedUrlSpanEndIndex(spannable: Spannable, start: Int, end: Int): Int { var end = end diff --git a/libraries/androidutils/src/test/kotlin/io/element/android/libraries/androidutils/text/LinkifierHelperTest.kt b/libraries/androidutils/src/test/kotlin/io/element/android/libraries/androidutils/text/LinkifierHelperTest.kt index 47e6b1a933..9d7e622ca6 100644 --- a/libraries/androidutils/src/test/kotlin/io/element/android/libraries/androidutils/text/LinkifierHelperTest.kt +++ b/libraries/androidutils/src/test/kotlin/io/element/android/libraries/androidutils/text/LinkifierHelperTest.kt @@ -65,6 +65,23 @@ class LinkifierHelperTest : RobolectricTest() { assertThat(urlSpans.first().url).isEqualTo("http://[::1]/x") } + @Test + fun `linkification finds an ipv6 URL of any scheme`() { + val text = "A url ssh://[2001:db8::1]/path" + val result = LinkifyHelper.linkify(text) + val urlSpans = result.toSpannable().getSpans() + assertThat(urlSpans.size).isEqualTo(1) + assertThat(urlSpans.first().url).isEqualTo("ssh://[2001:db8::1]/path") + } + + @Test + fun `linkification ignores a bracketed authority which is not an address`() { + val text = "A url http://[not-an-address]/path" + val result = LinkifyHelper.linkify(text) + val urlSpans = result.toSpannable().getSpans() + assertThat(urlSpans).isEmpty() + } + @Test fun `linkification ignores bracketed text which is not a URL`() { val text = "An array values[0]:1 and a bare [::1]"