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