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.util.Patterns;
26import android.webkit.WebView;
27import android.widget.TextView;
28
29
30import java.io.UnsupportedEncodingException;
31import java.net.URLEncoder;
32import java.util.ArrayList;
33import java.util.Collections;
34import java.util.Comparator;
35import java.util.regex.Matcher;
36import 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
54public 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@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
539class LinkSpec {
540    String url;
541    int start;
542    int end;
543}
544