From 85610a83110021217bf22911011a0c7127c95889 Mon Sep 17 00:00:00 2001 From: Levi Bard Date: Tue, 5 Feb 2019 19:55:28 +0100 Subject: [PATCH] Generalize url scheme parsing/highlighting (#1008) * Add support for highlighting dat, ssb, ipfs url schemes. #847 * Generalize scheme parsing for url highlighting. #847 * Migrate LinkHelper to kotlin --- .../keylesspalace/tusky/AccountActivity.kt | 6 +- .../tusky/BottomSheetActivity.kt | 4 +- .../keylesspalace/tusky/ComposeActivity.java | 6 +- .../tusky/adapter/AccountFieldAdapter.kt | 4 +- .../tusky/adapter/StatusBaseViewHolder.java | 4 +- .../keylesspalace/tusky/util/LinkHelper.java | 241 ---------------- .../keylesspalace/tusky/util/LinkHelper.kt | 262 ++++++++++++++++++ .../com/keylesspalace/tusky/util/SpanUtils.kt | 55 ++-- .../keylesspalace/tusky/view/LicenseCard.kt | 4 +- .../com/keylesspalace/tusky/SpanUtilsTest.kt | 10 +- 10 files changed, 317 insertions(+), 279 deletions(-) delete mode 100644 app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.java create mode 100644 app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.kt diff --git a/app/src/main/java/com/keylesspalace/tusky/AccountActivity.kt b/app/src/main/java/com/keylesspalace/tusky/AccountActivity.kt index ab5e1508..60213366 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) - LinkHelper.setClickableText(accountNoteTextView, emojifiedNote, null, this) + 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 { - LinkHelper.openLink(account.url, this) + 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) { - LinkHelper.openLink(loadedAccount?.url, this) + 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 0bb02371..ea378b44 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.LinkHelper +import com.keylesspalace.tusky.util.openLink import retrofit2.Call import retrofit2.Callback import retrofit2.Response @@ -156,7 +156,7 @@ abstract class BottomSheetActivity : BaseActivity() { @VisibleForTesting open fun openLink(url: String) { - LinkHelper.openLink(url, this) + 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 02c16dd5..3b7c00d9 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.SpanUtilsKt; +import com.keylesspalace.tusky.util.SpanUtils; 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(); - SpanUtilsKt.highlightSpans(textEditor.getText(), mentionColour); + SpanUtils.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) { - SpanUtilsKt.highlightSpans(editable, mentionColour); + SpanUtils.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 3dcc6d2f..5535a17c 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.LinkHelper +import com.keylesspalace.tusky.util.setClickableText 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) - LinkHelper.setClickableText(viewHolder.valueTextView, emojifiedValue, null, linkListener) + 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 bdcd10c1..00d38281 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java @@ -7,7 +7,6 @@ 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; @@ -28,7 +27,6 @@ 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; @@ -153,7 +151,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 deleted file mode 100644 index 0c23c132..00000000 --- a/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.java +++ /dev/null @@ -1,241 +0,0 @@ -/* Copyright 2017 Andrew Dawson - * - * This file is a part of Tusky. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . */ - -package com.keylesspalace.tusky.util; - -import android.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 new file mode 100644 index 00000000..5b2592b6 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.kt @@ -0,0 +1,262 @@ +/* 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 01dcf02e..50823358 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/SpanUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/SpanUtils.kt @@ -1,3 +1,4 @@ +@file:JvmName("SpanUtils") package com.keylesspalace.tusky.util import android.text.Spannable @@ -19,25 +20,24 @@ 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 HTTP_URL_REGEX = "(?:(^|\\b)http://[^\\s]+)" -private const val HTTPS_URL_REGEX = "(?:(^|\\b)https://[^\\s]+)" +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]+)" /** - * Dump of android.util.Patterns.WEB_URL + * Dump of android.util.Patterns.WEB_URL (with added schemes) */ -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 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 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.URL to PatternFinder(':', URL_REGEX, 0), FoundMatchType.TAG to PatternFinder('#', TAG_REGEX, 1), FoundMatchType.MENTION to PatternFinder('@', MENTION_REGEX, 1) ) private enum class FoundMatchType { - HTTP_URL, - HTTPS_URL, + URL, TAG, MENTION, } @@ -59,22 +59,38 @@ private fun clearSpans(text: Spannable, spanClass: Class) { } private fun findPattern(string: String, fromIndex: Int): FindCharsResult { - val result = FindCharsResult() + var foundResult: FindCharsResult? = null 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 || + for ((matchType, finder) in finders) { + if (finder.searchCharacter == c && + (finder.searchPrefixWidth == 0 || + (i - fromIndex) < finder.searchPrefixWidth || Character.isWhitespace(string.codePointAt(i - finder.searchPrefixWidth)))) { + val result = FindCharsResult() result.matchType = matchType - result.start = Math.max(0, i - finder.searchPrefixWidth) - findEndOfPattern(string, result, finder.pattern) - return result + 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 + } + } } } + + if (foundResult != null) { + return foundResult + } } - return result + return FindCharsResult() } private fun findEndOfPattern(string: String, result: FindCharsResult, pattern: Pattern) { @@ -87,7 +103,7 @@ private fun findEndOfPattern(string: String, result: FindCharsResult, pattern: P ++result.start } when(result.matchType) { - FoundMatchType.HTTP_URL, FoundMatchType.HTTPS_URL -> { + FoundMatchType.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 @@ -100,8 +116,7 @@ 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.HTTP_URL -> CustomURLSpan(string.substring(start, end)) - FoundMatchType.HTTPS_URL -> CustomURLSpan(string.substring(start, end)) + FoundMatchType.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 0d636473..b42b82dc 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 { LinkHelper.openLink(link, context) } + setOnClickListener { 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 3b4f5c10..a239376b 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 @two three https://thr.ee/meh?foo=bar&wat=@at#hmm four #four five @five" + 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 inputSpannable = FakeSpannable(input) highlightSpans(inputSpannable, 0xffffff) val spans = inputSpannable.spans - Assert.assertEquals(5, spans.size) + Assert.assertEquals(7, spans.size) } @Test @@ -38,6 +38,9 @@ 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" ) @@ -64,7 +67,7 @@ class SpanUtilsTest { @Test fun doesNotMatchSpanEmbeddedInText() { - val inputSpannable = FakeSpannable("aa${thingToHighlight}aa") + val inputSpannable = FakeSpannable("__${thingToHighlight}__") highlightSpans(inputSpannable, 0xffffff) val spans = inputSpannable.spans Assert.assertTrue(spans.isEmpty()) @@ -76,6 +79,7 @@ class SpanUtilsTest { highlightSpans(inputSpannable, 0xffffff) val spans = inputSpannable.spans Assert.assertEquals(1, spans.size) + Assert.assertEquals(0, spans[0].start) } @Test