OSDN Git Service

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