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