Revert "Generalize url scheme parsing/highlighting (#1008)"

This reverts commit 85610a8311.
This commit is contained in:
Conny Duck 2019-02-11 14:43:57 +01:00
parent 85593b519b
commit eeaadea905
10 changed files with 279 additions and 317 deletions

View file

@ -300,7 +300,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasSupportF
supportActionBar?.subtitle = subtitle supportActionBar?.subtitle = subtitle
} }
val emojifiedNote = CustomEmojiHelper.emojifyText(account.note, account.emojis, accountNoteTextView) val emojifiedNote = CustomEmojiHelper.emojifyText(account.note, account.emojis, accountNoteTextView)
setClickableText(accountNoteTextView, emojifiedNote, null, this) LinkHelper.setClickableText(accountNoteTextView, emojifiedNote, null, this)
accountLockedImageView.visible(account.locked) accountLockedImageView.visible(account.locked)
accountBadgeTextView.visible(account.bot) accountBadgeTextView.visible(account.bot)
@ -365,7 +365,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasSupportF
if (account.isRemote()) { if (account.isRemote()) {
accountRemoveView.show() accountRemoveView.show()
accountRemoveView.setOnClickListener { accountRemoveView.setOnClickListener {
openLink(account.url, this) LinkHelper.openLink(account.url, this)
} }
} }
@ -567,7 +567,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasSupportF
R.id.action_open_in_web -> { R.id.action_open_in_web -> {
// If the account isn't loaded yet, eat the input. // If the account isn't loaded yet, eat the input.
if (loadedAccount != null) { if (loadedAccount != null) {
openLink(loadedAccount?.url, this) LinkHelper.openLink(loadedAccount?.url, this)
} }
return true return true
} }

View file

