Skip to content

Commit

Permalink
fix(android): Respect "filename*" parameter in the field Content-Disp…
Browse files Browse the repository at this point in the history
…osition when detecting filenames for downloading. (react-native-webview#2767)
  • Loading branch information
UNIDY2002 committed Dec 7, 2022
1 parent 0817524 commit 47c05b0
Show file tree
Hide file tree
Showing 4 changed files with 186 additions and 8 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@
import android.webkit.RenderProcessGoneDetail;
import android.webkit.SslErrorHandler;
import android.webkit.PermissionRequest;
import android.webkit.URLUtil;
import android.webkit.ValueCallback;
import android.webkit.WebChromeClient;
import android.webkit.WebResourceRequest;
Expand Down
179 changes: 179 additions & 0 deletions android/src/main/java/com/reactnativecommunity/webview/URLUtil.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
/*
* Copyright (C) 2006 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

/*
* The source code is obtained from the Android SDK Sources (API level 31),
* and modified by UNIDY2002 <UNIDY2002@outlook.com>.
*
* Change list:
* - Remove all unused class members except guessFileName,
* CONTENT_DISPOSITION_PATTERN and parseContentDisposition
* - Improve CONTENT_DISPOSITION_PATTERN and parseContentDisposition to add
* support for the "filename*" parameter in content disposition
*/

package com.reactnativecommunity.webview;

import android.net.Uri;
import android.webkit.MimeTypeMap;
import androidx.annotation.Nullable;

import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public final class URLUtil {
/**
* Guesses canonical filename that a download would have, using
* the URL and contentDisposition. File extension, if not defined,
* is added based on the mimetype
* @param url Url to the content
* @param contentDisposition Content-Disposition HTTP header or {@code null}
* @param mimeType Mime-type of the content or {@code null}
*
* @return suggested filename
*/
public static final String guessFileName(
String url,
@Nullable String contentDisposition,
@Nullable String mimeType) {
String filename = null;
String extension = null;

// If we couldn't do anything with the hint, move toward the content disposition
if (filename == null && contentDisposition != null) {
filename = parseContentDisposition(contentDisposition);
if (filename != null) {
int index = filename.lastIndexOf('/') + 1;
if (index > 0) {
filename = filename.substring(index);
}
}
}

// If all the other http-related approaches failed, use the plain uri
if (filename == null) {
String decodedUrl = Uri.decode(url);
if (decodedUrl != null) {
int queryIndex = decodedUrl.indexOf('?');
// If there is a query string strip it, same as desktop browsers
if (queryIndex > 0) {
decodedUrl = decodedUrl.substring(0, queryIndex);
}
if (!decodedUrl.endsWith("/")) {
int index = decodedUrl.lastIndexOf('/') + 1;
if (index > 0) {
filename = decodedUrl.substring(index);
}
}
}
}

// Finally, if couldn't get filename from URI, get a generic filename
if (filename == null) {
filename = "downloadfile";
}

// Split filename between base and extension
// Add an extension if filename does not have one
int dotIndex = filename.indexOf('.');
if (dotIndex < 0) {
if (mimeType != null) {
extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType);
if (extension != null) {
extension = "." + extension;
}
}
if (extension == null) {
if (mimeType != null && mimeType.toLowerCase(Locale.ROOT).startsWith("text/")) {
if (mimeType.equalsIgnoreCase("text/html")) {
extension = ".html";
} else {
extension = ".txt";
}
} else {
extension = ".bin";
}
}
} else {
if (mimeType != null) {
// Compare the last segment of the extension against the mime type.
// If there's a mismatch, discard the entire extension.
int lastDotIndex = filename.lastIndexOf('.');
String typeFromExt = MimeTypeMap.getSingleton().getMimeTypeFromExtension(
filename.substring(lastDotIndex + 1));
if (typeFromExt != null && !typeFromExt.equalsIgnoreCase(mimeType)) {
extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType);
if (extension != null) {
extension = "." + extension;
}
}
}
if (extension == null) {
extension = filename.substring(dotIndex);
}
filename = filename.substring(0, dotIndex);
}

return filename + extension;
}

/** Regex used to parse content-disposition headers */
private static final Pattern CONTENT_DISPOSITION_PATTERN =
Pattern.compile("attachment(?:;\\s*filename\\s*=\\s*(\"?)([^\"]*)\\1)?(?:;\\s*filename\\s*\\*\\s*=\\s*([^']*)'[^']*'([^']*))?\\s*$",
Pattern.CASE_INSENSITIVE);

/**
* Parse the Content-Disposition HTTP Header. The format of the header
* is defined here: <a href="https://www.rfc-editor.org/rfc/rfc6266">RFC 6266</a>
* This header provides a filename for content that is going to be
* downloaded to the file system. We only support the attachment type.
*/
static String parseContentDisposition(String contentDisposition) {
try {
// The regex attempts to match the following pattern:
// attachment; filename="(Group 2)"; filename*=(Group 3)'(lang)'(Group 4)
// Group 4 refers to the percent-encoded filename, and the charset
// is specified in Group 3.
// Group 2 is the fallback filename.
// Group 1 refers to the quotation marks around Group 2.
//
// Test cases can be found at http://test.greenbytes.de/tech/tc2231/
// Examples can be found at https://www.rfc-editor.org/rfc/rfc6266#section-5
// There are a few known limitations:
// - any Content Disposition value that does not have parameters
// arranged in the order of "attachment...filename...filename*"
// or contains extra parameters shall fail to be parsed
// - any filename that contains " shall fail to be parsed
Matcher m = CONTENT_DISPOSITION_PATTERN.matcher(contentDisposition);
if (m.find()) {
if (m.group(3) != null && m.group(4) != null) {
try {
return URLDecoder.decode(m.group(4), m.group(3).isEmpty() ? "UTF-8" : m.group(3));
} catch (UnsupportedEncodingException e) {
// Skip the ext-parameter as the encoding is unsupported
}
}
return m.group(2);
}
} catch (IllegalStateException ex) {
// This function is defined as returning null when it can't parse the header
}
return null;
}
}
12 changes: 5 additions & 7 deletions example/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,13 +165,11 @@ export default class App extends Component<Props, State> {
title="LocalPageLoad"
onPress={() => this._changeTest('PageLoad')}
/>
{Platform.OS == 'ios' && (
<Button
testID="testType_downloads"
title="Downloads"
onPress={() => this._changeTest('Downloads')}
/>
)}
<Button
testID="testType_downloads"
title="Downloads"
onPress={() => this._changeTest('Downloads')}
/>
{Platform.OS === 'android' && (
<Button
testID="testType_uploads"
Expand Down
2 changes: 2 additions & 0 deletions example/examples/Downloads.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ const HTML = `
</head>
<body>
<a href="https://www.7-zip.org/a/7za920.zip">Example zip file download</a>
<br>
<a href="http://test.greenbytes.de/tech/tc2231/attwithisofn2231iso.asis">Download file with non-ascii filename: "foo-ä.html"</a>
</body>
</html>
`;
Expand Down

0 comments on commit 47c05b0

Please sign in to comment.