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
This commit is contained in:
parent
df33d8a999
commit
42b13caffc
@ -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();
|
||||
if (statusHideText) {
|
||||
charactersLeft -= contentWarningEditor.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);
|
||||
}
|
||||
}
|
||||
this.charactersLeft.setText(String.format(Locale.getDefault(), "%d", charactersLeft));
|
||||
int remaining = maximumTootCharacters - textEditor.length() + offset;
|
||||
if (statusHideText) {
|
||||
remaining -= contentWarningEditor.length();
|
||||
}
|
||||
return remaining;
|
||||
}
|
||||
|
||||
private void updateVisibleCharactersLeft() {
|
||||
this.charactersLeft.setText(String.format(Locale.getDefault(), "%d", calculateRemainingCharacters()));
|
||||
}
|
||||
|
||||
private void onContentWarningChanged() {
|
||||
|
@ -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 <http://www.gnu.org/licenses>. */
|
||||
|
||||
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 <a href="https://github.com/tootsuite/mastodon/blob/master/app/models/tag.rb">
|
||||
* Tag#HASHTAG_RE</a>.
|
||||
*/
|
||||
private static final String TAG_REGEX = "(?:^|[^/)\\w])#([\\w_]*[\\p{Alpha}_][\\w_]*)";
|
||||
private static Pattern TAG_PATTERN = Pattern.compile(TAG_REGEX, Pattern.CASE_INSENSITIVE);
|
||||
/**
|
||||
* @see <a href="https://github.com/tootsuite/mastodon/blob/master/app/models/account.rb">
|
||||
* Account#MENTION_RE</a>
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
131
app/src/main/java/com/keylesspalace/tusky/util/SpanUtils.kt
Normal file
131
app/src/main/java/com/keylesspalace/tusky/util/SpanUtils.kt
Normal file
@ -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 <a href="https://github.com/tootsuite/mastodon/blob/master/app/models/tag.rb">
|
||||
* Tag#HASHTAG_RE</a>.
|
||||
*/
|
||||
private const val TAG_REGEX = "(?:^|[^/)\\w])#([\\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),
|
||||
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 <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 ||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
@ -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<EditText>(R.id.composeEditField).setText("Some text")
|
||||
private fun insertSomeTextInContent(text: String? = null) {
|
||||
activity.findViewById<EditText>(R.id.composeEditField).setText(text ?: "Some text")
|
||||
}
|
||||
|
||||
private fun getInstanceWithMaximumTootCharacters(maximumTootCharacters: Int?): Instance
|
||||
|
151
app/src/test/java/com/keylesspalace/tusky/SpanUtilsTest.kt
Normal file
151
app/src/test/java/com/keylesspalace/tusky/SpanUtilsTest.kt
Normal file
@ -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<Any> {
|
||||
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<BoundedSpan>()
|
||||
|
||||
override fun setSpan(what: Any?, start: Int, end: Int, flags: Int) {
|
||||
spans.add(BoundedSpan(what, start, end))
|
||||
}
|
||||
|
||||
override fun <T : Any?> getSpans(start: Int, end: Int, type: Class<T>?): Array<T> {
|
||||
val matching = if (type == null) {
|
||||
ArrayList<T>()
|
||||
} 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<T>
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user