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