Merge pull request #7347 from hayaksi1/fix/2614-linkify-ipv6-urls

Linkify URLs whose host is a bracketed IPv6 address
This commit is contained in:
Benoit Marty
2026-08-13 09:49:40 +02:00
committed by GitHub
2 changed files with 80 additions and 0 deletions
@@ -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,6 +24,8 @@ import timber.log.Timber
* It also checks the linkified results to make sure URLs spans are not including trailing punctuation.
*/
object LinkifyHelper {
private val BRACKETED_AUTHORITY_URL_REGEX = Regex("""(?<![a-zA-Z0-9+.-])[a-zA-Z][a-zA-Z0-9+.-]*://\[\S*""")
fun linkify(
text: CharSequence,
@LinkifyCompat.LinkifyMask linkifyMask: Int = Linkify.WEB_URLS or Linkify.PHONE_NUMBERS or Linkify.EMAIL_ADDRESSES,
@@ -72,6 +75,24 @@ object LinkifyHelper {
}
}
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 link span")
}.getOrNull() ?: end
val url = spannable.substring(start, newEnd)
if (!url.hasBracketedHost()) continue
for (span in spannable.getSpans<URLSpan>(start, newEnd)) {
spannable.removeSpan(span)
}
spannable.setSpan(URLSpan(url), 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
@@ -87,6 +108,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 Spannable.isEmailInsideFediverseHandle(urlSpan: URLSpan, start: Int): Boolean {
return urlSpan.url.startsWith("mailto:") && start > 0 && this[start - 1] == '@'
}
@@ -36,6 +36,60 @@ 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 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<URLSpan>()
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<URLSpan>()
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]"
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"