Revert "Generalize url scheme parsing/highlighting (#1008)"
This reverts commit 85610a8311
.
This commit is contained in:
parent
85593b519b
commit
eeaadea905
10 changed files with 279 additions and 317 deletions
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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;
|
||||||
|
@ -151,7 +153,7 @@ abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
|
||||||
status.getContent(), status.getStatusEmojis(), this.content);
|
status.getContent(), status.getStatusEmojis(), this.content);
|
||||||
LinkHelper.setClickableText(this.content, emojifiedText, mentions, listener);
|
LinkHelper.setClickableText(this.content, emojifiedText, mentions, listener);
|
||||||
} else {
|
} else {
|
||||||
LinkHelper.setClickableMentions(this.content, mentions, listener);
|
LinkHelper.setClickableMentions(this.content, mentions, listener);
|
||||||
}
|
}
|
||||||
if(TextUtils.isEmpty(this.content.getText())) {
|
if(TextUtils.isEmpty(this.content.getText())) {
|
||||||
this.content.setVisibility(View.GONE);
|
this.content.setVisibility(View.GONE);
|
||||||
|
|
241
app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.java
Normal file
241
app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.java
Normal 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());
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
|
@ -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")
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (foundResult != null) {
|
|
||||||
return foundResult
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return FindCharsResult()
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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) }
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in a new issue