b00aa9b461
While it's not allowed to create such accounts on Mastodon, it federates fine with servers that allow dashes.
162 lines
8.7 KiB
Kotlin
162 lines
8.7 KiB
Kotlin
package com.keylesspalace.tusky.util
|
||
|
||
import android.text.Spannable
|
||
import android.text.Spanned
|
||
import android.text.style.CharacterStyle
|
||
import android.text.style.ForegroundColorSpan
|
||
import android.text.style.URLSpan
|
||
import java.util.regex.Pattern
|
||
import kotlin.math.max
|
||
|
||
/**
|
||
* @see <a href="https://github.com/tootsuite/mastodon/blob/master/app/models/tag.rb">
|
||
* Tag#HASHTAG_RE</a>.
|
||
*/
|
||
private const val TAG_REGEX = "(?:^|[^/)A-Za-z0-9_])#([\\w_]*[\\p{Alpha}_][\\w_]*)"
|
||
|
||
/**
|
||
* @see <a href="https://github.com/tootsuite/mastodon/blob/master/app/models/account.rb">
|
||
* Account#MENTION_RE</a>
|
||
*/
|
||
private const val MENTION_REGEX = "(?:^|[^/[:word:]])@([a-z0-9_-]+(?:@[a-z0-9\\.\\-]+[a-z0-9]+)?)"
|
||
|
||
private const val HTTP_URL_REGEX = "(?:(^|\\b)http://[^\\s]+)"
|
||
private const val HTTPS_URL_REGEX = "(?:(^|\\b)https://[^\\s]+)"
|
||
|
||
/**
|
||
* Dump of android.util.Patterns.WEB_URL
|
||
*/
|
||
private val STRICT_WEB_URL_PATTERN = Pattern.compile("(((?:(?i:http|https|rtsp)://(?:(?:[a-zA-Z0-9\\\$\\-\\_\\.\\+\\!\\*\\'\\(\\)\\,\\;\\?\\&\\=]|(?:\\%[a-fA-F0-9]{2})){1,64}(?:\\:(?:[a-zA-Z0-9\\\$\\-\\_\\.\\+\\!\\*\\'\\(\\)\\,\\;\\?\\&\\=]|(?:\\%[a-fA-F0-9]{2})){1,25})?\\@)?)?(?:(([a-zA-Z0-9[ -\uD7FF豈-\uFDCFﷰ-\uFFEF\uD800\uDC00-\uD83F\uDFFD\uD840\uDC00-\uD87F\uDFFD\uD880\uDC00-\uD8BF\uDFFD\uD8C0\uDC00-\uD8FF\uDFFD\uD900\uDC00-\uD93F\uDFFD\uD940\uDC00-\uD97F\uDFFD\uD980\uDC00-\uD9BF\uDFFD\uD9C0\uDC00-\uD9FF\uDFFD\uDA00\uDC00-\uDA3F\uDFFD\uDA40\uDC00-\uDA7F\uDFFD\uDA80\uDC00-\uDABF\uDFFD\uDAC0\uDC00-\uDAFF\uDFFD\uDB00\uDC00-\uDB3F\uDFFD\uDB44\uDC00-\uDB7F\uDFFD&&[^ [ - ]\u2028\u2029 ]]](?:[a-zA-Z0-9[ -\uD7FF豈-\uFDCFﷰ-\uFFEF\uD800\uDC00-\uD83F\uDFFD\uD840\uDC00-\uD87F\uDFFD\uD880\uDC00-\uD8BF\uDFFD\uD8C0\uDC00-\uD8FF\uDFFD\uD900\uDC00-\uD93F\uDFFD\uD940\uDC00-\uD97F\uDFFD\uD980\uDC00-\uD9BF\uDFFD\uD9C0\uDC00-\uD9FF\uDFFD\uDA00\uDC00-\uDA3F\uDFFD\uDA40\uDC00-\uDA7F\uDFFD\uDA80\uDC00-\uDABF\uDFFD\uDAC0\uDC00-\uDAFF\uDFFD\uDB00\uDC00-\uDB3F\uDFFD\uDB44\uDC00-\uDB7F\uDFFD&&[^ [ - ]\u2028\u2029 ]]_\\-]{0,61}[a-zA-Z0-9[ -\uD7FF豈-\uFDCFﷰ-\uFFEF\uD800\uDC00-\uD83F\uDFFD\uD840\uDC00-\uD87F\uDFFD\uD880\uDC00-\uD8BF\uDFFD\uD8C0\uDC00-\uD8FF\uDFFD\uD900\uDC00-\uD93F\uDFFD\uD940\uDC00-\uD97F\uDFFD\uD980\uDC00-\uD9BF\uDFFD\uD9C0\uDC00-\uD9FF\uDFFD\uDA00\uDC00-\uDA3F\uDFFD\uDA40\uDC00-\uDA7F\uDFFD\uDA80\uDC00-\uDABF\uDFFD\uDAC0\uDC00-\uDAFF\uDFFD\uDB00\uDC00-\uDB3F\uDFFD\uDB44\uDC00-\uDB7F\uDFFD&&[^ [ - ]\u2028\u2029 ]]]){0,1}\\.)+(xn\\-\\-[\\w\\-]{0,58}\\w|[a-zA-Z[ -\uD7FF豈-\uFDCFﷰ-\uFFEF\uD800\uDC00-\uD83F\uDFFD\uD840\uDC00-\uD87F\uDFFD\uD880\uDC00-\uD8BF\uDFFD\uD8C0\uDC00-\uD8FF\uDFFD\uD900\uDC00-\uD93F\uDFFD\uD940\uDC00-\uD97F\uDFFD\uD980\uDC00-\uD9BF\uDFFD\uD9C0\uDC00-\uD9FF\uDFFD\uDA00\uDC00-\uDA3F\uDFFD\uDA40\uDC00-\uDA7F\uDFFD\uDA80\uDC00-\uDABF\uDFFD\uDAC0\uDC00-\uDAFF\uDFFD\uDB00\uDC00-\uDB3F\uDFFD\uDB44\uDC00-\uDB7F\uDFFD&&[^ [ - ]\u2028\u2029 ]]]{2,63})|((25[0-5]|2[0-4][0-9]|[0-1][0-9]{2}|[1-9][0-9]|[1-9])\\.(25[0-5]|2[0-4][0-9]|[0-1][0-9]{2}|[1-9][0-9]|[1-9]|0)\\.(25[0-5]|2[0-4][0-9]|[0-1][0-9]{2}|[1-9][0-9]|[1-9]|0)\\.(25[0-5]|2[0-4][0-9]|[0-1][0-9]{2}|[1-9][0-9]|[0-9]))))(?:\\:\\d{1,5})?)([/\\?](?:(?:[a-zA-Z0-9[ -\uD7FF豈-\uFDCFﷰ-\uFFEF\uD800\uDC00-\uD83F\uDFFD\uD840\uDC00-\uD87F\uDFFD\uD880\uDC00-\uD8BF\uDFFD\uD8C0\uDC00-\uD8FF\uDFFD\uD900\uDC00-\uD93F\uDFFD\uD940\uDC00-\uD97F\uDFFD\uD980\uDC00-\uD9BF\uDFFD\uD9C0\uDC00-\uD9FF\uDFFD\uDA00\uDC00-\uDA3F\uDFFD\uDA40\uDC00-\uDA7F\uDFFD\uDA80\uDC00-\uDABF\uDFFD\uDAC0\uDC00-\uDAFF\uDFFD\uDB00\uDC00-\uDB3F\uDFFD\uDB44\uDC00-\uDB7F\uDFFD&&[^ [ - ]\u2028\u2029 ]];/\\?:@&=#~\\-\\.\\+!\\*'\\(\\),_\\\$])|(?:%[a-fA-F0-9]{2}))*)?(?:\\b|\$|^))")
|
||
|
||
private val spanClasses = listOf(ForegroundColorSpan::class.java, URLSpan::class.java)
|
||
private val finders = mapOf(
|
||
FoundMatchType.HTTP_URL to PatternFinder(':', HTTP_URL_REGEX, 5, Character::isWhitespace),
|
||
FoundMatchType.HTTPS_URL to PatternFinder(':', HTTPS_URL_REGEX, 6, Character::isWhitespace),
|
||
FoundMatchType.TAG to PatternFinder('#', TAG_REGEX, 1, ::isValidForTagPrefix),
|
||
FoundMatchType.MENTION to PatternFinder('@', MENTION_REGEX, 1, Character::isWhitespace) // TODO: We also need a proper validator for mentions
|
||
)
|
||
|
||
private enum class FoundMatchType {
|
||
HTTP_URL,
|
||
HTTPS_URL,
|
||
TAG,
|
||
MENTION,
|
||
}
|
||
|
||
private class FindCharsResult {
|
||
lateinit var matchType: FoundMatchType
|
||
var start: Int = -1
|
||
var end: Int = -1
|
||
}
|
||
|
||
private class PatternFinder(val searchCharacter: Char, regex: String, val searchPrefixWidth: Int,
|
||
val prefixValidator: (Int) -> Boolean) {
|
||
val pattern: Pattern = Pattern.compile(regex, Pattern.CASE_INSENSITIVE)
|
||
}
|
||
|
||
private fun <T> clearSpans(text: Spannable, spanClass: Class<T>) {
|
||
for(span in text.getSpans(0, text.length, spanClass)) {
|
||
text.removeSpan(span)
|
||
}
|
||
}
|
||
|
||
private fun findPattern(string: String, fromIndex: Int): FindCharsResult {
|
||
val result = FindCharsResult()
|
||
for (i in fromIndex..string.lastIndex) {
|
||
val c = string[i]
|
||
for (matchType in FoundMatchType.values()) {
|
||
val finder = finders[matchType]
|
||
if (finder!!.searchCharacter == c
|
||
&& ((i - fromIndex) < finder.searchPrefixWidth ||
|
||
finder.prefixValidator(string.codePointAt(i - finder.searchPrefixWidth)))) {
|
||
result.matchType = matchType
|
||
result.start = max(0, i - finder.searchPrefixWidth)
|
||
findEndOfPattern(string, result, finder.pattern)
|
||
if (result.start + finder.searchPrefixWidth <= i + 1 && // The found result is actually triggered by the correct search character
|
||
result.end >= result.start) { // ...and we actually found a valid result
|
||
return result
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
|
||
private fun findEndOfPattern(string: String, result: FindCharsResult, pattern: Pattern) {
|
||
val matcher = pattern.matcher(string)
|
||
if (matcher.find(result.start)) {
|
||
// Once we have API level 26+, we can use named captures...
|
||
val end = matcher.end()
|
||
result.start = matcher.start()
|
||
when (result.matchType) {
|
||
FoundMatchType.TAG -> {
|
||
if (isValidForTagPrefix(string.codePointAt(result.start))) {
|
||
if (string[result.start] != '#' ||
|
||
(string[result.start] == '#' && string[result.start + 1] == '#')) {
|
||
++result.start
|
||
}
|
||
}
|
||
}
|
||
else -> {
|
||
if (Character.isWhitespace(string.codePointAt(result.start))) {
|
||
++result.start
|
||
}
|
||
}
|
||
}
|
||
when (result.matchType) {
|
||
FoundMatchType.HTTP_URL, FoundMatchType.HTTPS_URL -> {
|
||
// Preliminary url patterns are fast/permissive, now we'll do full validation
|
||
if (STRICT_WEB_URL_PATTERN.matcher(string.substring(result.start, end)).matches()) {
|
||
result.end = end
|
||
}
|
||
}
|
||
else -> result.end = end
|
||
}
|
||
}
|
||
}
|
||
|
||
private fun getSpan(matchType: FoundMatchType, string: String, colour: Int, start: Int, end: Int): CharacterStyle {
|
||
return when(matchType) {
|
||
FoundMatchType.HTTP_URL -> CustomURLSpan(string.substring(start, end))
|
||
FoundMatchType.HTTPS_URL -> CustomURLSpan(string.substring(start, end))
|
||
else -> ForegroundColorSpan(colour)
|
||
}
|
||
}
|
||
|
||
/** Takes text containing mentions and hashtags and urls and makes them the given colour. */
|
||
fun highlightSpans(text: Spannable, colour: Int) {
|
||
// Strip all existing colour spans.
|
||
for (spanClass in spanClasses) {
|
||
clearSpans(text, spanClass)
|
||
}
|
||
|
||
// Colour the mentions and hashtags.
|
||
val string = text.toString()
|
||
val length = text.length
|
||
var start = 0
|
||
var end = 0
|
||
while (end >= 0 && end < length && start >= 0) {
|
||
// Search for url first because it can contain the other characters
|
||
val found = findPattern(string, end)
|
||
start = found.start
|
||
end = found.end
|
||
if (start >= 0 && end > start) {
|
||
text.setSpan(getSpan(found.matchType, string, colour, start, end), start, end, Spanned.SPAN_INCLUSIVE_EXCLUSIVE)
|
||
start += finders[found.matchType]!!.searchPrefixWidth
|
||
}
|
||
}
|
||
}
|
||
|
||
private fun isWordCharacters(codePoint: Int): Boolean {
|
||
return (codePoint in 0x30..0x39) || // [0-9]
|
||
(codePoint in 0x41..0x5a) || // [A-Z]
|
||
(codePoint == 0x5f) || // _
|
||
(codePoint in 0x61..0x7a) // [a-z]
|
||
}
|
||
|
||
private fun isValidForTagPrefix(codePoint: Int): Boolean {
|
||
return !(isWordCharacters(codePoint) || // \w
|
||
(codePoint == 0x2f) || // /
|
||
(codePoint == 0x29)) // )
|
||
}
|