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