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