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