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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue