Try avoiding trailing punctuation inside linkified URLs. (#4214)
Create `LinkfierHelper` and post-process URLSpans added to make sure they honor the actual URLs in text by removing unnecessarily added trailing punctuation.
This commit is contained in:
parent
b35feb0409
commit
5d8403b310
8 changed files with 274 additions and 32 deletions
|
|
@ -0,0 +1,110 @@
|
|||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.androidutils.text
|
||||
|
||||
import android.text.Spannable
|
||||
import android.text.style.URLSpan
|
||||
import android.text.util.Linkify
|
||||
import androidx.core.text.getSpans
|
||||
import androidx.core.text.toSpannable
|
||||
import androidx.core.text.util.LinkifyCompat
|
||||
import timber.log.Timber
|
||||
import kotlin.collections.component1
|
||||
import kotlin.collections.component2
|
||||
import kotlin.collections.isNotEmpty
|
||||
import kotlin.collections.iterator
|
||||
|
||||
/**
|
||||
* Helper class to linkify text while preserving existing URL spans.
|
||||
*
|
||||
* It also checks the linkified results to make sure URLs spans are not including trailing punctuation.
|
||||
*/
|
||||
object LinkifyHelper {
|
||||
fun linkify(
|
||||
text: CharSequence,
|
||||
@LinkifyCompat.LinkifyMask linkifyMask: Int = Linkify.WEB_URLS or Linkify.PHONE_NUMBERS or Linkify.EMAIL_ADDRESSES,
|
||||
): CharSequence {
|
||||
// Convert the text to a Spannable to be able to add URL spans, return the original text if it's not possible (in tests, i.e.)
|
||||
val spannable = text.toSpannable() ?: return text
|
||||
|
||||
// Get all URL spans, as they will be removed by LinkifyCompat.addLinks
|
||||
val oldURLSpans = spannable.getSpans<URLSpan>(0, text.length).associateWith {
|
||||
val start = spannable.getSpanStart(it)
|
||||
val end = spannable.getSpanEnd(it)
|
||||
Pair(start, end)
|
||||
}
|
||||
// Find and set as URLSpans any links present in the text
|
||||
val addedNewLinks = LinkifyCompat.addLinks(spannable, linkifyMask)
|
||||
|
||||
// Process newly added URL spans
|
||||
if (addedNewLinks) {
|
||||
val newUrlSpans = spannable.getSpans<URLSpan>(0, spannable.length)
|
||||
for (urlSpan in newUrlSpans) {
|
||||
val start = spannable.getSpanStart(urlSpan)
|
||||
val end = spannable.getSpanEnd(urlSpan)
|
||||
|
||||
// Try to avoid including trailing punctuation in the link.
|
||||
// Since this might fail in some edge cases, we catch the exception and just use the original end index.
|
||||
val newEnd = runCatching {
|
||||
adjustLinkifiedUrlSpanEndIndex(spannable, start, end)
|
||||
}.onFailure {
|
||||
Timber.e(it, "Failed to adjust end index for link span")
|
||||
}.getOrNull() ?: end
|
||||
|
||||
// Adapt the url in the URL span to the new end index too if needed
|
||||
if (end != newEnd) {
|
||||
val url = spannable.subSequence(start, newEnd).toString()
|
||||
spannable.removeSpan(urlSpan)
|
||||
spannable.setSpan(URLSpan(url), start, newEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||
} else {
|
||||
spannable.setSpan(urlSpan, start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Restore old spans, remove new ones if there is a conflict
|
||||
for ((urlSpan, location) in oldURLSpans) {
|
||||
val (start, end) = location
|
||||
val addedConflictingSpans = spannable.getSpans<URLSpan>(start, end)
|
||||
if (addedConflictingSpans.isNotEmpty()) {
|
||||
for (span in addedConflictingSpans) {
|
||||
spannable.removeSpan(span)
|
||||
}
|
||||
}
|
||||
|
||||
spannable.setSpan(urlSpan, start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||
}
|
||||
return spannable
|
||||
}
|
||||
|
||||
private fun adjustLinkifiedUrlSpanEndIndex(spannable: Spannable, start: Int, end: Int): Int {
|
||||
var end = end
|
||||
|
||||
// Trailing punctuation found, adjust the end index
|
||||
while (spannable[end - 1] in sequenceOf('.', ',', ';', ':', '!', '?', '…') && end > start) {
|
||||
end--
|
||||
}
|
||||
|
||||
// If the last character is a closing parenthesis, check if it's part of a pair
|
||||
if (spannable[end - 1] == ')' && end > start) {
|
||||
val linkifiedTextLastPath = spannable.substring(start, end).substringAfterLast('/')
|
||||
val closingParenthesisCount = linkifiedTextLastPath.count { it == ')' }
|
||||
val openingParenthesisCount = linkifiedTextLastPath.count { it == '(' }
|
||||
// If it's not part of a pair, remove it from the link span by adjusting the end index
|
||||
end -= closingParenthesisCount - openingParenthesisCount
|
||||
}
|
||||
return end
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Linkify the text with the default mask (WEB_URLS, PHONE_NUMBERS, EMAIL_ADDRESSES).
|
||||
*/
|
||||
fun CharSequence.safeLinkify(): CharSequence {
|
||||
return LinkifyHelper.linkify(this, Linkify.WEB_URLS or Linkify.PHONE_NUMBERS or Linkify.EMAIL_ADDRESSES)
|
||||
}
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.androidutils.text
|
||||
|
||||
import android.telephony.TelephonyManager
|
||||
import android.text.style.URLSpan
|
||||
import androidx.core.text.getSpans
|
||||
import androidx.core.text.toSpannable
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.Shadows.shadowOf
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.shadow.api.Shadow.newInstanceOf
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class LinkifierHelperTest {
|
||||
@get:Rule
|
||||
val warmUpRule = WarmUpRule()
|
||||
|
||||
@Test
|
||||
fun `linkification finds URL`() {
|
||||
val text = "A url https://matrix.org"
|
||||
val result = LinkifyHelper.linkify(text)
|
||||
val urlSpans = result.toSpannable().getSpans<URLSpan>()
|
||||
assertThat(urlSpans.size).isEqualTo(1)
|
||||
assertThat(urlSpans.first().url).isEqualTo("https://matrix.org")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `linkification finds partial URL`() {
|
||||
val text = "A partial url matrix.org/test"
|
||||
val result = LinkifyHelper.linkify(text)
|
||||
val urlSpans = result.toSpannable().getSpans<URLSpan>()
|
||||
assertThat(urlSpans.size).isEqualTo(1)
|
||||
assertThat(urlSpans.first().url).isEqualTo("http://matrix.org/test")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `linkification finds domain`() {
|
||||
val text = "A domain matrix.org"
|
||||
val result = LinkifyHelper.linkify(text)
|
||||
val urlSpans = result.toSpannable().getSpans<URLSpan>()
|
||||
assertThat(urlSpans.size).isEqualTo(1)
|
||||
assertThat(urlSpans.first().url).isEqualTo("http://matrix.org")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `linkification finds email`() {
|
||||
val text = "An email address john@doe.com"
|
||||
val result = LinkifyHelper.linkify(text)
|
||||
val urlSpans = result.toSpannable().getSpans<URLSpan>()
|
||||
assertThat(urlSpans.size).isEqualTo(1)
|
||||
assertThat(urlSpans.first().url).isEqualTo("mailto:john@doe.com")
|
||||
}
|
||||
|
||||
@Test
|
||||
@Config(sdk = [30])
|
||||
fun `linkification finds phone`() {
|
||||
val text = "Test phone number +34950123456"
|
||||
val result = LinkifyHelper.linkify(text)
|
||||
val urlSpans = result.toSpannable().getSpans<URLSpan>()
|
||||
assertThat(urlSpans.size).isEqualTo(1)
|
||||
assertThat(urlSpans.first().url).isEqualTo("tel:+34950123456")
|
||||
}
|
||||
|
||||
@Test
|
||||
@Config(sdk = [30])
|
||||
fun `linkification finds phone in Germany`() {
|
||||
// For some reason the linkification of phone numbers in Germany is very lenient and any number will fit here
|
||||
val telephonyManager = shadowOf(newInstanceOf(TelephonyManager::class.java))
|
||||
telephonyManager.setSimCountryIso("DE")
|
||||
|
||||
val text = "Test phone number 1234"
|
||||
val result = LinkifyHelper.linkify(text)
|
||||
val urlSpans = result.toSpannable().getSpans<URLSpan>()
|
||||
assertThat(urlSpans.size).isEqualTo(1)
|
||||
assertThat(urlSpans.first().url).isEqualTo("tel:1234")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `linkification handles trailing dot`() {
|
||||
val text = "A url https://matrix.org."
|
||||
val result = LinkifyHelper.linkify(text)
|
||||
val urlSpans = result.toSpannable().getSpans<URLSpan>()
|
||||
assertThat(urlSpans.size).isEqualTo(1)
|
||||
assertThat(urlSpans.first().url).isEqualTo("https://matrix.org")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `linkification handles trailing punctuation`() {
|
||||
val text = "A url https://matrix.org!?; Check it out!"
|
||||
val result = LinkifyHelper.linkify(text)
|
||||
val urlSpans = result.toSpannable().getSpans<URLSpan>()
|
||||
assertThat(urlSpans.size).isEqualTo(1)
|
||||
assertThat(urlSpans.first().url).isEqualTo("https://matrix.org")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `linkification handles parenthesis surrounding URL`() {
|
||||
val text = "A url (this one (https://github.com/element-hq/element-android/issues/1234))"
|
||||
val result = LinkifyHelper.linkify(text)
|
||||
val urlSpans = result.toSpannable().getSpans<URLSpan>()
|
||||
assertThat(urlSpans.size).isEqualTo(1)
|
||||
assertThat(urlSpans.first().url).isEqualTo("https://github.com/element-hq/element-android/issues/1234")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `linkification handles parenthesis in URL`() {
|
||||
val text = "A url: (https://github.com/element-hq/element-android/READ(ME))"
|
||||
val result = LinkifyHelper.linkify(text)
|
||||
val urlSpans = result.toSpannable().getSpans<URLSpan>()
|
||||
assertThat(urlSpans.size).isEqualTo(1)
|
||||
assertThat(urlSpans.first().url).isEqualTo("https://github.com/element-hq/element-android/READ(ME)")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue