diff --git a/app/src/main/java/com/keylesspalace/tusky/AccountActivity.kt b/app/src/main/java/com/keylesspalace/tusky/AccountActivity.kt index 60213366..ab5e1508 100644 --- a/app/src/main/java/com/keylesspalace/tusky/AccountActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/AccountActivity.kt @@ -300,7 +300,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasSupportF supportActionBar?.subtitle = subtitle } val emojifiedNote = CustomEmojiHelper.emojifyText(account.note, account.emojis, accountNoteTextView) - setClickableText(accountNoteTextView, emojifiedNote, null, this) + LinkHelper.setClickableText(accountNoteTextView, emojifiedNote, null, this) accountLockedImageView.visible(account.locked) accountBadgeTextView.visible(account.bot) @@ -365,7 +365,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasSupportF if (account.isRemote()) { accountRemoveView.show() 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 -> { // If the account isn't loaded yet, eat the input. if (loadedAccount != null) { - openLink(loadedAccount?.url, this) + LinkHelper.openLink(loadedAccount?.url, this) } return true } diff --git a/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt b/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt index ea378b44..0bb02371 100644 --- a/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt @@ -24,7 +24,7 @@ import android.widget.LinearLayout import com.keylesspalace.tusky.entity.SearchResults import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.MastodonApi -import com.keylesspalace.tusky.util.openLink +import com.keylesspalace.tusky.util.LinkHelper import retrofit2.Call import retrofit2.Callback import retrofit2.Response @@ -156,7 +156,7 @@ abstract class BottomSheetActivity : BaseActivity() { @VisibleForTesting open fun openLink(url: String) { - openLink(url, this) + LinkHelper.openLink(url, this) } private fun showQuerySheet() { diff --git a/app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java b/app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java index 30fcdf71..193eb799 100644 --- a/app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java @@ -86,7 +86,7 @@ import com.keylesspalace.tusky.util.DownsizeImageTask; import com.keylesspalace.tusky.util.ListUtils; import com.keylesspalace.tusky.util.MentionTagTokenizer; 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; @@ -501,7 +501,7 @@ public final class ComposeActivity // Setup the main text field. textEditor.setOnCommitContentListener(this); final int mentionColour = textEditor.getLinkTextColors().getDefaultColor(); - SpanUtils.highlightSpans(textEditor.getText(), mentionColour); + SpanUtilsKt.highlightSpans(textEditor.getText(), mentionColour); textEditor.addTextChangedListener(new TextWatcher() { @Override public void onTextChanged(CharSequence s, int start, int before, int count) { @@ -513,7 +513,7 @@ public final class ComposeActivity @Override public void afterTextChanged(Editable editable) { - SpanUtils.highlightSpans(editable, mentionColour); + SpanUtilsKt.highlightSpans(editable, mentionColour); updateVisibleCharactersLeft(); } }); diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/AccountFieldAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/AccountFieldAdapter.kt index 5535a17c..3dcc6d2f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/AccountFieldAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/AccountFieldAdapter.kt @@ -25,7 +25,7 @@ import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.Field import com.keylesspalace.tusky.interfaces.LinkListener 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.* class AccountFieldAdapter(private val linkListener: LinkListener) : RecyclerView.Adapter() { @@ -44,7 +44,7 @@ class AccountFieldAdapter(private val linkListener: LinkListener) : RecyclerView val field = fields[position] viewHolder.nameTextView.text = field.name 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) { viewHolder.valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.ic_check_circle, 0) diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java index 00d38281..bdcd10c1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java @@ -7,6 +7,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.content.res.AppCompatResources; import androidx.recyclerview.widget.RecyclerView; +import android.text.InputFilter; import android.text.Spanned; import android.text.TextUtils; 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.HtmlUtils; import com.keylesspalace.tusky.util.LinkHelper; +import com.keylesspalace.tusky.util.SmartLengthInputFilter; import com.keylesspalace.tusky.util.ThemeUtils; import com.keylesspalace.tusky.view.MediaPreviewImageView; import com.keylesspalace.tusky.viewdata.StatusViewData; @@ -151,7 +153,7 @@ abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { status.getContent(), status.getStatusEmojis(), this.content); LinkHelper.setClickableText(this.content, emojifiedText, mentions, listener); } else { - LinkHelper.setClickableMentions(this.content, mentions, listener); + LinkHelper.setClickableMentions(this.content, mentions, listener); } if(TextUtils.isEmpty(this.content.getText())) { this.content.setVisibility(View.GONE); diff --git a/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.java b/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.java new file mode 100644 index 00000000..0c23c132 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.java @@ -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 . */ + +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()); + } + + } + + +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.kt b/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.kt deleted file mode 100644 index 5b2592b6..00000000 --- a/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.kt +++ /dev/null @@ -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 . */ - -@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?, 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() - - 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, usedIds: Collection): 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?, 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") - } - -} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/SpanUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/SpanUtils.kt index 50823358..01dcf02e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/SpanUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/SpanUtils.kt @@ -1,4 +1,3 @@ -@file:JvmName("SpanUtils") package com.keylesspalace.tusky.util 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 WORD_START_PATTERN = "^|\\b" -private const val SCHEME_PATTERN = "\\p{Alpha}[\\p{Alpha}\\d\\.\\-\\+]+" -private const val URL_REGEX = "(?:(${WORD_START_PATTERN})(${SCHEME_PATTERN})://[^\\s]+)" +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 (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 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.MENTION to PatternFinder('@', MENTION_REGEX, 1) ) private enum class FoundMatchType { - URL, + HTTP_URL, + HTTPS_URL, TAG, MENTION, } @@ -59,38 +59,22 @@ private fun clearSpans(text: Spannable, spanClass: Class) { } private fun findPattern(string: String, fromIndex: Int): FindCharsResult { - var foundResult: FindCharsResult? = null + val result = FindCharsResult() for (i in fromIndex..string.lastIndex) { val c = string[i] - for ((matchType, finder) in finders) { - if (finder.searchCharacter == c && - (finder.searchPrefixWidth == 0 || - (i - fromIndex) < finder.searchPrefixWidth || + for (matchType in FoundMatchType.values()) { + val finder = finders[matchType] + if (finder!!.searchCharacter == c + && ((i - fromIndex) < finder.searchPrefixWidth || Character.isWhitespace(string.codePointAt(i - finder.searchPrefixWidth)))) { - val result = FindCharsResult() result.matchType = matchType - val patternStart = if (finder.searchPrefixWidth == 0) { - fromIndex - } else { - 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 - } - } + result.start = Math.max(0, i - finder.searchPrefixWidth) + findEndOfPattern(string, result, finder.pattern) + return result } } - - if (foundResult != null) { - return foundResult - } } - return FindCharsResult() + return result } private fun findEndOfPattern(string: String, result: FindCharsResult, pattern: Pattern) { @@ -103,7 +87,7 @@ private fun findEndOfPattern(string: String, result: FindCharsResult, pattern: P ++result.start } when(result.matchType) { - FoundMatchType.URL -> { + 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 @@ -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 { 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) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/view/LicenseCard.kt b/app/src/main/java/com/keylesspalace/tusky/view/LicenseCard.kt index b42b82dc..0d636473 100644 --- a/app/src/main/java/com/keylesspalace/tusky/view/LicenseCard.kt +++ b/app/src/main/java/com/keylesspalace/tusky/view/LicenseCard.kt @@ -19,9 +19,9 @@ import android.content.Context import android.util.AttributeSet import com.google.android.material.card.MaterialCardView import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.util.LinkHelper import com.keylesspalace.tusky.util.ThemeUtils import com.keylesspalace.tusky.util.hide -import com.keylesspalace.tusky.util.openLink import kotlinx.android.synthetic.main.card_license.view.* class LicenseCard @@ -49,7 +49,7 @@ class LicenseCard licenseCardLink.hide() } else { licenseCardLink.text = link - setOnClickListener { openLink(link, context) } + setOnClickListener { LinkHelper.openLink(link, context) } } } diff --git a/app/src/test/java/com/keylesspalace/tusky/SpanUtilsTest.kt b/app/src/test/java/com/keylesspalace/tusky/SpanUtilsTest.kt index a239376b..3b4f5c10 100644 --- a/app/src/test/java/com/keylesspalace/tusky/SpanUtilsTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/SpanUtilsTest.kt @@ -10,11 +10,11 @@ import org.junit.runners.Parameterized class SpanUtilsTest { @Test 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) highlightSpans(inputSpannable, 0xffffff) val spans = inputSpannable.spans - Assert.assertEquals(7, spans.size) + Assert.assertEquals(5, spans.size) } @Test @@ -38,9 +38,6 @@ class SpanUtilsTest { return listOf( "@mention", "#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", "http://thr.ee/meh?foo=bar&wat=@at#hmm" ) @@ -67,7 +64,7 @@ class SpanUtilsTest { @Test fun doesNotMatchSpanEmbeddedInText() { - val inputSpannable = FakeSpannable("__${thingToHighlight}__") + val inputSpannable = FakeSpannable("aa${thingToHighlight}aa") highlightSpans(inputSpannable, 0xffffff) val spans = inputSpannable.spans Assert.assertTrue(spans.isEmpty()) @@ -79,7 +76,6 @@ class SpanUtilsTest { highlightSpans(inputSpannable, 0xffffff) val spans = inputSpannable.spans Assert.assertEquals(1, spans.size) - Assert.assertEquals(0, spans[0].start) } @Test