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