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