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