1/* 2 * Copyright (C) 2016 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.support.v4.text.util; 18 19import static android.support.annotation.RestrictTo.Scope.LIBRARY_GROUP; 20 21import android.support.annotation.IntDef; 22import android.support.annotation.NonNull; 23import android.support.annotation.Nullable; 24import android.support.annotation.RestrictTo; 25import android.support.v4.util.PatternsCompat; 26import android.text.Spannable; 27import android.text.SpannableString; 28import android.text.Spanned; 29import android.text.method.LinkMovementMethod; 30import android.text.method.MovementMethod; 31import android.text.style.URLSpan; 32import android.text.util.Linkify; 33import android.text.util.Linkify.MatchFilter; 34import android.text.util.Linkify.TransformFilter; 35import android.webkit.WebView; 36import android.widget.TextView; 37 38import java.io.UnsupportedEncodingException; 39import java.lang.annotation.Retention; 40import java.lang.annotation.RetentionPolicy; 41import java.net.URLEncoder; 42import java.util.ArrayList; 43import java.util.Collections; 44import java.util.Comparator; 45import java.util.Locale; 46import java.util.regex.Matcher; 47import java.util.regex.Pattern; 48 49/** 50 * LinkifyCompat brings in {@code Linkify} improvements for URLs and email addresses to older API 51 * levels. 52 */ 53public final class LinkifyCompat { 54 private static final String[] EMPTY_STRING = new String[0]; 55 56 private static final Comparator<LinkSpec> COMPARATOR = new Comparator<LinkSpec>() { 57 @Override 58 public final int compare(LinkSpec a, LinkSpec b) { 59 if (a.start < b.start) { 60 return -1; 61 } 62 63 if (a.start > b.start) { 64 return 1; 65 } 66 67 if (a.end < b.end) { 68 return 1; 69 } 70 71 if (a.end > b.end) { 72 return -1; 73 } 74 75 return 0; 76 } 77 }; 78 79 /** @hide */ 80 @RestrictTo(LIBRARY_GROUP) 81 @IntDef(flag = true, value = { Linkify.WEB_URLS, Linkify.EMAIL_ADDRESSES, Linkify.PHONE_NUMBERS, 82 Linkify.MAP_ADDRESSES, Linkify.ALL }) 83 @Retention(RetentionPolicy.SOURCE) 84 public @interface LinkifyMask {} 85 86 /** 87 * Scans the text of the provided Spannable and turns all occurrences 88 * of the link types indicated in the mask into clickable links. 89 * If the mask is nonzero, it also removes any existing URLSpans 90 * attached to the Spannable, to avoid problems if you call it 91 * repeatedly on the same text. 92 * 93 * @param text Spannable whose text is to be marked-up with links 94 * @param mask Mask to define which kinds of links will be searched. 95 * 96 * @return True if at least one link is found and applied. 97 */ 98 public static final boolean addLinks(@NonNull Spannable text, @LinkifyMask int mask) { 99 if (mask == 0) { 100 return false; 101 } 102 103 URLSpan[] old = text.getSpans(0, text.length(), URLSpan.class); 104 105 for (int i = old.length - 1; i >= 0; i--) { 106 text.removeSpan(old[i]); 107 } 108 109 // Use framework to linkify phone numbers. 110 boolean frameworkReturn = false; 111 if ((mask & Linkify.PHONE_NUMBERS) != 0) { 112 frameworkReturn = Linkify.addLinks(text, Linkify.PHONE_NUMBERS); 113 } 114 115 ArrayList<LinkSpec> links = new ArrayList<LinkSpec>(); 116 117 if ((mask & Linkify.WEB_URLS) != 0) { 118 gatherLinks(links, text, PatternsCompat.AUTOLINK_WEB_URL, 119 new String[] { "http://", "https://", "rtsp://" }, 120 Linkify.sUrlMatchFilter, null); 121 } 122 123 if ((mask & Linkify.EMAIL_ADDRESSES) != 0) { 124 gatherLinks(links, text, PatternsCompat.AUTOLINK_EMAIL_ADDRESS, 125 new String[] { "mailto:" }, 126 null, null); 127 } 128 129 if ((mask & Linkify.MAP_ADDRESSES) != 0) { 130 gatherMapLinks(links, text); 131 } 132 133 pruneOverlaps(links, text); 134 135 if (links.size() == 0) { 136 return false; 137 } 138 139 for (LinkSpec link: links) { 140 if (link.frameworkAddedSpan == null) { 141 applyLink(link.url, link.start, link.end, text); 142 } 143 } 144 145 return true; 146 } 147 148 /** 149 * Scans the text of the provided TextView and turns all occurrences of 150 * the link types indicated in the mask into clickable links. If matches 151 * are found the movement method for the TextView is set to 152 * LinkMovementMethod. 153 * 154 * @param text TextView whose text is to be marked-up with links 155 * @param mask Mask to define which kinds of links will be searched. 156 * 157 * @return True if at least one link is found and applied. 158 */ 159 public static final boolean addLinks(@NonNull TextView text, @LinkifyMask int mask) { 160 if (mask == 0) { 161 return false; 162 } 163 164 CharSequence t = text.getText(); 165 166 if (t instanceof Spannable) { 167 if (addLinks((Spannable) t, mask)) { 168 addLinkMovementMethod(text); 169 return true; 170 } 171 172 return false; 173 } else { 174 SpannableString s = SpannableString.valueOf(t); 175 176 if (addLinks(s, mask)) { 177 addLinkMovementMethod(text); 178 text.setText(s); 179 180 return true; 181 } 182 183 return false; 184 } 185 } 186 187 /** 188 * Applies a regex to the text of a TextView turning the matches into 189 * links. If links are found then UrlSpans are applied to the link 190 * text match areas, and the movement method for the text is changed 191 * to LinkMovementMethod. 192 * 193 * @param text TextView whose text is to be marked-up with links 194 * @param pattern Regex pattern to be used for finding links 195 * @param scheme URL scheme string (eg <code>http://</code>) to be 196 * prepended to the links that do not start with this scheme. 197 */ 198 public static final void addLinks(@NonNull TextView text, @NonNull Pattern pattern, 199 @Nullable String scheme) { 200 addLinks(text, pattern, scheme, null, null, null); 201 } 202 203 /** 204 * Applies a regex to the text of a TextView turning the matches into 205 * links. If links are found then UrlSpans are applied to the link 206 * text match areas, and the movement method for the text is changed 207 * to LinkMovementMethod. 208 * 209 * @param text TextView whose text is to be marked-up with links 210 * @param pattern Regex pattern to be used for finding links 211 * @param scheme URL scheme string (eg <code>http://</code>) to be 212 * prepended to the links that do not start with this scheme. 213 * @param matchFilter The filter that is used to allow the client code 214 * additional control over which pattern matches are 215 * to be converted into links. 216 */ 217 public static final void addLinks(@NonNull TextView text, @NonNull Pattern pattern, 218 @Nullable String scheme, @Nullable MatchFilter matchFilter, 219 @Nullable TransformFilter transformFilter) { 220 addLinks(text, pattern, scheme, null, matchFilter, transformFilter); 221 } 222 223 /** 224 * Applies a regex to the text of a TextView turning the matches into 225 * links. If links are found then UrlSpans are applied to the link 226 * text match areas, and the movement method for the text is changed 227 * to LinkMovementMethod. 228 * 229 * @param text TextView whose text is to be marked-up with links. 230 * @param pattern Regex pattern to be used for finding links. 231 * @param defaultScheme The default scheme to be prepended to links if the link does not 232 * start with one of the <code>schemes</code> given. 233 * @param schemes Array of schemes (eg <code>http://</code>) to check if the link found 234 * contains a scheme. Passing a null or empty value means prepend defaultScheme 235 * to all links. 236 * @param matchFilter The filter that is used to allow the client code additional control 237 * over which pattern matches are to be converted into links. 238 * @param transformFilter Filter to allow the client code to update the link found. 239 */ 240 public static final void addLinks(@NonNull TextView text, @NonNull Pattern pattern, 241 @Nullable String defaultScheme, @Nullable String[] schemes, 242 @Nullable MatchFilter matchFilter, @Nullable TransformFilter transformFilter) { 243 SpannableString spannable = SpannableString.valueOf(text.getText()); 244 245 boolean linksAdded = addLinks(spannable, pattern, defaultScheme, schemes, matchFilter, 246 transformFilter); 247 if (linksAdded) { 248 text.setText(spannable); 249 addLinkMovementMethod(text); 250 } 251 } 252 253 /** 254 * Applies a regex to a Spannable turning the matches into 255 * links. 256 * 257 * @param text Spannable whose text is to be marked-up with links 258 * @param pattern Regex pattern to be used for finding links 259 * @param scheme URL scheme string (eg <code>http://</code>) to be 260 * prepended to the links that do not start with this scheme. 261 */ 262 public static final boolean addLinks(@NonNull Spannable text, @NonNull Pattern pattern, 263 @Nullable String scheme) { 264 return addLinks(text, pattern, scheme, null, null, null); 265 } 266 267 /** 268 * Applies a regex to a Spannable turning the matches into 269 * links. 270 * 271 * @param spannable Spannable whose text is to be marked-up with links 272 * @param pattern Regex pattern to be used for finding links 273 * @param scheme URL scheme string (eg <code>http://</code>) to be 274 * prepended to the links that do not start with this scheme. 275 * @param matchFilter The filter that is used to allow the client code 276 * additional control over which pattern matches are 277 * to be converted into links. 278 * @param transformFilter Filter to allow the client code to update the link found. 279 * 280 * @return True if at least one link is found and applied. 281 */ 282 public static final boolean addLinks(@NonNull Spannable spannable, @NonNull Pattern pattern, 283 @Nullable String scheme, @Nullable MatchFilter matchFilter, 284 @Nullable TransformFilter transformFilter) { 285 return addLinks(spannable, pattern, scheme, null, matchFilter, 286 transformFilter); 287 } 288 289 /** 290 * Applies a regex to a Spannable turning the matches into links. 291 * 292 * @param spannable Spannable whose text is to be marked-up with links. 293 * @param pattern Regex pattern to be used for finding links. 294 * @param defaultScheme The default scheme to be prepended to links if the link does not 295 * start with one of the <code>schemes</code> given. 296 * @param schemes Array of schemes (eg <code>http://</code>) to check if the link found 297 * contains a scheme. Passing a null or empty value means prepend defaultScheme 298 * to all links. 299 * @param matchFilter The filter that is used to allow the client code additional control 300 * over which pattern matches are to be converted into links. 301 * @param transformFilter Filter to allow the client code to update the link found. 302 * 303 * @return True if at least one link is found and applied. 304 */ 305 public static final boolean addLinks(@NonNull Spannable spannable, @NonNull Pattern pattern, 306 @Nullable String defaultScheme, @Nullable String[] schemes, 307 @Nullable MatchFilter matchFilter, @Nullable TransformFilter transformFilter) { 308 final String[] schemesCopy; 309 if (defaultScheme == null) defaultScheme = ""; 310 if (schemes == null || schemes.length < 1) { 311 schemes = EMPTY_STRING; 312 } 313 314 schemesCopy = new String[schemes.length + 1]; 315 schemesCopy[0] = defaultScheme.toLowerCase(Locale.ROOT); 316 for (int index = 0; index < schemes.length; index++) { 317 String scheme = schemes[index]; 318 schemesCopy[index + 1] = (scheme == null) ? "" : scheme.toLowerCase(Locale.ROOT); 319 } 320 321 boolean hasMatches = false; 322 Matcher m = pattern.matcher(spannable); 323 324 while (m.find()) { 325 int start = m.start(); 326 int end = m.end(); 327 boolean allowed = true; 328 329 if (matchFilter != null) { 330 allowed = matchFilter.acceptMatch(spannable, start, end); 331 } 332 333 if (allowed) { 334 String url = makeUrl(m.group(0), schemesCopy, m, transformFilter); 335 336 applyLink(url, start, end, spannable); 337 hasMatches = true; 338 } 339 } 340 341 return hasMatches; 342 } 343 344 private static void addLinkMovementMethod(@NonNull TextView t) { 345 MovementMethod m = t.getMovementMethod(); 346 347 if ((m == null) || !(m instanceof LinkMovementMethod)) { 348 if (t.getLinksClickable()) { 349 t.setMovementMethod(LinkMovementMethod.getInstance()); 350 } 351 } 352 } 353 354 private static String makeUrl(@NonNull String url, @NonNull String[] prefixes, 355 Matcher matcher, @Nullable Linkify.TransformFilter filter) { 356 if (filter != null) { 357 url = filter.transformUrl(matcher, url); 358 } 359 360 boolean hasPrefix = false; 361 362 for (int i = 0; i < prefixes.length; i++) { 363 if (url.regionMatches(true, 0, prefixes[i], 0, prefixes[i].length())) { 364 hasPrefix = true; 365 366 // Fix capitalization if necessary 367 if (!url.regionMatches(false, 0, prefixes[i], 0, prefixes[i].length())) { 368 url = prefixes[i] + url.substring(prefixes[i].length()); 369 } 370 371 break; 372 } 373 } 374 375 if (!hasPrefix && prefixes.length > 0) { 376 url = prefixes[0] + url; 377 } 378 379 return url; 380 } 381 382 private static void gatherLinks(ArrayList<LinkSpec> links, 383 Spannable s, Pattern pattern, String[] schemes, 384 Linkify.MatchFilter matchFilter, Linkify.TransformFilter transformFilter) { 385 Matcher m = pattern.matcher(s); 386 387 while (m.find()) { 388 int start = m.start(); 389 int end = m.end(); 390 391 if (matchFilter == null || matchFilter.acceptMatch(s, start, end)) { 392 LinkSpec spec = new LinkSpec(); 393 String url = makeUrl(m.group(0), schemes, m, transformFilter); 394 395 spec.url = url; 396 spec.start = start; 397 spec.end = end; 398 399 links.add(spec); 400 } 401 } 402 } 403 404 private static void applyLink(String url, int start, int end, Spannable text) { 405 URLSpan span = new URLSpan(url); 406 407 text.setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 408 } 409 410 private static final void gatherMapLinks(ArrayList<LinkSpec> links, Spannable s) { 411 String string = s.toString(); 412 String address; 413 int base = 0; 414 415 try { 416 while ((address = WebView.findAddress(string)) != null) { 417 int start = string.indexOf(address); 418 419 if (start < 0) { 420 break; 421 } 422 423 LinkSpec spec = new LinkSpec(); 424 int length = address.length(); 425 int end = start + length; 426 427 spec.start = base + start; 428 spec.end = base + end; 429 string = string.substring(end); 430 base += end; 431 432 String encodedAddress = null; 433 434 try { 435 encodedAddress = URLEncoder.encode(address,"UTF-8"); 436 } catch (UnsupportedEncodingException e) { 437 continue; 438 } 439 440 spec.url = "geo:0,0?q=" + encodedAddress; 441 links.add(spec); 442 } 443 } catch (UnsupportedOperationException e) { 444 // findAddress may fail with an unsupported exception on platforms without a WebView. 445 // In this case, we will not append anything to the links variable: it would have died 446 // in WebView.findAddress. 447 return; 448 } 449 } 450 451 private static final void pruneOverlaps(ArrayList<LinkSpec> links, Spannable text) { 452 // Append spans added by framework 453 URLSpan[] urlSpans = text.getSpans(0, text.length(), URLSpan.class); 454 for (int i = 0; i < urlSpans.length; i++) { 455 LinkSpec spec = new LinkSpec(); 456 spec.frameworkAddedSpan = urlSpans[i]; 457 spec.start = text.getSpanStart(urlSpans[i]); 458 spec.end = text.getSpanEnd(urlSpans[i]); 459 links.add(spec); 460 } 461 462 Collections.sort(links, COMPARATOR); 463 464 int len = links.size(); 465 int i = 0; 466 467 while (i < len - 1) { 468 LinkSpec a = links.get(i); 469 LinkSpec b = links.get(i + 1); 470 int remove = -1; 471 472 if ((a.start <= b.start) && (a.end > b.start)) { 473 if (b.end <= a.end) { 474 remove = i + 1; 475 } else if ((a.end - a.start) > (b.end - b.start)) { 476 remove = i + 1; 477 } else if ((a.end - a.start) < (b.end - b.start)) { 478 remove = i; 479 } 480 481 if (remove != -1) { 482 URLSpan span = links.get(remove).frameworkAddedSpan; 483 if (span != null) { 484 text.removeSpan(span); 485 } 486 links.remove(remove); 487 len--; 488 continue; 489 } 490 491 } 492 493 i++; 494 } 495 } 496 497 /** 498 * Do not create this static utility class. 499 */ 500 private LinkifyCompat() {} 501 502 private static class LinkSpec { 503 URLSpan frameworkAddedSpan; 504 String url; 505 int start; 506 int end; 507 508 LinkSpec() { 509 } 510 } 511} 512