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 < {@code VANILLA_ICE_CREAM} it will be parsed based on RFC 322 * 2616. 323 * <li>For targetSDK versions >= {@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 < {@code VANILLA_ICE_CREAM} it will parse based on RFC 2616. 551 * <li>For targetSDK versions >= {@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