Home | History | Annotate | Download | only in util
      1 /*
      2  * Copyright (C) 2007 The Android Open Source Project
      3  *
      4  * Licensed under the Apache License, Version 2.0 (the "License");
      5  * you may not use this file except in compliance with the License.
      6  * You may obtain a copy of the License at
      7  *
      8  *      http://www.apache.org/licenses/LICENSE-2.0
      9  *
     10  * Unless required by applicable law or agreed to in writing, software
     11  * distributed under the License is distributed on an "AS IS" BASIS,
     12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
     13  * See the License for the specific language governing permissions and
     14  * limitations under the License.
     15  */
     16 
     17 package android.text.util;
     18 
     19 import android.annotation.IntDef;
     20 import android.annotation.NonNull;
     21 import android.annotation.Nullable;
     22 import android.telephony.PhoneNumberUtils;
     23 import android.text.Spannable;
     24 import android.text.SpannableString;
     25 import android.text.Spanned;
     26 import android.text.method.LinkMovementMethod;
     27 import android.text.method.MovementMethod;
     28 import android.text.style.URLSpan;
     29 import android.util.Patterns;
     30 import android.webkit.WebView;
     31 import android.widget.TextView;
     32 
     33 import com.android.i18n.phonenumbers.PhoneNumberMatch;
     34 import com.android.i18n.phonenumbers.PhoneNumberUtil;
     35 import com.android.i18n.phonenumbers.PhoneNumberUtil.Leniency;
     36 
     37 import libcore.util.EmptyArray;
     38 
     39 import java.io.UnsupportedEncodingException;
     40 import java.lang.annotation.Retention;
     41 import java.lang.annotation.RetentionPolicy;
     42 import java.net.URLEncoder;
     43 import java.util.ArrayList;
     44 import java.util.Collections;
     45 import java.util.Comparator;
     46 import java.util.Locale;
     47 import java.util.regex.Matcher;
     48 import java.util.regex.Pattern;
     49 
     50 /**
     51  *  Linkify take a piece of text and a regular expression and turns all of the
     52  *  regex matches in the text into clickable links.  This is particularly
     53  *  useful for matching things like email addresses, web URLs, etc. and making
     54  *  them actionable.
     55  *
     56  *  Alone with the pattern that is to be matched, a URL scheme prefix is also
     57  *  required.  Any pattern match that does not begin with the supplied scheme
     58  *  will have the scheme prepended to the matched text when the clickable URL
     59  *  is created.  For instance, if you are matching web URLs you would supply
     60  *  the scheme <code>http://</code>. If the pattern matches example.com, which
     61  *  does not have a URL scheme prefix, the supplied scheme will be prepended to
     62  *  create <code>http://example.com</code> when the clickable URL link is
     63  *  created.
     64  */
     65 
     66 public class Linkify {
     67     /**
     68      *  Bit field indicating that web URLs should be matched in methods that
     69      *  take an options mask
     70      */
     71     public static final int WEB_URLS = 0x01;
     72 
     73     /**
     74      *  Bit field indicating that email addresses should be matched in methods
     75      *  that take an options mask
     76      */
     77     public static final int EMAIL_ADDRESSES = 0x02;
     78 
     79     /**
     80      *  Bit field indicating that phone numbers should be matched in methods that
     81      *  take an options mask
     82      */
     83     public static final int PHONE_NUMBERS = 0x04;
     84 
     85     /**
     86      *  Bit field indicating that street addresses should be matched in methods that
     87      *  take an options mask. Note that this uses the
     88      *  {@link android.webkit.WebView#findAddress(String) findAddress()} method in
     89      *  {@link android.webkit.WebView} for finding addresses, which has various
     90      *  limitations.
     91      */
     92     public static final int MAP_ADDRESSES = 0x08;
     93 
     94     /**
     95      *  Bit mask indicating that all available patterns should be matched in
     96      *  methods that take an options mask
     97      */
     98     public static final int ALL = WEB_URLS | EMAIL_ADDRESSES | PHONE_NUMBERS | MAP_ADDRESSES;
     99 
    100     /**
    101      * Don't treat anything with fewer than this many digits as a
    102      * phone number.
    103      */
    104     private static final int PHONE_NUMBER_MINIMUM_DIGITS = 5;
    105 
    106     /** @hide */
    107     @IntDef(flag = true, value = { WEB_URLS, EMAIL_ADDRESSES, PHONE_NUMBERS, MAP_ADDRESSES, ALL })
    108     @Retention(RetentionPolicy.SOURCE)
    109     public @interface LinkifyMask {}
    110 
    111     /**
    112      *  Filters out web URL matches that occur after an at-sign (@).  This is
    113      *  to prevent turning the domain name in an email address into a web link.
    114      */
    115     public static final MatchFilter sUrlMatchFilter = new MatchFilter() {
    116         public final boolean acceptMatch(CharSequence s, int start, int end) {
    117             if (start == 0) {
    118                 return true;
    119             }
    120 
    121             if (s.charAt(start - 1) == '@') {
    122                 return false;
    123             }
    124 
    125             return true;
    126         }
    127     };
    128 
    129     /**
    130      *  Filters out URL matches that don't have enough digits to be a
    131      *  phone number.
    132      */
    133     public static final MatchFilter sPhoneNumberMatchFilter = new MatchFilter() {
    134         public final boolean acceptMatch(CharSequence s, int start, int end) {
    135             int digitCount = 0;
    136 
    137             for (int i = start; i < end; i++) {
    138                 if (Character.isDigit(s.charAt(i))) {
    139                     digitCount++;
    140                     if (digitCount >= PHONE_NUMBER_MINIMUM_DIGITS) {
    141                         return true;
    142                     }
    143                 }
    144             }
    145             return false;
    146         }
    147     };
    148 
    149     /**
    150      *  Transforms matched phone number text into something suitable
    151      *  to be used in a tel: URL.  It does this by removing everything
    152      *  but the digits and plus signs.  For instance:
    153      *  &apos;+1 (919) 555-1212&apos;
    154      *  becomes &apos;+19195551212&apos;
    155      */
    156     public static final TransformFilter sPhoneNumberTransformFilter = new TransformFilter() {
    157         public final String transformUrl(final Matcher match, String url) {
    158             return Patterns.digitsAndPlusOnly(match);
    159         }
    160     };
    161 
    162     /**
    163      *  MatchFilter enables client code to have more control over
    164      *  what is allowed to match and become a link, and what is not.
    165      *
    166      *  For example:  when matching web URLs you would like things like
    167      *  http://www.example.com to match, as well as just example.com itelf.
    168      *  However, you would not want to match against the domain in
    169      *  support (at) example.com.  So, when matching against a web URL pattern you
    170      *  might also include a MatchFilter that disallows the match if it is
    171      *  immediately preceded by an at-sign (@).
    172      */
    173     public interface MatchFilter {
    174         /**
    175          *  Examines the character span matched by the pattern and determines
    176          *  if the match should be turned into an actionable link.
    177          *
    178          *  @param s        The body of text against which the pattern
    179          *                  was matched
    180          *  @param start    The index of the first character in s that was
    181          *                  matched by the pattern - inclusive
    182          *  @param end      The index of the last character in s that was
    183          *                  matched - exclusive
    184          *
    185          *  @return         Whether this match should be turned into a link
    186          */
    187         boolean acceptMatch(CharSequence s, int start, int end);
    188     }
    189 
    190     /**
    191      *  TransformFilter enables client code to have more control over
    192      *  how matched patterns are represented as URLs.
    193      *
    194      *  For example:  when converting a phone number such as (919)  555-1212
    195      *  into a tel: URL the parentheses, white space, and hyphen need to be
    196      *  removed to produce tel:9195551212.
    197      */
    198     public interface TransformFilter {
    199         /**
    200          *  Examines the matched text and either passes it through or uses the
    201          *  data in the Matcher state to produce a replacement.
    202          *
    203          *  @param match    The regex matcher state that found this URL text
    204          *  @param url      The text that was matched
    205          *
    206          *  @return         The transformed form of the URL
    207          */
    208         String transformUrl(final Matcher match, String url);
    209     }
    210 
    211     /**
    212      *  Scans the text of the provided Spannable and turns all occurrences
    213      *  of the link types indicated in the mask into clickable links.
    214      *  If the mask is nonzero, it also removes any existing URLSpans
    215      *  attached to the Spannable, to avoid problems if you call it
    216      *  repeatedly on the same text.
    217      *
    218      *  @param text Spannable whose text is to be marked-up with links
    219      *  @param mask Mask to define which kinds of links will be searched.
    220      *
    221      *  @return True if at least one link is found and applied.
    222      */
    223     public static final boolean addLinks(@NonNull Spannable text, @LinkifyMask int mask) {
    224         if (mask == 0) {
    225             return false;
    226         }
    227 
    228         URLSpan[] old = text.getSpans(0, text.length(), URLSpan.class);
    229 
    230         for (int i = old.length - 1; i >= 0; i--) {
    231             text.removeSpan(old[i]);
    232         }
    233 
    234         ArrayList<LinkSpec> links = new ArrayList<LinkSpec>();
    235 
    236         if ((mask & WEB_URLS) != 0) {
    237             gatherLinks(links, text, Patterns.AUTOLINK_WEB_URL,
    238                 new String[] { "http://", "https://", "rtsp://" },
    239                 sUrlMatchFilter, null);
    240         }
    241 
    242         if ((mask & EMAIL_ADDRESSES) != 0) {
    243             gatherLinks(links, text, Patterns.AUTOLINK_EMAIL_ADDRESS,
    244                 new String[] { "mailto:" },
    245                 null, null);
    246         }
    247 
    248         if ((mask & PHONE_NUMBERS) != 0) {
    249             gatherTelLinks(links, text);
    250         }
    251 
    252         if ((mask & MAP_ADDRESSES) != 0) {
    253             gatherMapLinks(links, text);
    254         }
    255 
    256         pruneOverlaps(links);
    257 
    258         if (links.size() == 0) {
    259             return false;
    260         }
    261 
    262         for (LinkSpec link: links) {
    263             applyLink(link.url, link.start, link.end, text);
    264         }
    265 
    266         return true;
    267     }
    268 
    269     /**
    270      *  Scans the text of the provided TextView and turns all occurrences of
    271      *  the link types indicated in the mask into clickable links.  If matches
    272      *  are found the movement method for the TextView is set to
    273      *  LinkMovementMethod.
    274      *
    275      *  @param text TextView whose text is to be marked-up with links
    276      *  @param mask Mask to define which kinds of links will be searched.
    277      *
    278      *  @return True if at least one link is found and applied.
    279      */
    280     public static final boolean addLinks(@NonNull TextView text, @LinkifyMask int mask) {
    281         if (mask == 0) {
    282             return false;
    283         }
    284 
    285         CharSequence t = text.getText();
    286 
    287         if (t instanceof Spannable) {
    288             if (addLinks((Spannable) t, mask)) {
    289                 addLinkMovementMethod(text);
    290                 return true;
    291             }
    292 
    293             return false;
    294         } else {
    295             SpannableString s = SpannableString.valueOf(t);
    296 
    297             if (addLinks(s, mask)) {
    298                 addLinkMovementMethod(text);
    299                 text.setText(s);
    300 
    301                 return true;
    302             }
    303 
    304             return false;
    305         }
    306     }
    307 
    308     private static final void addLinkMovementMethod(@NonNull TextView t) {
    309         MovementMethod m = t.getMovementMethod();
    310 
    311         if ((m == null) || !(m instanceof LinkMovementMethod)) {
    312             if (t.getLinksClickable()) {
    313                 t.setMovementMethod(LinkMovementMethod.getInstance());
    314             }
    315         }
    316     }
    317 
    318     /**
    319      *  Applies a regex to the text of a TextView turning the matches into
    320      *  links.  If links are found then UrlSpans are applied to the link
    321      *  text match areas, and the movement method for the text is changed
    322      *  to LinkMovementMethod.
    323      *
    324      *  @param text         TextView whose text is to be marked-up with links
    325      *  @param pattern      Regex pattern to be used for finding links
    326      *  @param scheme       URL scheme string (eg <code>http://</code>) to be
    327      *                      prepended to the links that do not start with this scheme.
    328      */
    329     public static final void addLinks(@NonNull TextView text, @NonNull Pattern pattern,
    330             @Nullable String scheme) {
    331         addLinks(text, pattern, scheme, null, null, null);
    332     }
    333 
    334     /**
    335      *  Applies a regex to the text of a TextView turning the matches into
    336      *  links.  If links are found then UrlSpans are applied to the link
    337      *  text match areas, and the movement method for the text is changed
    338      *  to LinkMovementMethod.
    339      *
    340      *  @param text         TextView whose text is to be marked-up with links
    341      *  @param pattern      Regex pattern to be used for finding links
    342      *  @param scheme       URL scheme string (eg <code>http://</code>) to be
    343      *                      prepended to the links that do not start with this scheme.
    344      *  @param matchFilter  The filter that is used to allow the client code
    345      *                      additional control over which pattern matches are
    346      *                      to be converted into links.
    347      */
    348     public static final void addLinks(@NonNull TextView text, @NonNull Pattern pattern,
    349             @Nullable String scheme, @Nullable MatchFilter matchFilter,
    350             @Nullable TransformFilter transformFilter) {
    351         addLinks(text, pattern, scheme, null, matchFilter, transformFilter);
    352     }
    353 
    354     /**
    355      *  Applies a regex to the text of a TextView turning the matches into
    356      *  links.  If links are found then UrlSpans are applied to the link
    357      *  text match areas, and the movement method for the text is changed
    358      *  to LinkMovementMethod.
    359      *
    360      *  @param text TextView whose text is to be marked-up with links.
    361      *  @param pattern Regex pattern to be used for finding links.
    362      *  @param defaultScheme The default scheme to be prepended to links if the link does not
    363      *                       start with one of the <code>schemes</code> given.
    364      *  @param schemes Array of schemes (eg <code>http://</code>) to check if the link found
    365      *                 contains a scheme. Passing a null or empty value means prepend defaultScheme
    366      *                 to all links.
    367      *  @param matchFilter  The filter that is used to allow the client code additional control
    368      *                      over which pattern matches are to be converted into links.
    369      *  @param transformFilter Filter to allow the client code to update the link found.
    370      */
    371     public static final void addLinks(@NonNull TextView text, @NonNull Pattern pattern,
    372              @Nullable  String defaultScheme, @Nullable String[] schemes,
    373              @Nullable MatchFilter matchFilter, @Nullable TransformFilter transformFilter) {
    374         SpannableString spannable = SpannableString.valueOf(text.getText());
    375 
    376         boolean linksAdded = addLinks(spannable, pattern, defaultScheme, schemes, matchFilter,
    377                 transformFilter);
    378         if (linksAdded) {
    379             text.setText(spannable);
    380             addLinkMovementMethod(text);
    381         }
    382     }
    383 
    384     /**
    385      *  Applies a regex to a Spannable turning the matches into
    386      *  links.
    387      *
    388      *  @param text         Spannable whose text is to be marked-up with links
    389      *  @param pattern      Regex pattern to be used for finding links
    390      *  @param scheme       URL scheme string (eg <code>http://</code>) to be
    391      *                      prepended to the links that do not start with this scheme.
    392      */
    393     public static final boolean addLinks(@NonNull Spannable text, @NonNull Pattern pattern,
    394             @Nullable String scheme) {
    395         return addLinks(text, pattern, scheme, null, null, null);
    396     }
    397 
    398     /**
    399      * Applies a regex to a Spannable turning the matches into
    400      * links.
    401      *
    402      * @param spannable    Spannable whose text is to be marked-up with links
    403      * @param pattern      Regex pattern to be used for finding links
    404      * @param scheme       URL scheme string (eg <code>http://</code>) to be
    405      *                     prepended to the links that do not start with this scheme.
    406      * @param matchFilter  The filter that is used to allow the client code
    407      *                     additional control over which pattern matches are
    408      *                     to be converted into links.
    409      * @param transformFilter Filter to allow the client code to update the link found.
    410      *
    411      * @return True if at least one link is found and applied.
    412      */
    413     public static final boolean addLinks(@NonNull Spannable spannable, @NonNull Pattern pattern,
    414             @Nullable String scheme, @Nullable MatchFilter matchFilter,
    415             @Nullable TransformFilter transformFilter) {
    416         return addLinks(spannable, pattern, scheme, null, matchFilter,
    417                 transformFilter);
    418     }
    419 
    420     /**
    421      * Applies a regex to a Spannable turning the matches into links.
    422      *
    423      * @param spannable Spannable whose text is to be marked-up with links.
    424      * @param pattern Regex pattern to be used for finding links.
    425      * @param defaultScheme The default scheme to be prepended to links if the link does not
    426      *                      start with one of the <code>schemes</code> given.
    427      * @param schemes Array of schemes (eg <code>http://</code>) to check if the link found
    428      *                contains a scheme. Passing a null or empty value means prepend defaultScheme
    429      *                to all links.
    430      * @param matchFilter  The filter that is used to allow the client code additional control
    431      *                     over which pattern matches are to be converted into links.
    432      * @param transformFilter Filter to allow the client code to update the link found.
    433      *
    434      * @return True if at least one link is found and applied.
    435      */
    436     public static final boolean addLinks(@NonNull Spannable spannable, @NonNull Pattern pattern,
    437             @Nullable  String defaultScheme, @Nullable String[] schemes,
    438             @Nullable MatchFilter matchFilter, @Nullable TransformFilter transformFilter) {
    439         final String[] schemesCopy;
    440         if (defaultScheme == null) defaultScheme = "";
    441         if (schemes == null || schemes.length < 1) {
    442             schemes = EmptyArray.STRING;
    443         }
    444 
    445         schemesCopy = new String[schemes.length + 1];
    446         schemesCopy[0] = defaultScheme.toLowerCase(Locale.ROOT);
    447         for (int index = 0; index < schemes.length; index++) {
    448             String scheme = schemes[index];
    449             schemesCopy[index + 1] = (scheme == null) ? "" : scheme.toLowerCase(Locale.ROOT);
    450         }
    451 
    452         boolean hasMatches = false;
    453         Matcher m = pattern.matcher(spannable);
    454 
    455         while (m.find()) {
    456             int start = m.start();
    457             int end = m.end();
    458             boolean allowed = true;
    459 
    460             if (matchFilter != null) {
    461                 allowed = matchFilter.acceptMatch(spannable, start, end);
    462             }
    463 
    464             if (allowed) {
    465                 String url = makeUrl(m.group(0), schemesCopy, m, transformFilter);
    466 
    467                 applyLink(url, start, end, spannable);
    468                 hasMatches = true;
    469             }
    470         }
    471 
    472         return hasMatches;
    473     }
    474 
    475     private static final void applyLink(String url, int start, int end, Spannable text) {
    476         URLSpan span = new URLSpan(url);
    477 
    478         text.setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
    479     }
    480 
    481     private static final String makeUrl(@NonNull String url, @NonNull String[] prefixes,
    482             Matcher matcher, @Nullable TransformFilter filter) {
    483         if (filter != null) {
    484             url = filter.transformUrl(matcher, url);
    485         }
    486 
    487         boolean hasPrefix = false;
    488 
    489         for (int i = 0; i < prefixes.length; i++) {
    490             if (url.regionMatches(true, 0, prefixes[i], 0, prefixes[i].length())) {
    491                 hasPrefix = true;
    492 
    493                 // Fix capitalization if necessary
    494                 if (!url.regionMatches(false, 0, prefixes[i], 0, prefixes[i].length())) {
    495                     url = prefixes[i] + url.substring(prefixes[i].length());
    496                 }
    497 
    498                 break;
    499             }
    500         }
    501 
    502         if (!hasPrefix && prefixes.length > 0) {
    503             url = prefixes[0] + url;
    504         }
    505 
    506         return url;
    507     }
    508 
    509     private static final void gatherLinks(ArrayList<LinkSpec> links,
    510             Spannable s, Pattern pattern, String[] schemes,
    511             MatchFilter matchFilter, TransformFilter transformFilter) {
    512         Matcher m = pattern.matcher(s);
    513 
    514         while (m.find()) {
    515             int start = m.start();
    516             int end = m.end();
    517 
    518             if (matchFilter == null || matchFilter.acceptMatch(s, start, end)) {
    519                 LinkSpec spec = new LinkSpec();
    520                 String url = makeUrl(m.group(0), schemes, m, transformFilter);
    521 
    522                 spec.url = url;
    523                 spec.start = start;
    524                 spec.end = end;
    525 
    526                 links.add(spec);
    527             }
    528         }
    529     }
    530 
    531     private static final void gatherTelLinks(ArrayList<LinkSpec> links, Spannable s) {
    532         PhoneNumberUtil phoneUtil = PhoneNumberUtil.getInstance();
    533         Iterable<PhoneNumberMatch> matches = phoneUtil.findNumbers(s.toString(),
    534                 Locale.getDefault().getCountry(), Leniency.POSSIBLE, Long.MAX_VALUE);
    535         for (PhoneNumberMatch match : matches) {
    536             LinkSpec spec = new LinkSpec();
    537             spec.url = "tel:" + PhoneNumberUtils.normalizeNumber(match.rawString());
    538             spec.start = match.start();
    539             spec.end = match.end();
    540             links.add(spec);
    541         }
    542     }
    543 
    544     private static final void gatherMapLinks(ArrayList<LinkSpec> links, Spannable s) {
    545         String string = s.toString();
    546         String address;
    547         int base = 0;
    548 
    549         try {
    550             while ((address = WebView.findAddress(string)) != null) {
    551                 int start = string.indexOf(address);
    552 
    553                 if (start < 0) {
    554                     break;
    555                 }
    556 
    557                 LinkSpec spec = new LinkSpec();
    558                 int length = address.length();
    559                 int end = start + length;
    560 
    561                 spec.start = base + start;
    562                 spec.end = base + end;
    563                 string = string.substring(end);
    564                 base += end;
    565 
    566                 String encodedAddress = null;
    567 
    568                 try {
    569                     encodedAddress = URLEncoder.encode(address,"UTF-8");
    570                 } catch (UnsupportedEncodingException e) {
    571                     continue;
    572                 }
    573 
    574                 spec.url = "geo:0,0?q=" + encodedAddress;
    575                 links.add(spec);
    576             }
    577         } catch (UnsupportedOperationException e) {
    578             // findAddress may fail with an unsupported exception on platforms without a WebView.
    579             // In this case, we will not append anything to the links variable: it would have died
    580             // in WebView.findAddress.
    581             return;
    582         }
    583     }
    584 
    585     private static final void pruneOverlaps(ArrayList<LinkSpec> links) {
    586         Comparator<LinkSpec>  c = new Comparator<LinkSpec>() {
    587             public final int compare(LinkSpec a, LinkSpec b) {
    588                 if (a.start < b.start) {
    589                     return -1;
    590                 }
    591 
    592                 if (a.start > b.start) {
    593                     return 1;
    594                 }
    595 
    596                 if (a.end < b.end) {
    597                     return 1;
    598                 }
    599 
    600                 if (a.end > b.end) {
    601                     return -1;
    602                 }
    603 
    604                 return 0;
    605             }
    606         };
    607 
    608         Collections.sort(links, c);
    609 
    610         int len = links.size();
    611         int i = 0;
    612 
    613         while (i < len - 1) {
    614             LinkSpec a = links.get(i);
    615             LinkSpec b = links.get(i + 1);
    616             int remove = -1;
    617 
    618             if ((a.start <= b.start) && (a.end > b.start)) {
    619                 if (b.end <= a.end) {
    620                     remove = i + 1;
    621                 } else if ((a.end - a.start) > (b.end - b.start)) {
    622                     remove = i + 1;
    623                 } else if ((a.end - a.start) < (b.end - b.start)) {
    624                     remove = i;
    625                 }
    626 
    627                 if (remove != -1) {
    628                     links.remove(remove);
    629                     len--;
    630                     continue;
    631                 }
    632 
    633             }
    634 
    635             i++;
    636         }
    637     }
    638 }
    639 
    640 class LinkSpec {
    641     String url;
    642     int start;
    643     int end;
    644 }
    645