• 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.annotation.UiThread;
23 import android.content.Context;
24 import android.telephony.PhoneNumberUtils;
25 import android.telephony.TelephonyManager;
26 import android.text.Spannable;
27 import android.text.SpannableString;
28 import android.text.Spanned;
29 import android.text.method.LinkMovementMethod;
30 import android.text.method.MovementMethod;
31 import android.text.style.URLSpan;
32 import android.util.Log;
33 import android.util.Patterns;
34 import android.view.textclassifier.TextClassifier;
35 import android.view.textclassifier.TextLinks;
36 import android.view.textclassifier.TextLinks.TextLinkSpan;
37 import android.view.textclassifier.TextLinksParams;
38 import android.webkit.WebView;
39 import android.widget.TextView;
40 
41 import com.android.i18n.phonenumbers.PhoneNumberMatch;
42 import com.android.i18n.phonenumbers.PhoneNumberUtil;
43 import com.android.i18n.phonenumbers.PhoneNumberUtil.Leniency;
44 import com.android.internal.util.Preconditions;
45 
46 import libcore.util.EmptyArray;
47 
48 import java.io.UnsupportedEncodingException;
49 import java.lang.annotation.Retention;
50 import java.lang.annotation.RetentionPolicy;
51 import java.net.URLEncoder;
52 import java.util.ArrayList;
53 import java.util.Collections;
54 import java.util.Comparator;
55 import java.util.Locale;
56 import java.util.concurrent.CompletableFuture;
57 import java.util.concurrent.Executor;
58 import java.util.concurrent.Future;
59 import java.util.function.Consumer;
60 import java.util.function.Supplier;
61 import java.util.regex.Matcher;
62 import java.util.regex.Pattern;
63 
64 /**
65  *  Linkify take a piece of text and a regular expression and turns all of the
66  *  regex matches in the text into clickable links.  This is particularly
67  *  useful for matching things like email addresses, web URLs, etc. and making
68  *  them actionable.
69  *
70  *  Alone with the pattern that is to be matched, a URL scheme prefix is also
71  *  required.  Any pattern match that does not begin with the supplied scheme
72  *  will have the scheme prepended to the matched text when the clickable URL
73  *  is created.  For instance, if you are matching web URLs you would supply
74  *  the scheme <code>http://</code>. If the pattern matches example.com, which
75  *  does not have a URL scheme prefix, the supplied scheme will be prepended to
76  *  create <code>http://example.com</code> when the clickable URL link is
77  *  created.
78  */
79 
80 public class Linkify {
81 
82     private static final String LOG_TAG = "Linkify";
83 
84     /**
85      *  Bit field indicating that web URLs should be matched in methods that
86      *  take an options mask
87      */
88     public static final int WEB_URLS = 0x01;
89 
90     /**
91      *  Bit field indicating that email addresses should be matched in methods
92      *  that take an options mask
93      */
94     public static final int EMAIL_ADDRESSES = 0x02;
95 
96     /**
97      *  Bit field indicating that phone numbers should be matched in methods that
98      *  take an options mask
99      */
100     public static final int PHONE_NUMBERS = 0x04;
101 
102     /**
103      *  Bit field indicating that street addresses should be matched in methods that
104      *  take an options mask. Note that this uses the
105      *  {@link android.webkit.WebView#findAddress(String) findAddress()} method in
106      *  {@link android.webkit.WebView} for finding addresses, which has various
107      *  limitations and has been deprecated.
108      *  @deprecated use {@link android.view.textclassifier.TextClassifier#generateLinks(
109      *  TextLinks.Request)} instead and avoid it even when targeting API levels where no alternative
110      *  is available.
111      */
112     @Deprecated
113     public static final int MAP_ADDRESSES = 0x08;
114 
115     /**
116      *  Bit mask indicating that all available patterns should be matched in
117      *  methods that take an options mask
118      *  <p><strong>Note:</strong></p> {@link #MAP_ADDRESSES} is deprecated.
119      *  Use {@link android.view.textclassifier.TextClassifier#generateLinks(TextLinks.Request)}
120      *  instead and avoid it even when targeting API levels where no alternative is available.
121      */
122     public static final int ALL = WEB_URLS | EMAIL_ADDRESSES | PHONE_NUMBERS | MAP_ADDRESSES;
123 
124     /**
125      * Don't treat anything with fewer than this many digits as a
126      * phone number.
127      */
128     private static final int PHONE_NUMBER_MINIMUM_DIGITS = 5;
129 
130     /** @hide */
131     @IntDef(flag = true, value = { WEB_URLS, EMAIL_ADDRESSES, PHONE_NUMBERS, MAP_ADDRESSES, ALL })
132     @Retention(RetentionPolicy.SOURCE)
133     public @interface LinkifyMask {}
134 
135     /**
136      *  Filters out web URL matches that occur after an at-sign (@).  This is
137      *  to prevent turning the domain name in an email address into a web link.
138      */
139     public static final MatchFilter sUrlMatchFilter = new MatchFilter() {
140         public final boolean acceptMatch(CharSequence s, int start, int end) {
141             if (start == 0) {
142                 return true;
143             }
144 
145             if (s.charAt(start - 1) == '@') {
146                 return false;
147             }
148 
149             return true;
150         }
151     };
152 
153     /**
154      *  Filters out URL matches that don't have enough digits to be a
155      *  phone number.
156      */
157     public static final MatchFilter sPhoneNumberMatchFilter = new MatchFilter() {
158         public final boolean acceptMatch(CharSequence s, int start, int end) {
159             int digitCount = 0;
160 
161             for (int i = start; i < end; i++) {
162                 if (Character.isDigit(s.charAt(i))) {
163                     digitCount++;
164                     if (digitCount >= PHONE_NUMBER_MINIMUM_DIGITS) {
165                         return true;
166                     }
167                 }
168             }
169             return false;
170         }
171     };
172 
173     /**
174      *  Transforms matched phone number text into something suitable
175      *  to be used in a tel: URL.  It does this by removing everything
176      *  but the digits and plus signs.  For instance:
177      *  &apos;+1 (919) 555-1212&apos;
178      *  becomes &apos;+19195551212&apos;
179      */
180     public static final TransformFilter sPhoneNumberTransformFilter = new TransformFilter() {
181         public final String transformUrl(final Matcher match, String url) {
182             return Patterns.digitsAndPlusOnly(match);
183         }
184     };
185 
186     /**
187      *  MatchFilter enables client code to have more control over
188      *  what is allowed to match and become a link, and what is not.
189      *
190      *  For example:  when matching web URLs you would like things like
191      *  http://www.example.com to match, as well as just example.com itelf.
192      *  However, you would not want to match against the domain in
193      *  support@example.com.  So, when matching against a web URL pattern you
194      *  might also include a MatchFilter that disallows the match if it is
195      *  immediately preceded by an at-sign (@).
196      */
197     public interface MatchFilter {
198         /**
199          *  Examines the character span matched by the pattern and determines
200          *  if the match should be turned into an actionable link.
201          *
202          *  @param s        The body of text against which the pattern
203          *                  was matched
204          *  @param start    The index of the first character in s that was
205          *                  matched by the pattern - inclusive
206          *  @param end      The index of the last character in s that was
207          *                  matched - exclusive
208          *
209          *  @return         Whether this match should be turned into a link
210          */
acceptMatch(CharSequence s, int start, int end)211         boolean acceptMatch(CharSequence s, int start, int end);
212     }
213 
214     /**
215      *  TransformFilter enables client code to have more control over
216      *  how matched patterns are represented as URLs.
217      *
218      *  For example:  when converting a phone number such as (919)  555-1212
219      *  into a tel: URL the parentheses, white space, and hyphen need to be
220      *  removed to produce tel:9195551212.
221      */
222     public interface TransformFilter {
223         /**
224          *  Examines the matched text and either passes it through or uses the
225          *  data in the Matcher state to produce a replacement.
226          *
227          *  @param match    The regex matcher state that found this URL text
228          *  @param url      The text that was matched
229          *
230          *  @return         The transformed form of the URL
231          */
transformUrl(final Matcher match, String url)232         String transformUrl(final Matcher match, String url);
233     }
234 
235     /**
236      *  Scans the text of the provided Spannable and turns all occurrences
237      *  of the link types indicated in the mask into clickable links.
238      *  If the mask is nonzero, it also removes any existing URLSpans
239      *  attached to the Spannable, to avoid problems if you call it
240      *  repeatedly on the same text.
241      *
242      *  @param text Spannable whose text is to be marked-up with links
243      *  @param mask Mask to define which kinds of links will be searched.
244      *
245      *  @return True if at least one link is found and applied.
246      */
addLinks(@onNull Spannable text, @LinkifyMask int mask)247     public static final boolean addLinks(@NonNull Spannable text, @LinkifyMask int mask) {
248         return addLinks(text, mask, null);
249     }
250 
addLinks(@onNull Spannable text, @LinkifyMask int mask, @Nullable Context context)251     private static boolean addLinks(@NonNull Spannable text, @LinkifyMask int mask,
252             @Nullable Context context) {
253         if (text != null && containsUnsupportedCharacters(text.toString())) {
254             android.util.EventLog.writeEvent(0x534e4554, "116321860", -1, "");
255             return false;
256         }
257 
258         if (mask == 0) {
259             return false;
260         }
261 
262         URLSpan[] old = text.getSpans(0, text.length(), URLSpan.class);
263 
264         for (int i = old.length - 1; i >= 0; i--) {
265             text.removeSpan(old[i]);
266         }
267 
268         ArrayList<LinkSpec> links = new ArrayList<LinkSpec>();
269 
270         if ((mask & WEB_URLS) != 0) {
271             gatherLinks(links, text, Patterns.AUTOLINK_WEB_URL,
272                 new String[] { "http://", "https://", "rtsp://" },
273                 sUrlMatchFilter, null);
274         }
275 
276         if ((mask & EMAIL_ADDRESSES) != 0) {
277             gatherLinks(links, text, Patterns.AUTOLINK_EMAIL_ADDRESS,
278                 new String[] { "mailto:" },
279                 null, null);
280         }
281 
282         if ((mask & PHONE_NUMBERS) != 0) {
283             gatherTelLinks(links, text, context);
284         }
285 
286         if ((mask & MAP_ADDRESSES) != 0) {
287             gatherMapLinks(links, text);
288         }
289 
290         pruneOverlaps(links);
291 
292         if (links.size() == 0) {
293             return false;
294         }
295 
296         for (LinkSpec link: links) {
297             applyLink(link.url, link.start, link.end, text);
298         }
299 
300         return true;
301     }
302 
303     /**
304      * Returns true if the specified text contains at least one unsupported character for applying
305      * links. Also logs the error.
306      *
307      * @param text the text to apply links to
308      * @hide
309      */
containsUnsupportedCharacters(String text)310     public static boolean containsUnsupportedCharacters(String text) {
311         if (text.contains("\u202C")) {
312             Log.e(LOG_TAG, "Unsupported character for applying links: u202C");
313             return true;
314         }
315         if (text.contains("\u202D")) {
316             Log.e(LOG_TAG, "Unsupported character for applying links: u202D");
317             return true;
318         }
319         if (text.contains("\u202E")) {
320             Log.e(LOG_TAG, "Unsupported character for applying links: u202E");
321             return true;
322         }
323         return false;
324     }
325 
326     /**
327      *  Scans the text of the provided TextView and turns all occurrences of
328      *  the link types indicated in the mask into clickable links.  If matches
329      *  are found the movement method for the TextView is set to
330      *  LinkMovementMethod.
331      *
332      *  @param text TextView whose text is to be marked-up with links
333      *  @param mask Mask to define which kinds of links will be searched.
334      *
335      *  @return True if at least one link is found and applied.
336      */
addLinks(@onNull TextView text, @LinkifyMask int mask)337     public static final boolean addLinks(@NonNull TextView text, @LinkifyMask int mask) {
338         if (mask == 0) {
339             return false;
340         }
341 
342         final Context context = text.getContext();
343         final CharSequence t = text.getText();
344         if (t instanceof Spannable) {
345             if (addLinks((Spannable) t, mask, context)) {
346                 addLinkMovementMethod(text);
347                 return true;
348             }
349 
350             return false;
351         } else {
352             SpannableString s = SpannableString.valueOf(t);
353 
354             if (addLinks(s, mask, context)) {
355                 addLinkMovementMethod(text);
356                 text.setText(s);
357 
358                 return true;
359             }
360 
361             return false;
362         }
363     }
364 
addLinkMovementMethod(@onNull TextView t)365     private static final void addLinkMovementMethod(@NonNull TextView t) {
366         MovementMethod m = t.getMovementMethod();
367 
368         if ((m == null) || !(m instanceof LinkMovementMethod)) {
369             if (t.getLinksClickable()) {
370                 t.setMovementMethod(LinkMovementMethod.getInstance());
371             }
372         }
373     }
374 
375     /**
376      *  Applies a regex to the text of a TextView turning the matches into
377      *  links.  If links are found then UrlSpans are applied to the link
378      *  text match areas, and the movement method for the text is changed
379      *  to LinkMovementMethod.
380      *
381      *  @param text         TextView whose text is to be marked-up with links
382      *  @param pattern      Regex pattern to be used for finding links
383      *  @param scheme       URL scheme string (eg <code>http://</code>) to be
384      *                      prepended to the links that do not start with this scheme.
385      */
addLinks(@onNull TextView text, @NonNull Pattern pattern, @Nullable String scheme)386     public static final void addLinks(@NonNull TextView text, @NonNull Pattern pattern,
387             @Nullable String scheme) {
388         addLinks(text, pattern, scheme, null, null, null);
389     }
390 
391     /**
392      *  Applies a regex to the text of a TextView turning the matches into
393      *  links.  If links are found then UrlSpans are applied to the link
394      *  text match areas, and the movement method for the text is changed
395      *  to LinkMovementMethod.
396      *
397      *  @param text         TextView whose text is to be marked-up with links
398      *  @param pattern      Regex pattern to be used for finding links
399      *  @param scheme       URL scheme string (eg <code>http://</code>) to be
400      *                      prepended to the links that do not start with this scheme.
401      *  @param matchFilter  The filter that is used to allow the client code
402      *                      additional control over which pattern matches are
403      *                      to be converted into links.
404      */
addLinks(@onNull TextView text, @NonNull Pattern pattern, @Nullable String scheme, @Nullable MatchFilter matchFilter, @Nullable TransformFilter transformFilter)405     public static final void addLinks(@NonNull TextView text, @NonNull Pattern pattern,
406             @Nullable String scheme, @Nullable MatchFilter matchFilter,
407             @Nullable TransformFilter transformFilter) {
408         addLinks(text, pattern, scheme, null, matchFilter, transformFilter);
409     }
410 
411     /**
412      *  Applies a regex to the text of a TextView turning the matches into
413      *  links.  If links are found then UrlSpans are applied to the link
414      *  text match areas, and the movement method for the text is changed
415      *  to LinkMovementMethod.
416      *
417      *  @param text TextView whose text is to be marked-up with links.
418      *  @param pattern Regex pattern to be used for finding links.
419      *  @param defaultScheme The default scheme to be prepended to links if the link does not
420      *                       start with one of the <code>schemes</code> given.
421      *  @param schemes Array of schemes (eg <code>http://</code>) to check if the link found
422      *                 contains a scheme. Passing a null or empty value means prepend defaultScheme
423      *                 to all links.
424      *  @param matchFilter  The filter that is used to allow the client code additional control
425      *                      over which pattern matches are to be converted into links.
426      *  @param transformFilter Filter to allow the client code to update the link found.
427      */
addLinks(@onNull TextView text, @NonNull Pattern pattern, @Nullable String defaultScheme, @Nullable String[] schemes, @Nullable MatchFilter matchFilter, @Nullable TransformFilter transformFilter)428     public static final void addLinks(@NonNull TextView text, @NonNull Pattern pattern,
429              @Nullable  String defaultScheme, @Nullable String[] schemes,
430              @Nullable MatchFilter matchFilter, @Nullable TransformFilter transformFilter) {
431         SpannableString spannable = SpannableString.valueOf(text.getText());
432 
433         boolean linksAdded = addLinks(spannable, pattern, defaultScheme, schemes, matchFilter,
434                 transformFilter);
435         if (linksAdded) {
436             text.setText(spannable);
437             addLinkMovementMethod(text);
438         }
439     }
440 
441     /**
442      *  Applies a regex to a Spannable turning the matches into
443      *  links.
444      *
445      *  @param text         Spannable whose text is to be marked-up with links
446      *  @param pattern      Regex pattern to be used for finding links
447      *  @param scheme       URL scheme string (eg <code>http://</code>) to be
448      *                      prepended to the links that do not start with this scheme.
449      */
addLinks(@onNull Spannable text, @NonNull Pattern pattern, @Nullable String scheme)450     public static final boolean addLinks(@NonNull Spannable text, @NonNull Pattern pattern,
451             @Nullable String scheme) {
452         return addLinks(text, pattern, scheme, null, null, null);
453     }
454 
455     /**
456      * Applies a regex to a Spannable turning the matches into
457      * links.
458      *
459      * @param spannable    Spannable whose text is to be marked-up with links
460      * @param pattern      Regex pattern to be used for finding links
461      * @param scheme       URL scheme string (eg <code>http://</code>) to be
462      *                     prepended to the links that do not start with this scheme.
463      * @param matchFilter  The filter that is used to allow the client code
464      *                     additional control over which pattern matches are
465      *                     to be converted into links.
466      * @param transformFilter Filter to allow the client code to update the link found.
467      *
468      * @return True if at least one link is found and applied.
469      */
addLinks(@onNull Spannable spannable, @NonNull Pattern pattern, @Nullable String scheme, @Nullable MatchFilter matchFilter, @Nullable TransformFilter transformFilter)470     public static final boolean addLinks(@NonNull Spannable spannable, @NonNull Pattern pattern,
471             @Nullable String scheme, @Nullable MatchFilter matchFilter,
472             @Nullable TransformFilter transformFilter) {
473         return addLinks(spannable, pattern, scheme, null, matchFilter,
474                 transformFilter);
475     }
476 
477     /**
478      * Applies a regex to a Spannable turning the matches into links.
479      *
480      * @param spannable Spannable whose text is to be marked-up with links.
481      * @param pattern Regex pattern to be used for finding links.
482      * @param defaultScheme The default scheme to be prepended to links if the link does not
483      *                      start with one of the <code>schemes</code> given.
484      * @param schemes Array of schemes (eg <code>http://</code>) to check if the link found
485      *                contains a scheme. Passing a null or empty value means prepend defaultScheme
486      *                to all links.
487      * @param matchFilter  The filter that is used to allow the client code additional control
488      *                     over which pattern matches are to be converted into links.
489      * @param transformFilter Filter to allow the client code to update the link found.
490      *
491      * @return True if at least one link is found and applied.
492      */
addLinks(@onNull Spannable spannable, @NonNull Pattern pattern, @Nullable String defaultScheme, @Nullable String[] schemes, @Nullable MatchFilter matchFilter, @Nullable TransformFilter transformFilter)493     public static final boolean addLinks(@NonNull Spannable spannable, @NonNull Pattern pattern,
494             @Nullable  String defaultScheme, @Nullable String[] schemes,
495             @Nullable MatchFilter matchFilter, @Nullable TransformFilter transformFilter) {
496         if (spannable != null && containsUnsupportedCharacters(spannable.toString())) {
497             android.util.EventLog.writeEvent(0x534e4554, "116321860", -1, "");
498             return false;
499         }
500 
501         final String[] schemesCopy;
502         if (defaultScheme == null) defaultScheme = "";
503         if (schemes == null || schemes.length < 1) {
504             schemes = EmptyArray.STRING;
505         }
506 
507         schemesCopy = new String[schemes.length + 1];
508         schemesCopy[0] = defaultScheme.toLowerCase(Locale.ROOT);
509         for (int index = 0; index < schemes.length; index++) {
510             String scheme = schemes[index];
511             schemesCopy[index + 1] = (scheme == null) ? "" : scheme.toLowerCase(Locale.ROOT);
512         }
513 
514         boolean hasMatches = false;
515         Matcher m = pattern.matcher(spannable);
516 
517         while (m.find()) {
518             int start = m.start();
519             int end = m.end();
520             boolean allowed = true;
521 
522             if (matchFilter != null) {
523                 allowed = matchFilter.acceptMatch(spannable, start, end);
524             }
525 
526             if (allowed) {
527                 String url = makeUrl(m.group(0), schemesCopy, m, transformFilter);
528 
529                 applyLink(url, start, end, spannable);
530                 hasMatches = true;
531             }
532         }
533 
534         return hasMatches;
535     }
536 
537     /**
538      * Scans the text of the provided TextView and turns all occurrences of the entity types
539      * specified by {@code options} into clickable links. If links are found, this method
540      * removes any pre-existing {@link TextLinkSpan} attached to the text (to avoid
541      * problems if you call it repeatedly on the same text) and sets the movement method for the
542      * TextView to LinkMovementMethod.
543      *
544      * <p><strong>Note:</strong> This method returns immediately but generates the links with
545      * the specified classifier on a background thread. The generated links are applied on the
546      * calling thread.
547      *
548      * @param textView TextView whose text is to be marked-up with links
549      * @param params optional parameters to specify how to generate the links
550      *
551      * @return a future that may be used to interrupt or query the background task
552      * @hide
553      */
554     @UiThread
addLinksAsync( @onNull TextView textView, @Nullable TextLinksParams params)555     public static Future<Void> addLinksAsync(
556             @NonNull TextView textView,
557             @Nullable TextLinksParams params) {
558         return addLinksAsync(textView, params, null /* executor */, null /* callback */);
559     }
560 
561     /**
562      * Scans the text of the provided TextView and turns all occurrences of the entity types
563      * specified by {@code options} into clickable links. If links are found, this method
564      * removes any pre-existing {@link TextLinkSpan} attached to the text (to avoid
565      * problems if you call it repeatedly on the same text) and sets the movement method for the
566      * TextView to LinkMovementMethod.
567      *
568      * <p><strong>Note:</strong> This method returns immediately but generates the links with
569      * the specified classifier on a background thread. The generated links are applied on the
570      * calling thread.
571      *
572      * @param textView TextView whose text is to be marked-up with links
573      * @param mask mask to define which kinds of links will be generated
574      *
575      * @return a future that may be used to interrupt or query the background task
576      * @hide
577      */
578     @UiThread
addLinksAsync( @onNull TextView textView, @LinkifyMask int mask)579     public static Future<Void> addLinksAsync(
580             @NonNull TextView textView,
581             @LinkifyMask int mask) {
582         return addLinksAsync(textView, TextLinksParams.fromLinkMask(mask),
583                 null /* executor */, null /* callback */);
584     }
585 
586     /**
587      * Scans the text of the provided TextView and turns all occurrences of the entity types
588      * specified by {@code options} into clickable links. If links are found, this method
589      * removes any pre-existing {@link TextLinkSpan} attached to the text (to avoid
590      * problems if you call it repeatedly on the same text) and sets the movement method for the
591      * TextView to LinkMovementMethod.
592      *
593      * <p><strong>Note:</strong> This method returns immediately but generates the links with
594      * the specified classifier on a background thread. The generated links are applied on the
595      * calling thread.
596      *
597      * @param textView TextView whose text is to be marked-up with links
598      * @param params optional parameters to specify how to generate the links
599      * @param executor Executor that runs the background task
600      * @param callback Callback that receives the final status of the background task execution
601      *
602      * @return a future that may be used to interrupt or query the background task
603      * @hide
604      */
605     @UiThread
addLinksAsync( @onNull TextView textView, @Nullable TextLinksParams params, @Nullable Executor executor, @Nullable Consumer<Integer> callback)606     public static Future<Void> addLinksAsync(
607             @NonNull TextView textView,
608             @Nullable TextLinksParams params,
609             @Nullable Executor executor,
610             @Nullable Consumer<Integer> callback) {
611         Preconditions.checkNotNull(textView);
612         final CharSequence text = textView.getText();
613         final Spannable spannable = (text instanceof Spannable)
614                 ? (Spannable) text : SpannableString.valueOf(text);
615         final Runnable modifyTextView = () -> {
616             addLinkMovementMethod(textView);
617             if (spannable != text) {
618                 textView.setText(spannable);
619             }
620         };
621         return addLinksAsync(spannable, textView.getTextClassifier(),
622                 params, executor, callback, modifyTextView);
623     }
624 
625     /**
626      * Scans the text of the provided TextView and turns all occurrences of the entity types
627      * specified by {@code options} into clickable links. If links are found, this method
628      * removes any pre-existing {@link TextLinkSpan} attached to the text to avoid
629      * problems if you call it repeatedly on the same text.
630      *
631      * <p><strong>Note:</strong> This method returns immediately but generates the links with
632      * the specified classifier on a background thread. The generated links are applied on the
633      * calling thread.
634      *
635      * <p><strong>Note:</strong> If the text is currently attached to a TextView, this method
636      * should be called on the UI thread.
637      *
638      * @param text Spannable whose text is to be marked-up with links
639      * @param classifier the TextClassifier to use to generate the links
640      * @param params optional parameters to specify how to generate the links
641      *
642      * @return a future that may be used to interrupt or query the background task
643      * @hide
644      */
addLinksAsync( @onNull Spannable text, @NonNull TextClassifier classifier, @Nullable TextLinksParams params)645     public static Future<Void> addLinksAsync(
646             @NonNull Spannable text,
647             @NonNull TextClassifier classifier,
648             @Nullable TextLinksParams params) {
649         return addLinksAsync(text, classifier, params, null /* executor */, null /* callback */);
650     }
651 
652     /**
653      * Scans the text of the provided TextView and turns all occurrences of the entity types
654      * specified by the link {@code mask} into clickable links. If links are found, this method
655      * removes any pre-existing {@link TextLinkSpan} attached to the text to avoid
656      * problems if you call it repeatedly on the same text.
657      *
658      * <p><strong>Note:</strong> This method returns immediately but generates the links with
659      * the specified classifier on a background thread. The generated links are applied on the
660      * calling thread.
661      *
662      * <p><strong>Note:</strong> If the text is currently attached to a TextView, this method
663      * should be called on the UI thread.
664      *
665      * @param text Spannable whose text is to be marked-up with links
666      * @param classifier the TextClassifier to use to generate the links
667      * @param mask mask to define which kinds of links will be generated
668      *
669      * @return a future that may be used to interrupt or query the background task
670      * @hide
671      */
addLinksAsync( @onNull Spannable text, @NonNull TextClassifier classifier, @LinkifyMask int mask)672     public static Future<Void> addLinksAsync(
673             @NonNull Spannable text,
674             @NonNull TextClassifier classifier,
675             @LinkifyMask int mask) {
676         return addLinksAsync(text, classifier, TextLinksParams.fromLinkMask(mask),
677                 null /* executor */, null /* callback */);
678     }
679 
680     /**
681      * Scans the text of the provided TextView and turns all occurrences of the entity types
682      * specified by {@code options} into clickable links. If links are found, this method
683      * removes any pre-existing {@link TextLinkSpan} attached to the text to avoid
684      * problems if you call it repeatedly on the same text.
685      *
686      * <p><strong>Note:</strong> This method returns immediately but generates the links with
687      * the specified classifier on a background thread. The generated links are applied on the
688      * calling thread.
689      *
690      * <p><strong>Note:</strong> If the text is currently attached to a TextView, this method
691      * should be called on the UI thread.
692      *
693      * @param text Spannable whose text is to be marked-up with links
694      * @param classifier the TextClassifier to use to generate the links
695      * @param params optional parameters to specify how to generate the links
696      * @param executor Executor that runs the background task
697      * @param callback Callback that receives the final status of the background task execution
698      *
699      * @return a future that may be used to interrupt or query the background task
700      * @hide
701      */
addLinksAsync( @onNull Spannable text, @NonNull TextClassifier classifier, @Nullable TextLinksParams params, @Nullable Executor executor, @Nullable Consumer<Integer> callback)702     public static Future<Void> addLinksAsync(
703             @NonNull Spannable text,
704             @NonNull TextClassifier classifier,
705             @Nullable TextLinksParams params,
706             @Nullable Executor executor,
707             @Nullable Consumer<Integer> callback) {
708         return addLinksAsync(text, classifier, params, executor, callback,
709                 null /* modifyTextView */);
710     }
711 
addLinksAsync( @onNull Spannable text, @NonNull TextClassifier classifier, @Nullable TextLinksParams params, @Nullable Executor executor, @Nullable Consumer<Integer> callback, @Nullable Runnable modifyTextView)712     private static Future<Void> addLinksAsync(
713             @NonNull Spannable text,
714             @NonNull TextClassifier classifier,
715             @Nullable TextLinksParams params,
716             @Nullable Executor executor,
717             @Nullable Consumer<Integer> callback,
718             @Nullable Runnable modifyTextView) {
719         Preconditions.checkNotNull(text);
720         Preconditions.checkNotNull(classifier);
721 
722         // TODO: This is a bug. We shouldnot call getMaxGenerateLinksTextLength() on the UI thread.
723         // The input text may exceed the maximum length the text classifier can handle. In such
724         // cases, we process the text up to the maximum length.
725         final CharSequence truncatedText = text.subSequence(
726                 0, Math.min(text.length(), classifier.getMaxGenerateLinksTextLength()));
727 
728         final TextClassifier.EntityConfig entityConfig = (params == null)
729                 ? null : params.getEntityConfig();
730         final TextLinks.Request request = new TextLinks.Request.Builder(truncatedText)
731                 .setLegacyFallback(true)
732                 .setEntityConfig(entityConfig)
733                 .build();
734         final Supplier<TextLinks> supplier = () -> classifier.generateLinks(request);
735         final Consumer<TextLinks> consumer = links -> {
736             if (links.getLinks().isEmpty()) {
737                 if (callback != null) {
738                     callback.accept(TextLinks.STATUS_NO_LINKS_FOUND);
739                 }
740                 return;
741             }
742 
743             // Remove spans only for the part of the text we generated links for.
744             final TextLinkSpan[] old =
745                     text.getSpans(0, truncatedText.length(), TextLinkSpan.class);
746             for (int i = old.length - 1; i >= 0; i--) {
747                 text.removeSpan(old[i]);
748             }
749 
750             final @TextLinks.Status int result = params.apply(text, links);
751             if (result == TextLinks.STATUS_LINKS_APPLIED) {
752                 if (modifyTextView != null) {
753                     modifyTextView.run();
754                 }
755             }
756             if (callback != null) {
757                 callback.accept(result);
758             }
759         };
760         if (executor == null) {
761             return CompletableFuture.supplyAsync(supplier).thenAccept(consumer);
762         } else {
763             return CompletableFuture.supplyAsync(supplier, executor).thenAccept(consumer);
764         }
765     }
766 
applyLink(String url, int start, int end, Spannable text)767     private static final void applyLink(String url, int start, int end, Spannable text) {
768         URLSpan span = new URLSpan(url);
769 
770         text.setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
771     }
772 
makeUrl(@onNull String url, @NonNull String[] prefixes, Matcher matcher, @Nullable TransformFilter filter)773     private static final String makeUrl(@NonNull String url, @NonNull String[] prefixes,
774             Matcher matcher, @Nullable TransformFilter filter) {
775         if (filter != null) {
776             url = filter.transformUrl(matcher, url);
777         }
778 
779         boolean hasPrefix = false;
780 
781         for (int i = 0; i < prefixes.length; i++) {
782             if (url.regionMatches(true, 0, prefixes[i], 0, prefixes[i].length())) {
783                 hasPrefix = true;
784 
785                 // Fix capitalization if necessary
786                 if (!url.regionMatches(false, 0, prefixes[i], 0, prefixes[i].length())) {
787                     url = prefixes[i] + url.substring(prefixes[i].length());
788                 }
789 
790                 break;
791             }
792         }
793 
794         if (!hasPrefix && prefixes.length > 0) {
795             url = prefixes[0] + url;
796         }
797 
798         return url;
799     }
800 
gatherLinks(ArrayList<LinkSpec> links, Spannable s, Pattern pattern, String[] schemes, MatchFilter matchFilter, TransformFilter transformFilter)801     private static final void gatherLinks(ArrayList<LinkSpec> links,
802             Spannable s, Pattern pattern, String[] schemes,
803             MatchFilter matchFilter, TransformFilter transformFilter) {
804         Matcher m = pattern.matcher(s);
805 
806         while (m.find()) {
807             int start = m.start();
808             int end = m.end();
809 
810             if (matchFilter == null || matchFilter.acceptMatch(s, start, end)) {
811                 LinkSpec spec = new LinkSpec();
812                 String url = makeUrl(m.group(0), schemes, m, transformFilter);
813 
814                 spec.url = url;
815                 spec.start = start;
816                 spec.end = end;
817 
818                 links.add(spec);
819             }
820         }
821     }
822 
gatherTelLinks(ArrayList<LinkSpec> links, Spannable s, @Nullable Context context)823     private static void gatherTelLinks(ArrayList<LinkSpec> links, Spannable s,
824             @Nullable Context context) {
825         PhoneNumberUtil phoneUtil = PhoneNumberUtil.getInstance();
826         final TelephonyManager tm = (context == null)
827                 ? TelephonyManager.getDefault()
828                 : TelephonyManager.from(context);
829         Iterable<PhoneNumberMatch> matches = phoneUtil.findNumbers(s.toString(),
830                 tm.getSimCountryIso().toUpperCase(Locale.US),
831                 Leniency.POSSIBLE, Long.MAX_VALUE);
832         for (PhoneNumberMatch match : matches) {
833             LinkSpec spec = new LinkSpec();
834             spec.url = "tel:" + PhoneNumberUtils.normalizeNumber(match.rawString());
835             spec.start = match.start();
836             spec.end = match.end();
837             links.add(spec);
838         }
839     }
840 
gatherMapLinks(ArrayList<LinkSpec> links, Spannable s)841     private static final void gatherMapLinks(ArrayList<LinkSpec> links, Spannable s) {
842         String string = s.toString();
843         String address;
844         int base = 0;
845 
846         try {
847             while ((address = WebView.findAddress(string)) != null) {
848                 int start = string.indexOf(address);
849 
850                 if (start < 0) {
851                     break;
852                 }
853 
854                 LinkSpec spec = new LinkSpec();
855                 int length = address.length();
856                 int end = start + length;
857 
858                 spec.start = base + start;
859                 spec.end = base + end;
860                 string = string.substring(end);
861                 base += end;
862 
863                 String encodedAddress = null;
864 
865                 try {
866                     encodedAddress = URLEncoder.encode(address,"UTF-8");
867                 } catch (UnsupportedEncodingException e) {
868                     continue;
869                 }
870 
871                 spec.url = "geo:0,0?q=" + encodedAddress;
872                 links.add(spec);
873             }
874         } catch (UnsupportedOperationException e) {
875             // findAddress may fail with an unsupported exception on platforms without a WebView.
876             // In this case, we will not append anything to the links variable: it would have died
877             // in WebView.findAddress.
878             return;
879         }
880     }
881 
pruneOverlaps(ArrayList<LinkSpec> links)882     private static final void pruneOverlaps(ArrayList<LinkSpec> links) {
883         Comparator<LinkSpec>  c = new Comparator<LinkSpec>() {
884             public final int compare(LinkSpec a, LinkSpec b) {
885                 if (a.start < b.start) {
886                     return -1;
887                 }
888 
889                 if (a.start > b.start) {
890                     return 1;
891                 }
892 
893                 if (a.end < b.end) {
894                     return 1;
895                 }
896 
897                 if (a.end > b.end) {
898                     return -1;
899                 }
900 
901                 return 0;
902             }
903         };
904 
905         Collections.sort(links, c);
906 
907         int len = links.size();
908         int i = 0;
909 
910         while (i < len - 1) {
911             LinkSpec a = links.get(i);
912             LinkSpec b = links.get(i + 1);
913             int remove = -1;
914 
915             if ((a.start <= b.start) && (a.end > b.start)) {
916                 if (b.end <= a.end) {
917                     remove = i + 1;
918                 } else if ((a.end - a.start) > (b.end - b.start)) {
919                     remove = i + 1;
920                 } else if ((a.end - a.start) < (b.end - b.start)) {
921                     remove = i;
922                 }
923 
924                 if (remove != -1) {
925                     links.remove(remove);
926                     len--;
927                     continue;
928                 }
929 
930             }
931 
932             i++;
933         }
934     }
935 }
936 
937 class LinkSpec {
938     String url;
939     int start;
940     int end;
941 }
942