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