Implement Blob support for XMLHttpRequest

Summary:
This PR is a followup to https://github.com/facebook/react-native/pull/11417 and should be merged after that one is merged.

  1. Add support for creating blobs from strings, not just other blobs
  1. Add the `File` constructor which is a superset of `Blob`
  1. Add the `FileReader` API which can be used to read blobs as strings or data url (base64)
  1. Add support for uploading and downloading blobs via `XMLHttpRequest` and `fetch`
  1. Add ability to download local files on Android so you can do `fetch(uri).then(res => res.blob())` to get a blob for a local file (iOS already supported this)

  1. Clone the repo https://github.com/expo/react-native-blob-test
  1. Change the `package.json` and update `react-native` dependency to point to this branch, then run `npm install`
  1. Run the `server.js` file with `node server.js`
  1. Open the `index.common.js` file and replace `localhost` with your computer's IP address
  1. Start the packager with `yarn start` and run the app on your device

If everything went well, all tests should pass, and you should see a screen like this:

![screen shot 2017-06-08 at 7 53 08 pm](https://user-images.githubusercontent.com/1174278/26936407-435bbce2-4c8c-11e7-9ae3-eb104e46961e.png)!

Pull to rerun all tests or tap on specific test to re-run it

  [GENERAL] [FEATURE] [Blob] - Implement blob support for XMLHttpRequest
Closes https://github.com/facebook/react-native/pull/11573

Reviewed By: shergin

Differential Revision: D6082054

Pulled By: hramos

fbshipit-source-id: cc9c174fdefdfaf6e5d9fd7b300120a01a50e8c1
This commit is contained in:
Satyajit Sahoo
2018-01-26 09:06:14 -08:00
committed by Facebook Github Bot
parent 3fc33bb54f
commit be56a3efee
40 changed files with 2060 additions and 386 deletions

View File

@@ -14,11 +14,13 @@ rn_android_library(
react_native_dep("libraries/fbcore/src/main/java/com/facebook/common/logging:logging"),
react_native_dep("third-party/java/infer-annotations:infer-annotations"),
react_native_dep("third-party/java/jsr-305:jsr-305"),
react_native_dep("third-party/java/okhttp:okhttp3"),
react_native_dep("third-party/java/okio:okio"),
react_native_target("java/com/facebook/react:react"),
react_native_target("java/com/facebook/react/bridge:bridge"),
react_native_target("java/com/facebook/react/common:common"),
react_native_target("java/com/facebook/react/module/annotations:annotations"),
react_native_target("java/com/facebook/react/modules/network:network"),
react_native_target("java/com/facebook/react/modules/websocket:websocket"),
],
)

View File

