From 42b13caffc289ca81d3e36e2c84292babb90334c Mon Sep 17 00:00:00 2001 From: Levi Bard Date: Wed, 16 May 2018 19:14:26 +0200 Subject: [PATCH] Behave like Mastodon web ui and only count URLs as 23 characters when composing (#629) * Refactor-all-the-things version of the fix for issue #573 * Migrate SpanUtils to kotlin because why not * Minimal fix for issue #573 * Add tests for compose spanning * Clean up code suggestions * Make FakeSpannable.getSpans implementation less awkward * Add secondary validation pass for urls * Address code review feedback * Fixup type filtering in FakeSpannable again * Make all mentions in compose activity use the default link color --- .../keylesspalace/tusky/ComposeActivity.java | 32 ++-- .../keylesspalace/tusky/util/SpanUtils.java | 142 ---------------- .../com/keylesspalace/tusky/util/SpanUtils.kt | 131 +++++++++++++++ .../tusky/ComposeActivityTest.kt | 22 ++- .../com/keylesspalace/tusky/SpanUtilsTest.kt | 151 ++++++++++++++++++ 5 files changed, 325 insertions(+), 153 deletions(-) delete mode 100644 app/src/main/java/com/keylesspalace/tusky/util/SpanUtils.java create mode 100644 app/src/main/java/com/keylesspalace/tusky/util/SpanUtils.kt create mode 100644 app/src/test/java/com/keylesspalace/tusky/SpanUtilsTest.kt diff --git a/app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java b/app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java index 61884e96..0b099802 100644 --- a/app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java @@ -62,6 +62,7 @@ import android.text.Editable; import android.text.InputType; import android.text.TextUtils; import android.text.TextWatcher; +import android.text.style.URLSpan; import android.util.Log; import android.view.MenuItem; import android.view.View; @@ -101,7 +102,7 @@ import com.keylesspalace.tusky.util.ListUtils; import com.keylesspalace.tusky.util.MediaUtils; import com.keylesspalace.tusky.util.MentionTokenizer; import com.keylesspalace.tusky.util.SaveTootHelper; -import com.keylesspalace.tusky.util.SpanUtils; +import com.keylesspalace.tusky.util.SpanUtilsKt; import com.keylesspalace.tusky.util.StringUtils; import com.keylesspalace.tusky.util.ThemeUtils; import com.keylesspalace.tusky.view.ComposeOptionsListener; @@ -163,6 +164,8 @@ public final class ComposeActivity private static final String MENTIONED_USERNAMES_EXTRA = "netnioned_usernames"; private static final String REPLYING_STATUS_AUTHOR_USERNAME_EXTRA = "replying_author_nickname_extra"; private static final String REPLYING_STATUS_CONTENT_EXTRA = "replying_status_content"; + // Mastodon only counts URLs as this long in terms of status character limits + static final int MAXIMUM_URL_LENGTH = 23; @Inject public MastodonApi mastodonApi; @@ -453,12 +456,11 @@ public final class ComposeActivity // Setup the main text field. textEditor.setOnCommitContentListener(this); - final int mentionColour = ThemeUtils.getColor(this, R.attr.compose_mention_color); - SpanUtils.highlightSpans(textEditor.getText(), mentionColour); + final int mentionColour = textEditor.getLinkTextColors().getDefaultColor(); + SpanUtilsKt.highlightSpans(textEditor.getText(), mentionColour); textEditor.addTextChangedListener(new TextWatcher() { @Override public void onTextChanged(CharSequence s, int start, int before, int count) { - updateVisibleCharactersLeft(); } @Override @@ -467,7 +469,8 @@ public final class ComposeActivity @Override public void afterTextChanged(Editable editable) { - SpanUtils.highlightSpans(editable, mentionColour); + SpanUtilsKt.highlightSpans(editable, mentionColour); + updateVisibleCharactersLeft(); } }); @@ -765,12 +768,23 @@ public final class ComposeActivity setStatusVisibility(visibility); } - private void updateVisibleCharactersLeft() { - int charactersLeft = maximumTootCharacters - textEditor.length(); + int calculateRemainingCharacters() { + int offset = 0; + URLSpan[] urlSpans = textEditor.getUrls(); + if (urlSpans != null) { + for (URLSpan span : urlSpans) { + offset += Math.max(0, span.getURL().length() - MAXIMUM_URL_LENGTH); + } + } + int remaining = maximumTootCharacters - textEditor.length() + offset; if (statusHideText) { - charactersLeft -= contentWarningEditor.length(); + remaining -= contentWarningEditor.length(); } - this.charactersLeft.setText(String.format(Locale.getDefault(), "%d", charactersLeft)); + return remaining; + } + + private void updateVisibleCharactersLeft() { + this.charactersLeft.setText(String.format(Locale.getDefault(), "%d", calculateRemainingCharacters())); } private void onContentWarningChanged() { diff --git a/app/src/main/java/com/keylesspalace/tusky/util/SpanUtils.java b/app/src/main/java/com/keylesspalace/tusky/util/SpanUtils.java deleted file mode 100644 index e5697d5c..00000000 --- a/app/src/main/java/com/keylesspalace/tusky/util/SpanUtils.java +++ /dev/null @@ -1,142 +0,0 @@ -/* Copyright 2017 Andrew Dawson - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . */ - -package com.keylesspalace.tusky.util; - -import android.text.Spannable; -import android.text.Spanned; -import android.text.style.ForegroundColorSpan; - -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -public class SpanUtils { - /** - * @see - * Tag#HASHTAG_RE. - */ - private static final String TAG_REGEX = "(?:^|[^/)\\w])#([\\w_]*[\\p{Alpha}_][\\w_]*)"; - private static Pattern TAG_PATTERN = Pattern.compile(TAG_REGEX, Pattern.CASE_INSENSITIVE); - /** - * @see - * Account#MENTION_RE - */ - private static final String MENTION_REGEX = - "(?:^|[^/[:word:]])@([a-z0-9_]+(?:@[a-z0-9\\.\\-]+[a-z0-9]+)?)"; - private static Pattern MENTION_PATTERN = - Pattern.compile(MENTION_REGEX, Pattern.CASE_INSENSITIVE); - - private static class FindCharsResult { - int charIndex; - int stringIndex; - - FindCharsResult() { - charIndex = -1; - stringIndex = -1; - } - } - - private static FindCharsResult findChars(String string, int fromIndex, char[] chars) { - FindCharsResult result = new FindCharsResult(); - final int length = string.length(); - for (int i = fromIndex; i < length; i++) { - char c = string.charAt(i); - for (int j = 0; j < chars.length; j++) { - if (chars[j] == c) { - result.charIndex = j; - result.stringIndex = i; - return result; - } - } - } - return result; - } - - private static FindCharsResult findStart(String string, int fromIndex, char[] chars) { - final int length = string.length(); - while (fromIndex < length) { - FindCharsResult found = findChars(string, fromIndex, chars); - int i = found.stringIndex; - if (i < 0) { - break; - } else if (i == 0 || i >= 1 && Character.isWhitespace(string.codePointBefore(i))) { - return found; - } else { - fromIndex = i + 1; - } - } - return new FindCharsResult(); - } - - private static int findEndOfHashtag(String string, int fromIndex) { - Matcher matcher = TAG_PATTERN.matcher(string); - if (fromIndex >= 1) { - fromIndex--; - } - boolean found = matcher.find(fromIndex); - if (found) { - return matcher.end(); - } else { - return -1; - } - } - - private static int findEndOfMention(String string, int fromIndex) { - Matcher matcher = MENTION_PATTERN.matcher(string); - if (fromIndex >= 1) { - fromIndex--; - } - boolean found = matcher.find(fromIndex); - if (found) { - return matcher.end(); - } else { - return -1; - } - } - - /** Takes text containing mentions and hashtags and makes them the given colour. */ - public static void highlightSpans(Spannable text, int colour) { - // Strip all existing colour spans. - int n = text.length(); - ForegroundColorSpan[] oldSpans = text.getSpans(0, n, ForegroundColorSpan.class); - for (int i = oldSpans.length - 1; i >= 0; i--) { - text.removeSpan(oldSpans[i]); - } - // Colour the mentions and hashtags. - String string = text.toString(); - int start; - int end = 0; - while (end < n) { - char[] chars = { '#', '@' }; - FindCharsResult found = findStart(string, end, chars); - start = found.stringIndex; - if (start < 0) { - break; - } - if (found.charIndex == 0) { - end = findEndOfHashtag(string, start); - } else if (found.charIndex == 1) { - end = findEndOfMention(string, start); - } else { - break; - } - if (end < 0) { - break; - } - text.setSpan(new ForegroundColorSpan(colour), start, end, - Spanned.SPAN_INCLUSIVE_EXCLUSIVE); - } - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/SpanUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/SpanUtils.kt new file mode 100644 index 00000000..01dcf02e --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/SpanUtils.kt @@ -0,0 +1,131 @@ +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 + +/** + * @see + * Tag#HASHTAG_RE. + */ +private const val TAG_REGEX = "(?:^|[^/)\\w])#([\\w_]*[\\p{Alpha}_][\\w_]*)" + +/** + * @see + * Account#MENTION_RE + */ +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), + FoundMatchType.HTTPS_URL to PatternFinder(':', HTTPS_URL_REGEX, 6), + FoundMatchType.TAG to PatternFinder('#', TAG_REGEX, 1), + FoundMatchType.MENTION to PatternFinder('@', MENTION_REGEX, 1) +) + +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 pattern: Pattern = Pattern.compile(regex, Pattern.CASE_INSENSITIVE) +} + +private fun clearSpans(text: Spannable, spanClass: Class) { + 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 || + Character.isWhitespace(string.codePointAt(i - finder.searchPrefixWidth)))) { + result.matchType = matchType + result.start = Math.max(0, i - finder.searchPrefixWidth) + findEndOfPattern(string, result, finder.pattern) + 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() + 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 + } + } +} diff --git a/app/src/test/java/com/keylesspalace/tusky/ComposeActivityTest.kt b/app/src/test/java/com/keylesspalace/tusky/ComposeActivityTest.kt index 7896ae17..5192d75b 100644 --- a/app/src/test/java/com/keylesspalace/tusky/ComposeActivityTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/ComposeActivityTest.kt @@ -26,6 +26,7 @@ import com.keylesspalace.tusky.entity.Instance import com.keylesspalace.tusky.network.MastodonApi import okhttp3.Request import okhttp3.ResponseBody +import org.junit.Assert import org.junit.Assert.* import org.junit.Before import org.junit.Test @@ -198,6 +199,23 @@ class ComposeActivityTest { assertEquals(ComposeActivity.STATUS_CHARACTER_LIMIT, activity.maximumTootCharacters) } + @Test + fun whenTextContainsUrl_onlyEllipsizedURLIsCountedAgainstCharacterLimit() { + val url = "https://www.google.dk/search?biw=1920&bih=990&tbm=isch&sa=1&ei=bmDrWuOoKMv6kwWOkIaoDQ&q=indiana+jones+i+hate+snakes+animated&oq=indiana+jones+i+hate+snakes+animated&gs_l=psy-ab.3...54174.55443.0.55553.9.7.0.0.0.0.255.333.1j0j1.2.0....0...1c.1.64.psy-ab..7.0.0....0.40G-kcDkC6A#imgdii=PSp15hQjN1JqvM:&imgrc=H0hyE2JW5wrpBM:" + val additionalContent = "Check out this @image #search result: " + insertSomeTextInContent(additionalContent + url) + Assert.assertEquals(activity.calculateRemainingCharacters(), activity.maximumTootCharacters - additionalContent.length - ComposeActivity.MAXIMUM_URL_LENGTH) + } + + @Test + fun whenTextContainsMultipleURLs_allURLsGetEllipsized() { + val url = "https://www.google.dk/search?biw=1920&bih=990&tbm=isch&sa=1&ei=bmDrWuOoKMv6kwWOkIaoDQ&q=indiana+jones+i+hate+snakes+animated&oq=indiana+jones+i+hate+snakes+animated&gs_l=psy-ab.3...54174.55443.0.55553.9.7.0.0.0.0.255.333.1j0j1.2.0....0...1c.1.64.psy-ab..7.0.0....0.40G-kcDkC6A#imgdii=PSp15hQjN1JqvM:&imgrc=H0hyE2JW5wrpBM:" + val additionalContent = " Check out this @image #search result: " + insertSomeTextInContent(url + additionalContent + url) + Assert.assertEquals(activity.calculateRemainingCharacters(), + activity.maximumTootCharacters - additionalContent.length - (ComposeActivity.MAXIMUM_URL_LENGTH * 2)) + } + private fun clickUp() { val menuItem = RoboMenuItem(android.R.id.home) activity.onOptionsItemSelected(menuItem) @@ -207,8 +225,8 @@ class ComposeActivityTest { activity.onBackPressed() } - private fun insertSomeTextInContent() { - activity.findViewById(R.id.composeEditField).setText("Some text") + private fun insertSomeTextInContent(text: String? = null) { + activity.findViewById(R.id.composeEditField).setText(text ?: "Some text") } private fun getInstanceWithMaximumTootCharacters(maximumTootCharacters: Int?): Instance diff --git a/app/src/test/java/com/keylesspalace/tusky/SpanUtilsTest.kt b/app/src/test/java/com/keylesspalace/tusky/SpanUtilsTest.kt new file mode 100644 index 00000000..f52eabf3 --- /dev/null +++ b/app/src/test/java/com/keylesspalace/tusky/SpanUtilsTest.kt @@ -0,0 +1,151 @@ +package com.keylesspalace.tusky + +import android.text.Spannable +import com.keylesspalace.tusky.util.highlightSpans +import junit.framework.Assert +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized + +class SpanUtilsTest { + @Test + fun matchesMixedSpans() { + val input = "one #one two @two three https://thr.ee/meh?foo=bar&wat=@at#hmm four #four five @five" + val inputSpannable = FakeSpannable(input) + highlightSpans(inputSpannable, 0xffffff) + val spans = inputSpannable.spans + Assert.assertEquals(5, spans.size) + } + + @Test + fun doesntMergeAdjacentURLs() { + val firstURL = "http://first.thing" + val secondURL = "https://second.thing" + val inputSpannable = FakeSpannable("${firstURL} ${secondURL}") + highlightSpans(inputSpannable, 0xffffff) + val spans = inputSpannable.spans + Assert.assertEquals(2, spans.size) + Assert.assertEquals(firstURL.length, spans[0].end - spans[0].start) + Assert.assertEquals(secondURL.length, spans[1].end - spans[1].start) + } + + @RunWith(Parameterized::class) + class MatchingTests(private val thingToHighlight: String) { + companion object { + @Parameterized.Parameters(name = "{0}") + @JvmStatic + fun data(): Iterable { + return listOf( + "@mention", + "#tag", + "https://thr.ee/meh?foo=bar&wat=@at#hmm", + "http://thr.ee/meh?foo=bar&wat=@at#hmm" + ) + } + } + + @Test + fun matchesSpanAtStart() { + val inputSpannable = FakeSpannable(thingToHighlight) + highlightSpans(inputSpannable, 0xffffff) + val spans = inputSpannable.spans + Assert.assertEquals(1, spans.size) + Assert.assertEquals(thingToHighlight.length, spans[0].end - spans[0].start) + } + + @Test + fun matchesSpanNotAtStart() { + val inputSpannable = FakeSpannable(" ${thingToHighlight}") + highlightSpans(inputSpannable, 0xffffff) + val spans = inputSpannable.spans + Assert.assertEquals(1, spans.size) + Assert.assertEquals(thingToHighlight.length, spans[0].end - spans[0].start) + } + + @Test + fun doesNotMatchSpanEmbeddedInText() { + val inputSpannable = FakeSpannable("aa${thingToHighlight}aa") + highlightSpans(inputSpannable, 0xffffff) + val spans = inputSpannable.spans + Assert.assertTrue(spans.isEmpty()) + } + + @Test + fun doesNotMatchSpanEmbeddedInAnotherSpan() { + val inputSpannable = FakeSpannable("@aa${thingToHighlight}aa") + highlightSpans(inputSpannable, 0xffffff) + val spans = inputSpannable.spans + Assert.assertEquals(1, spans.size) + } + + @Test + fun spansDoNotOverlap() { + val begin = "@begin" + val end = "#end" + val inputSpannable = FakeSpannable("${begin} ${thingToHighlight} ${end}") + highlightSpans(inputSpannable, 0xffffff) + val spans = inputSpannable.spans + Assert.assertEquals(3, spans.size) + + val middleSpan = spans.single ({ span -> span.start > 0 && span.end < inputSpannable.lastIndex }) + Assert.assertEquals(begin.length + 1, middleSpan.start) + Assert.assertEquals(inputSpannable.length - end.length - 1, middleSpan.end) + } + } + + class FakeSpannable(private val text: String) : Spannable { + val spans = mutableListOf() + + override fun setSpan(what: Any?, start: Int, end: Int, flags: Int) { + spans.add(BoundedSpan(what, start, end)) + } + + override fun getSpans(start: Int, end: Int, type: Class?): Array { + val matching = if (type == null) { + ArrayList() + } else { + spans.filter ({ it.start >= start && it.end <= end && type?.isAssignableFrom(it.span?.javaClass) }) + .map({ it -> it.span }) + .let { ArrayList(it) } + } + return matching.toArray() as Array + } + + override fun removeSpan(what: Any?) { + spans.removeIf({ span -> span.span == what}) + } + + override fun toString(): String { + return text + } + + override val length: Int + get() = text.length + + class BoundedSpan(val span: Any?, val start: Int, val end: Int) + + override fun nextSpanTransition(start: Int, limit: Int, type: Class<*>?): Int { + throw NotImplementedError() + } + + override fun getSpanEnd(tag: Any?): Int { + throw NotImplementedError() + } + + override fun getSpanFlags(tag: Any?): Int { + throw NotImplementedError() + } + + override fun get(index: Int): Char { + throw NotImplementedError() + } + + override fun subSequence(startIndex: Int, endIndex: Int): CharSequence { + throw NotImplementedError() + } + + override fun getSpanStart(tag: Any?): Int { + throw NotImplementedError() + } + } +} \ No newline at end of file