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