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