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