@ -24,7 +24,7 @@ import android.widget.LinearLayout
import com.keylesspalace.tusky.entity.SearchResults import com.keylesspalace.tusky.entity.SearchResults
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.openLink import com.keylesspalace.tusky.util.LinkHelper
import retrofit2.Call import retrofit2.Call
import retrofit2.Callback import retrofit2.Callback
import retrofit2.Response import retrofit2.Response
@ -156,7 +156,7 @@ abstract class BottomSheetActivity : BaseActivity() {
@VisibleForTesting @VisibleForTesting
open fun openLink(url: String) { open fun openLink(url: String) {
openLink(url, this) LinkHelper.openLink(url, this)
} }
private fun showQuerySheet() { private fun showQuerySheet() {

View file

@ -86,7 +86,7 @@ import com.keylesspalace.tusky.util.DownsizeImageTask;
import com.keylesspalace.tusky.util.ListUtils; import com.keylesspalace.tusky.util.ListUtils;
import com.keylesspalace.tusky.util.MentionTagTokenizer; import com.keylesspalace.tusky.util.MentionTagTokenizer;
import com.keylesspalace.tusky.util.SaveTootHelper; 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.StringUtils;
import com.keylesspalace.tusky.util.ThemeUtils; import com.keylesspalace.tusky.util.ThemeUtils;
import com.keylesspalace.tusky.view.ComposeOptionsListener; import com.keylesspalace.tusky.view.ComposeOptionsListener;
@ -501,7 +501,7 @@ public final class ComposeActivity
// Setup the main text field. // Setup the main text field.
textEditor.setOnCommitContentListener(this); textEditor.setOnCommitContentListener(this);
final int mentionColour = textEditor.getLinkTextColors().getDefaultColor(); final int mentionColour = textEditor.getLinkTextColors().getDefaultColor();
SpanUtils.highlightSpans(textEditor.getText(), mentionColour); SpanUtilsKt.highlightSpans(textEditor.getText(), mentionColour);
textEditor.addTextChangedListener(new TextWatcher() { textEditor.addTextChangedListener(new TextWatcher() {
@Override @Override
public void onTextChanged(CharSequence s, int start, int before, int count) { public void onTextChanged(CharSequence s, int start, int before, int count) {
@ -513,7 +513,7 @@ public final class ComposeActivity
@Override @Override
public void afterTextChanged(Editable editable) { public void afterTextChanged(Editable editable) {
SpanUtils.highlightSpans(editable, mentionColour); SpanUtilsKt.highlightSpans(editable, mentionColour);
updateVisibleCharactersLeft(); updateVisibleCharactersLeft();
} }
}); });

View file

@ -25,7 +25,7 @@ import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.Field import com.keylesspalace.tusky.entity.Field
import com.keylesspalace.tusky.interfaces.LinkListener import com.keylesspalace.tusky.interfaces.LinkListener
import com.keylesspalace.tusky.util.CustomEmojiHelper import com.keylesspalace.tusky.util.CustomEmojiHelper
import com.keylesspalace.tusky.util.setClickableText import com.keylesspalace.tusky.util.LinkHelper
import kotlinx.android.synthetic.main.item_account_field.view.* import kotlinx.android.synthetic.main.item_account_field.view.*
class AccountFieldAdapter(private val linkListener: LinkListener) : RecyclerView.Adapter<AccountFieldAdapter.ViewHolder>() { class AccountFieldAdapter(private val linkListener: LinkListener) : RecyclerView.Adapter<AccountFieldAdapter.ViewHolder>() {
@ -44,7 +44,7 @@ class AccountFieldAdapter(private val linkListener: LinkListener) : RecyclerView
val field = fields[position] val field = fields[position]
viewHolder.nameTextView.text = field.name viewHolder.nameTextView.text = field.name
val emojifiedValue = CustomEmojiHelper.emojifyText(field.value, emojis, viewHolder.valueTextView) val emojifiedValue = CustomEmojiHelper.emojifyText(field.value, emojis, viewHolder.valueTextView)
setClickableText(viewHolder.valueTextView, emojifiedValue, null, linkListener) LinkHelper.setClickableText(viewHolder.valueTextView, emojifiedValue, null, linkListener)
if(field.verifiedAt != null) { if(field.verifiedAt != null) {
viewHolder.valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.ic_check_circle, 0) viewHolder.valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.ic_check_circle, 0)

View file

@ -7,6 +7,7 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.appcompat.content.res.AppCompatResources; import androidx.appcompat.content.res.AppCompatResources;
import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerView;
import android.text.InputFilter;
import android.text.Spanned; import android.text.Spanned;
import android.text.TextUtils; import android.text.TextUtils;
import android.view.View; import android.view.View;
@ -27,6 +28,7 @@ import com.keylesspalace.tusky.util.CustomEmojiHelper;
import com.keylesspalace.tusky.util.DateUtils; import com.keylesspalace.tusky.util.DateUtils;
import com.keylesspalace.tusky.util.HtmlUtils; import com.keylesspalace.tusky.util.HtmlUtils;
import com.keylesspalace.tusky.util.LinkHelper; import com.keylesspalace.tusky.util.LinkHelper;
import com.keylesspalace.tusky.util.SmartLengthInputFilter;
import com.keylesspalace.tusky.util.ThemeUtils; import com.keylesspalace.tusky.util.ThemeUtils;
import com.keylesspalace.tusky.view.MediaPreviewImageView; import com.keylesspalace.tusky.view.MediaPreviewImageView;
import com.keylesspalace.tusky.viewdata.StatusViewData; import com.keylesspalace.tusky.viewdata.StatusViewData;

View file

@ -0,0 +1,241 @@
/* 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.content.ActivityNotFoundException;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.preference.PreferenceManager;
import androidx.annotation.Nullable;
import androidx.browser.customtabs.CustomTabsIntent;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.method.LinkMovementMethod;
import android.text.style.ClickableSpan;
import android.text.style.URLSpan;
import android.util.Log;
import android.view.View;
import android.widget.TextView;
import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.interfaces.LinkListener;
import java.lang.CharSequence;
import java.net.URI;
import java.net.URISyntaxException;
public class LinkHelper {
private static String getDomain(String urlString) {
URI uri;
try {
uri = new URI(urlString);
} catch (URISyntaxException e) {
return "";
}
String host = uri.getHost();
if (host.startsWith("www.")) {
return host.substring(4);
} else {
return host;
}
}
/**
* Finds links, mentions, and hashtags in a piece of text and makes them clickable, associating
* them with callbacks to notify when they're clicked.
*
* @param view the returned text will be put in
* @param content containing text with mentions, links, or hashtags
* @param mentions any '@' mentions which are known to be in the content
* @param listener to notify about particular spans that are clicked
*/
public static void setClickableText(TextView view, Spanned content,
@Nullable Status.Mention[] mentions, final LinkListener listener) {
SpannableStringBuilder builder = new SpannableStringBuilder(content);
URLSpan[] urlSpans = content.getSpans(0, content.length(), URLSpan.class);
for (URLSpan span : urlSpans) {
int start = builder.getSpanStart(span);
int end = builder.getSpanEnd(span);
int flags = builder.getSpanFlags(span);
CharSequence text = builder.subSequence(start, end);
ClickableSpan customSpan = null;
if (text.charAt(0) == '#') {
final String tag = text.subSequence(1, text.length()).toString();
customSpan = new ClickableSpanNoUnderline() {
@Override
public void onClick(View widget) { listener.onViewTag(tag); }
};
} else if (text.charAt(0) == '@' && mentions != null && mentions.length > 0) {
String accountUsername = text.subSequence(1, text.length()).toString();
/* There may be multiple matches for users on different instances with the same
* username. If a match has the same domain we know it's for sure the same, but if
* that can't be found then just go with whichever one matched last. */
String id = null;
for (Status.Mention mention : mentions) {
if (mention.getLocalUsername().equalsIgnoreCase(accountUsername)) {
id = mention.getId();
if (mention.getUrl().contains(getDomain(span.getURL()))) {
break;
}
}
}
if (id != null) {
final String accountId = id;
customSpan = new ClickableSpanNoUnderline() {
@Override
public void onClick(View widget) { listener.onViewAccount(accountId); }
};
}
}
if (customSpan == null) {
customSpan = new CustomURLSpan(span.getURL()) {
@Override
public void onClick(View widget) {
listener.onViewUrl(getURL());
}
};
}
builder.removeSpan(span);
builder.setSpan(customSpan, start, end, flags);
/* Add zero-width space after links in end of line to fix its too large hitbox.
* See also : https://github.com/tuskyapp/Tusky/issues/846
* https://github.com/tuskyapp/Tusky/pull/916 */
if (end >= builder.length() ||
builder.subSequence(end, end + 1).toString().equals("\n")){
builder.insert(end, "\u200B");
}
}
view.setText(builder);
view.setLinksClickable(true);
view.setMovementMethod(LinkMovementMethod.getInstance());
}
/**
* Put mentions in a piece of text and makes them clickable, associating them with callbacks to
* notify when they're clicked.
*
* @param view the returned text will be put in
* @param mentions any '@' mentions which are known to be in the content
* @param listener to notify about particular spans that are clicked
*/
public static void setClickableMentions(
TextView view, @Nullable Status.Mention[] mentions, final LinkListener listener) {
if (mentions == null || mentions.length == 0) {
view.setText(null);
return;
}
SpannableStringBuilder builder = new SpannableStringBuilder();
int start = 0;
int end = 0;
int flags;
boolean firstMention = true;
for (Status.Mention mention : mentions) {
String accountUsername = mention.getLocalUsername();
final String accountId = mention.getId();
ClickableSpan customSpan = new ClickableSpanNoUnderline() {
@Override
public void onClick(View widget) { listener.onViewAccount(accountId); }
};
end += 1 + accountUsername.length(); // length of @ + username
flags = builder.getSpanFlags(customSpan);
if (firstMention) {
firstMention = false;
} else {
builder.append(" ");
start += 1;
end += 1;
}
builder.append("@");
builder.append(accountUsername);
builder.setSpan(customSpan, start, end, flags);
builder.append("\u200B"); // same reasonning than in setClickableText
end += 1; // shift position to take the previous character into account
start = end;
}
view.setText(builder);
}
/**
* Opens a link, depending on the settings, either in the browser or in a custom tab
*
* @param url a string containing the url to open
* @param context context
*/
public static void openLink(String url, Context context) {
Uri uri = Uri.parse(url).normalizeScheme();
boolean useCustomTabs = PreferenceManager.getDefaultSharedPreferences(context)
.getBoolean("customTabs", false);
if (useCustomTabs) {
openLinkInCustomTab(uri, context);
} else {
openLinkInBrowser(uri, context);
}
}
/**
* opens a link in the browser via Intent.ACTION_VIEW
*
* @param uri the uri to open
* @param context context
*/
public static void openLinkInBrowser(Uri uri, Context context) {
Intent intent = new Intent(Intent.ACTION_VIEW, uri);
try {
context.startActivity(intent);
} catch (ActivityNotFoundException e) {
Log.w("URLSpan", "Actvity was not found for intent, " + intent.toString());
}
}
/**
* tries to open a link in a custom tab
* falls back to browser if not possible
*
* @param uri the uri to open
* @param context context
*/
public static void openLinkInCustomTab(Uri uri, Context context) {
int toolbarColor = ThemeUtils.getColorById(context, "custom_tab_toolbar");
CustomTabsIntent.Builder builder = new CustomTabsIntent.Builder();
builder.setToolbarColor(toolbarColor);
CustomTabsIntent customTabsIntent = builder.build();
try {
String packageName = CustomTabsHelper.getPackageNameToUse(context);
//If we cant find a package name, it means theres no browser that supports
//Chrome Custom Tabs installed. So, we fallback to the webview
if (packageName == null) {
openLinkInBrowser(uri, context);
} else {
customTabsIntent.intent.setPackage(packageName);
customTabsIntent.launchUrl(context, uri);
}
} catch (ActivityNotFoundException e) {
Log.w("URLSpan", "Activity was not found for intent, " + customTabsIntent.toString());
}
}
}

View file

@ -1,262 +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>. */
@file:JvmName("LinkHelper")
package com.keylesspalace.tusky.util
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.preference.PreferenceManager
import android.text.ParcelableSpan
import androidx.browser.customtabs.CustomTabsIntent
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.text.method.LinkMovementMethod
import android.text.style.ClickableSpan
import android.text.style.ForegroundColorSpan
import android.text.style.URLSpan
import android.util.Log
import android.view.View
import android.widget.TextView
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.interfaces.LinkListener
import java.util.HashSet
const val ZERO_WIDTH_SPACE = "\u200B"
private const val TAG = "LinkHelper"
/**
* Finds links, mentions, and hashtags in a piece of text and makes them clickable, associating
* them with callbacks to notify when they're clicked.
*
* @param view the returned text will be put in
* @param content containing text with mentions, links, or hashtags
* @param mentions any '@' mentions which are known to be in the content
* @param listener to notify about particular spans that are clicked
*/
fun setClickableText(view: TextView, content: Spanned, mentions: Array<Status.Mention>?, listener: LinkListener) {
val builder = SpannableStringBuilder(content)
highlightSpans(builder, view.linkTextColors.defaultColor)
val urlSpans = builder.getSpans(0, content.length, URLSpan::class.java)
for (span in urlSpans) {
replaceSpan(builder, span, getLinkSpan(span.url, listener))
}
val otherSpans = builder.getSpans(0, content.length, ForegroundColorSpan::class.java)
val usedMentionIds = HashSet<String>()
for (span in otherSpans) {
val start = builder.getSpanStart(span)
val end = builder.getSpanEnd(span)
val text = builder.subSequence(start, end)
val customSpan = when (text[0]) {
'#' -> getTagSpan(text.substring(1), listener)
'@' -> {
if (!mentions.isNullOrEmpty()) {
/* There may be multiple matches for users on different instances with the same
* username. If a match has the same domain we know it's for sure the same, but if
* that can't be found then just go with whichever one matched last. */
firstUnusedMention(text.substring(1), mentions, usedMentionIds)?.let { id ->
usedMentionIds.add(id)
getAccountSpan(id, listener)
}
} else {
null
}
}
else -> null
}
replaceSpan(builder, span, customSpan)
}
view.text = builder
view.linksClickable = true
view.movementMethod = LinkMovementMethod.getInstance()
}
/**
* Replace a span within a spannable string builder
* @param builder the builder to replace spans within
* @param oldSpan the span to be replaced
* @param newSpan the new span to be used
*/
private fun replaceSpan(builder: SpannableStringBuilder, oldSpan: ParcelableSpan?, newSpan: ClickableSpan?) {
val start = builder.getSpanStart(oldSpan)
val end = builder.getSpanEnd(oldSpan)
val flags = builder.getSpanFlags(oldSpan)
builder.removeSpan(oldSpan)
builder.setSpan(newSpan, start, end, flags)
/* Add zero-width space after links in end of line to fix its too large hitbox.
* See also : https://github.com/tuskyapp/Tusky/issues/846
* https://github.com/tuskyapp/Tusky/pull/916 */
if (end >= builder.length || builder[end] == '\n') {
builder.insert(end, ZERO_WIDTH_SPACE)
}
}
/**
* Returns the first account id with matching username from mentions that isn't contained in usedIds,
* or the id of the last matching account, if all matching ids are already contained
* @param username the username to match
* @param mentions the mentions to search
* @param usedIds the collection of ids already used
*/
private fun firstUnusedMention(username: String, mentions: Array<Status.Mention>, usedIds: Collection<String>): String? {
var id: String? = null
for (mention in mentions) {
if (mention.localUsername.equals(username, true)) {
id = mention.id
if (!usedIds.contains(id)) {
break
}
}
}
return id
}
private fun getTagSpan(tag: String, listener: LinkListener): ClickableSpan {
return object : ClickableSpanNoUnderline() {
override fun onClick(widget: View) {
listener.onViewTag(tag)
}
}
}
private fun getAccountSpan(id: String?, listener: LinkListener): ClickableSpan {
return object : ClickableSpanNoUnderline() {
override fun onClick(widget: View) {
listener.onViewAccount(id)
}
}
}
private fun getLinkSpan(url: String, listener: LinkListener): ClickableSpan {
return object: CustomURLSpan(url) {
override fun onClick(widget: View?) {
listener.onViewUrl(url)
}
}
}
/**
* Put mentions in a piece of text and makes them clickable, associating them with callbacks to
* notify when they're clicked.
*
* @param view the returned text will be put in
* @param mentions any '@' mentions which are known to be in the content
* @param listener to notify about particular spans that are clicked
*/
fun setClickableMentions(view: TextView, mentions: Array<Status.Mention>?, listener: LinkListener) {
if (mentions.isNullOrEmpty()) {
view.text = null
return
}
val builder = SpannableStringBuilder()
var start = 0
var end = 0
var firstMention = true
for (mention in mentions) {
val accountUsername = mention.localUsername
val customSpan = getAccountSpan(mention.id, listener)
end += 1 + accountUsername!!.length // length of @ + username
val flags = builder.getSpanFlags(customSpan)
if (firstMention) {
firstMention = false
} else {
builder.append(" ")
start += 1
end += 1
}
builder.append("@")
builder.append(accountUsername)
builder.setSpan(customSpan, start, end, flags)
builder.append(ZERO_WIDTH_SPACE) // same reasoning as in setClickableText
end += 1 // shift position to take the previous character into account
start = end
}
view.text = builder
}
/**
* Opens a link, depending on the settings, either in the browser or in a custom tab
*
* @param url a string containing the url to open
* @param context context
*/
fun openLink(url: String?, context: Context) {
val uri = Uri.parse(url).normalizeScheme()
val useCustomTabs = PreferenceManager.getDefaultSharedPreferences(context).getBoolean("customTabs", false)
if (useCustomTabs) {
openLinkInCustomTab(uri, context)
} else {
openLinkInBrowser(uri, context)
}
}
/**
* opens a link in the browser via Intent.ACTION_VIEW
*
* @param uri the uri to open
* @param context context
*/
fun openLinkInBrowser(uri: Uri, context: Context) {
val intent = Intent(Intent.ACTION_VIEW, uri)
try {
context.startActivity(intent)
} catch (e: ActivityNotFoundException) {
Log.w(TAG, "Actvity was not found for intent, $intent")
}
}
/**
* tries to open a link in a custom tab
* falls back to browser if not possible
*
* @param uri the uri to open
* @param context context
*/
fun openLinkInCustomTab(uri: Uri, context: Context) {
val toolbarColor = ThemeUtils.getColorById(context, "custom_tab_toolbar")
val builder = CustomTabsIntent.Builder()
builder.setToolbarColor(toolbarColor)
val customTabsIntent = builder.build()
try {
val packageName = CustomTabsHelper.getPackageNameToUse(context)
//If we cant find a package name, it means theres no browser that supports
//Chrome Custom Tabs installed. So, we fallback to the webview
if (packageName == null) {
openLinkInBrowser(uri, context)
} else {
customTabsIntent.intent.setPackage(packageName)
customTabsIntent.launchUrl(context, uri)
}
} catch (e: ActivityNotFoundException) {
Log.w(TAG, "Activity was not found for intent, $customTabsIntent")
}
}

View file

@ -1,4 +1,3 @@
@file:JvmName("SpanUtils")
package com.keylesspalace.tusky.util package com.keylesspalace.tusky.util
import android.text.Spannable import android.text.Spannable
@ -20,24 +19,25 @@ private const val TAG_REGEX = "(?:^|[^/)\\w])#([\\w_]*[\\p{Alpha}_][\\w_]*)"
*/ */
private const val MENTION_REGEX = "(?:^|[^/[:word:]])@([a-z0-9_]+(?:@[a-z0-9\\.\\-]+[a-z0-9]+)?)" private const val MENTION_REGEX = "(?:^|[^/[:word:]])@([a-z0-9_]+(?:@[a-z0-9\\.\\-]+[a-z0-9]+)?)"
private const val WORD_START_PATTERN = "^|\\b" private const val HTTP_URL_REGEX = "(?:(^|\\b)http://[^\\s]+)"
private const val SCHEME_PATTERN = "\\p{Alpha}[\\p{Alpha}\\d\\.\\-\\+]+" private const val HTTPS_URL_REGEX = "(?:(^|\\b)https://[^\\s]+)"
private const val URL_REGEX = "(?:(${WORD_START_PATTERN})(${SCHEME_PATTERN})://[^\\s]+)"
/** /**
* Dump of android.util.Patterns.WEB_URL (with added schemes) * Dump of android.util.Patterns.WEB_URL
*/ */
private val STRICT_WEB_URL_PATTERN = Pattern.compile("(((?:(?:(${WORD_START_PATTERN})(${SCHEME_PATTERN}))://(?:(?:[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 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 spanClasses = listOf(ForegroundColorSpan::class.java, URLSpan::class.java)
private val finders = mapOf( private val finders = mapOf(
FoundMatchType.URL to PatternFinder(':', URL_REGEX, 0), 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.TAG to PatternFinder('#', TAG_REGEX, 1),
FoundMatchType.MENTION to PatternFinder('@', MENTION_REGEX, 1) FoundMatchType.MENTION to PatternFinder('@', MENTION_REGEX, 1)
) )
private enum class FoundMatchType { private enum class FoundMatchType {
URL, HTTP_URL,
HTTPS_URL,
TAG, TAG,
MENTION, MENTION,
} }
@ -59,38 +59,22 @@ private fun <T> clearSpans(text: Spannable, spanClass: Class<T>) {
} }
private fun findPattern(string: String, fromIndex: Int): FindCharsResult { private fun findPattern(string: String, fromIndex: Int): FindCharsResult {
var foundResult: FindCharsResult? = null val result = FindCharsResult()
for (i in fromIndex..string.lastIndex) { for (i in fromIndex..string.lastIndex) {
val c = string[i] val c = string[i]
for ((matchType, finder) in finders) { for (matchType in FoundMatchType.values()) {
if (finder.searchCharacter == c && val finder = finders[matchType]
(finder.searchPrefixWidth == 0 || if (finder!!.searchCharacter == c
(i - fromIndex) < finder.searchPrefixWidth || && ((i - fromIndex) < finder.searchPrefixWidth ||
Character.isWhitespace(string.codePointAt(i - finder.searchPrefixWidth)))) { Character.isWhitespace(string.codePointAt(i - finder.searchPrefixWidth)))) {
val result = FindCharsResult()
result.matchType = matchType result.matchType = matchType
val patternStart = if (finder.searchPrefixWidth == 0) { result.start = Math.max(0, i - finder.searchPrefixWidth)
fromIndex findEndOfPattern(string, result, finder.pattern)
} else { return result
Math.max(0, i - finder.searchPrefixWidth)
}
result.start = 0
findEndOfPattern(string.substring(patternStart), result, finder.pattern)
if (result.start >= 0 && result.end > result.start) {
result.start += patternStart
result.end += patternStart
if (foundResult == null || result.start < foundResult.start) {
foundResult = result
} }
} }
} }
} return result
if (foundResult != null) {
return foundResult
}
}
return FindCharsResult()
} }
private fun findEndOfPattern(string: String, result: FindCharsResult, pattern: Pattern) { private fun findEndOfPattern(string: String, result: FindCharsResult, pattern: Pattern) {
@ -103,7 +87,7 @@ private fun findEndOfPattern(string: String, result: FindCharsResult, pattern: P
++result.start ++result.start
} }
when(result.matchType) { when(result.matchType) {
FoundMatchType.URL -> { FoundMatchType.HTTP_URL, FoundMatchType.HTTPS_URL -> {
// Preliminary url patterns are fast/permissive, now we'll do full validation // Preliminary url patterns are fast/permissive, now we'll do full validation
if (STRICT_WEB_URL_PATTERN.matcher(string.substring(result.start, end)).matches()) { if (STRICT_WEB_URL_PATTERN.matcher(string.substring(result.start, end)).matches()) {
result.end = end result.end = end
@ -116,7 +100,8 @@ private fun findEndOfPattern(string: String, result: FindCharsResult, pattern: P
private fun getSpan(matchType: FoundMatchType, string: String, colour: Int, start: Int, end: Int): CharacterStyle { private fun getSpan(matchType: FoundMatchType, string: String, colour: Int, start: Int, end: Int): CharacterStyle {
return when(matchType) { return when(matchType) {
FoundMatchType.URL -> CustomURLSpan(string.substring(start, end)) FoundMatchType.HTTP_URL -> CustomURLSpan(string.substring(start, end))
FoundMatchType.HTTPS_URL -> CustomURLSpan(string.substring(start, end))
else -> ForegroundColorSpan(colour) else -> ForegroundColorSpan(colour)
} }
} }

View file

@ -19,9 +19,9 @@ import android.content.Context
import android.util.AttributeSet import android.util.AttributeSet
import com.google.android.material.card.MaterialCardView import com.google.android.material.card.MaterialCardView
import com.keylesspalace.tusky.R import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.util.LinkHelper
import com.keylesspalace.tusky.util.ThemeUtils import com.keylesspalace.tusky.util.ThemeUtils
import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.openLink
import kotlinx.android.synthetic.main.card_license.view.* import kotlinx.android.synthetic.main.card_license.view.*
class LicenseCard class LicenseCard
@ -49,7 +49,7 @@ class LicenseCard
licenseCardLink.hide() licenseCardLink.hide()
} else { } else {
licenseCardLink.text = link licenseCardLink.text = link
setOnClickListener { openLink(link, context) } setOnClickListener { LinkHelper.openLink(link, context) }
} }
} }

View file

@ -10,11 +10,11 @@ import org.junit.runners.Parameterized
class SpanUtilsTest { class SpanUtilsTest {
@Test @Test
fun matchesMixedSpans() { fun matchesMixedSpans() {
val input = "one #one two dat://tw.wo?no=yes three @three four https://fo.ur/meh?foo=bar&wat=@at#hmm five #five ipfs://si.xx/?pick=up#sticks seven @seven " val input = "one #one two @two three https://thr.ee/meh?foo=bar&wat=@at#hmm four #four five @five"
val inputSpannable = FakeSpannable(input) val inputSpannable = FakeSpannable(input)
highlightSpans(inputSpannable, 0xffffff) highlightSpans(inputSpannable, 0xffffff)
val spans = inputSpannable.spans val spans = inputSpannable.spans
Assert.assertEquals(7, spans.size) Assert.assertEquals(5, spans.size)
} }
@Test @Test
@ -38,9 +38,6 @@ class SpanUtilsTest {
return listOf( return listOf(
"@mention", "@mention",
"#tag", "#tag",
"dat://thr.ee/meh?foo=bar&wat=@at#hmm",
"ssb://thr.ee/meh?foo=bar&wat=@at#hmm",
"ipfs://thr.ee/meh?foo=bar&wat=@at#hmm",
"https://thr.ee/meh?foo=bar&wat=@at#hmm", "https://thr.ee/meh?foo=bar&wat=@at#hmm",
"http://thr.ee/meh?foo=bar&wat=@at#hmm" "http://thr.ee/meh?foo=bar&wat=@at#hmm"
) )
@ -67,7 +64,7 @@ class SpanUtilsTest {
@Test @Test
fun doesNotMatchSpanEmbeddedInText() { fun doesNotMatchSpanEmbeddedInText() {
val inputSpannable = FakeSpannable("__${thingToHighlight}__") val inputSpannable = FakeSpannable("aa${thingToHighlight}aa")
highlightSpans(inputSpannable, 0xffffff) highlightSpans(inputSpannable, 0xffffff)
val spans = inputSpannable.spans val spans = inputSpannable.spans
Assert.assertTrue(spans.isEmpty()) Assert.assertTrue(spans.isEmpty())
@ -79,7 +76,6 @@ class SpanUtilsTest {
highlightSpans(inputSpannable, 0xffffff) highlightSpans(inputSpannable, 0xffffff)
val spans = inputSpannable.spans val spans = inputSpannable.spans
Assert.assertEquals(1, spans.size) Assert.assertEquals(1, spans.size)
Assert.assertEquals(0, spans[0].start)
} }
@Test @Test