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