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