@@ -7,9 +7,15 @@
*/
package com.facebook.react.modules.blob;
import android.content.ContentResolver;
import android.content.Context;
import android.content.res.Resources;
import android.database.Cursor;
import android.net.Uri;
import android.provider.MediaStore;
import android.support.annotation.Nullable;
import android.webkit.MimeTypeMap;
import com.facebook.react.bridge.Arguments;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
@@ -19,13 +25,25 @@ import com.facebook.react.bridge.ReadableMap;
import com.facebook.react.bridge.WritableMap;
import com.facebook.react.common.MapBuilder;
import com.facebook.react.module.annotations.ReactModule;
import com.facebook.react.modules.network.NetworkingModule;
import com.facebook.react.modules.websocket.WebSocketModule;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.nio.ByteBuffer;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import okhttp3.MediaType;
import okhttp3.RequestBody;
import okhttp3.ResponseBody;
import okio.ByteString;
@ReactModule(name = BlobModule.NAME)
@@ -35,27 +53,100 @@ public class BlobModule extends ReactContextBaseJavaModule {
private final Map<String, byte[]> mBlobs = new HashMap<>();
protected final WebSocketModule.ContentHandler mContentHandler =
new WebSocketModule.ContentHandler() {
@Override
public void onMessage(String text, WritableMap params) {
params.putString("data", text);
private final WebSocketModule.ContentHandler mWebSocketContentHandler =
new WebSocketModule.ContentHandler() {
@Override
public void onMessage(String text, WritableMap params) {
params.putString("data", text);
}
@Override
public void onMessage(ByteString bytes, WritableMap params) {
byte[] data = bytes.toByteArray();
WritableMap blob = Arguments.createMap();
blob.putString("blobId", store(data));
blob.putInt("offset", 0);
blob.putInt("size", data.length);
params.putMap("data", blob);
params.putString("type", "blob");
}
};
private final NetworkingModule.UriHandler mNetworkingUriHandler =
new NetworkingModule.UriHandler() {
@Override
public boolean supports(Uri uri, String responseType) {
String scheme = uri.getScheme();
boolean isRemote = scheme.equals("http") || scheme.equals("https");
return (!isRemote && responseType.equals("blob"));
}
@Override
public WritableMap fetch(Uri uri) throws IOException {
byte[] data = getBytesFromUri(uri);
WritableMap blob = Arguments.createMap();
blob.putString("blobId", store(data));
blob.putInt("offset", 0);
blob.putInt("size", data.length);
blob.putString("type", getMimeTypeFromUri(uri));
// Needed for files
blob.putString("name", getNameFromUri(uri));
blob.putDouble("lastModified", getLastModifiedFromUri(uri));
return blob;
}
};
private final NetworkingModule.RequestBodyHandler mNetworkingRequestBodyHandler =
new NetworkingModule.RequestBodyHandler() {
@Override
public boolean supports(ReadableMap data) {
return data.hasKey("blob");
}
@Override
public RequestBody toRequestBody(ReadableMap data, String contentType) {
String type = contentType;
if (data.hasKey("type") && !data.getString("type").isEmpty()) {
type = data.getString("type");
}
@Override
public void onMessage(ByteString bytes, WritableMap params) {
byte[] data = bytes.toByteArray();
WritableMap blob = Arguments.createMap();
blob.putString("blobId", store(data));
blob.putInt("offset", 0);
blob.putInt("size", data.length);
params.putMap("data", blob);
params.putString("type", "blob");
if (type == null) {
type = "application/octet-stream";
}
};
ReadableMap blob = data.getMap("blob");
String blobId = blob.getString("blobId");
byte[] bytes = resolve(
blobId,
blob.getInt("offset"),
blob.getInt("size"));
return RequestBody.create(MediaType.parse(type), bytes);
}
};
private final NetworkingModule.ResponseHandler mNetworkingResponseHandler =
new NetworkingModule.ResponseHandler() {
@Override
public boolean supports(String responseType) {
return responseType.equals("blob");
}
@Override
public WritableMap toResponseData(ResponseBody body) throws IOException {
byte[] data = body.bytes();
WritableMap blob = Arguments.createMap();
blob.putString("blobId", store(data));
blob.putInt("offset", 0);
blob.putInt("size", data.length);
return blob;
}
};
public BlobModule(ReactApplicationContext reactContext) {
super(reactContext);
@@ -67,8 +158,7 @@ public class BlobModule extends ReactContextBaseJavaModule {
}
@Override
@Nullable
public Map getConstants() {
public @Nullable Map<String, Object> getConstants() {
// The application can register BlobProvider as a ContentProvider so that blobs are resolvable.
// If it does, it needs to tell us what authority was used via this string resource.
Resources resources = getReactApplicationContext().getResources();
@@ -78,8 +168,8 @@ public class BlobModule extends ReactContextBaseJavaModule {
return null;
}
return MapBuilder.of(
"BLOB_URI_SCHEME", "content", "BLOB_URI_HOST", resources.getString(resourceId));
return MapBuilder.<String, Object>of(
"BLOB_URI_SCHEME", "content", "BLOB_URI_HOST", resources.getString(resourceId));
}
public String store(byte[] data) {
@@ -96,8 +186,7 @@ public class BlobModule extends ReactContextBaseJavaModule {
mBlobs.remove(blobId);
}
@Nullable
public byte[] resolve(Uri uri) {
public @Nullable byte[] resolve(Uri uri) {
String blobId = uri.getLastPathSegment();
int offset = 0;
int size = -1;
@@ -112,8 +201,7 @@ public class BlobModule extends ReactContextBaseJavaModule {
return resolve(blobId, offset, size);
}
@Nullable
public byte[] resolve(String blobId, int offset, int size) {
public @Nullable byte[] resolve(String blobId, int offset, int size) {
byte[] data = mBlobs.get(blobId);
if (data == null) {
return null;
@@ -121,33 +209,101 @@ public class BlobModule extends ReactContextBaseJavaModule {
if (size == -1) {
size = data.length - offset;
}
if (offset > 0) {
if (offset > 0 || size != data.length) {
data = Arrays.copyOfRange(data, offset, offset + size);
}
return data;
}
@Nullable
public byte[] resolve(ReadableMap blob) {
public @Nullable byte[] resolve(ReadableMap blob) {
return resolve(blob.getString("blobId"), blob.getInt("offset"), blob.getInt("size"));
}
private byte[] getBytesFromUri(Uri contentUri) throws IOException {
InputStream is = getReactApplicationContext().getContentResolver().openInputStream(contentUri);
if (is == null) {
throw new FileNotFoundException("File not found for " + contentUri);
}
ByteArrayOutputStream byteBuffer = new ByteArrayOutputStream();
int bufferSize = 1024;
byte[] buffer = new byte[bufferSize];
int len;
while ((len = is.read(buffer)) != -1) {
byteBuffer.write(buffer, 0, len);
}
return byteBuffer.toByteArray();
}
private String getNameFromUri(Uri contentUri) {
if (contentUri.getScheme().equals("file")) {
return contentUri.getLastPathSegment();
}
String[] projection = {MediaStore.MediaColumns.DISPLAY_NAME};
Cursor metaCursor = getReactApplicationContext()
.getContentResolver()
.query(contentUri, projection, null, null, null);
if (metaCursor != null) {
try {
if (metaCursor.moveToFirst()) {
return metaCursor.getString(0);
}
} finally {
metaCursor.close();
}
}
return contentUri.getLastPathSegment();
}
private long getLastModifiedFromUri(Uri contentUri) {
if (contentUri.getScheme().equals("file")) {
return new File(contentUri.toString()).lastModified();
}
return 0;
}
private String getMimeTypeFromUri(Uri contentUri) {
String type = getReactApplicationContext().getContentResolver().getType(contentUri);
if (type == null) {
String ext = MimeTypeMap.getFileExtensionFromUrl(contentUri.getPath());
if (ext != null) {
type = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext);
}
}
if (type == null) {
type = "";
}
return type;
}
private WebSocketModule getWebSocketModule() {
return getReactApplicationContext().getNativeModule(WebSocketModule.class);
}
@ReactMethod
public void enableBlobSupport(final int id) {
getWebSocketModule().setContentHandler(id, mContentHandler);
public void addNetworkingHandler() {
NetworkingModule networkingModule = getReactApplicationContext().getNativeModule(NetworkingModule.class);
networkingModule.addUriHandler(mNetworkingUriHandler);
networkingModule.addRequestBodyHandler(mNetworkingRequestBodyHandler);
networkingModule.addResponseHandler(mNetworkingResponseHandler);
}
@ReactMethod
public void disableBlobSupport(final int id) {
public void addWebSocketHandler(final int id) {
getWebSocketModule().setContentHandler(id, mWebSocketContentHandler);
}
@ReactMethod
public void removeWebSocketHandler(final int id) {
getWebSocketModule().setContentHandler(id, null);
}
@ReactMethod
public void sendBlob(ReadableMap blob, int id) {
public void sendOverSocket(ReadableMap blob, int id) {
byte[] data = resolve(blob.getString("blobId"), blob.getInt("offset"), blob.getInt("size"));
if (data != null) {
@@ -160,15 +316,27 @@ public class BlobModule extends ReactContextBaseJavaModule {
@ReactMethod
public void createFromParts(ReadableArray parts, String blobId) {
int totalBlobSize = 0;
ArrayList<ReadableMap> partList = new ArrayList<>(parts.size());
ArrayList<byte[]> partList = new ArrayList<>(parts.size());
for (int i = 0; i < parts.size(); i++) {
ReadableMap part = parts.getMap(i);
totalBlobSize += part.getInt("size");
partList.add(i, part);
switch (part.getString("type")) {
case "blob":
ReadableMap blob = part.getMap("data");
totalBlobSize += blob.getInt("size");
partList.add(i, resolve(blob));
break;
case "string":
byte[] bytes = part.getString("data").getBytes(Charset.forName("UTF-8"));
totalBlobSize += bytes.length;
partList.add(i, bytes);
break;
default:
throw new IllegalArgumentException("Invalid type for blob: " + part.getString("type"));
}
}
ByteBuffer buffer = ByteBuffer.allocate(totalBlobSize);
for (ReadableMap part : partList) {
buffer.put(resolve(part));
for (byte[] bytes : partList) {
buffer.put(bytes);
}
store(buffer.array(), blobId);
}

View File

@@ -0,0 +1,91 @@
/**
* Copyright (c) 2015-present, Facebook, Inc.
* All rights reserved.
*
* This source code is licensed under the BSD-style license found in the
* LICENSE file in the root directory of this source tree. An additional grant
* of patent rights can be found in the PATENTS file in the same directory.
*/
package com.facebook.react.modules.blob;
import android.util.Base64;
import com.facebook.react.bridge.Promise;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactMethod;
import com.facebook.react.bridge.ReadableMap;
import com.facebook.react.module.annotations.ReactModule;
@ReactModule(name = FileReaderModule.NAME)
public class FileReaderModule extends ReactContextBaseJavaModule {
protected static final String NAME = "FileReaderModule";
private static final String ERROR_INVALID_BLOB = "ERROR_INVALID_BLOB";
public FileReaderModule(ReactApplicationContext reactContext) {
super(reactContext);
}
@Override
public String getName() {
return NAME;
}
private BlobModule getBlobModule() {
return getReactApplicationContext().getNativeModule(BlobModule.class);
}
@ReactMethod
public void readAsText(ReadableMap blob, String encoding, Promise promise) {
byte[] bytes = getBlobModule().resolve(
blob.getString("blobId"),
blob.getInt("offset"),
blob.getInt("size"));
if (bytes == null) {
promise.reject(ERROR_INVALID_BLOB, "The specified blob is invalid");
return;
}
try {
promise.resolve(new String(bytes, encoding));
} catch (Exception e) {
promise.reject(e);
}
}
@ReactMethod
public void readAsDataURL(ReadableMap blob, Promise promise) {
byte[] bytes = getBlobModule().resolve(
blob.getString("blobId"),
blob.getInt("offset"),
blob.getInt("size"));
if (bytes == null) {
promise.reject(ERROR_INVALID_BLOB, "The specified blob is invalid");
return;
}
try {
StringBuilder sb = new StringBuilder();
sb.append("data:");
if (blob.hasKey("type") && !blob.getString("type").isEmpty()) {
sb.append(blob.getString("type"));
} else {
sb.append("application/octet-stream");
}
sb.append(";base64,");
sb.append(Base64.encodeToString(bytes, Base64.NO_WRAP));
promise.resolve(sb.toString());
} catch (Exception e) {
promise.reject(e);
}
}
}

View File

@@ -6,20 +6,9 @@
* LICENSE file in the root directory of this source tree. An additional grant
* of patent rights can be found in the PATENTS file in the same directory.
*/
package com.facebook.react.modules.network;
import javax.annotation.Nullable;
import java.io.IOException;
import java.io.InputStream;
import java.io.Reader;
import java.nio.charset.Charset;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import android.net.Uri;
import android.util.Base64;
import com.facebook.react.bridge.Arguments;
@@ -35,6 +24,17 @@ import com.facebook.react.common.network.OkHttpCallUtil;
import com.facebook.react.module.annotations.ReactModule;
import com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import javax.annotation.Nullable;
import okhttp3.Call;
import okhttp3.Callback;
import okhttp3.CookieJar;
@@ -56,6 +56,52 @@ import okio.ByteString;
@ReactModule(name = NetworkingModule.NAME)
public final class NetworkingModule extends ReactContextBaseJavaModule {
/**
* Allows to implement a custom fetching process for specific URIs. It is the handler's job
* to fetch the URI and return the JS body payload.
*/
public interface UriHandler {
/**
* Returns if the handler should be used for an URI.
*/
boolean supports(Uri uri, String responseType);
/**
* Fetch the URI and return the JS body payload.
*/
WritableMap fetch(Uri uri) throws IOException;
}
/**
* Allows adding custom handling to build the {@link RequestBody} from the JS body payload.
*/
public interface RequestBodyHandler {
/**
* Returns if the handler should be used for a JS body payload.
*/
boolean supports(ReadableMap map);
/**
* Returns the {@link RequestBody} for the JS body payload.
*/
RequestBody toRequestBody(ReadableMap map, String contentType);
}
/**
* Allows adding custom handling to build the JS body payload from the {@link ResponseBody}.
*/
public interface ResponseHandler {
/**
* Returns if the handler should be used for a response type.
*/
boolean supports(String responseType);
/**
* Returns the JS body payload for the {@link ResponseBody}.
*/
WritableMap toResponseData(ResponseBody body) throws IOException;
}
protected static final String NAME = "Networking";
private static final String CONTENT_ENCODING_HEADER_NAME = "content-encoding";
@@ -73,6 +119,9 @@ public final class NetworkingModule extends ReactContextBaseJavaModule {
private final @Nullable String mDefaultUserAgent;
private final CookieJarContainer mCookieJarContainer;
private final Set<Integer> mRequestIds;
private final List<RequestBodyHandler> mRequestBodyHandlers = new ArrayList<>();
private final List<UriHandler> mUriHandlers = new ArrayList<>();
private final List<ResponseHandler> mResponseHandlers = new ArrayList<>();
private boolean mShuttingDown;
/* package */ NetworkingModule(
@@ -154,6 +203,34 @@ public final class NetworkingModule extends ReactContextBaseJavaModule {
mCookieHandler.destroy();
mCookieJarContainer.removeCookieJar();
mRequestBodyHandlers.clear();
mResponseHandlers.clear();
mUriHandlers.clear();
}
public void addUriHandler(UriHandler handler) {
mUriHandlers.add(handler);
}
public void addRequestBodyHandler(RequestBodyHandler handler) {
mRequestBodyHandlers.add(handler);
}
public void addResponseHandler(ResponseHandler handler) {
mResponseHandlers.add(handler);
}
public void removeUriHandler(UriHandler handler) {
mUriHandlers.remove(handler);
}
public void removeRequestBodyHandler(RequestBodyHandler handler) {
mRequestBodyHandlers.remove(handler);
}
public void removeResponseHandler(ResponseHandler handler) {
mResponseHandlers.remove(handler);
}
@ReactMethod
@@ -170,13 +247,31 @@ public final class NetworkingModule extends ReactContextBaseJavaModule {
final boolean useIncrementalUpdates,
int timeout,
boolean withCredentials) {
final RCTDeviceEventEmitter eventEmitter = getEventEmitter();
try {
Uri uri = Uri.parse(url);
// Check if a handler is registered
for (UriHandler handler : mUriHandlers) {
if (handler.supports(uri, responseType)) {
WritableMap res = handler.fetch(uri);
ResponseUtil.onDataReceived(eventEmitter, requestId, res);
ResponseUtil.onRequestSuccess(eventEmitter, requestId);
return;
}
}
} catch (IOException e) {
ResponseUtil.onRequestError(eventEmitter, requestId, e.getMessage(), e);
return;
}
Request.Builder requestBuilder = new Request.Builder().url(url);
if (requestId != 0) {
requestBuilder.tag(requestId);
}
final RCTDeviceEventEmitter eventEmitter = getEventEmitter();
OkHttpClient.Builder clientBuilder = mClient.newBuilder();
if (!withCredentials) {
@@ -237,8 +332,22 @@ public final class NetworkingModule extends ReactContextBaseJavaModule {
String contentEncoding = requestHeaders.get(CONTENT_ENCODING_HEADER_NAME);
requestBuilder.headers(requestHeaders);
// Check if a handler is registered
RequestBodyHandler handler = null;
if (data != null) {
for (RequestBodyHandler curHandler : mRequestBodyHandlers) {
if (curHandler.supports(data)) {
handler = curHandler;
break;
}
}
}
if (data == null) {
requestBuilder.method(method, RequestBodyUtil.getEmptyBody(method));
} else if (handler != null) {
RequestBody requestBody = handler.toRequestBody(data, contentType);
requestBuilder.method(method, requestBody);
} else if (data.hasKey(REQUEST_BODY_KEY_STRING)) {
if (contentType == null) {
ResponseUtil.onRequestError(
@@ -360,6 +469,16 @@ public final class NetworkingModule extends ReactContextBaseJavaModule {
ResponseBody responseBody = response.body();
try {
// Check if a handler is registered
for (ResponseHandler handler : mResponseHandlers) {
if (handler.supports(responseType)) {
WritableMap res = handler.toResponseData(responseBody);
ResponseUtil.onDataReceived(eventEmitter, requestId, res);
ResponseUtil.onRequestSuccess(eventEmitter, requestId);
return;
}
}
// If JS wants progress updates during the download, and it requested a text response,
// periodically send response data updates to JS.
if (useIncrementalUpdates && responseType.equals("text")) {

View File

@@ -9,14 +9,14 @@
package com.facebook.react.modules.network;
import java.io.IOException;
import java.net.SocketTimeoutException;
import com.facebook.react.bridge.Arguments;
import com.facebook.react.bridge.WritableArray;
import com.facebook.react.bridge.WritableMap;
import com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter;
import java.io.IOException;
import java.net.SocketTimeoutException;
/**
* Util methods to send network responses to JS.
*/
@@ -72,6 +72,17 @@ public class ResponseUtil {
eventEmitter.emit("didReceiveNetworkData", args);
}
public static void onDataReceived(
RCTDeviceEventEmitter eventEmitter,
int requestId,
WritableMap data) {
WritableArray args = Arguments.createArray();
args.pushInt(requestId);
args.pushMap(data);
eventEmitter.emit("didReceiveNetworkData", args);
}
public static void onRequestError(
RCTDeviceEventEmitter eventEmitter,
int requestId,

View File

@@ -30,6 +30,7 @@ import com.facebook.react.module.model.ReactModuleInfoProvider;
import com.facebook.react.modules.accessibilityinfo.AccessibilityInfoModule;
import com.facebook.react.modules.appstate.AppStateModule;
import com.facebook.react.modules.blob.BlobModule;
import com.facebook.react.modules.blob.FileReaderModule;
import com.facebook.react.modules.camera.CameraRollManager;
import com.facebook.react.modules.camera.ImageEditingManager;
import com.facebook.react.modules.camera.ImageStoreManager;
@@ -125,6 +126,14 @@ public class MainReactPackage extends LazyReactPackage {
return new BlobModule(context);
}
}),
ModuleSpec.nativeModuleSpec(
FileReaderModule.class,
new Provider<NativeModule>() {
@Override
public NativeModule get() {
return new FileReaderModule(context);
}
}),
ModuleSpec.nativeModuleSpec(
AsyncStorageModule.class,
new Provider<NativeModule>() {