Linkify.java revision 047e816fe2e6c94ff425188c4b12f02921d3f5bf
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
17package android.text.util;
18
19import android.telephony.PhoneNumberUtils;
20import android.text.method.LinkMovementMethod;
21import android.text.method.MovementMethod;
22import android.text.style.URLSpan;
23import android.text.Spannable;
24import android.text.SpannableString;
25import android.text.Spanned;
26import android.util.Patterns;
27import android.webkit.WebView;
28import android.widget.TextView;
29
30
31import java.io.UnsupportedEncodingException;
32import java.net.URLEncoder;
33import java.util.ArrayList;
34import java.util.Collections;
35import java.util.Comparator;
36import java.util.Locale;
37import java.util.regex.Matcher;
38import java.util.regex.Pattern;
39
40import com.android.i18n.phonenumbers.PhoneNumberMatch;
41import com.android.i18n.phonenumbers.PhoneNumberUtil;
42import 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
60public 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@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
563class LinkSpec {
564    String url;
565    int start;
566    int end;
567}
568