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