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