315 lines
15 KiB
Java
315 lines
15 KiB
Java
/*
|
|
* Twidere - Twitter client for Android
|
|
*
|
|
* Copyright (C) 2012-2014 Mariotaku Lee <mariotaku.lee@gmail.com>
|
|
*
|
|
* 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.
|
|
*
|
|
* This program 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 this program. If not, see <http://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
package org.mariotaku.twidere.util;
|
|
|
|
import androidx.annotation.IntDef;
|
|
import androidx.annotation.NonNull;
|
|
import androidx.annotation.Nullable;
|
|
import android.text.Spannable;
|
|
import android.text.Spanned;
|
|
import android.text.style.URLSpan;
|
|
|
|
import com.twitter.twittertext.Extractor;
|
|
import com.twitter.twittertext.Extractor.Entity;
|
|
import com.twitter.twittertext.Regex;
|
|
|
|
import org.mariotaku.twidere.Constants;
|
|
import org.mariotaku.twidere.model.UserKey;
|
|
import org.mariotaku.twidere.text.AcctMentionSpan;
|
|
import org.mariotaku.twidere.text.HashtagSpan;
|
|
import org.mariotaku.twidere.text.TwidereURLSpan;
|
|
|
|
import java.lang.annotation.Retention;
|
|
import java.lang.annotation.RetentionPolicy;
|
|
import java.util.List;
|
|
import java.util.regex.Matcher;
|
|
import java.util.regex.Pattern;
|
|
|
|
import static org.mariotaku.twidere.util.RegexUtils.matcherEnd;
|
|
import static org.mariotaku.twidere.util.RegexUtils.matcherGroup;
|
|
import static org.mariotaku.twidere.util.RegexUtils.matcherStart;
|
|
|
|
/**
|
|
* Linkify take a piece of text and a regular expression and turns all of the
|
|
* regex matches in the text into clickable links. This is particularly useful
|
|
* for matching things like email addresses, web urls, etc. and making them
|
|
* actionable. Alone with the pattern that is to be matched, a url scheme prefix
|
|
* is also required. Any pattern match that does not begin with the supplied
|
|
* scheme will have the scheme prepended to the matched text when the clickable
|
|
* url is created. For instance, if you are matching web urls you would supply
|
|
* the scheme <code>http://</code>. If the pattern matches example.com, which
|
|
* does not have a url scheme prefix, the supplied scheme will be prepended to
|
|
* create <code>http://example.com</code> when the clickable url link is
|
|
* created.
|
|
*/
|
|
|
|
public final class TwidereLinkify implements Constants {
|
|
|
|
public static final int LINK_TYPE_MENTION = 1;
|
|
public static final int LINK_TYPE_HASHTAG = 2;
|
|
public static final int LINK_TYPE_BANGTAG = 3;
|
|
public static final int LINK_TYPE_ENTITY_URL = 4;
|
|
public static final int LINK_TYPE_LINK_IN_TEXT = 5;
|
|
public static final int LINK_TYPE_LIST = 6;
|
|
public static final int LINK_TYPE_CASHTAG = 7;
|
|
public static final int LINK_TYPE_USER_ID = 8;
|
|
public static final int LINK_TYPE_USER_ACCT = 9;
|
|
|
|
public static final int[] ALL_LINK_TYPES = new int[]{LINK_TYPE_ENTITY_URL, LINK_TYPE_LINK_IN_TEXT,
|
|
LINK_TYPE_MENTION, LINK_TYPE_HASHTAG, LINK_TYPE_CASHTAG};
|
|
|
|
public static final String AVAILABLE_URL_SCHEME_PREFIX = "(https?://)?";
|
|
|
|
public static final String TWITTER_PROFILE_IMAGES_AVAILABLE_SIZES = "(bigger|normal|mini|reasonably_small)";
|
|
private static final String STRING_PATTERN_TWITTER_PROFILE_IMAGES_NO_SCHEME = "(twimg[\\d\\w\\-]+\\.akamaihd\\.net|[\\w\\d]+\\.twimg\\.com)/profile_images/([\\d\\w\\-_]+)/([\\d\\w\\-_]+)_"
|
|
+ TWITTER_PROFILE_IMAGES_AVAILABLE_SIZES + "(\\.?" + "(png|jpeg|jpg|gif|bmp)" + ")?";
|
|
private static final String STRING_PATTERN_TWITTER_PROFILE_IMAGES = AVAILABLE_URL_SCHEME_PREFIX
|
|
+ STRING_PATTERN_TWITTER_PROFILE_IMAGES_NO_SCHEME;
|
|
|
|
public static final Pattern PATTERN_TWITTER_PROFILE_IMAGES = Pattern.compile(STRING_PATTERN_TWITTER_PROFILE_IMAGES,
|
|
Pattern.CASE_INSENSITIVE);
|
|
public static final int GROUP_ID_TWITTER_LIST_SCREEN_NAME = 4;
|
|
public static final int GROUP_ID_TWITTER_LIST_LIST_NAME = 5;
|
|
private static final String STRING_PATTERN_TWITTER_LIST_NO_SCHEME = "((mobile|www)\\.)?twitter\\.com/(?:#!/)?(\\w+)/lists/(.+)/?";
|
|
private static final String STRING_PATTERN_TWITTER_LIST = AVAILABLE_URL_SCHEME_PREFIX
|
|
+ STRING_PATTERN_TWITTER_LIST_NO_SCHEME;
|
|
public static final Pattern PATTERN_TWITTER_LIST = Pattern.compile(STRING_PATTERN_TWITTER_LIST,
|
|
Pattern.CASE_INSENSITIVE);
|
|
private final OnLinkClickListener mOnLinkClickListener;
|
|
private final Extractor mExtractor = new Extractor();
|
|
private int mHighlightOption;
|
|
|
|
public TwidereLinkify(final OnLinkClickListener listener) {
|
|
this(listener, VALUE_LINK_HIGHLIGHT_OPTION_CODE_BOTH);
|
|
}
|
|
|
|
public TwidereLinkify(final OnLinkClickListener listener, final int highlightOption) {
|
|
mOnLinkClickListener = listener;
|
|
setHighlightOption(highlightOption);
|
|
}
|
|
|
|
public void applyAllLinks(@Nullable Spannable text, @Nullable final UserKey accountKey,
|
|
final long extraId, final boolean sensitive,
|
|
final boolean skipLinksInText) {
|
|
applyAllLinks(text, mOnLinkClickListener, accountKey, extraId, sensitive,
|
|
mHighlightOption, skipLinksInText);
|
|
}
|
|
|
|
public void applyAllLinks(@Nullable Spannable text, @Nullable final UserKey accountKey,
|
|
final boolean sensitive, final boolean skipLinksInText) {
|
|
applyAllLinks(text, mOnLinkClickListener, accountKey, -1, sensitive, mHighlightOption,
|
|
skipLinksInText);
|
|
}
|
|
|
|
public void applyAllLinks(@Nullable Spannable text, @Nullable final UserKey accountKey,
|
|
final long extraId, final boolean sensitive, final int highlightOption,
|
|
final boolean skipLinksInText) {
|
|
applyAllLinks(text, mOnLinkClickListener, accountKey, extraId, sensitive, highlightOption,
|
|
skipLinksInText);
|
|
}
|
|
|
|
public void applyAllLinks(@Nullable final Spannable text, final OnLinkClickListener listener,
|
|
@Nullable final UserKey accountKey, final long extraId, final boolean sensitive,
|
|
final int highlightOption, boolean skipLinksInText) {
|
|
if (text == null) return;
|
|
for (final int type : ALL_LINK_TYPES) {
|
|
if (type == LINK_TYPE_LINK_IN_TEXT && skipLinksInText) continue;
|
|
addLinks(text, accountKey, extraId, type, sensitive, listener, highlightOption);
|
|
}
|
|
}
|
|
|
|
public void setHighlightOption(@HighlightStyle final int style) {
|
|
mHighlightOption = style;
|
|
}
|
|
|
|
private boolean addCashtagLinks(final Spannable spannable, @Nullable final UserKey accountKey,
|
|
final long extraId, final OnLinkClickListener listener, final int highlightOption) {
|
|
boolean hasMatches = false;
|
|
for (final Entity entity : mExtractor.extractCashtagsWithIndices(spannable.toString())) {
|
|
final int start = entity.getStart();
|
|
final int end = entity.getEnd();
|
|
applyLink(entity.getValue(), null, start, end, spannable, accountKey, extraId,
|
|
LINK_TYPE_CASHTAG, false, highlightOption, listener);
|
|
hasMatches = true;
|
|
}
|
|
return hasMatches;
|
|
}
|
|
|
|
private boolean addHashtagLinks(final Spannable spannable, @Nullable final UserKey accountKey,
|
|
final long extraId, final OnLinkClickListener listener, final int highlightOption) {
|
|
boolean hasMatches = false;
|
|
for (final Entity entity : mExtractor.extractHashtagsWithIndices(spannable.toString())) {
|
|
final int start = entity.getStart();
|
|
final int end = entity.getEnd();
|
|
applyLink(entity.getValue(), null, start, end, spannable, accountKey, extraId,
|
|
LINK_TYPE_HASHTAG, false, highlightOption, listener);
|
|
hasMatches = true;
|
|
}
|
|
return hasMatches;
|
|
}
|
|
|
|
/**
|
|
* Applies a regex to the text of a TextView turning the matches into links.
|
|
*/
|
|
private void addLinks(final Spannable string, @Nullable final UserKey accountKey,
|
|
final long extraId, final int type, final boolean sensitive,
|
|
final OnLinkClickListener listener, final int highlightOption) {
|
|
switch (type) {
|
|
case LINK_TYPE_MENTION: {
|
|
addMentionOrListLinks(string, accountKey, extraId, highlightOption, listener);
|
|
break;
|
|
}
|
|
case LINK_TYPE_HASHTAG: {
|
|
addHashtagLinks(string, accountKey, extraId, listener, highlightOption);
|
|
break;
|
|
}
|
|
case LINK_TYPE_ENTITY_URL: {
|
|
final int length = string.length();
|
|
final URLSpan[] spans = string.getSpans(0, length, URLSpan.class);
|
|
for (final URLSpan span : spans) {
|
|
int start = string.getSpanStart(span), end = string.getSpanEnd(span);
|
|
if (span instanceof TwidereURLSpan || start < 0 || end > length || start > end) {
|
|
continue;
|
|
}
|
|
string.removeSpan(span);
|
|
String url = span.getURL();
|
|
if (url == null) break;
|
|
int linkType = type;
|
|
if (span instanceof AcctMentionSpan) {
|
|
linkType = LINK_TYPE_USER_ACCT;
|
|
} else if (span instanceof HashtagSpan) {
|
|
linkType = LINK_TYPE_HASHTAG;
|
|
} else if (accountKey != null && USER_TYPE_FANFOU_COM.equals(accountKey.getHost())) {
|
|
// Fix search path
|
|
if (url.startsWith("/")) {
|
|
url = "http://fanfou.com" + url;
|
|
}
|
|
if ("fanfou.com".equals(UriUtils.getAuthority(url)) && start > 0) {
|
|
// Process special case for fanfou
|
|
final char ch = string.charAt(start - 1);
|
|
// Extend selection
|
|
if (isAtSymbol(ch)) {
|
|
start = start - 1;
|
|
} else if (isHashSymbol(ch) && end < length && isHashSymbol(string.charAt(end))) {
|
|
start = start - 1;
|
|
end = end + 1;
|
|
}
|
|
}
|
|
}
|
|
applyLink(url, String.valueOf(string.subSequence(start, end)), start, end,
|
|
string, accountKey, extraId, linkType, sensitive, highlightOption,
|
|
listener);
|
|
}
|
|
break;
|
|
}
|
|
case LINK_TYPE_LINK_IN_TEXT: {
|
|
final List<Extractor.Entity> urls = mExtractor.extractURLsWithIndices(ParseUtils.parseString(string));
|
|
for (final Extractor.Entity entity : urls) {
|
|
final int start = entity.getStart(), end = entity.getEnd();
|
|
if (entity.getType() != Extractor.Entity.Type.URL
|
|
|| string.getSpans(start, end, URLSpan.class).length > 0) {
|
|
continue;
|
|
}
|
|
applyLink(entity.getValue(), null, start, end, string, accountKey, extraId,
|
|
LINK_TYPE_LINK_IN_TEXT, sensitive, highlightOption, listener);
|
|
}
|
|
break;
|
|
}
|
|
case LINK_TYPE_CASHTAG: {
|
|
addCashtagLinks(string, accountKey, extraId, listener, highlightOption);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
static boolean isAtSymbol(char ch) {
|
|
return ch == '@' || ch == '\uff20';
|
|
}
|
|
|
|
static boolean isHashSymbol(char ch) {
|
|
return ch == '#' || ch == '\uff03';
|
|
}
|
|
|
|
private boolean addMentionOrListLinks(final Spannable spannable, final UserKey accountKey,
|
|
final long extraId, final int highlightOption, final OnLinkClickListener listener) {
|
|
boolean hasMatches = false;
|
|
// Extract lists from status text
|
|
final Matcher matcher = Regex.VALID_MENTION_OR_LIST.matcher(spannable);
|
|
while (matcher.find()) {
|
|
final int start = matcherStart(matcher, Regex.VALID_MENTION_OR_LIST_GROUP_AT);
|
|
final int usernameEnd = matcherEnd(matcher, Regex.VALID_MENTION_OR_LIST_GROUP_USERNAME);
|
|
final int listStart = matcherStart(matcher, Regex.VALID_MENTION_OR_LIST_GROUP_LIST);
|
|
final int listEnd = matcherEnd(matcher, Regex.VALID_MENTION_OR_LIST_GROUP_LIST);
|
|
final String username = matcherGroup(matcher, Regex.VALID_MENTION_OR_LIST_GROUP_USERNAME);
|
|
final String list = matcherGroup(matcher, Regex.VALID_MENTION_OR_LIST_GROUP_LIST);
|
|
if (username != null) {
|
|
applyLink(username, null, start, usernameEnd, spannable, accountKey, extraId,
|
|
LINK_TYPE_MENTION, false, highlightOption, listener);
|
|
if (listStart >= 0 && listEnd >= 0 && list != null) {
|
|
StringBuilder sb = new StringBuilder(username);
|
|
if (!list.startsWith("/")) {
|
|
sb.append("/");
|
|
}
|
|
sb.append(list);
|
|
applyLink(sb.toString(), null, listStart, listEnd, spannable, accountKey, extraId,
|
|
LINK_TYPE_LIST, false, highlightOption, listener);
|
|
}
|
|
hasMatches = true;
|
|
}
|
|
}
|
|
// Extract lists from twitter.com links.
|
|
final URLSpan[] spans = spannable.getSpans(0, spannable.length(), URLSpan.class);
|
|
for (final URLSpan span : spans) {
|
|
final Matcher m = PATTERN_TWITTER_LIST.matcher(span.getURL());
|
|
if (m.matches()) {
|
|
final int start = spannable.getSpanStart(span);
|
|
final int end = spannable.getSpanEnd(span);
|
|
final String screenName = matcherGroup(m, GROUP_ID_TWITTER_LIST_SCREEN_NAME);
|
|
final String listName = matcherGroup(m, GROUP_ID_TWITTER_LIST_LIST_NAME);
|
|
spannable.removeSpan(span);
|
|
applyLink(screenName + "/" + listName, null, start, end, spannable, accountKey,
|
|
extraId, LINK_TYPE_LIST, false, highlightOption, listener);
|
|
hasMatches = true;
|
|
}
|
|
}
|
|
return hasMatches;
|
|
}
|
|
|
|
private void applyLink(@NonNull final String url, @Nullable final String orig, final int start, final int end,
|
|
final Spannable text, @Nullable final UserKey accountKey, final long extraId, final int type, final boolean sensitive,
|
|
final int highlightOption, final OnLinkClickListener listener) {
|
|
final TwidereURLSpan span = new TwidereURLSpan(url, orig, accountKey, extraId, type, sensitive,
|
|
highlightOption, start, end, listener);
|
|
text.setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
|
}
|
|
|
|
@IntDef({VALUE_LINK_HIGHLIGHT_OPTION_CODE_NONE, VALUE_LINK_HIGHLIGHT_OPTION_CODE_HIGHLIGHT,
|
|
VALUE_LINK_HIGHLIGHT_OPTION_CODE_UNDERLINE, VALUE_LINK_HIGHLIGHT_OPTION_CODE_BOTH})
|
|
@Retention(RetentionPolicy.SOURCE)
|
|
public @interface HighlightStyle {
|
|
|
|
}
|
|
|
|
public interface OnLinkClickListener {
|
|
boolean onLinkClick(@NonNull String link, @Nullable String orig, @Nullable UserKey accountKey, long extraId, int type,
|
|
boolean sensitive, int start, int end);
|
|
}
|
|
}
|