blob: b7bf3528b4745ce0ef4a0252c13dd13ba1082f67 [file] [log] [blame]
/*
* Copyright (C) 2016 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package android.support.v4.text.util;
import static android.support.annotation.RestrictTo.Scope.LIBRARY_GROUP;
import android.os.Build;
import android.support.annotation.IntDef;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.annotation.RestrictTo;
import android.support.v4.util.PatternsCompat;
import android.text.Spannable;
import android.text.SpannableString;
import android.text.Spanned;
import android.text.method.LinkMovementMethod;
import android.text.method.MovementMethod;
import android.text.style.URLSpan;
import android.text.util.Linkify;
import android.text.util.Linkify.MatchFilter;
import android.text.util.Linkify.TransformFilter;
import android.webkit.WebView;
import android.widget.TextView;
import java.io.UnsupportedEncodingException;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.net.URLEncoder;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* LinkifyCompat brings in {@code Linkify} improvements for URLs and email addresses to older API
* levels.
*/
public final class LinkifyCompat {
private static final String[] EMPTY_STRING = new String[0];
private static final Comparator<LinkSpec> COMPARATOR = new Comparator<LinkSpec>() {
@Override
public final int compare(LinkSpec a, LinkSpec b) {
if (a.start < b.start) {
return -1;
}
if (a.start > b.start) {
return 1;
}
if (a.end < b.end) {
return 1;
}
if (a.end > b.end) {
return -1;
}
return 0;
}
};
/** @hide */
@RestrictTo(LIBRARY_GROUP)
@IntDef(flag = true, value = { Linkify.WEB_URLS, Linkify.EMAIL_ADDRESSES, Linkify.PHONE_NUMBERS,
Linkify.MAP_ADDRESSES, Linkify.ALL })
@Retention(RetentionPolicy.SOURCE)
public @interface LinkifyMask {}
/**
* Scans the text of the provided Spannable and turns all occurrences
* of the link types indicated in the mask into clickable links.
* If the mask is nonzero, it also removes any existing URLSpans
* attached to the Spannable, to avoid problems if you call it
* repeatedly on the same text.
*
* @param text Spannable whose text is to be marked-up with links
* @param mask Mask to define which kinds of links will be searched.
*
* @return True if at least one link is found and applied.
*/
public static final boolean addLinks(@NonNull Spannable text, @LinkifyMask int mask) {
if (Build.VERSION.SDK_INT >= 27) {
return Linkify.addLinks(text, mask);
}
if (mask == 0) {
return false;
}
URLSpan[] old = text.getSpans(0, text.length(), URLSpan.class);
for (int i = old.length - 1; i >= 0; i--) {
text.removeSpan(old[i]);
}
// Use framework to linkify phone numbers.
boolean frameworkReturn = false;
if ((mask & Linkify.PHONE_NUMBERS) != 0) {
frameworkReturn = Linkify.addLinks(text, Linkify.PHONE_NUMBERS);
}
ArrayList<LinkSpec> links = new ArrayList<LinkSpec>();
if ((mask & Linkify.WEB_URLS) != 0) {
gatherLinks(links, text, PatternsCompat.AUTOLINK_WEB_URL,
new String[] { "http://", "https://", "rtsp://" },
Linkify.sUrlMatchFilter, null);
}
if ((mask & Linkify.EMAIL_ADDRESSES) != 0) {
gatherLinks(links, text, PatternsCompat.AUTOLINK_EMAIL_ADDRESS,
new String[] { "mailto:" },
null, null);
}
if ((mask & Linkify.MAP_ADDRESSES) != 0) {
gatherMapLinks(links, text);
}
pruneOverlaps(links, text);
if (links.size() == 0) {
return false;
}
for (LinkSpec link: links) {
if (link.frameworkAddedSpan == null) {
applyLink(link.url, link.start, link.end, text);
}
}
return true;
}
/**
* Scans the text of the provided TextView and turns all occurrences of
* the link types indicated in the mask into clickable links. If matches
* are found the movement method for the TextView is set to
* LinkMovementMethod.
*
* @param text TextView whose text is to be marked-up with links
* @param mask Mask to define which kinds of links will be searched.
*
* @return True if at least one link is found and applied.
*/
public static final boolean addLinks(@NonNull TextView text, @LinkifyMask int mask) {
if (Build.VERSION.SDK_INT >= 26) {
return Linkify.addLinks(text, mask);
}
if (mask == 0) {
return false;
}
CharSequence t = text.getText();
if (t instanceof Spannable) {
if (addLinks((Spannable) t, mask)) {
addLinkMovementMethod(text);
return true;
}
return false;
} else {
SpannableString s = SpannableString.valueOf(t);
if (addLinks(s, mask)) {
addLinkMovementMethod(text);
text.setText(s);
return true;
}
return false;
}
}
/**
* Applies a regex to the text of a TextView turning the matches into
* links. If links are found then UrlSpans are applied to the link
* text match areas, and the movement method for the text is changed
* to LinkMovementMethod.
*
* @param text TextView whose text is to be marked-up with links
* @param pattern Regex pattern to be used for finding links
* @param scheme URL scheme string (eg <code>http://</code>) to be
* prepended to the links that do not start with this scheme.
*/
public static final void addLinks(@NonNull TextView text, @NonNull Pattern pattern,
@Nullable String scheme) {
if (Build.VERSION.SDK_INT >= 26) {
Linkify.addLinks(text, pattern, scheme);
return;
}
addLinks(text, pattern, scheme, null, null, null);
}
/**
* Applies a regex to the text of a TextView turning the matches into
* links. If links are found then UrlSpans are applied to the link
* text match areas, and the movement method for the text is changed
* to LinkMovementMethod.
*
* @param text TextView whose text is to be marked-up with links
* @param pattern Regex pattern to be used for finding links
* @param scheme URL scheme string (eg <code>http://</code>) to be
* prepended to the links that do not start with this scheme.
* @param matchFilter The filter that is used to allow the client code
* additional control over which pattern matches are
* to be converted into links.
*/
public static final void addLinks(@NonNull TextView text, @NonNull Pattern pattern,
@Nullable String scheme, @Nullable MatchFilter matchFilter,
@Nullable TransformFilter transformFilter) {
if (Build.VERSION.SDK_INT >= 26) {
Linkify.addLinks(text, pattern, scheme, matchFilter, transformFilter);
return;
}
addLinks(text, pattern, scheme, null, matchFilter, transformFilter);
}
/**
* Applies a regex to the text of a TextView turning the matches into
* links. If links are found then UrlSpans are applied to the link
* text match areas, and the movement method for the text is changed
* to LinkMovementMethod.
*
* @param text TextView whose text is to be marked-up with links.
* @param pattern Regex pattern to be used for finding links.
* @param defaultScheme The default scheme to be prepended to links if the link does not
* start with one of the <code>schemes</code> given.
* @param schemes Array of schemes (eg <code>http://</code>) to check if the link found
* contains a scheme. Passing a null or empty value means prepend defaultScheme
* to all links.
* @param matchFilter The filter that is used to allow the client code additional control
* over which pattern matches are to be converted into links.
* @param transformFilter Filter to allow the client code to update the link found.
*/
public static final void addLinks(@NonNull TextView text, @NonNull Pattern pattern,
@Nullable String defaultScheme, @Nullable String[] schemes,
@Nullable MatchFilter matchFilter, @Nullable TransformFilter transformFilter) {
if (Build.VERSION.SDK_INT >= 26) {
Linkify.addLinks(text, pattern, defaultScheme, schemes, matchFilter, transformFilter);
return;
}
SpannableString spannable = SpannableString.valueOf(text.getText());
boolean linksAdded = addLinks(spannable, pattern, defaultScheme, schemes, matchFilter,
transformFilter);
if (linksAdded) {
text.setText(spannable);
addLinkMovementMethod(text);
}
}
/**
* Applies a regex to a Spannable turning the matches into
* links.
*
* @param text Spannable whose text is to be marked-up with links
* @param pattern Regex pattern to be used for finding links
* @param scheme URL scheme string (eg <code>http://</code>) to be
* prepended to the links that do not start with this scheme.
*/
public static final boolean addLinks(@NonNull Spannable text, @NonNull Pattern pattern,
@Nullable String scheme) {
if (Build.VERSION.SDK_INT >= 26) {
return Linkify.addLinks(text, pattern, scheme);
}
return addLinks(text, pattern, scheme, null, null, null);
}
/**
* Applies a regex to a Spannable turning the matches into
* links.
*
* @param spannable Spannable whose text is to be marked-up with links
* @param pattern Regex pattern to be used for finding links
* @param scheme URL scheme string (eg <code>http://</code>) to be
* prepended to the links that do not start with this scheme.
* @param matchFilter The filter that is used to allow the client code
* additional control over which pattern matches are
* to be converted into links.
* @param transformFilter Filter to allow the client code to update the link found.
*
* @return True if at least one link is found and applied.
*/
public static final boolean addLinks(@NonNull Spannable spannable, @NonNull Pattern pattern,
@Nullable String scheme, @Nullable MatchFilter matchFilter,
@Nullable TransformFilter transformFilter) {
if (Build.VERSION.SDK_INT >= 26) {
return Linkify.addLinks(spannable, pattern, scheme, matchFilter, transformFilter);
}
return addLinks(spannable, pattern, scheme, null, matchFilter,
transformFilter);
}
/**
* Applies a regex to a Spannable turning the matches into links.
*
* @param spannable Spannable whose text is to be marked-up with links.
* @param pattern Regex pattern to be used for finding links.
* @param defaultScheme The default scheme to be prepended to links if the link does not
* start with one of the <code>schemes</code> given.
* @param schemes Array of schemes (eg <code>http://</code>) to check if the link found
* contains a scheme. Passing a null or empty value means prepend defaultScheme
* to all links.
* @param matchFilter The filter that is used to allow the client code additional control
* over which pattern matches are to be converted into links.
* @param transformFilter Filter to allow the client code to update the link found.
*
* @return True if at least one link is found and applied.
*/
public static final boolean addLinks(@NonNull Spannable spannable, @NonNull Pattern pattern,
@Nullable String defaultScheme, @Nullable String[] schemes,
@Nullable MatchFilter matchFilter, @Nullable TransformFilter transformFilter) {
if (Build.VERSION.SDK_INT >= 26) {
return Linkify.addLinks(spannable, pattern, defaultScheme, schemes, matchFilter,
transformFilter);
}
final String[] schemesCopy;
if (defaultScheme == null) defaultScheme = "";
if (schemes == null || schemes.length < 1) {
schemes = EMPTY_STRING;
}
schemesCopy = new String[schemes.length + 1];
schemesCopy[0] = defaultScheme.toLowerCase(Locale.ROOT);
for (int index = 0; index < schemes.length; index++) {
String scheme = schemes[index];
schemesCopy[index + 1] = (scheme == null) ? "" : scheme.toLowerCase(Locale.ROOT);
}
boolean hasMatches = false;
Matcher m = pattern.matcher(spannable);
while (m.find()) {
int start = m.start();
int end = m.end();
boolean allowed = true;
if (matchFilter != null) {
allowed = matchFilter.acceptMatch(spannable, start, end);
}
if (allowed) {
String url = makeUrl(m.group(0), schemesCopy, m, transformFilter);
applyLink(url, start, end, spannable);
hasMatches = true;
}
}
return hasMatches;
}
private static void addLinkMovementMethod(@NonNull TextView t) {
MovementMethod m = t.getMovementMethod();
if ((m == null) || !(m instanceof LinkMovementMethod)) {
if (t.getLinksClickable()) {
t.setMovementMethod(LinkMovementMethod.getInstance());
}
}
}
private static String makeUrl(@NonNull String url, @NonNull String[] prefixes,
Matcher matcher, @Nullable Linkify.TransformFilter filter) {
if (filter != null) {
url = filter.transformUrl(matcher, url);
}
boolean hasPrefix = false;
for (int i = 0; i < prefixes.length; i++) {
if (url.regionMatches(true, 0, prefixes[i], 0, prefixes[i].length())) {
hasPrefix = true;
// Fix capitalization if necessary
if (!url.regionMatches(false, 0, prefixes[i], 0, prefixes[i].length())) {
url = prefixes[i] + url.substring(prefixes[i].length());
}
break;
}
}
if (!hasPrefix && prefixes.length > 0) {
url = prefixes[0] + url;
}
return url;
}
private static void gatherLinks(ArrayList<LinkSpec> links,
Spannable s, Pattern pattern, String[] schemes,
Linkify.MatchFilter matchFilter, Linkify.TransformFilter transformFilter) {
Matcher m = pattern.matcher(s);
while (m.find()) {
int start = m.start();
int end = m.end();
if (matchFilter == null || matchFilter.acceptMatch(s, start, end)) {
LinkSpec spec = new LinkSpec();
String url = makeUrl(m.group(0), schemes, m, transformFilter);
spec.url = url;
spec.start = start;
spec.end = end;
links.add(spec);
}
}
}
private static void applyLink(String url, int start, int end, Spannable text) {
URLSpan span = new URLSpan(url);
text.setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
private static final void gatherMapLinks(ArrayList<LinkSpec> links, Spannable s) {
String string = s.toString();
String address;
int base = 0;
try {
while ((address = WebView.findAddress(string)) != null) {
int start = string.indexOf(address);
if (start < 0) {
break;
}
LinkSpec spec = new LinkSpec();
int length = address.length();
int end = start + length;
spec.start = base + start;
spec.end = base + end;
string = string.substring(end);
base += end;
String encodedAddress = null;
try {
encodedAddress = URLEncoder.encode(address,"UTF-8");
} catch (UnsupportedEncodingException e) {
continue;
}
spec.url = "geo:0,0?q=" + encodedAddress;
links.add(spec);
}
} catch (UnsupportedOperationException e) {
// findAddress may fail with an unsupported exception on platforms without a WebView.
// In this case, we will not append anything to the links variable: it would have died
// in WebView.findAddress.
return;
}
}
private static final void pruneOverlaps(ArrayList<LinkSpec> links, Spannable text) {
// Append spans added by framework
URLSpan[] urlSpans = text.getSpans(0, text.length(), URLSpan.class);
for (int i = 0; i < urlSpans.length; i++) {
LinkSpec spec = new LinkSpec();
spec.frameworkAddedSpan = urlSpans[i];
spec.start = text.getSpanStart(urlSpans[i]);
spec.end = text.getSpanEnd(urlSpans[i]);
links.add(spec);
}
Collections.sort(links, COMPARATOR);
int len = links.size();
int i = 0;
while (i < len - 1) {
LinkSpec a = links.get(i);
LinkSpec b = links.get(i + 1);
int remove = -1;
if ((a.start <= b.start) && (a.end > b.start)) {
if (b.end <= a.end) {
remove = i + 1;
} else if ((a.end - a.start) > (b.end - b.start)) {
remove = i + 1;
} else if ((a.end - a.start) < (b.end - b.start)) {
remove = i;
}
if (remove != -1) {
URLSpan span = links.get(remove).frameworkAddedSpan;
if (span != null) {
text.removeSpan(span);
}
links.remove(remove);
len--;
continue;
}
}
i++;
}
}
/**
* Do not create this static utility class.
*/
private LinkifyCompat() {}
private static class LinkSpec {
URLSpan frameworkAddedSpan;
String url;
int start;
int end;
LinkSpec() {
}
}
}