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 * '+1 (919) 555-1212' 167 * becomes '+19195551212' 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