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 441f819546..03dde18006 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 @@ -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 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(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 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 bb7a272908..47e6b1a933 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 @@ -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() + 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() + 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() + 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() + assertThat(urlSpans).isEmpty() + } + @Test fun `linkification finds partial URL`() { val text = "A partial url matrix.org/test"