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