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