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