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