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 
17 package android.text.util;
18 
19 import android.annotation.IntDef;
20 import android.annotation.NonNull;
21 import android.annotation.Nullable;
22 import android.telephony.PhoneNumberUtils;
23 import android.text.Spannable;
24 import android.text.SpannableString;
25 import android.text.Spanned;
26 import android.text.method.LinkMovementMethod;
27 import android.text.method.MovementMethod;
28 import android.text.style.URLSpan;
29 import android.util.Patterns;
30 import android.webkit.WebView;
31 import android.widget.TextView;
32 
33 import com.android.i18n.phonenumbers.PhoneNumberMatch;
34 import com.android.i18n.phonenumbers.PhoneNumberUtil;
35 import com.android.i18n.phonenumbers.PhoneNumberUtil.Leniency;
36 
37 import libcore.util.EmptyArray;
38 
39 import java.io.UnsupportedEncodingException;
40 import java.lang.annotation.Retention;
41 import java.lang.annotation.RetentionPolicy;
42 import java.net.URLEncoder;
43 import java.util.ArrayList;
44 import java.util.Collections;
45 import java.util.Comparator;
46 import java.util.Locale;
47 import java.util.regex.Matcher;
48 import 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 
66 public 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          */
acceptMatch(CharSequence s, int start, int end)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          */
transformUrl(final Matcher match, String url)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      */
addLinks(@onNull Spannable text, @LinkifyMask int mask)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      */
addLinks(@onNull TextView text, @LinkifyMask int mask)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 
addLinkMovementMethod(@onNull TextView t)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      */
addLinks(@onNull TextView text, @NonNull Pattern pattern, @Nullable String scheme)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      */
addLinks(@onNull TextView text, @NonNull Pattern pattern, @Nullable String scheme, @Nullable MatchFilter matchFilter, @Nullable TransformFilter transformFilter)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      */
addLinks(@onNull TextView text, @NonNull Pattern pattern, @Nullable String defaultScheme, @Nullable String[] schemes, @Nullable MatchFilter matchFilter, @Nullable TransformFilter transformFilter)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      */
addLinks(@onNull Spannable text, @NonNull Pattern pattern, @Nullable String scheme)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      */
addLinks(@onNull Spannable spannable, @NonNull Pattern pattern, @Nullable String scheme, @Nullable MatchFilter matchFilter, @Nullable TransformFilter transformFilter)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      */
addLinks(@onNull Spannable spannable, @NonNull Pattern pattern, @Nullable String defaultScheme, @Nullable String[] schemes, @Nullable MatchFilter matchFilter, @Nullable TransformFilter transformFilter)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 
applyLink(String url, int start, int end, Spannable text)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 
makeUrl(@onNull String url, @NonNull String[] prefixes, Matcher matcher, @Nullable TransformFilter filter)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 
gatherLinks(ArrayList<LinkSpec> links, Spannable s, Pattern pattern, String[] schemes, MatchFilter matchFilter, TransformFilter transformFilter)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 
gatherTelLinks(ArrayList<LinkSpec> links, Spannable s)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 
gatherMapLinks(ArrayList<LinkSpec> links, Spannable s)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 
pruneOverlaps(ArrayList<LinkSpec> links)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 
640 class LinkSpec {
641     String url;
642     int start;
643     int end;
644 }
645