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