• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2006 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.webkit;
18 
19 import android.annotation.NonNull;
20 import android.annotation.Nullable;
21 import android.compat.Compatibility;
22 import android.compat.annotation.ChangeId;
23 import android.compat.annotation.EnabledSince;
24 import android.compat.annotation.UnsupportedAppUsage;
25 import android.net.ParseException;
26 import android.net.Uri;
27 import android.net.WebAddress;
28 import android.os.Build;
29 import android.util.Log;
30 
31 import java.io.UnsupportedEncodingException;
32 import java.net.URLDecoder;
33 import java.nio.charset.Charset;
34 import java.util.Locale;
35 import java.util.regex.Matcher;
36 import java.util.regex.Pattern;
37 
38 public final class URLUtil {
39 
40     /**
41      * This feature enables parsing of Content-Disposition headers that conform to RFC 6266. In
42      * particular, this enables parsing of {@code filename*} values which can use a different
43      * character encoding.
44      *
45      * @hide
46      */
47     @ChangeId
48     @EnabledSince(targetSdkVersion = Build.VERSION_CODES.VANILLA_ICE_CREAM)
49     static final long PARSE_CONTENT_DISPOSITION_USING_RFC_6266 = 319400769L;
50 
51     private static final String LOGTAG = "webkit";
52     private static final boolean TRACE = false;
53 
54     // to refer to bar.png under your package's asset/foo/ directory, use
55     // "file:///android_asset/foo/bar.png".
56     static final String ASSET_BASE = "file:///android_asset/";
57     // to refer to bar.png under your package's res/drawable/ directory, use
58     // "file:///android_res/drawable/bar.png". Use "drawable" to refer to
59     // "drawable-hdpi" directory as well.
60     static final String RESOURCE_BASE = "file:///android_res/";
61     static final String FILE_BASE = "file:";
62     static final String PROXY_BASE = "file:///cookieless_proxy/";
63     static final String CONTENT_BASE = "content:";
64 
65     /** Cleans up (if possible) user-entered web addresses */
guessUrl(String inUrl)66     public static String guessUrl(String inUrl) {
67 
68         String retVal = inUrl;
69         WebAddress webAddress;
70 
71         if (TRACE) Log.v(LOGTAG, "guessURL before queueRequest: " + inUrl);
72 
73         if (inUrl.length() == 0) return inUrl;
74         if (inUrl.startsWith("about:")) return inUrl;
75         // Do not try to interpret data scheme URLs
76         if (inUrl.startsWith("data:")) return inUrl;
77         // Do not try to interpret file scheme URLs
78         if (inUrl.startsWith("file:")) return inUrl;
79         // Do not try to interpret javascript scheme URLs
80         if (inUrl.startsWith("javascript:")) return inUrl;
81 
82         // bug 762454: strip period off end of url
83         if (inUrl.endsWith(".") == true) {
84             inUrl = inUrl.substring(0, inUrl.length() - 1);
85         }
86 
87         try {
88             webAddress = new WebAddress(inUrl);
89         } catch (ParseException ex) {
90 
91             if (TRACE) {
92                 Log.v(LOGTAG, "smartUrlFilter: failed to parse url = " + inUrl);
93             }
94             return retVal;
95         }
96 
97         // Check host
98         if (webAddress.getHost().indexOf('.') == -1) {
99             // no dot: user probably entered a bare domain.  try .com
100             webAddress.setHost("www." + webAddress.getHost() + ".com");
101         }
102         return webAddress.toString();
103     }
104 
105     /**
106      * Inserts the {@code inQuery} in the {@code template} after URL-encoding it. The encoded query
107      * will replace the {@code queryPlaceHolder}.
108      */
composeSearchUrl( String inQuery, String template, String queryPlaceHolder)109     public static String composeSearchUrl(
110             String inQuery, String template, String queryPlaceHolder) {
111         int placeHolderIndex = template.indexOf(queryPlaceHolder);
112         if (placeHolderIndex < 0) {
113             return null;
114         }
115 
116         String query;
117         StringBuilder buffer = new StringBuilder();
118         buffer.append(template.substring(0, placeHolderIndex));
119 
120         try {
121             query = java.net.URLEncoder.encode(inQuery, "utf-8");
122             buffer.append(query);
123         } catch (UnsupportedEncodingException ex) {
124             return null;
125         }
126 
127         buffer.append(template.substring(placeHolderIndex + queryPlaceHolder.length()));
128 
129         return buffer.toString();
130     }
131 
decode(byte[] url)132     public static byte[] decode(byte[] url) throws IllegalArgumentException {
133         if (url.length == 0) {
134             return new byte[0];
135         }
136 
137         // Create a new byte array with the same length to ensure capacity
138         byte[] tempData = new byte[url.length];
139 
140         int tempCount = 0;
141         for (int i = 0; i < url.length; i++) {
142             byte b = url[i];
143             if (b == '%') {
144                 if (url.length - i > 2) {
145                     b = (byte) (parseHex(url[i + 1]) * 16 + parseHex(url[i + 2]));
146                     i += 2;
147                 } else {
148                     throw new IllegalArgumentException("Invalid format");
149                 }
150             }
151             tempData[tempCount++] = b;
152         }
153         byte[] retData = new byte[tempCount];
154         System.arraycopy(tempData, 0, retData, 0, tempCount);
155         return retData;
156     }
157 
158     /**
159      * @return {@code true} if the url is correctly URL encoded
160      */
161     @UnsupportedAppUsage
verifyURLEncoding(String url)162     static boolean verifyURLEncoding(String url) {
163         int count = url.length();
164         if (count == 0) {
165             return false;
166         }
167 
168         int index = url.indexOf('%');
169         while (index >= 0 && index < count) {
170             if (index < count - 2) {
171                 try {
172                     parseHex((byte) url.charAt(++index));
173                     parseHex((byte) url.charAt(++index));
174                 } catch (IllegalArgumentException e) {
175                     return false;
176                 }
177             } else {
178                 return false;
179             }
180             index = url.indexOf('%', index + 1);
181         }
182         return true;
183     }
184 
parseHex(byte b)185     private static int parseHex(byte b) {
186         if (b >= '0' && b <= '9') return (b - '0');
187         if (b >= 'A' && b <= 'F') return (b - 'A' + 10);
188         if (b >= 'a' && b <= 'f') return (b - 'a' + 10);
189 
190         throw new IllegalArgumentException("Invalid hex char '" + b + "'");
191     }
192 
193     /**
194      * @return {@code true} if the url is an asset file.
195      */
isAssetUrl(String url)196     public static boolean isAssetUrl(String url) {
197         return (null != url) && url.startsWith(ASSET_BASE);
198     }
199 
200     /**
201      * @return {@code true} if the url is a resource file.
202      * @hide
203      */
204     @UnsupportedAppUsage
isResourceUrl(String url)205     public static boolean isResourceUrl(String url) {
206         return (null != url) && url.startsWith(RESOURCE_BASE);
207     }
208 
209     /**
210      * @return {@code true} if the url is a proxy url to allow cookieless network requests from a
211      *     file url.
212      * @deprecated Cookieless proxy is no longer supported.
213      */
214     @Deprecated
isCookielessProxyUrl(String url)215     public static boolean isCookielessProxyUrl(String url) {
216         return (null != url) && url.startsWith(PROXY_BASE);
217     }
218 
219     /**
220      * @return {@code true} if the url is a local file.
221      */
isFileUrl(String url)222     public static boolean isFileUrl(String url) {
223         return (null != url)
224                 && (url.startsWith(FILE_BASE)
225                         && !url.startsWith(ASSET_BASE)
226                         && !url.startsWith(PROXY_BASE));
227     }
228 
229     /**
230      * @return {@code true} if the url is an about: url.
231      */
isAboutUrl(String url)232     public static boolean isAboutUrl(String url) {
233         return (null != url) && url.startsWith("about:");
234     }
235 
236     /**
237      * @return {@code true} if the url is a data: url.
238      */
isDataUrl(String url)239     public static boolean isDataUrl(String url) {
240         return (null != url) && url.startsWith("data:");
241     }
242 
243     /**
244      * @return {@code true} if the url is a javascript: url.
245      */
isJavaScriptUrl(String url)246     public static boolean isJavaScriptUrl(String url) {
247         return (null != url) && url.startsWith("javascript:");
248     }
249 
250     /**
251      * @return {@code true} if the url is an http: url.
252      */
isHttpUrl(String url)253     public static boolean isHttpUrl(String url) {
254         return (null != url)
255                 && (url.length() > 6)
256                 && url.substring(0, 7).equalsIgnoreCase("http://");
257     }
258 
259     /**
260      * @return {@code true} if the url is an https: url.
261      */
isHttpsUrl(String url)262     public static boolean isHttpsUrl(String url) {
263         return (null != url)
264                 && (url.length() > 7)
265                 && url.substring(0, 8).equalsIgnoreCase("https://");
266     }
267 
268     /**
269      * @return {@code true} if the url is a network url.
270      */
isNetworkUrl(String url)271     public static boolean isNetworkUrl(String url) {
272         if (url == null || url.length() == 0) {
273             return false;
274         }
275         return isHttpUrl(url) || isHttpsUrl(url);
276     }
277 
278     /**
279      * @return {@code true} if the url is a content: url.
280      */
isContentUrl(String url)281     public static boolean isContentUrl(String url) {
282         return (null != url) && url.startsWith(CONTENT_BASE);
283     }
284 
285     /**
286      * @return {@code true} if the url is valid.
287      */
isValidUrl(String url)288     public static boolean isValidUrl(String url) {
289         if (url == null || url.length() == 0) {
290             return false;
291         }
292 
293         return (isAssetUrl(url)
294                 || isResourceUrl(url)
295                 || isFileUrl(url)
296                 || isAboutUrl(url)
297                 || isHttpUrl(url)
298                 || isHttpsUrl(url)
299                 || isJavaScriptUrl(url)
300                 || isContentUrl(url));
301     }
302 
303     /** Strips the url of the anchor. */
stripAnchor(String url)304     public static String stripAnchor(String url) {
305         int anchorIndex = url.indexOf('#');
306         if (anchorIndex != -1) {
307             return url.substring(0, anchorIndex);
308         }
309         return url;
310     }
311 
312     /**
313      * Guesses canonical filename that a download would have, using the URL and contentDisposition.
314      *
315      * <p>File extension, if not defined, is added based on the mimetype.
316      *
317      * <p>The {@code contentDisposition} argument will be treated differently depending on
318      * targetSdkVersion.
319      *
320      * <ul>
321      *   <li>For targetSDK versions &lt; {@code VANILLA_ICE_CREAM} it will be parsed based on RFC
322      *       2616.
323      *   <li>For targetSDK versions &gt;= {@code VANILLA_ICE_CREAM} it will be parsed based on RFC
324      *       6266.
325      * </ul>
326      *
327      * In practice, this means that from {@code VANILLA_ICE_CREAM}, this method will be able to
328      * parse {@code filename*} directives in the {@code contentDisposition} string.
329      *
330      * <p>The function also changed in the following ways in {@code VANILLA_ICE_CREAM}:
331      *
332      * <ul>
333      *   <li>If the suggested file type extension doesn't match the passed {@code mimeType}, the
334      *       method will append the appropriate extension instead of replacing the current
335      *       extension.
336      *   <li>If the suggested file name contains a path separator ({@code "/"}), the method will
337      *       replace this with the underscore character ({@code "_"}) instead of splitting the
338      *       result and only using the last part.
339      * </ul>
340      *
341      * @param url Url to the content
342      * @param contentDisposition Content-Disposition HTTP header or {@code null}
343      * @param mimeType Mime-type of the content or {@code null}
344      * @return suggested filename
345      */
guessFileName( String url, @Nullable String contentDisposition, @Nullable String mimeType)346     public static String guessFileName(
347             String url, @Nullable String contentDisposition, @Nullable String mimeType) {
348         if (android.os.Flags.androidOsBuildVanillaIceCream()) {
349             if (Compatibility.isChangeEnabled(PARSE_CONTENT_DISPOSITION_USING_RFC_6266)) {
350                 return guessFileNameRfc6266(url, contentDisposition, mimeType);
351             }
352         }
353 
354         return guessFileNameRfc2616(url, contentDisposition, mimeType);
355     }
356 
357     /** Legacy implementation of guessFileName, based on RFC 2616. */
guessFileNameRfc2616( String url, @Nullable String contentDisposition, @Nullable String mimeType)358     private static String guessFileNameRfc2616(
359             String url, @Nullable String contentDisposition, @Nullable String mimeType) {
360         String filename = null;
361         String extension = null;
362 
363         // If we couldn't do anything with the hint, move toward the content disposition
364         if (contentDisposition != null) {
365             filename = parseContentDispositionRfc2616(contentDisposition);
366             if (filename != null) {
367                 int index = filename.lastIndexOf('/') + 1;
368                 if (index > 0) {
369                     filename = filename.substring(index);
370                 }
371             }
372         }
373 
374         // If all the other http-related approaches failed, use the plain uri
375         if (filename == null) {
376             String decodedUrl = Uri.decode(url);
377             if (decodedUrl != null) {
378                 int queryIndex = decodedUrl.indexOf('?');
379                 // If there is a query string strip it, same as desktop browsers
380                 if (queryIndex > 0) {
381                     decodedUrl = decodedUrl.substring(0, queryIndex);
382                 }
383                 if (!decodedUrl.endsWith("/")) {
384                     int index = decodedUrl.lastIndexOf('/') + 1;
385                     if (index > 0) {
386                         filename = decodedUrl.substring(index);
387                     }
388                 }
389             }
390         }
391 
392         // Finally, if couldn't get filename from URI, get a generic filename
393         if (filename == null) {
394             filename = "downloadfile";
395         }
396 
397         // Split filename between base and extension
398         // Add an extension if filename does not have one
399         int dotIndex = filename.indexOf('.');
400         if (dotIndex < 0) {
401             if (mimeType != null) {
402                 extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType);
403                 if (extension != null) {
404                     extension = "." + extension;
405                 }
406             }
407             if (extension == null) {
408                 if (mimeType != null && mimeType.toLowerCase(Locale.ROOT).startsWith("text/")) {
409                     if (mimeType.equalsIgnoreCase("text/html")) {
410                         extension = ".html";
411                     } else {
412                         extension = ".txt";
413                     }
414                 } else {
415                     extension = ".bin";
416                 }
417             }
418         } else {
419             if (mimeType != null) {
420                 // Compare the last segment of the extension against the mime type.
421                 // If there's a mismatch, discard the entire extension.
422                 int lastDotIndex = filename.lastIndexOf('.');
423                 String typeFromExt =
424                         MimeTypeMap.getSingleton()
425                                 .getMimeTypeFromExtension(filename.substring(lastDotIndex + 1));
426                 if (typeFromExt != null && !typeFromExt.equalsIgnoreCase(mimeType)) {
427                     extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType);
428                     if (extension != null) {
429                         extension = "." + extension;
430                     }
431                 }
432             }
433             if (extension == null) {
434                 extension = filename.substring(dotIndex);
435             }
436             filename = filename.substring(0, dotIndex);
437         }
438 
439         return filename + extension;
440     }
441 
442     /**
443      * Guesses canonical filename that a download would have, using the URL and contentDisposition.
444      * Uses RFC 6266 for parsing the contentDisposition header value.
445      */
446     @NonNull
guessFileNameRfc6266( @onNull String url, @Nullable String contentDisposition, @Nullable String mimeType)447     private static String guessFileNameRfc6266(
448             @NonNull String url, @Nullable String contentDisposition, @Nullable String mimeType) {
449         String filename = getFilenameSuggestion(url, contentDisposition);
450         // Split filename between base and extension
451         // Add an extension if filename does not have one
452         String extensionFromMimeType = suggestExtensionFromMimeType(mimeType);
453 
454         if (filename.indexOf('.') < 0) {
455             // Filename does not have an extension, use the suggested one.
456             return filename + extensionFromMimeType;
457         }
458 
459         // Filename already contains at least one dot.
460         // Compare the last segment of the extension against the mime type.
461         // If there's a mismatch, add the suggested extension instead.
462         if (mimeType != null && extensionDifferentFromMimeType(filename, mimeType)) {
463             return filename + extensionFromMimeType;
464         }
465         return filename;
466     }
467 
468     /**
469      * Get the suggested file name from the {@code contentDisposition} or {@code url}. Will ensure
470      * that the filename contains no path separators by replacing them with the {@code "_"}
471      * character.
472      */
473     @NonNull
getFilenameSuggestion(String url, @Nullable String contentDisposition)474     private static String getFilenameSuggestion(String url, @Nullable String contentDisposition) {
475         // First attempt to parse the Content-Disposition header if available
476         if (contentDisposition != null) {
477             String filename = getFilenameFromContentDispositionRfc6266(contentDisposition);
478             if (filename != null) {
479                 return replacePathSeparators(filename);
480             }
481         }
482 
483         // Try to generate a filename based on the URL.
484         if (url != null) {
485             Uri parsedUri = Uri.parse(url);
486             String lastPathSegment = parsedUri.getLastPathSegment();
487             if (lastPathSegment != null) {
488                 return replacePathSeparators(lastPathSegment);
489             }
490         }
491 
492         // Finally, if couldn't get filename from URI, get a generic filename.
493         return "downloadfile";
494     }
495 
496     /**
497      * Replace all instances of {@code "/"} with {@code "_"} to avoid filenames that navigate the
498      * path.
499      */
500     @NonNull
replacePathSeparators(@onNull String raw)501     private static String replacePathSeparators(@NonNull String raw) {
502         return raw.replaceAll("/", "_");
503     }
504 
505     /**
506      * Check if the {@code filename} has an extension that is different from the expected one based
507      * on the {@code mimeType}.
508      */
extensionDifferentFromMimeType( @onNull String filename, @NonNull String mimeType)509     private static boolean extensionDifferentFromMimeType(
510             @NonNull String filename, @NonNull String mimeType) {
511         int lastDotIndex = filename.lastIndexOf('.');
512         String typeFromExt =
513                 MimeTypeMap.getSingleton()
514                         .getMimeTypeFromExtension(filename.substring(lastDotIndex + 1));
515         return typeFromExt != null && !typeFromExt.equalsIgnoreCase(mimeType);
516     }
517 
518     /**
519      * Get a candidate file extension (including the {@code .}) for the given mimeType. will return
520      * {@code ".bin"} if {@code mimeType} is {@code null}
521      *
522      * @param mimeType Reported mimetype
523      * @return A file extension, including the {@code .}
524      */
525     @NonNull
suggestExtensionFromMimeType(@ullable String mimeType)526     private static String suggestExtensionFromMimeType(@Nullable String mimeType) {
527         if (mimeType == null) {
528             return ".bin";
529         }
530         String extensionFromMimeType =
531                 MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType);
532         if (extensionFromMimeType != null) {
533             return "." + extensionFromMimeType;
534         }
535         if (mimeType.equalsIgnoreCase("text/html")) {
536             return ".html";
537         } else if (mimeType.toLowerCase(Locale.ROOT).startsWith("text/")) {
538             return ".txt";
539         } else {
540             return ".bin";
541         }
542     }
543 
544     /**
545      * Parse the Content-Disposition HTTP Header.
546      *
547      * <p>Behavior depends on targetSdkVersion.
548      *
549      * <ul>
550      *   <li>For targetSDK versions &lt; {@code VANILLA_ICE_CREAM} it will parse based on RFC 2616.
551      *   <li>For targetSDK versions &gt;= {@code VANILLA_ICE_CREAM} it will parse based on RFC 6266.
552      * </ul>
553      */
554     @UnsupportedAppUsage
parseContentDisposition(String contentDisposition)555     static String parseContentDisposition(String contentDisposition) {
556         if (android.os.Flags.androidOsBuildVanillaIceCream()) {
557             if (Compatibility.isChangeEnabled(PARSE_CONTENT_DISPOSITION_USING_RFC_6266)) {
558                 return getFilenameFromContentDispositionRfc6266(contentDisposition);
559             }
560         }
561         return parseContentDispositionRfc2616(contentDisposition);
562     }
563 
564     /** Regex used to parse content-disposition headers */
565     private static final Pattern CONTENT_DISPOSITION_PATTERN =
566             Pattern.compile(
567                     "attachment;\\s*filename\\s*=\\s*(\"?)([^\"]*)\\1\\s*$",
568                     Pattern.CASE_INSENSITIVE);
569 
570     /**
571      * Parse the Content-Disposition HTTP Header. The format of the header is defined here: <a
572      * href="http://www.w3.org/Protocols/rfc2616/rfc2616-sec19.html">rfc2616 Section 19</a>. This
573      * header provides a filename for content that is going to be downloaded to the file system. We
574      * only support the attachment type. Note that RFC 2616 specifies the filename value must be
575      * double-quoted. Unfortunately some servers do not quote the value so to maintain consistent
576      * behaviour with other browsers, we allow unquoted values too.
577      */
parseContentDispositionRfc2616(String contentDisposition)578     private static String parseContentDispositionRfc2616(String contentDisposition) {
579         try {
580             Matcher m = CONTENT_DISPOSITION_PATTERN.matcher(contentDisposition);
581             if (m.find()) {
582                 return m.group(2);
583             }
584         } catch (IllegalStateException ex) {
585             // This function is defined as returning null when it can't parse the header
586         }
587         return null;
588     }
589 
590     /**
591      * Pattern for parsing individual content disposition key-value pairs.
592      *
593      * <p>The pattern will attempt to parse the value as either single-, double-, or unquoted. For
594      * the single- and double-quoted options, the pattern allows escaped quotes as part of the
595      * value, as per <a href="https://datatracker.ietf.org/doc/html/rfc2616#section-2.2">rfc2616
596      * section-2.2</a>
597      */
598     @SuppressWarnings("RegExpRepeatedSpace") // Spaces are only for readability.
599     private static final Pattern DISPOSITION_PATTERN =
600             Pattern.compile(
601                     """
602                             \\s*(\\S+?) # Group 1: parameter name
603                             \\s*=\\s* # Match equals sign
604                             (?: # non-capturing group of options
605                                '( (?: [^'\\\\] | \\\\. )* )' # Group 2: single-quoted
606                              | "( (?: [^"\\\\] | \\\\. )*  )" # Group 3: double-quoted
607                              | ( [^'"][^;\\s]* ) # Group 4: un-quoted parameter
608                             )\\s*;? # Optional end semicolon""",
609                     Pattern.COMMENTS);
610 
611     /**
612      * Extract filename from a {@code Content-Disposition} header value.
613      *
614      * <p>This method implements the parsing defined in <a
615      * href="https://datatracker.ietf.org/doc/html/rfc6266">RFC 6266</a>, supporting both the {@code
616      * filename} and {@code filename*} disposition parameters. If the passed header value has the
617      * {@code "inline"} disposition type, this method will return {@code null} to indicate that a
618      * download was not intended.
619      *
620      * <p>If both {@code filename*} and {@code filename} is present, the former will be returned, as
621      * per the RFC. Invalid encoded values will be ignored.
622      *
623      * @param contentDisposition Value of {@code Content-Disposition} header.
624      * @return The filename suggested by the header or {@code null} if no filename could be parsed
625      *     from the header value.
626      */
627     @Nullable
628     private static String getFilenameFromContentDispositionRfc6266(
629             @NonNull String contentDisposition) {
630         String[] parts = contentDisposition.trim().split(";", 2);
631         if (parts.length < 2) {
632             // Need at least 2 parts, the `disposition-type` and at least one `disposition-parm`.
633             return null;
634         }
635         String dispositionType = parts[0].trim();
636         if ("inline".equalsIgnoreCase(dispositionType)) {
637             // "inline" should not result in a download.
638             // Unknown disposition types should be handles as "attachment"
639             // https://datatracker.ietf.org/doc/html/rfc6266#section-4.2
640             return null;
641         }
642         String dispositionParameters = parts[1];
643         Matcher matcher = DISPOSITION_PATTERN.matcher(dispositionParameters);
644         String filename = null;
645         String filenameExt = null;
646         while (matcher.find()) {
647             String parameter = matcher.group(1);
648             String value;
649             if (matcher.group(2) != null) {
650                 value = removeSlashEscapes(matcher.group(2)); // Value was single-quoted
651             } else if (matcher.group(3) != null) {
652                 value = removeSlashEscapes(matcher.group(3)); // Value was double-quoted
653             } else {
654                 value = matcher.group(4); // Value was un-quoted
655             }
656 
657             if (parameter == null || value == null) {
658                 continue;
659             }
660 
661             if ("filename*".equalsIgnoreCase(parameter)) {
662                 filenameExt = parseExtValueString(value);
663             } else if ("filename".equalsIgnoreCase(parameter)) {
664                 filename = value;
665             }
666         }
667 
668         // RFC 6266 dictates the filenameExt should be preferred if present.
669         if (filenameExt != null) {
670             return filenameExt;
671         }
672         return filename;
673     }
674 
675     /** Replace escapes of the \X form with X. */
676     private static String removeSlashEscapes(String raw) {
677         if (raw == null) {
678             return null;
679         }
680         return raw.replaceAll("\\\\(.)", "$1");
681     }
682 
683     /**
684      * Parse an extended value string which can be percent-encoded. Return {@code} null if unable to
685      * parse the string.
686      */
687     private static String parseExtValueString(String raw) {
688         String[] parts = raw.split("'", 3);
689         if (parts.length < 3) {
690             return null;
691         }
692 
693         String encoding = parts[0];
694         // Intentionally ignore parts[1] (language).
695         String valueChars = parts[2];
696 
697         try {
698             // The URLDecoder force-decodes + as " "
699             // so preemptively replace all values with the encoded value to preserve them.
700             Charset charset = Charset.forName(encoding);
701             String valueWithEncodedPlus = encodePlusCharacters(valueChars, charset);
702             return URLDecoder.decode(valueWithEncodedPlus, charset);
703         } catch (RuntimeException ignored) {
704             return null; // Ignoring an un-parsable value is within spec.
705         }
706     }
707 
708     /**
709      * Replace all instances of {@code "+"} with the percent-encoded equivalent for the given {@code
710      * charset}.
711      */
712     @NonNull
713     private static String encodePlusCharacters(@NonNull String valueChars, Charset charset) {
714         StringBuilder sb = new StringBuilder();
715         for (byte b : charset.encode("+").array()) {
716             // Formatting a byte is not possible with TextUtils.formatSimple
717             sb.append(String.format("%02x", b));
718         }
719         return valueChars.replaceAll("\\+", sb.toString());
720     }
721 }
722