Linkify URLs whose host is a bracketed IPv6 address

Patterns.WEB_URL, which LinkifyCompat uses to find links, has no
production for a bracketed IPv6 authority, so a URL such as
http://[2001:db8::1]:8008/path was never turned into a link. Worse, the
phone number matcher claimed the digit runs inside the address, leaving a
tel: link over part of it.

LinkifyHelper now runs its own pass for those URLs. It requires both a
scheme and the brackets, so array syntax like values[0]:1 is not matched,
and it runs after LinkifyCompat.addLinks, which strips any span set before
it, but before the pre-existing spans are restored, so real anchors and
pill spans keep their precedence. Any span Linkify left inside the address
is evicted, and the same trailing punctuation trimming as the other links
is applied.

Relates to https://github.com/element-hq/element-x-android/issues/2614
This commit is contained in:
hayaksi1
2026-08-09 03:51:38 +03:00
parent 044737a3a7
commit 0ccb9b23cc
2 changed files with 64 additions and 0 deletions
@@ -23,6 +23,12 @@ 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*)?""")
fun linkify(
text: CharSequence,
@LinkifyCompat.LinkifyMask linkifyMask: Int = Linkify.WEB_URLS or Linkify.PHONE_NUMBERS or Linkify.EMAIL_ADDRESSES,
@@ -67,6 +73,27 @@ object LinkifyHelper {
}
}
// Patterns.WEB_URL, which LinkifyCompat uses, has no production for a bracketed IPv6
// authority, so such a URL is never linkified — and worse, the phone-number matcher claims
// the digit runs inside the address. Add the links here, after addLinks (which strips any
// span set before it) and before the old spans are restored, so real <a href> and pill
// spans still win. See https://github.com/element-hq/element-x-android/issues/2614
for (match in IPV6_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")
}.getOrNull() ?: end
// Evict whatever Linkify put inside the address, typically a tel: span.
for (span in spannable.getSpans<URLSpan>(start, newEnd)) {
spannable.removeSpan(span)
}
spannable.setSpan(URLSpan(spannable.substring(start, newEnd)), start, newEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
}
// Restore old spans, remove new ones if there is a conflict
for ((urlSpan, location) in oldURLSpans) {
val (start, end) = location
@@ -36,6 +36,43 @@ class LinkifierHelperTest : RobolectricTest() {
assertThat(urlSpans.first().url).isEqualTo("https://matrix.org")
}
@Test
fun `linkification finds ipv6 URL`() {
val text = "A url http://[2001:db8::1]:8008/path"
val result = LinkifyHelper.linkify(text)
val urlSpans = result.toSpannable().getSpans<URLSpan>()
assertThat(urlSpans.size).isEqualTo(1)
assertThat(urlSpans.first().url).isEqualTo("http://[2001:db8::1]:8008/path")
}
@Test
@Config(sdk = [30])
fun `linkification of an ipv6 URL does not leave a phone number span`() {
shadowOf(newInstanceOf(TelephonyManager::class.java)).setSimCountryIso("DE")
val text = "A url http://[2001:db8::1]:8008/path"
val result = LinkifyHelper.linkify(text)
val urlSpans = result.toSpannable().getSpans<URLSpan>()
assertThat(urlSpans.size).isEqualTo(1)
assertThat(urlSpans.first().url).isEqualTo("http://[2001:db8::1]:8008/path")
}
@Test
fun `linkification of an ipv6 URL trims trailing punctuation`() {
val text = "A url http://[::1]/x."
val result = LinkifyHelper.linkify(text)
val urlSpans = result.toSpannable().getSpans<URLSpan>()
assertThat(urlSpans.size).isEqualTo(1)
assertThat(urlSpans.first().url).isEqualTo("http://[::1]/x")
}
@Test
fun `linkification ignores bracketed text which is not a URL`() {
val text = "An array values[0]:1 and a bare [::1]"
val result = LinkifyHelper.linkify(text)
val urlSpans = result.toSpannable().getSpans<URLSpan>()
assertThat(urlSpans).isEmpty()
}
@Test
fun `linkification finds partial URL`() {
val text = "A partial url matrix.org/test"