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