From be56a3efeefefa6dca816ca5149a3dabfa5164e2 Mon Sep 17 00:00:00 2001 From: Satyajit Sahoo Date: Fri, 26 Jan 2018 09:06:14 -0800 Subject: [PATCH] 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 --- Libraries/Blob/Blob.js | 94 +++--- Libraries/Blob/BlobManager.js | 146 +++++++++ Libraries/Blob/BlobRegistry.js | 41 +++ Libraries/Blob/BlobTypes.js | 9 +- Libraries/Blob/File.js | 58 ++++ Libraries/Blob/FileReader.js | 156 ++++++++++ .../Blob/RCTBlob.xcodeproj/project.pbxproj | 32 +- Libraries/Blob/RCTBlobManager.h | 14 + Libraries/Blob/RCTBlobManager.m | 218 ------------- Libraries/Blob/RCTBlobManager.mm | 290 ++++++++++++++++++ Libraries/Blob/RCTFileReaderModule.h | 14 + Libraries/Blob/RCTFileReaderModule.m | 71 +++++ Libraries/Blob/URL.js | 8 +- Libraries/Blob/__mocks__/BlobModule.js | 17 + Libraries/Blob/__mocks__/FileReaderModule.js | 21 ++ Libraries/Blob/__tests__/Blob-test.js | 84 +++++ Libraries/Blob/__tests__/BlobManager-test.js | 27 ++ Libraries/Blob/__tests__/File-test.js | 46 +++ Libraries/Blob/__tests__/FileReader-test.js | 42 +++ Libraries/Core/InitializeCore.js | 2 + Libraries/Network/RCTNetworking.h | 24 ++ Libraries/Network/RCTNetworking.ios.js | 4 +- Libraries/Network/RCTNetworking.mm | 85 ++++- Libraries/Network/XMLHttpRequest.js | 31 +- Libraries/Network/convertRequestBody.js | 11 +- Libraries/WebSocket/RCTWebSocketModule.h | 5 +- Libraries/WebSocket/RCTWebSocketModule.m | 7 +- Libraries/WebSocket/WebSocket.js | 29 +- RNTester/RNTester.xcodeproj/project.pbxproj | 13 + .../RNTesterLegacy.xcodeproj/project.pbxproj | 69 +++++ .../RNTesterUnitTests/RCTBlobManagerTests.m | 104 +++++++ React.podspec | 2 +- .../java/com/facebook/react/modules/blob/BUCK | 2 + .../react/modules/blob/BlobModule.java | 240 ++++++++++++--- .../react/modules/blob/FileReaderModule.java | 91 ++++++ .../modules/network/NetworkingModule.java | 145 ++++++++- .../react/modules/network/ResponseUtil.java | 17 +- .../react/shell/MainReactPackage.java | 9 + .../test/java/com/facebook/react/modules/BUCK | 1 + .../react/modules/blob/BlobModuleTest.java | 159 ++++++++++ 40 files changed, 2056 insertions(+), 382 deletions(-) create mode 100644 Libraries/Blob/BlobManager.js create mode 100644 Libraries/Blob/BlobRegistry.js create mode 100644 Libraries/Blob/File.js create mode 100644 Libraries/Blob/FileReader.js delete mode 100755 Libraries/Blob/RCTBlobManager.m create mode 100755 Libraries/Blob/RCTBlobManager.mm create mode 100644 Libraries/Blob/RCTFileReaderModule.h create mode 100644 Libraries/Blob/RCTFileReaderModule.m create mode 100644 Libraries/Blob/__mocks__/BlobModule.js create mode 100644 Libraries/Blob/__mocks__/FileReaderModule.js create mode 100644 Libraries/Blob/__tests__/Blob-test.js create mode 100644 Libraries/Blob/__tests__/BlobManager-test.js create mode 100644 Libraries/Blob/__tests__/File-test.js create mode 100644 Libraries/Blob/__tests__/FileReader-test.js create mode 100644 RNTester/RNTesterUnitTests/RCTBlobManagerTests.m create mode 100644 ReactAndroid/src/main/java/com/facebook/react/modules/blob/FileReaderModule.java create mode 100644 ReactAndroid/src/test/java/com/facebook/react/modules/blob/BlobModuleTest.java diff --git a/Libraries/Blob/Blob.js b/Libraries/Blob/Blob.js index 590f48fc50dcc3..3f077a05d71fc1 100644 --- a/Libraries/Blob/Blob.js +++ b/Libraries/Blob/Blob.js @@ -8,19 +8,12 @@ * * @providesModule Blob * @flow + * @format */ 'use strict'; -const invariant = require('fbjs/lib/invariant'); -/* $FlowFixMe(>=0.54.0 site=react_native_oss) This comment suppresses an error - * found when Flow v0.54 was deployed. To see the error delete this comment and - * run Flow. */ -const uuid = require('uuid'); - -const { BlobModule } = require('NativeModules'); - -import type { BlobProps } from 'BlobTypes'; +import type {BlobData, BlobOptions} from 'BlobTypes'; /** * Opaque JS representation of some binary data in native. @@ -60,51 +53,16 @@ import type { BlobProps } from 'BlobTypes'; * Reference: https://developer.mozilla.org/en-US/docs/Web/API/Blob */ class Blob { - /** - * Size of the data contained in the Blob object, in bytes. - */ - size: number; - /* - * String indicating the MIME type of the data contained in the Blob. - * If the type is unknown, this string is empty. - */ - type: string; - - /* - * Unique id to identify the blob on native side (non-standard) - */ - blobId: string; - /* - * Offset to indicate part of blob, used when sliced (non-standard) - */ - offset: number; - - /** - * Construct blob instance from blob data from native. - * Used internally by modules like XHR, WebSocket, etc. - */ - static create(props: BlobProps): Blob { - return Object.assign(Object.create(Blob.prototype), props); - } + _data: ?BlobData; /** * Constructor for JS consumers. * Currently we only support creating Blobs from other Blobs. * Reference: https://developer.mozilla.org/en-US/docs/Web/API/Blob/Blob */ - constructor(parts: Array, options: any) { - const blobId = uuid(); - let size = 0; - parts.forEach((part) => { - invariant(part instanceof Blob, 'Can currently only create a Blob from other Blobs'); - size += part.size; - }); - BlobModule.createFromParts(parts, blobId); - return Blob.create({ - blobId, - offset: 0, - size, - }); + constructor(parts: Array = [], options?: BlobOptions) { + const BlobManager = require('BlobManager'); + this.data = BlobManager.createFromParts(parts, options).data; } /* @@ -112,9 +70,22 @@ class Blob { * the data in the specified range of bytes of the source Blob. * Reference: https://developer.mozilla.org/en-US/docs/Web/API/Blob/slice */ + set data(data: ?BlobData) { + this._data = data; + } + + get data(): BlobData { + if (!this._data) { + throw new Error('Blob has been closed and is no longer available'); + } + + return this._data; + } + slice(start?: number, end?: number): Blob { - let offset = this.offset; - let size = this.size; + const BlobManager = require('BlobManager'); + let {offset, size} = this.data; + if (typeof start === 'number') { if (start > size) { start = size; @@ -129,8 +100,8 @@ class Blob { size = end - start; } } - return Blob.create({ - blobId: this.blobId, + return BlobManager.createFromOptions({ + blobId: this.data.blobId, offset, size, }); @@ -149,7 +120,24 @@ class Blob { * `new Blob([blob, ...])` actually copies the data in memory. */ close() { - BlobModule.release(this.blobId); + const BlobManager = require('BlobManager'); + BlobManager.release(this.data.blobId); + this.data = null; + } + + /** + * Size of the data contained in the Blob object, in bytes. + */ + get size(): number { + return this.data.size; + } + + /* + * String indicating the MIME type of the data contained in the Blob. + * If the type is unknown, this string is empty. + */ + get type(): string { + return this.data.type || ''; } } diff --git a/Libraries/Blob/BlobManager.js b/Libraries/Blob/BlobManager.js new file mode 100644 index 00000000000000..a1cf9b489e6007 --- /dev/null +++ b/Libraries/Blob/BlobManager.js @@ -0,0 +1,146 @@ +/** + * Copyright (c) 2013-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. + * + * @providesModule BlobManager + * @flow + * @format + */ + +'use strict'; + +const Blob = require('Blob'); +const BlobRegistry = require('BlobRegistry'); +const {BlobModule} = require('NativeModules'); + +import type {BlobData, BlobOptions} from 'BlobTypes'; + +/*eslint-disable no-bitwise */ +/*eslint-disable eqeqeq */ + +/** + * Based on the rfc4122-compliant solution posted at + * http://stackoverflow.com/questions/105034 + */ +function uuidv4(): string { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { + const r = (Math.random() * 16) | 0, + v = c == 'x' ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); +} + +/** + * Module to manage blobs. Wrapper around the native blob module. + */ +class BlobManager { + /** + * If the native blob module is available. + */ + static isAvailable = !!BlobModule; + + /** + * Create blob from existing array of blobs. + */ + static createFromParts( + parts: Array, + options?: BlobOptions, + ): Blob { + const blobId = uuidv4(); + const items = parts.map(part => { + if ( + part instanceof ArrayBuffer || + (global.ArrayBufferView && part instanceof global.ArrayBufferView) + ) { + throw new Error( + "Creating blobs from 'ArrayBuffer' and 'ArrayBufferView' are not supported", + ); + } + if (part instanceof Blob) { + return { + data: part.data, + type: 'blob', + }; + } else { + return { + data: String(part), + type: 'string', + }; + } + }); + const size = items.reduce((acc, curr) => { + if (curr.type === 'string') { + return acc + global.unescape(encodeURI(curr.data)).length; + } else { + return acc + curr.data.size; + } + }, 0); + + BlobModule.createFromParts(items, blobId); + + return BlobManager.createFromOptions({ + blobId, + offset: 0, + size, + type: options ? options.type : '', + lastModified: options ? options.lastModified : Date.now(), + }); + } + + /** + * Create blob instance from blob data from native. + * Used internally by modules like XHR, WebSocket, etc. + */ + static createFromOptions(options: BlobData): Blob { + BlobRegistry.register(options.blobId); + return Object.assign(Object.create(Blob.prototype), {data: options}); + } + + /** + * Deallocate resources for a blob. + */ + static release(blobId: string): void { + BlobRegistry.unregister(blobId); + if (BlobRegistry.has(blobId)) { + return; + } + BlobModule.release(blobId); + } + + /** + * Inject the blob content handler in the networking module to support blob + * requests and responses. + */ + static addNetworkingHandler(): void { + BlobModule.addNetworkingHandler(); + } + + /** + * Indicate the websocket should return a blob for incoming binary + * messages. + */ + static addWebSocketHandler(socketId: number): void { + BlobModule.addWebSocketHandler(socketId); + } + + /** + * Indicate the websocket should no longer return a blob for incoming + * binary messages. + */ + static removeWebSocketHandler(socketId: number): void { + BlobModule.removeWebSocketHandler(socketId); + } + + /** + * Send a blob message to a websocket. + */ + static sendOverSocket(blob: Blob, socketId: number): void { + BlobModule.sendOverSocket(blob.data, socketId); + } +} + +module.exports = BlobManager; diff --git a/Libraries/Blob/BlobRegistry.js b/Libraries/Blob/BlobRegistry.js new file mode 100644 index 00000000000000..925e78f3a78453 --- /dev/null +++ b/Libraries/Blob/BlobRegistry.js @@ -0,0 +1,41 @@ +/** + * Copyright (c) 2013-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. + * + * @providesModule BlobRegistry + * @flow + * @format + */ + +const registry: {[key: string]: number} = {}; + +const register = (id: string) => { + if (registry[id]) { + registry[id]++; + } else { + registry[id] = 1; + } +}; + +const unregister = (id: string) => { + if (registry[id]) { + registry[id]--; + if (registry[id] <= 0) { + delete registry[id]; + } + } +}; + +const has = (id: string) => { + return registry[id] && registry[id] > 0; +}; + +module.exports = { + register, + unregister, + has, +}; diff --git a/Libraries/Blob/BlobTypes.js b/Libraries/Blob/BlobTypes.js index 8201df47bc306f..c053c08cfc8deb 100644 --- a/Libraries/Blob/BlobTypes.js +++ b/Libraries/Blob/BlobTypes.js @@ -8,18 +8,21 @@ * * @providesModule BlobTypes * @flow + * @format */ 'use strict'; -export type BlobProps = { +export type BlobData = { blobId: string, offset: number, size: number, + name?: string, type?: string, + lastModified?: number, }; -export type FileProps = BlobProps & { - name: string, +export type BlobOptions = { + type: string, lastModified: number, }; diff --git a/Libraries/Blob/File.js b/Libraries/Blob/File.js new file mode 100644 index 00000000000000..8e999b20f41dd0 --- /dev/null +++ b/Libraries/Blob/File.js @@ -0,0 +1,58 @@ +/** + * Copyright (c) 2013-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. + * + * @providesModule File + * @flow + * @format + */ +'use strict'; + +const Blob = require('Blob'); + +const invariant = require('fbjs/lib/invariant'); + +import type {BlobOptions} from 'BlobTypes'; + +/** + * The File interface provides information about files. + */ +class File extends Blob { + /** + * Constructor for JS consumers. + */ + constructor( + parts: Array, + name: string, + options?: BlobOptions, + ) { + invariant( + parts != null && name != null, + 'Failed to construct `File`: Must pass both `parts` and `name` arguments.', + ); + + super(parts, options); + this.data.name = name; + } + + /** + * Name of the file. + */ + get name(): string { + invariant(this.data.name != null, 'Files must have a name set.'); + return this.data.name; + } + + /* + * Last modified time of the file. + */ + get lastModified(): number { + return this.data.lastModified || 0; + } +} + +module.exports = File; diff --git a/Libraries/Blob/FileReader.js b/Libraries/Blob/FileReader.js new file mode 100644 index 00000000000000..121f7ef76caa21 --- /dev/null +++ b/Libraries/Blob/FileReader.js @@ -0,0 +1,156 @@ +/** + * 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. + * + * @providesModule FileReader + * @flow + * @format + */ + +'use strict'; + +const EventTarget = require('event-target-shim'); +const Blob = require('Blob'); +const {FileReaderModule} = require('NativeModules'); + +type ReadyState = + | 0 // EMPTY + | 1 // LOADING + | 2; // DONE + +type ReaderResult = string | ArrayBuffer; + +const READER_EVENTS = [ + 'abort', + 'error', + 'load', + 'loadstart', + 'loadend', + 'progress', +]; + +const EMPTY = 0; +const LOADING = 1; +const DONE = 2; + +class FileReader extends EventTarget(...READER_EVENTS) { + static EMPTY = EMPTY; + static LOADING = LOADING; + static DONE = DONE; + + EMPTY = EMPTY; + LOADING = LOADING; + DONE = DONE; + + _readyState: ReadyState; + _error: ?Error; + _result: ?ReaderResult; + _aborted: boolean = false; + _subscriptions: Array<*> = []; + + constructor() { + super(); + this._reset(); + } + + _reset(): void { + this._readyState = EMPTY; + this._error = null; + this._result = null; + } + + _clearSubscriptions(): void { + this._subscriptions.forEach(sub => sub.remove()); + this._subscriptions = []; + } + + _setReadyState(newState: ReadyState) { + this._readyState = newState; + this.dispatchEvent({type: 'readystatechange'}); + if (newState === DONE) { + if (this._aborted) { + this.dispatchEvent({type: 'abort'}); + } else if (this._error) { + this.dispatchEvent({type: 'error'}); + } else { + this.dispatchEvent({type: 'load'}); + } + this.dispatchEvent({type: 'loadend'}); + } + } + + readAsArrayBuffer() { + throw new Error('FileReader.readAsArrayBuffer is not implemented'); + } + + readAsDataURL(blob: Blob) { + this._aborted = false; + + FileReaderModule.readAsDataURL(blob.data).then( + (text: string) => { + if (this._aborted) { + return; + } + this._result = text; + this._setReadyState(DONE); + }, + error => { + if (this._aborted) { + return; + } + this._error = error; + this._setReadyState(DONE); + }, + ); + } + + readAsText(blob: Blob, encoding: string = 'UTF-8') { + this._aborted = false; + + FileReaderModule.readAsText(blob.data, encoding).then( + (text: string) => { + if (this._aborted) { + return; + } + this._result = text; + this._setReadyState(DONE); + }, + error => { + if (this._aborted) { + return; + } + this._error = error; + this._setReadyState(DONE); + }, + ); + } + + abort() { + this._aborted = true; + // only call onreadystatechange if there is something to abort, as per spec + if (this._readyState !== EMPTY && this._readyState !== DONE) { + this._reset(); + this._setReadyState(DONE); + } + // Reset again after, in case modified in handler + this._reset(); + } + + get readyState(): ReadyState { + return this._readyState; + } + + get error(): ?Error { + return this._error; + } + + get result(): ?ReaderResult { + return this._result; + } +} + +module.exports = FileReader; diff --git a/Libraries/Blob/RCTBlob.xcodeproj/project.pbxproj b/Libraries/Blob/RCTBlob.xcodeproj/project.pbxproj index bb5944caae6ad0..2ffded6f074d39 100755 --- a/Libraries/Blob/RCTBlob.xcodeproj/project.pbxproj +++ b/Libraries/Blob/RCTBlob.xcodeproj/project.pbxproj @@ -7,12 +7,18 @@ objects = { /* Begin PBXBuildFile section */ + 19BA88FE1F84391700741C5A /* RCTFileReaderModule.h in Copy Headers */ = {isa = PBXBuildFile; fileRef = ADDFBA6A1F33455F0064C998 /* RCTFileReaderModule.h */; }; + 19BA88FF1F84392900741C5A /* RCTFileReaderModule.h in Headers */ = {isa = PBXBuildFile; fileRef = ADDFBA6A1F33455F0064C998 /* RCTFileReaderModule.h */; }; + 19BA89001F84392F00741C5A /* RCTFileReaderModule.h in Copy Headers */ = {isa = PBXBuildFile; fileRef = ADDFBA6A1F33455F0064C998 /* RCTFileReaderModule.h */; }; + 19BA89011F84393D00741C5A /* RCTFileReaderModule.m in Sources */ = {isa = PBXBuildFile; fileRef = ADDFBA6B1F33455F0064C998 /* RCTFileReaderModule.m */; }; AD0871131E215B28007D136D /* RCTBlobManager.h in Copy Headers */ = {isa = PBXBuildFile; fileRef = AD9A43C11DFC7126008DC588 /* RCTBlobManager.h */; }; AD0871161E215EC9007D136D /* RCTBlobManager.h in Headers */ = {isa = PBXBuildFile; fileRef = AD9A43C11DFC7126008DC588 /* RCTBlobManager.h */; }; AD0871181E215ED1007D136D /* RCTBlobManager.h in Headers */ = {isa = PBXBuildFile; fileRef = AD9A43C11DFC7126008DC588 /* RCTBlobManager.h */; }; AD08711A1E2162C8007D136D /* RCTBlobManager.h in Copy Headers */ = {isa = PBXBuildFile; fileRef = AD9A43C11DFC7126008DC588 /* RCTBlobManager.h */; }; - AD9A43C31DFC7126008DC588 /* RCTBlobManager.m in Sources */ = {isa = PBXBuildFile; fileRef = AD9A43C21DFC7126008DC588 /* RCTBlobManager.m */; }; - ADD01A711E09404A00F6D226 /* RCTBlobManager.m in Sources */ = {isa = PBXBuildFile; fileRef = AD9A43C21DFC7126008DC588 /* RCTBlobManager.m */; }; + AD9A43C31DFC7126008DC588 /* RCTBlobManager.mm in Sources */ = {isa = PBXBuildFile; fileRef = AD9A43C21DFC7126008DC588 /* RCTBlobManager.mm */; }; + ADD01A711E09404A00F6D226 /* RCTBlobManager.mm in Sources */ = {isa = PBXBuildFile; fileRef = AD9A43C21DFC7126008DC588 /* RCTBlobManager.mm */; }; + ADDFBA6C1F33455F0064C998 /* RCTFileReaderModule.h in Headers */ = {isa = PBXBuildFile; fileRef = ADDFBA6A1F33455F0064C998 /* RCTFileReaderModule.h */; }; + ADDFBA6D1F33455F0064C998 /* RCTFileReaderModule.m in Sources */ = {isa = PBXBuildFile; fileRef = ADDFBA6B1F33455F0064C998 /* RCTFileReaderModule.m */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -22,6 +28,7 @@ dstPath = include/RCTBlob; dstSubfolderSpec = 16; files = ( + 19BA88FE1F84391700741C5A /* RCTFileReaderModule.h in Copy Headers */, AD08711A1E2162C8007D136D /* RCTBlobManager.h in Copy Headers */, ); name = "Copy Headers"; @@ -33,6 +40,7 @@ dstPath = include/RCTBlob; dstSubfolderSpec = 16; files = ( + 19BA89001F84392F00741C5A /* RCTFileReaderModule.h in Copy Headers */, AD0871131E215B28007D136D /* RCTBlobManager.h in Copy Headers */, ); name = "Copy Headers"; @@ -42,17 +50,21 @@ /* Begin PBXFileReference section */ 358F4ED71D1E81A9004DF814 /* libRCTBlob.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libRCTBlob.a; sourceTree = BUILT_PRODUCTS_DIR; }; - AD9A43C11DFC7126008DC588 /* RCTBlobManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RCTBlobManager.h; sourceTree = ""; }; - AD9A43C21DFC7126008DC588 /* RCTBlobManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTBlobManager.m; sourceTree = ""; }; + AD9A43C11DFC7126008DC588 /* RCTBlobManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; lineEnding = 0; path = RCTBlobManager.h; sourceTree = ""; }; + AD9A43C21DFC7126008DC588 /* RCTBlobManager.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = RCTBlobManager.mm; sourceTree = ""; }; ADD01A681E09402E00F6D226 /* libRCTBlob-tvOS.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libRCTBlob-tvOS.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + ADDFBA6A1F33455F0064C998 /* RCTFileReaderModule.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; lineEnding = 0; path = RCTFileReaderModule.h; sourceTree = ""; }; + ADDFBA6B1F33455F0064C998 /* RCTFileReaderModule.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTFileReaderModule.m; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXGroup section */ 358F4ECE1D1E81A9004DF814 = { isa = PBXGroup; children = ( + ADDFBA6A1F33455F0064C998 /* RCTFileReaderModule.h */, + ADDFBA6B1F33455F0064C998 /* RCTFileReaderModule.m */, AD9A43C11DFC7126008DC588 /* RCTBlobManager.h */, - AD9A43C21DFC7126008DC588 /* RCTBlobManager.m */, + AD9A43C21DFC7126008DC588 /* RCTBlobManager.mm */, 358F4ED81D1E81A9004DF814 /* Products */, ); indentWidth = 2; @@ -77,6 +89,7 @@ buildActionMask = 2147483647; files = ( AD0871161E215EC9007D136D /* RCTBlobManager.h in Headers */, + ADDFBA6C1F33455F0064C998 /* RCTFileReaderModule.h in Headers */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -84,6 +97,7 @@ isa = PBXHeadersBuildPhase; buildActionMask = 2147483647; files = ( + 19BA88FF1F84392900741C5A /* RCTFileReaderModule.h in Headers */, AD0871181E215ED1007D136D /* RCTBlobManager.h in Headers */, ); runOnlyForDeploymentPostprocessing = 0; @@ -132,7 +146,7 @@ isa = PBXProject; attributes = { LastUpgradeCheck = 0730; - ORGANIZATIONNAME = "Silk Labs"; + ORGANIZATIONNAME = Facebook; TargetAttributes = { 358F4ED61D1E81A9004DF814 = { CreatedOnToolsVersion = 7.3; @@ -166,7 +180,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - AD9A43C31DFC7126008DC588 /* RCTBlobManager.m in Sources */, + ADDFBA6D1F33455F0064C998 /* RCTFileReaderModule.m in Sources */, + AD9A43C31DFC7126008DC588 /* RCTBlobManager.mm in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -174,7 +189,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - ADD01A711E09404A00F6D226 /* RCTBlobManager.m in Sources */, + 19BA89011F84393D00741C5A /* RCTFileReaderModule.m in Sources */, + ADD01A711E09404A00F6D226 /* RCTBlobManager.mm in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Libraries/Blob/RCTBlobManager.h b/Libraries/Blob/RCTBlobManager.h index 03b3ffc60a6fb4..8e8d355f60b7ac 100755 --- a/Libraries/Blob/RCTBlobManager.h +++ b/Libraries/Blob/RCTBlobManager.h @@ -13,4 +13,18 @@ @interface RCTBlobManager : NSObject +- (NSString *)store:(NSData *)data; + +- (void)store:(NSData *)data withId:(NSString *)blobId; + +- (NSData *)resolve:(NSDictionary *)blob; + +- (NSData *)resolve:(NSString *)blobId offset:(NSInteger)offset size:(NSInteger)size; + +- (NSData *)resolveURL:(NSURL *)url; + +- (void)remove:(NSString *)blobId; + +- (void)createFromParts:(NSArray *> *)parts withId:(NSString *)blobId; + @end diff --git a/Libraries/Blob/RCTBlobManager.m b/Libraries/Blob/RCTBlobManager.m deleted file mode 100755 index d556d634a1c7ff..00000000000000 --- a/Libraries/Blob/RCTBlobManager.m +++ /dev/null @@ -1,218 +0,0 @@ -/** - * 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. - */ - -#import "RCTBlobManager.h" - -#import -#import - -static NSString *const kBlobUriScheme = @"blob"; - -@interface _RCTBlobContentHandler : NSObject - -- (instancetype)initWithBlobManager:(RCTBlobManager *)blobManager; - -@end - - -@implementation RCTBlobManager -{ - NSMutableDictionary *_blobs; - _RCTBlobContentHandler *_contentHandler; - NSOperationQueue *_queue; -} - -RCT_EXPORT_MODULE(BlobModule) - -@synthesize bridge = _bridge; - -+ (BOOL)requiresMainQueueSetup -{ - return NO; -} - -- (NSDictionary *)constantsToExport -{ - return @{ - @"BLOB_URI_SCHEME": kBlobUriScheme, - @"BLOB_URI_HOST": [NSNull null], - }; -} - -- (dispatch_queue_t)methodQueue -{ - return [[_bridge webSocketModule] methodQueue]; -} - -- (NSString *)store:(NSData *)data -{ - NSString *blobId = [NSUUID UUID].UUIDString; - [self store:data withId:blobId]; - return blobId; -} - -- (void)store:(NSData *)data withId:(NSString *)blobId -{ - if (!_blobs) { - _blobs = [NSMutableDictionary new]; - } - - _blobs[blobId] = data; -} - -- (NSData *)resolve:(NSDictionary *)blob -{ - NSString *blobId = [RCTConvert NSString:blob[@"blobId"]]; - NSNumber *offset = [RCTConvert NSNumber:blob[@"offset"]]; - NSNumber *size = [RCTConvert NSNumber:blob[@"size"]]; - - return [self resolve:blobId - offset:offset ? [offset integerValue] : 0 - size:size ? [size integerValue] : -1]; -} - -- (NSData *)resolve:(NSString *)blobId offset:(NSInteger)offset size:(NSInteger)size -{ - NSData *data = _blobs[blobId]; - if (!data) { - return nil; - } - if (offset != 0 || (size != -1 && size != data.length)) { - data = [data subdataWithRange:NSMakeRange(offset, size)]; - } - return data; -} - -RCT_EXPORT_METHOD(enableBlobSupport:(nonnull NSNumber *)socketID) -{ - if (!_contentHandler) { - _contentHandler = [[_RCTBlobContentHandler alloc] initWithBlobManager:self]; - } - [[_bridge webSocketModule] setContentHandler:_contentHandler forSocketID:socketID]; -} - -RCT_EXPORT_METHOD(disableBlobSupport:(nonnull NSNumber *)socketID) -{ - [[_bridge webSocketModule] setContentHandler:nil forSocketID:socketID]; -} - -RCT_EXPORT_METHOD(sendBlob:(NSDictionary *)blob socketID:(nonnull NSNumber *)socketID) -{ - [[_bridge webSocketModule] sendData:[self resolve:blob] forSocketID:socketID]; -} - -RCT_EXPORT_METHOD(createFromParts:(NSArray *> *)parts withId:(NSString *)blobId) -{ - NSMutableData *data = [NSMutableData new]; - for (NSDictionary *part in parts) { - NSData *partData = [self resolve:part]; - [data appendData:partData]; - } - [self store:data withId:blobId]; -} - -RCT_EXPORT_METHOD(release:(NSString *)blobId) -{ - [_blobs removeObjectForKey:blobId]; -} - -#pragma mark - RCTURLRequestHandler methods - -- (BOOL)canHandleRequest:(NSURLRequest *)request -{ - return [request.URL.scheme caseInsensitiveCompare:kBlobUriScheme] == NSOrderedSame; -} - -- (id)sendRequest:(NSURLRequest *)request withDelegate:(id)delegate -{ - // Lazy setup - if (!_queue) { - _queue = [NSOperationQueue new]; - _queue.maxConcurrentOperationCount = 2; - } - - __weak __block NSBlockOperation *weakOp; - __block NSBlockOperation *op = [NSBlockOperation blockOperationWithBlock:^{ - NSURLResponse *response = [[NSURLResponse alloc] initWithURL:request.URL - MIMEType:nil - expectedContentLength:-1 - textEncodingName:nil]; - - [delegate URLRequest:weakOp didReceiveResponse:response]; - - NSURLComponents *components = [[NSURLComponents alloc] initWithURL:request.URL resolvingAgainstBaseURL:NO]; - - NSString *blobId = components.path; - NSInteger offset = 0; - NSInteger size = -1; - - if (components.queryItems) { - for (NSURLQueryItem *queryItem in components.queryItems) { - if ([queryItem.name isEqualToString:@"offset"]) { - offset = [queryItem.value integerValue]; - } - if ([queryItem.name isEqualToString:@"size"]) { - size = [queryItem.value integerValue]; - } - } - } - - NSData *data; - if (blobId) { - data = [self resolve:blobId offset:offset size:size]; - } - NSError *error; - if (data) { - [delegate URLRequest:weakOp didReceiveData:data]; - } else { - error = [[NSError alloc] initWithDomain:NSURLErrorDomain code:NSURLErrorBadURL userInfo:nil]; - } - [delegate URLRequest:weakOp didCompleteWithError:error]; - }]; - - weakOp = op; - [_queue addOperation:op]; - return op; -} - -- (void)cancelRequest:(NSOperation *)op -{ - [op cancel]; -} - -@end - -@implementation _RCTBlobContentHandler { - __weak RCTBlobManager *_blobManager; -} - -- (instancetype)initWithBlobManager:(RCTBlobManager *)blobManager -{ - if (self = [super init]) { - _blobManager = blobManager; - } - return self; -} - -- (id)processMessage:(id)message forSocketID:(NSNumber *)socketID withType:(NSString *__autoreleasing _Nonnull *)type -{ - if (![message isKindOfClass:[NSData class]]) { - *type = @"text"; - return message; - } - - *type = @"blob"; - return @{ - @"blobId": [_blobManager store:message], - @"offset": @0, - @"size": @(((NSData *)message).length), - }; -} - -@end diff --git a/Libraries/Blob/RCTBlobManager.mm b/Libraries/Blob/RCTBlobManager.mm new file mode 100755 index 00000000000000..8d77cd5b15f553 --- /dev/null +++ b/Libraries/Blob/RCTBlobManager.mm @@ -0,0 +1,290 @@ +/** + * 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. + */ + +#import "RCTBlobManager.h" + +#import + +#import +#import +#import + +static NSString *const kBlobURIScheme = @"blob"; + +@interface RCTBlobManager () + +@end + +@implementation RCTBlobManager +{ + // Blobs should be thread safe since they are used from the websocket and networking module, + // make sure to use proper locking when accessing this. + NSMutableDictionary *_blobs; + std::mutex _blobsMutex; + + NSOperationQueue *_queue; +} + +RCT_EXPORT_MODULE(BlobModule) + +@synthesize bridge = _bridge; + +- (void)setBridge:(RCTBridge *)bridge +{ + _bridge = bridge; + + std::lock_guard lock(_blobsMutex); + _blobs = [NSMutableDictionary new]; +} + ++ (BOOL)requiresMainQueueSetup +{ + return NO; +} + +- (NSDictionary *)constantsToExport +{ + return @{ + @"BLOB_URI_SCHEME": kBlobURIScheme, + @"BLOB_URI_HOST": [NSNull null], + }; +} + +- (NSString *)store:(NSData *)data +{ + NSString *blobId = [NSUUID UUID].UUIDString; + [self store:data withId:blobId]; + return blobId; +} + +- (void)store:(NSData *)data withId:(NSString *)blobId +{ + std::lock_guard lock(_blobsMutex); + _blobs[blobId] = data; +} + +- (NSData *)resolve:(NSDictionary *)blob +{ + NSString *blobId = [RCTConvert NSString:blob[@"blobId"]]; + NSNumber *offset = [RCTConvert NSNumber:blob[@"offset"]]; + NSNumber *size = [RCTConvert NSNumber:blob[@"size"]]; + return [self resolve:blobId + offset:offset ? [offset integerValue] : 0 + size:size ? [size integerValue] : -1]; +} + +- (NSData *)resolve:(NSString *)blobId offset:(NSInteger)offset size:(NSInteger)size +{ + NSData *data; + { + std::lock_guard lock(_blobsMutex); + data = _blobs[blobId]; + } + if (!data) { + return nil; + } + if (offset != 0 || (size != -1 && size != data.length)) { + data = [data subdataWithRange:NSMakeRange(offset, size)]; + } + return data; +} + +- (NSData *)resolveURL:(NSURL *)url +{ + NSURLComponents *components = [[NSURLComponents alloc] initWithURL:url resolvingAgainstBaseURL:NO]; + + NSString *blobId = components.path; + NSInteger offset = 0; + NSInteger size = -1; + + if (components.queryItems) { + for (NSURLQueryItem *queryItem in components.queryItems) { + if ([queryItem.name isEqualToString:@"offset"]) { + offset = [queryItem.value integerValue]; + } + if ([queryItem.name isEqualToString:@"size"]) { + size = [queryItem.value integerValue]; + } + } + } + + if (blobId) { + return [self resolve:blobId offset:offset size:size]; + } + return nil; +} + +- (void)remove:(NSString *)blobId +{ + std::lock_guard lock(_blobsMutex); + [_blobs removeObjectForKey:blobId]; +} + +RCT_EXPORT_METHOD(addNetworkingHandler) +{ + dispatch_async(_bridge.networking.methodQueue, ^{ + [self->_bridge.networking addRequestHandler:self]; + [self->_bridge.networking addResponseHandler:self]; + }); +} + +RCT_EXPORT_METHOD(addWebSocketHandler:(nonnull NSNumber *)socketID) +{ + dispatch_async(_bridge.webSocketModule.methodQueue, ^{ + [self->_bridge.webSocketModule setContentHandler:self forSocketID:socketID]; + }); +} + +RCT_EXPORT_METHOD(removeWebSocketHandler:(nonnull NSNumber *)socketID) +{ + dispatch_async(_bridge.webSocketModule.methodQueue, ^{ + [self->_bridge.webSocketModule setContentHandler:nil forSocketID:socketID]; + }); +} + +// @lint-ignore FBOBJCUNTYPEDCOLLECTION1 +RCT_EXPORT_METHOD(sendOverSocket:(NSDictionary *)blob socketID:(nonnull NSNumber *)socketID) +{ + dispatch_async(_bridge.webSocketModule.methodQueue, ^{ + [self->_bridge.webSocketModule sendData:[self resolve:blob] forSocketID:socketID]; + }); +} + +RCT_EXPORT_METHOD(createFromParts:(NSArray *> *)parts withId:(NSString *)blobId) +{ + NSMutableData *data = [NSMutableData new]; + for (NSDictionary *part in parts) { + NSString *type = [RCTConvert NSString:part[@"type"]]; + + if ([type isEqualToString:@"blob"]) { + NSData *partData = [self resolve:part[@"data"]]; + [data appendData:partData]; + } else if ([type isEqualToString:@"string"]) { + NSData *partData = [[RCTConvert NSString:part[@"data"]] dataUsingEncoding:NSUTF8StringEncoding]; + [data appendData:partData]; + } else { + [NSException raise:@"Invalid type for blob" format:@"%@ is invalid", type]; + } + } + [self store:data withId:blobId]; +} + +RCT_EXPORT_METHOD(release:(NSString *)blobId) +{ + [self remove:blobId]; +} + +#pragma mark - RCTURLRequestHandler methods + +- (BOOL)canHandleRequest:(NSURLRequest *)request +{ + return [request.URL.scheme caseInsensitiveCompare:kBlobURIScheme] == NSOrderedSame; +} + +- (id)sendRequest:(NSURLRequest *)request withDelegate:(id)delegate +{ + // Lazy setup + if (!_queue) { + _queue = [NSOperationQueue new]; + _queue.maxConcurrentOperationCount = 2; + } + + __weak __typeof(self) weakSelf = self; + __weak __block NSBlockOperation *weakOp; + __block NSBlockOperation *op = [NSBlockOperation blockOperationWithBlock:^{ + __typeof(self) strongSelf = weakSelf; + if (!strongSelf) { + return; + } + NSURLResponse *response = [[NSURLResponse alloc] initWithURL:request.URL + MIMEType:nil + expectedContentLength:-1 + textEncodingName:nil]; + + [delegate URLRequest:weakOp didReceiveResponse:response]; + + NSData *data = [strongSelf resolveURL:response.URL]; + NSError *error; + if (data) { + [delegate URLRequest:weakOp didReceiveData:data]; + } else { + error = [[NSError alloc] initWithDomain:NSURLErrorDomain code:NSURLErrorBadURL userInfo:nil]; + } + [delegate URLRequest:weakOp didCompleteWithError:error]; + }]; + + weakOp = op; + [_queue addOperation:op]; + return op; +} + +- (void)cancelRequest:(NSOperation *)op +{ + [op cancel]; +} + +#pragma mark - RCTNetworkingRequestHandler methods + +// @lint-ignore FBOBJCUNTYPEDCOLLECTION1 +- (BOOL)canHandleNetworkingRequest:(NSDictionary *)data +{ + return data[@"blob"] != nil; +} + +// @lint-ignore FBOBJCUNTYPEDCOLLECTION1 +- (NSDictionary *)handleNetworkingRequest:(NSDictionary *)data +{ + // @lint-ignore FBOBJCUNTYPEDCOLLECTION1 + NSDictionary *blob = [RCTConvert NSDictionary:data[@"blob"]]; + + NSString *contentType = @"application/octet-stream"; + NSString *blobType = [RCTConvert NSString:blob[@"type"]]; + if (blobType != nil && blobType.length > 0) { + contentType = blob[@"type"]; + } + + return @{@"body": [self resolve:blob], @"contentType": contentType}; +} + +- (BOOL)canHandleNetworkingResponse:(NSString *)responseType +{ + return [responseType isEqualToString:@"blob"]; +} + +- (id)handleNetworkingResponse:(NSURLResponse *)response data:(NSData *)data +{ + return @{ + @"blobId": [self store:data], + @"offset": @0, + @"size": @(data.length), + @"name": [response suggestedFilename], + @"type": [response MIMEType], + }; +} + +#pragma mark - RCTWebSocketContentHandler methods + +- (id)processWebsocketMessage:(id)message + forSocketID:(NSNumber *)socketID + withType:(NSString *__autoreleasing _Nonnull *)type +{ + if (![message isKindOfClass:[NSData class]]) { + *type = @"text"; + return message; + } + + *type = @"blob"; + return @{ + @"blobId": [self store:message], + @"offset": @0, + @"size": @(((NSData *)message).length), + }; +} + +@end diff --git a/Libraries/Blob/RCTFileReaderModule.h b/Libraries/Blob/RCTFileReaderModule.h new file mode 100644 index 00000000000000..8728c7c3dc0b67 --- /dev/null +++ b/Libraries/Blob/RCTFileReaderModule.h @@ -0,0 +1,14 @@ +/** + * 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. + */ + +#import + +@interface RCTFileReaderModule : NSObject + +@end diff --git a/Libraries/Blob/RCTFileReaderModule.m b/Libraries/Blob/RCTFileReaderModule.m new file mode 100644 index 00000000000000..debbae8cbd771b --- /dev/null +++ b/Libraries/Blob/RCTFileReaderModule.m @@ -0,0 +1,71 @@ +/** + * 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. + */ + + +#import "RCTFileReaderModule.h" + +#import +#import + +#import "RCTBlobManager.h" + + +@implementation RCTFileReaderModule + +RCT_EXPORT_MODULE(FileReaderModule) + +@synthesize bridge = _bridge; + +RCT_EXPORT_METHOD(readAsText:(NSDictionary *)blob + encoding:(NSString *)encoding + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) +{ + RCTBlobManager *blobManager = [[self bridge] moduleForClass:[RCTBlobManager class]]; + NSData *data = [blobManager resolve:blob]; + + if (data == nil) { + reject(RCTErrorUnspecified, + [NSString stringWithFormat:@"Unable to resolve data for blob: %@", [RCTConvert NSString:blob[@"blobId"]]], nil); + } else { + NSStringEncoding stringEncoding; + + if (encoding == nil) { + stringEncoding = NSUTF8StringEncoding; + } else { + stringEncoding = CFStringConvertEncodingToNSStringEncoding(CFStringConvertIANACharSetNameToEncoding((CFStringRef) encoding)); + } + + NSString *text = [[NSString alloc] initWithData:data encoding:stringEncoding]; + + resolve(text); + } +} + +RCT_EXPORT_METHOD(readAsDataURL:(NSDictionary *)blob + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) +{ + RCTBlobManager *blobManager = [[self bridge] moduleForClass:[RCTBlobManager class]]; + NSData *data = [blobManager resolve:blob]; + + if (data == nil) { + reject(RCTErrorUnspecified, + [NSString stringWithFormat:@"Unable to resolve data for blob: %@", [RCTConvert NSString:blob[@"blobId"]]], nil); + } else { + NSString *type = [RCTConvert NSString:blob[@"type"]]; + NSString *text = [NSString stringWithFormat:@"data:%@;base64,%@", + type != nil && [type length] > 0 ? type : @"application/octet-stream", + [data base64EncodedStringWithOptions:0]]; + + resolve(text); + } +} + +@end diff --git a/Libraries/Blob/URL.js b/Libraries/Blob/URL.js index ed40cae6567dc9..8b761ca708ce0b 100644 --- a/Libraries/Blob/URL.js +++ b/Libraries/Blob/URL.js @@ -52,16 +52,16 @@ if (BlobModule && typeof BlobModule.BLOB_URI_SCHEME === 'string') { */ class URL { constructor() { - throw new Error('Creating BlobURL objects is not supported yet.'); + throw new Error('Creating URL objects is not supported yet.'); } static createObjectURL(blob: Blob) { if (BLOB_URL_PREFIX === null) { throw new Error('Cannot create URL for blob!'); } - return `${BLOB_URL_PREFIX}${blob.blobId}?offset=${blob.offset}&size=${ - blob.size - }`; + return `${BLOB_URL_PREFIX}${blob.data.blobId}?offset=${ + blob.data.offset + }&size=${blob.size}`; } static revokeObjectURL(url: string) { diff --git a/Libraries/Blob/__mocks__/BlobModule.js b/Libraries/Blob/__mocks__/BlobModule.js new file mode 100644 index 00000000000000..1cbda950883312 --- /dev/null +++ b/Libraries/Blob/__mocks__/BlobModule.js @@ -0,0 +1,17 @@ +/** + * 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. + * + * @flow + * @format + */ +const BlobModule = { + createFromParts() {}, + release() {}, +}; + +module.exports = BlobModule; diff --git a/Libraries/Blob/__mocks__/FileReaderModule.js b/Libraries/Blob/__mocks__/FileReaderModule.js new file mode 100644 index 00000000000000..d4b35e00c24cc2 --- /dev/null +++ b/Libraries/Blob/__mocks__/FileReaderModule.js @@ -0,0 +1,21 @@ +/** + * 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. + * + * @flow + * @format + */ +const FileReaderModule = { + async readAsText() { + return ''; + }, + async readAsDataURL() { + return 'data:text/plain;base64,NDI='; + }, +}; + +module.exports = FileReaderModule; diff --git a/Libraries/Blob/__tests__/Blob-test.js b/Libraries/Blob/__tests__/Blob-test.js new file mode 100644 index 00000000000000..85532c819494f9 --- /dev/null +++ b/Libraries/Blob/__tests__/Blob-test.js @@ -0,0 +1,84 @@ +/** + * 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. + * + * @format + * @emails oncall+react_native + */ +'use strict'; + +jest.setMock('NativeModules', { + BlobModule: require('../__mocks__/BlobModule'), +}); + +var Blob = require('Blob'); + +describe('Blob', function() { + it('should create empty blob', () => { + const blob = new Blob(); + expect(blob).toBeInstanceOf(Blob); + expect(blob.data.offset).toBe(0); + expect(blob.data.size).toBe(0); + expect(blob.size).toBe(0); + expect(blob.type).toBe(''); + }); + + it('should create blob from other blobs and strings', () => { + const blobA = new Blob(); + const blobB = new Blob(); + const textA = 'i \u2665 dogs'; + const textB = '\uD800\uDC00'; + const textC = + 'Z\u0351\u036B\u0343\u036A\u0302\u036B\u033D\u034F\u0334\u0319\u0324' + + '\u031E\u0349\u035A\u032F\u031E\u0320\u034DA\u036B\u0357\u0334\u0362' + + '\u0335\u031C\u0330\u0354L\u0368\u0367\u0369\u0358\u0320G\u0311\u0357' + + '\u030E\u0305\u035B\u0341\u0334\u033B\u0348\u034D\u0354\u0339O\u0342' + + '\u030C\u030C\u0358\u0328\u0335\u0339\u033B\u031D\u0333!\u033F\u030B' + + '\u0365\u0365\u0302\u0363\u0310\u0301\u0301\u035E\u035C\u0356\u032C' + + '\u0330\u0319\u0317'; + + blobA.data.size = 34540; + blobB.data.size = 65452; + + const blob = new Blob([blobA, blobB, textA, textB, textC]); + + expect(blob.size).toBe( + blobA.size + + blobB.size + + global.Buffer.byteLength(textA, 'UTF-8') + + global.Buffer.byteLength(textB, 'UTF-8') + + global.Buffer.byteLength(textC, 'UTF-8'), + ); + expect(blob.type).toBe(''); + }); + + it('should slice a blob', () => { + const blob = new Blob(); + + blob.data.size = 34546; + + const sliceA = blob.slice(0, 2354); + + expect(sliceA.data.offset).toBe(0); + expect(sliceA.size).toBe(2354); + expect(sliceA.type).toBe(''); + + const sliceB = blob.slice(2384, 7621); + + expect(sliceB.data.offset).toBe(2384); + expect(sliceB.size).toBe(7621 - 2384); + expect(sliceB.type).toBe(''); + }); + + it('should close a blob', () => { + const blob = new Blob(); + + blob.close(); + + expect(() => blob.size).toThrow(); + }); +}); diff --git a/Libraries/Blob/__tests__/BlobManager-test.js b/Libraries/Blob/__tests__/BlobManager-test.js new file mode 100644 index 00000000000000..6422861b0d2780 --- /dev/null +++ b/Libraries/Blob/__tests__/BlobManager-test.js @@ -0,0 +1,27 @@ +/** + * 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. + * + * @format + * @emails oncall+react_native + */ +'use strict'; + +jest.setMock('NativeModules', { + BlobModule: require('../__mocks__/BlobModule'), +}); + +var Blob = require('Blob'); +var BlobManager = require('BlobManager'); + +describe('BlobManager', function() { + it('should create blob from parts', () => { + const blob = BlobManager.createFromParts([], {type: 'text/html'}); + expect(blob).toBeInstanceOf(Blob); + expect(blob.type).toBe('text/html'); + }); +}); diff --git a/Libraries/Blob/__tests__/File-test.js b/Libraries/Blob/__tests__/File-test.js new file mode 100644 index 00000000000000..99957873242ff6 --- /dev/null +++ b/Libraries/Blob/__tests__/File-test.js @@ -0,0 +1,46 @@ +/** + * 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. + * + * @format + * @emails oncall+react_native + */ +'use strict'; + +jest.setMock('NativeModules', { + BlobModule: require('../__mocks__/BlobModule'), +}); + +const File = require('File'); + +describe('File', function() { + it('should create empty file', () => { + const file = new File([], 'test.jpg'); + expect(file).toBeInstanceOf(File); + expect(file.data.offset).toBe(0); + expect(file.data.size).toBe(0); + expect(file.size).toBe(0); + expect(file.type).toBe(''); + expect(file.name).toBe('test.jpg'); + expect(file.lastModified).toEqual(expect.any(Number)); + }); + + it('should create empty file with type', () => { + const file = new File([], 'test.jpg', {type: 'image/jpeg'}); + expect(file.type).toBe('image/jpeg'); + }); + + it('should create empty file with lastModified', () => { + const file = new File([], 'test.jpg', {lastModified: 1337}); + expect(file.lastModified).toBe(1337); + }); + + it('should throw on invalid arguments', () => { + expect(() => new File()).toThrow(); + expect(() => new File([])).toThrow(); + }); +}); diff --git a/Libraries/Blob/__tests__/FileReader-test.js b/Libraries/Blob/__tests__/FileReader-test.js new file mode 100644 index 00000000000000..58a0e92f6218f7 --- /dev/null +++ b/Libraries/Blob/__tests__/FileReader-test.js @@ -0,0 +1,42 @@ +/** + * 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. + * + * @format + * @emails oncall+react_native + */ +'use strict'; + +jest.unmock('event-target-shim').setMock('NativeModules', { + BlobModule: require('../__mocks__/BlobModule'), + FileReaderModule: require('../__mocks__/FileReaderModule'), +}); + +var Blob = require('Blob'); +var FileReader = require('FileReader'); + +describe('FileReader', function() { + it('should read blob as text', async () => { + const e = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = resolve; + reader.onerror = reject; + reader.readAsText(new Blob()); + }); + expect(e.target.result).toBe(''); + }); + + it('should read blob as data URL', async () => { + const e = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = resolve; + reader.onerror = reject; + reader.readAsDataURL(new Blob()); + }); + expect(e.target.result).toBe('data:text/plain;base64,NDI='); + }); +}); diff --git a/Libraries/Core/InitializeCore.js b/Libraries/Core/InitializeCore.js index f1e2d51b002ae0..f5f3d3cc4d8429 100644 --- a/Libraries/Core/InitializeCore.js +++ b/Libraries/Core/InitializeCore.js @@ -171,6 +171,8 @@ polyfillGlobal('Request', () => require('fetch').Request); polyfillGlobal('Response', () => require('fetch').Response); polyfillGlobal('WebSocket', () => require('WebSocket')); polyfillGlobal('Blob', () => require('Blob')); +polyfillGlobal('File', () => require('File')); +polyfillGlobal('FileReader', () => require('FileReader')); polyfillGlobal('URL', () => require('URL')); // Set up alert diff --git a/Libraries/Network/RCTNetworking.h b/Libraries/Network/RCTNetworking.h index eabc411617d4b4..2aba529bf4eac1 100644 --- a/Libraries/Network/RCTNetworking.h +++ b/Libraries/Network/RCTNetworking.h @@ -10,6 +10,22 @@ #import #import +@protocol RCTNetworkingRequestHandler + +// @lint-ignore FBOBJCUNTYPEDCOLLECTION1 +- (BOOL)canHandleNetworkingRequest:(NSDictionary *)data; +// @lint-ignore FBOBJCUNTYPEDCOLLECTION1 +- (NSDictionary *)handleNetworkingRequest:(NSDictionary *)data; + +@end + +@protocol RCTNetworkingResponseHandler + +- (BOOL)canHandleNetworkingResponse:(NSString *)responseType; +- (id)handleNetworkingResponse:(NSURLResponse *)response data:(NSData *)data; + +@end + @interface RCTNetworking : RCTEventEmitter /** @@ -24,6 +40,14 @@ - (RCTNetworkTask *)networkTaskWithRequest:(NSURLRequest *)request completionBlock:(RCTURLRequestCompletionBlock)completionBlock; +- (void)addRequestHandler:(id)handler; + +- (void)addResponseHandler:(id)handler; + +- (void)removeRequestHandler:(id)handler; + +- (void)removeResponseHandler:(id)handler; + @end @interface RCTBridge (RCTNetworking) diff --git a/Libraries/Network/RCTNetworking.ios.js b/Libraries/Network/RCTNetworking.ios.js index 35d432dee08222..4e2bfd1bef765f 100644 --- a/Libraries/Network/RCTNetworking.ios.js +++ b/Libraries/Network/RCTNetworking.ios.js @@ -18,6 +18,8 @@ const convertRequestBody = require('convertRequestBody'); import type {RequestBody} from 'convertRequestBody'; +import type { NativeResponseType } from './XMLHttpRequest'; + class RCTNetworking extends NativeEventEmitter { isAvailable: boolean = true; @@ -32,7 +34,7 @@ class RCTNetworking extends NativeEventEmitter { url: string, headers: Object, data: RequestBody, - responseType: 'text' | 'base64', + responseType: NativeResponseType, incrementalUpdates: boolean, timeout: number, callback: (requestId: number) => any, diff --git a/Libraries/Network/RCTNetworking.mm b/Libraries/Network/RCTNetworking.mm index 402620b0f43fca..9e10791efc0edf 100644 --- a/Libraries/Network/RCTNetworking.mm +++ b/Libraries/Network/RCTNetworking.mm @@ -131,12 +131,20 @@ @implementation RCTNetworking NSMutableDictionary *_tasksByRequestID; std::mutex _handlersLock; NSArray> *_handlers; + NSMutableArray> *_requestHandlers; + NSMutableArray> *_responseHandlers; } @synthesize methodQueue = _methodQueue; RCT_EXPORT_MODULE() +- (void)invalidate +{ + _requestHandlers = nil; + _responseHandlers = nil; +} + - (NSArray *)supportedEvents { return @[@"didCompleteNetworkResponse", @@ -297,6 +305,8 @@ - (BOOL)canHandleRequest:(NSURLRequest *)request * * - {"formData": [...]}: list of data payloads that will be combined into a multipart/form-data request * + * - {"blob": {...}}: an object representing a blob + * * If successful, the callback be called with a result dictionary containing the following (optional) keys: * * - @"body" (NSData): the body of the request @@ -312,6 +322,15 @@ - (RCTURLRequestCancellationBlock)processDataForHTTPQuery:(nullable NSDictionary if (!query) { return callback(nil, nil); } + for (id handler in _requestHandlers) { + if ([handler canHandleNetworkingRequest:query]) { + // @lint-ignore FBOBJCUNTYPEDCOLLECTION1 + NSDictionary *body = [handler handleNetworkingRequest:query]; + if (body) { + return callback(nil, body); + } + } + } NSData *body = [RCTConvert NSData:query[@"string"]]; if (body) { return callback(nil, @{@"body": body}); @@ -417,6 +436,7 @@ + (NSString *)decodeTextData:(NSData *)data fromResponse:(NSURLResponse *)respon - (void)sendData:(NSData *)data responseType:(NSString *)responseType + response:(NSURLResponse *)response forTask:(RCTNetworkTask *)task { RCTAssertThread(_methodQueue, @"sendData: must be called on method queue"); @@ -425,23 +445,31 @@ - (void)sendData:(NSData *)data return; } - NSString *responseString; - if ([responseType isEqualToString:@"text"]) { - // No carry storage is required here because the entire data has been loaded. - responseString = [RCTNetworking decodeTextData:data fromResponse:task.response withCarryData:nil]; - if (!responseString) { - RCTLogWarn(@"Received data was not a string, or was not a recognised encoding."); + id responseData = nil; + for (id handler in _responseHandlers) { + if ([handler canHandleNetworkingResponse:responseType]) { + responseData = [handler handleNetworkingResponse:response data:data]; + break; + } + } + + if (!responseData) { + if ([responseType isEqualToString:@"text"]) { + // No carry storage is required here because the entire data has been loaded. + responseData = [RCTNetworking decodeTextData:data fromResponse:task.response withCarryData:nil]; + if (!responseData) { + RCTLogWarn(@"Received data was not a string, or was not a recognised encoding."); + return; + } + } else if ([responseType isEqualToString:@"base64"]) { + responseData = [data base64EncodedStringWithOptions:0]; + } else { + RCTLogWarn(@"Invalid responseType: %@", responseType); return; } - } else if ([responseType isEqualToString:@"base64"]) { - responseString = [data base64EncodedStringWithOptions:0]; - } else { - RCTLogWarn(@"Invalid responseType: %@", responseType); - return; } - NSArray *responseJSON = @[task.requestID, responseString]; - [self sendEventWithName:@"didReceiveNetworkData" body:responseJSON]; + [self sendEventWithName:@"didReceiveNetworkData" body:@[task.requestID, responseData]]; } - (void)sendRequest:(NSURLRequest *)request @@ -523,7 +551,10 @@ - (void)sendRequest:(NSURLRequest *)request // Unless we were sending incremental (text) chunks to JS, all along, now // is the time to send the request body to JS. if (!(incrementalUpdates && [responseType isEqualToString:@"text"])) { - [strongSelf sendData:data responseType:responseType forTask:task]; + [strongSelf sendData:data + responseType:responseType + response:response + forTask:task]; } NSArray *responseJSON = @[task.requestID, RCTNullIfNil(error.localizedDescription), @@ -553,6 +584,32 @@ - (void)sendRequest:(NSURLRequest *)request #pragma mark - Public API +- (void)addRequestHandler:(id)handler +{ + if (!_requestHandlers) { + _requestHandlers = [NSMutableArray new]; + } + [_requestHandlers addObject:handler]; +} + +- (void)addResponseHandler:(id)handler +{ + if (!_responseHandlers) { + _responseHandlers = [NSMutableArray new]; + } + [_responseHandlers addObject:handler]; +} + +- (void)removeRequestHandler:(id)handler +{ + [_requestHandlers removeObject:handler]; +} + +- (void)removeResponseHandler:(id)handler +{ + [_responseHandlers removeObject:handler]; +} + - (RCTNetworkTask *)networkTaskWithRequest:(NSURLRequest *)request completionBlock:(RCTURLRequestCompletionBlock)completionBlock { id handler = [self handlerForRequest:request]; diff --git a/Libraries/Network/XMLHttpRequest.js b/Libraries/Network/XMLHttpRequest.js index be5905ff07102a..082aaa8932fe23 100644 --- a/Libraries/Network/XMLHttpRequest.js +++ b/Libraries/Network/XMLHttpRequest.js @@ -23,9 +23,11 @@ const invariant = require('fbjs/lib/invariant'); * found when Flow v0.54 was deployed. To see the error delete this comment and * run Flow. */ const warning = require('fbjs/lib/warning'); +const BlobManager = require('BlobManager'); -type ResponseType = '' | 'arraybuffer' | 'blob' | 'document' | 'json' | 'text'; -type Response = ?Object | string; +export type NativeResponseType = 'base64' | 'blob' | 'text'; +export type ResponseType = '' | 'arraybuffer' | 'blob' | 'document' | 'json' | 'text'; +export type Response = ?Object | string; type XHRInterceptor = { requestSent( @@ -54,6 +56,11 @@ type XHRInterceptor = { ): void, }; +// The native blob module is optional so inject it here if available. +if (BlobManager.isAvailable) { + BlobManager.addNetworkingHandler(); +} + const UNSENT = 0; const OPENED = 1; const HEADERS_RECEIVED = 2; @@ -200,6 +207,10 @@ class XMLHttpRequest extends EventTarget(...XHR_EVENTS) { SUPPORTED_RESPONSE_TYPES[responseType] || responseType === 'document', `The provided value '${responseType}' is unsupported in this environment.` ); + + if (responseType === 'blob') { + invariant(BlobManager.isAvailable, 'Native module BlobModule is required for blob support'); + } this._responseType = responseType; } @@ -242,10 +253,11 @@ class XMLHttpRequest extends EventTarget(...XHR_EVENTS) { break; case 'blob': - this._cachedResponse = new global.Blob( - [base64.toByteArray(this._response).buffer], - {type: this.getResponseHeader('content-type') || ''} - ); + if (typeof this._response === 'object' && this._response) { + this._cachedResponse = BlobManager.createFromOptions(this._response); + } else { + throw new Error(`Invalid response for blob: ${this._response}`); + } break; case 'json': @@ -493,10 +505,13 @@ class XMLHttpRequest extends EventTarget(...XHR_EVENTS) { (args) => this.__didCompleteResponse(...args) )); - let nativeResponseType = 'text'; - if (this._responseType === 'arraybuffer' || this._responseType === 'blob') { + let nativeResponseType: NativeResponseType = 'text'; + if (this._responseType === 'arraybuffer') { nativeResponseType = 'base64'; } + if (this._responseType === 'blob') { + nativeResponseType = 'blob'; + } invariant(this._method, 'Request method needs to be defined.'); invariant(this._url, 'Request URL needs to be defined.'); diff --git a/Libraries/Network/convertRequestBody.js b/Libraries/Network/convertRequestBody.js index e82689692bf1ef..9a826d46e2c603 100644 --- a/Libraries/Network/convertRequestBody.js +++ b/Libraries/Network/convertRequestBody.js @@ -8,25 +8,30 @@ * * @providesModule convertRequestBody * @flow + * @format */ 'use strict'; const binaryToBase64 = require('binaryToBase64'); +const Blob = require('Blob'); const FormData = require('FormData'); export type RequestBody = - string + | string + | Blob | FormData | {uri: string} | ArrayBuffer - | $ArrayBufferView - ; + | $ArrayBufferView; function convertRequestBody(body: RequestBody): Object { if (typeof body === 'string') { return {string: body}; } + if (body instanceof Blob) { + return {blob: body.data}; + } if (body instanceof FormData) { return {formData: body.getParts()}; } diff --git a/Libraries/WebSocket/RCTWebSocketModule.h b/Libraries/WebSocket/RCTWebSocketModule.h index b2012bc4afbdde..2875d0eb3a9f3d 100644 --- a/Libraries/WebSocket/RCTWebSocketModule.h +++ b/Libraries/WebSocket/RCTWebSocketModule.h @@ -13,8 +13,9 @@ NS_ASSUME_NONNULL_BEGIN @protocol RCTWebSocketContentHandler -- (id)processMessage:(id __nullable)message forSocketID:(NSNumber *)socketID - withType:(NSString *__nonnull __autoreleasing *__nonnull)type; +- (id)processWebsocketMessage:(id __nullable)message + forSocketID:(NSNumber *)socketID + withType:(NSString *__nonnull __autoreleasing *__nonnull)type; @end diff --git a/Libraries/WebSocket/RCTWebSocketModule.m b/Libraries/WebSocket/RCTWebSocketModule.m index 1feb4b97a524ee..90d6180c124f8d 100644 --- a/Libraries/WebSocket/RCTWebSocketModule.m +++ b/Libraries/WebSocket/RCTWebSocketModule.m @@ -37,7 +37,7 @@ @interface RCTWebSocketModule () @implementation RCTWebSocketModule { NSMutableDictionary *_sockets; - NSMutableDictionary *_contentHandlers; + NSMutableDictionary> *_contentHandlers; } RCT_EXPORT_MODULE() @@ -53,8 +53,9 @@ - (NSArray *)supportedEvents @"websocketClosed"]; } -- (void)dealloc +- (void)invalidate { + _contentHandlers = nil; for (RCTSRWebSocket *socket in _sockets.allValues) { socket.delegate = nil; [socket close]; @@ -135,7 +136,7 @@ - (void)webSocket:(RCTSRWebSocket *)webSocket didReceiveMessage:(id)message NSNumber *socketID = [webSocket reactTag]; id contentHandler = _contentHandlers[socketID]; if (contentHandler) { - message = [contentHandler processMessage:message forSocketID:socketID withType:&type]; + message = [contentHandler processWebsocketMessage:message forSocketID:socketID withType:&type]; } else { if ([message isKindOfClass:[NSData class]]) { type = @"binary"; diff --git a/Libraries/WebSocket/WebSocket.js b/Libraries/WebSocket/WebSocket.js index 463b4c607d8fb4..3a70b3792803d5 100644 --- a/Libraries/WebSocket/WebSocket.js +++ b/Libraries/WebSocket/WebSocket.js @@ -14,6 +14,7 @@ const Blob = require('Blob'); const EventTarget = require('event-target-shim'); const NativeEventEmitter = require('NativeEventEmitter'); +const BlobManager = require('BlobManager'); const NativeModules = require('NativeModules'); const Platform = require('Platform'); const WebSocketEvent = require('WebSocketEvent'); @@ -147,19 +148,20 @@ class WebSocket extends EventTarget(...WEBSOCKET_EVENTS) { throw new Error('binaryType must be either \'blob\' or \'arraybuffer\''); } if (this._binaryType === 'blob' || binaryType === 'blob') { - const BlobModule = NativeModules.BlobModule; - invariant(BlobModule, 'Native module BlobModule is required for blob support'); - if (BlobModule) { - if (binaryType === 'blob') { - BlobModule.enableBlobSupport(this._socketId); - } else { - BlobModule.disableBlobSupport(this._socketId); - } + invariant(BlobManager.isAvailable, 'Native module BlobModule is required for blob support'); + if (binaryType === 'blob') { + BlobManager.addWebSocketHandler(this._socketId); + } else { + BlobManager.removeWebSocketHandler(this._socketId); } } this._binaryType = binaryType; } + get binaryType(): ?BinaryType { + return this._binaryType; + } + close(code?: number, reason?: string): void { if (this.readyState === this.CLOSING || this.readyState === this.CLOSED) { @@ -176,9 +178,8 @@ class WebSocket extends EventTarget(...WEBSOCKET_EVENTS) { } if (data instanceof Blob) { - const BlobModule = NativeModules.BlobModule; - invariant(BlobModule, 'Native module BlobModule is required for blob support'); - BlobModule.sendBlob(data, this._socketId); + invariant(BlobManager.isAvailable, 'Native module BlobModule is required for blob support'); + BlobManager.sendOverSocket(data, this._socketId); return; } @@ -212,6 +213,10 @@ class WebSocket extends EventTarget(...WEBSOCKET_EVENTS) { } else { WebSocketModule.close(this._socketId); } + + if (BlobManager.isAvailable && this._binaryType === 'blob') { + BlobManager.removeWebSocketHandler(this._socketId); + } } _unregisterEvents(): void { @@ -231,7 +236,7 @@ class WebSocket extends EventTarget(...WEBSOCKET_EVENTS) { data = base64.toByteArray(ev.data).buffer; break; case 'blob': - data = Blob.create(ev.data); + data = BlobManager.createFromOptions(ev.data); break; } this.dispatchEvent(new WebSocketEvent('message', { data })); diff --git a/RNTester/RNTester.xcodeproj/project.pbxproj b/RNTester/RNTester.xcodeproj/project.pbxproj index 8002102adb3cbd..84c137c7e40cf9 100644 --- a/RNTester/RNTester.xcodeproj/project.pbxproj +++ b/RNTester/RNTester.xcodeproj/project.pbxproj @@ -53,6 +53,8 @@ 192F69B91E82409A008692C7 /* RCTConvert_YGValueTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 192F69B61E82409A008692C7 /* RCTConvert_YGValueTests.m */; }; 192F69BA1E82409A008692C7 /* RCTNativeAnimatedNodesManagerTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 192F69B71E82409A008692C7 /* RCTNativeAnimatedNodesManagerTests.m */; }; 192F69DA1E8240E2008692C7 /* libRCTAnimation.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 13E501A31D07A502005F35D8 /* libRCTAnimation.a */; }; + 19BA88D51F84344F00741C5A /* RCTBlobManagerTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 19BA88D41F84344F00741C5A /* RCTBlobManagerTests.m */; }; + 19BA89031F8439A700741C5A /* libRCTBlob.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5281CA511EEAC9A700AC40CD /* libRCTBlob.a */; }; 272E6B3F1BEA849E001FCF37 /* UpdatePropertiesExampleView.m in Sources */ = {isa = PBXBuildFile; fileRef = 272E6B3C1BEA849E001FCF37 /* UpdatePropertiesExampleView.m */; }; 27B885561BED29AF00008352 /* RCTRootViewIntegrationTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 27B885551BED29AF00008352 /* RCTRootViewIntegrationTests.m */; }; 27F441EC1BEBE5030039B79C /* FlexibleSizeExampleView.m in Sources */ = {isa = PBXBuildFile; fileRef = 27F441E81BEBE5030039B79C /* FlexibleSizeExampleView.m */; }; @@ -515,6 +517,7 @@ 192F69B51E82409A008692C7 /* RCTAnimationUtilsTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTAnimationUtilsTests.m; sourceTree = ""; }; 192F69B61E82409A008692C7 /* RCTConvert_YGValueTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTConvert_YGValueTests.m; sourceTree = ""; }; 192F69B71E82409A008692C7 /* RCTNativeAnimatedNodesManagerTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTNativeAnimatedNodesManagerTests.m; sourceTree = ""; }; + 19BA88D41F84344F00741C5A /* RCTBlobManagerTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RCTBlobManagerTests.m; sourceTree = ""; }; 272E6B3B1BEA849E001FCF37 /* UpdatePropertiesExampleView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = UpdatePropertiesExampleView.h; path = RNTester/NativeExampleViews/UpdatePropertiesExampleView.h; sourceTree = ""; }; 272E6B3C1BEA849E001FCF37 /* UpdatePropertiesExampleView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = UpdatePropertiesExampleView.m; path = RNTester/NativeExampleViews/UpdatePropertiesExampleView.m; sourceTree = ""; }; 27B885551BED29AF00008352 /* RCTRootViewIntegrationTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTRootViewIntegrationTests.m; sourceTree = ""; }; @@ -553,6 +556,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 19BA89031F8439A700741C5A /* libRCTBlob.a in Frameworks */, 192F69DA1E8240E2008692C7 /* libRCTAnimation.a in Frameworks */, 14D6D71E1B2222EF001FB087 /* libRCTActionSheet.a in Frameworks */, 14D6D7201B2222EF001FB087 /* libRCTGeolocation.a in Frameworks */, @@ -767,6 +771,7 @@ isa = PBXGroup; children = ( 192F69B51E82409A008692C7 /* RCTAnimationUtilsTests.m */, + 19BA88D41F84344F00741C5A /* RCTBlobManagerTests.m */, 192F69B61E82409A008692C7 /* RCTConvert_YGValueTests.m */, 192F69B71E82409A008692C7 /* RCTNativeAnimatedNodesManagerTests.m */, 13B6C1A21C34225900D3FAF5 /* RCTURLUtilsTests.m */, @@ -884,6 +889,13 @@ name = Products; sourceTree = ""; }; + 19BA89021F8439A700741C5A /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; 272E6B3A1BEA846C001FCF37 /* NativeExampleViews */ = { isa = PBXGroup; children = ( @@ -1683,6 +1695,7 @@ 8385CF041B87479200C6273E /* RCTImageLoaderHelpers.m in Sources */, 192F69B91E82409A008692C7 /* RCTConvert_YGValueTests.m in Sources */, BC9C03401DC9F1D600B1C635 /* RCTDevMenuTests.m in Sources */, + 19BA88D51F84344F00741C5A /* RCTBlobManagerTests.m in Sources */, 68FF44381CF6111500720EFD /* RCTBundleURLProviderTests.m in Sources */, 8385CEF51B873B5C00C6273E /* RCTImageLoaderTests.m in Sources */, ); diff --git a/RNTester/RNTesterLegacy.xcodeproj/project.pbxproj b/RNTester/RNTesterLegacy.xcodeproj/project.pbxproj index e6bad8cd2973af..206f0a5dac51a6 100644 --- a/RNTester/RNTesterLegacy.xcodeproj/project.pbxproj +++ b/RNTester/RNTesterLegacy.xcodeproj/project.pbxproj @@ -97,6 +97,7 @@ 2DD323E71DA2DE3F000FE1B8 /* libRCTSettings-tvOS.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 2DD323C81DA2DD8B000FE1B8 /* libRCTSettings-tvOS.a */; }; 2DD323E81DA2DE3F000FE1B8 /* libRCTText-tvOS.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 2DD323D01DA2DD8B000FE1B8 /* libRCTText-tvOS.a */; }; 2DD323E91DA2DE3F000FE1B8 /* libRCTWebSocket-tvOS.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 2DD323D51DA2DD8B000FE1B8 /* libRCTWebSocket-tvOS.a */; }; + 2DD323EA1DA2DE3F000FE1B8 /* libReact.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 2DD323D91DA2DD8B000FE1B8 /* libReact.a */; }; 3578590A1B28D2CF00341EDB /* libRCTLinking.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 357859011B28D2C500341EDB /* libRCTLinking.a */; }; 39AA31A41DC1DFDC000F7EBB /* RCTUnicodeDecodeTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 39AA31A31DC1DFDC000F7EBB /* RCTUnicodeDecodeTests.m */; }; 3D05746D1DE6008900184BB4 /* libRCTPushNotification-tvOS.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 3D05746C1DE6008900184BB4 /* libRCTPushNotification-tvOS.a */; }; @@ -117,6 +118,9 @@ 83636F8F1B53F22C009F943E /* RCTUIManagerScenarioTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 83636F8E1B53F22C009F943E /* RCTUIManagerScenarioTests.m */; }; 8385CEF51B873B5C00C6273E /* RCTImageLoaderTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 8385CEF41B873B5C00C6273E /* RCTImageLoaderTests.m */; }; 8385CF041B87479200C6273E /* RCTImageLoaderHelpers.m in Sources */ = {isa = PBXBuildFile; fileRef = 8385CF031B87479200C6273E /* RCTImageLoaderHelpers.m */; }; + ADAC7A091E093BB900D77272 /* libRCTBlob.a in Frameworks */ = {isa = PBXBuildFile; fileRef = ADBDB9F11DFEC24500ED6528 /* libRCTBlob.a */; }; + ADBDBA0D1DFEC24D00ED6528 /* libRCTBlob.a in Frameworks */ = {isa = PBXBuildFile; fileRef = ADBDB9F11DFEC24500ED6528 /* libRCTBlob.a */; }; + ADD01A631E093FA900F6D226 /* libRCTBlob-tvOS.a in Frameworks */ = {isa = PBXBuildFile; fileRef = ADD01A471E093FA100F6D226 /* libRCTBlob-tvOS.a */; }; BC9C03401DC9F1D600B1C635 /* RCTDevMenuTests.m in Sources */ = {isa = PBXBuildFile; fileRef = BC9C033F1DC9F1D600B1C635 /* RCTDevMenuTests.m */; }; C654F14C1EB34D0C000B7A9A /* RNTesterTestModule.m in Sources */ = {isa = PBXBuildFile; fileRef = C654F14B1EB34D0C000B7A9A /* RNTesterTestModule.m */; }; C654F16E1EB34D14000B7A9A /* RNTesterTestModule.m in Sources */ = {isa = PBXBuildFile; fileRef = C654F14B1EB34D0C000B7A9A /* RNTesterTestModule.m */; }; @@ -383,6 +387,20 @@ remoteGlobalIDString = 134814201AA4EA6300B7C361; remoteInfo = RCTSettings; }; + ADBDB9F01DFEC24500ED6528 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = ADBDB9E81DFEC24500ED6528 /* RCTBlob.xcodeproj */; + proxyType = 2; + remoteGlobalIDString = 358F4ED71D1E81A9004DF814; + remoteInfo = RCTBlob; + }; + ADD01A461E093FA100F6D226 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = ADBDB9E81DFEC24500ED6528 /* RCTBlob.xcodeproj */; + proxyType = 2; + remoteGlobalIDString = ADAC7A2E1E093EF800D77272; + remoteInfo = "RCTBlob-tvOS"; + }; D85B829B1AB6D5CE003F4FE2 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = D85B82911AB6D5CE003F4FE2 /* RCTVibration.xcodeproj */; @@ -470,6 +488,7 @@ 8385CEF41B873B5C00C6273E /* RCTImageLoaderTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTImageLoaderTests.m; sourceTree = ""; }; 8385CF031B87479200C6273E /* RCTImageLoaderHelpers.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTImageLoaderHelpers.m; sourceTree = ""; }; 8385CF051B8747A000C6273E /* RCTImageLoaderHelpers.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RCTImageLoaderHelpers.h; sourceTree = ""; }; + ADBDB9E81DFEC24500ED6528 /* RCTBlob.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = RCTBlob.xcodeproj; path = ../../Libraries/Blob/RCTBlob.xcodeproj; sourceTree = ""; }; BC9C033F1DC9F1D600B1C635 /* RCTDevMenuTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTDevMenuTests.m; sourceTree = ""; }; C654F14B1EB34D0C000B7A9A /* RNTesterTestModule.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RNTesterTestModule.m; sourceTree = ""; }; D85B82911AB6D5CE003F4FE2 /* RCTVibration.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = RCTVibration.xcodeproj; path = ../Libraries/Vibration/RCTVibration.xcodeproj; sourceTree = ""; }; @@ -544,6 +563,7 @@ 2DD323E61DA2DE3F000FE1B8 /* libRCTNetwork-tvOS.a in Frameworks */, 3D05746D1DE6008900184BB4 /* libRCTPushNotification-tvOS.a in Frameworks */, 2DD323E71DA2DE3F000FE1B8 /* libRCTSettings-tvOS.a in Frameworks */, + ADD01A631E093FA900F6D226 /* libRCTBlob-tvOS.a in Frameworks */, 2DD323E81DA2DE3F000FE1B8 /* libRCTText-tvOS.a in Frameworks */, 2DD323E91DA2DE3F000FE1B8 /* libRCTWebSocket-tvOS.a in Frameworks */, ); @@ -755,6 +775,21 @@ name = Products; sourceTree = ""; }; + 14AADF001AC3DB95002390C9 /* Products */ = { + isa = PBXGroup; + children = ( + 14AADF041AC3DB95002390C9 /* libReact.a */, + 2DD323D91DA2DD8B000FE1B8 /* libReact.a */, + 3D3C08811DE3424E00C268FA /* libyoga.a */, + 3D3C08831DE3424E00C268FA /* libyoga.a */, + 3D05748C1DE6008900184BB4 /* libcxxreact.a */, + 3D05748E1DE6008900184BB4 /* libcxxreact.a */, + 3D0574901DE6008900184BB4 /* libjschelpers.a */, + 3D0574921DE6008900184BB4 /* libjschelpers.a */, + ); + name = Products; + sourceTree = ""; + }; 14D6D6EA1B2205C0001FB087 /* OCMock */ = { isa = PBXGroup; children = ( @@ -892,6 +927,15 @@ name = Products; sourceTree = ""; }; + ADBDB9E91DFEC24500ED6528 /* Products */ = { + isa = PBXGroup; + children = ( + ADBDB9F11DFEC24500ED6528 /* libRCTBlob.a */, + ADD01A471E093FA100F6D226 /* libRCTBlob-tvOS.a */, + ); + name = Products; + sourceTree = ""; + }; D85B82921AB6D5CE003F4FE2 /* Products */ = { isa = PBXGroup; children = ( @@ -1082,6 +1126,10 @@ ProductGroup = 13E5019D1D07A502005F35D8 /* Products */; ProjectRef = 13E5019C1D07A502005F35D8 /* RCTAnimation.xcodeproj */; }, + { + ProductGroup = ADBDB9E91DFEC24500ED6528 /* Products */; + ProjectRef = ADBDB9E81DFEC24500ED6528 /* RCTBlob.xcodeproj */; + }, { ProductGroup = 138DEE031B9EDDDB007F4EA5 /* Products */; ProjectRef = 138DEE021B9EDDDB007F4EA5 /* RCTCameraRoll.xcodeproj */; @@ -1320,6 +1368,13 @@ remoteRef = 2DD323D41DA2DD8B000FE1B8 /* PBXContainerItemProxy */; sourceTree = BUILT_PRODUCTS_DIR; }; + 2DD323D91DA2DD8B000FE1B8 /* libReact.a */ = { + isa = PBXReferenceProxy; + fileType = archive.ar; + path = libReact.a; + remoteRef = 2DD323D81DA2DD8B000FE1B8 /* PBXContainerItemProxy */; + sourceTree = BUILT_PRODUCTS_DIR; + }; 357859011B28D2C500341EDB /* libRCTLinking.a */ = { isa = PBXReferenceProxy; fileType = archive.ar; @@ -1376,6 +1431,20 @@ remoteRef = 834C36D11AF8DA610019C93C /* PBXContainerItemProxy */; sourceTree = BUILT_PRODUCTS_DIR; }; + ADBDB9F11DFEC24500ED6528 /* libRCTBlob.a */ = { + isa = PBXReferenceProxy; + fileType = archive.ar; + path = libRCTBlob.a; + remoteRef = ADBDB9F01DFEC24500ED6528 /* PBXContainerItemProxy */; + sourceTree = BUILT_PRODUCTS_DIR; + }; + ADD01A471E093FA100F6D226 /* libRCTBlob-tvOS.a */ = { + isa = PBXReferenceProxy; + fileType = archive.ar; + path = "libRCTBlob-tvOS.a"; + remoteRef = ADD01A461E093FA100F6D226 /* PBXContainerItemProxy */; + sourceTree = BUILT_PRODUCTS_DIR; + }; D85B829C1AB6D5CE003F4FE2 /* libRCTVibration.a */ = { isa = PBXReferenceProxy; fileType = archive.ar; diff --git a/RNTester/RNTesterUnitTests/RCTBlobManagerTests.m b/RNTester/RNTesterUnitTests/RCTBlobManagerTests.m new file mode 100644 index 00000000000000..892a5f76f17d4e --- /dev/null +++ b/RNTester/RNTesterUnitTests/RCTBlobManagerTests.m @@ -0,0 +1,104 @@ +/** + * 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. + * + */ + +#import + +#import + +@interface RCTBlobManagerTests : XCTestCase + +@end + +@implementation RCTBlobManagerTests +{ + RCTBlobManager *_module; + NSMutableData *_data; + NSString *_blobId; +} + +- (void)setUp +{ + [super setUp]; + + _module = [RCTBlobManager new]; + [_module setValue:nil forKey:@"bridge"]; + NSInteger size = 120; + _data = [NSMutableData dataWithCapacity:size]; + for (NSInteger i = 0; i < size / 4; i++) { + uint32_t randomBits = arc4random(); + [_data appendBytes:(void *)&randomBits length:4]; + } + _blobId = [NSUUID UUID].UUIDString; + [_module store:_data withId:_blobId]; +} + +- (void)testResolve +{ + XCTAssertTrue([_data isEqualToData:[_module resolve:_blobId offset:0 size:_data.length]]); + NSData *rangeData = [_data subdataWithRange:NSMakeRange(30, _data.length - 30)]; + XCTAssertTrue([rangeData isEqualToData:[_module resolve:_blobId offset:30 size:_data.length - 30]]); +} + +- (void)testResolveMap +{ + NSDictionary *map = @{ + @"blobId": _blobId, + @"size": @(_data.length), + @"offset": @0, + }; + XCTAssertTrue([_data isEqualToData:[_module resolve:map]]); +} + +- (void)testResolveURL +{ + NSURLComponents *components = [NSURLComponents new]; + [components setPath:_blobId]; + [components setQuery:[NSString stringWithFormat:@"offset=0&size=%lu", (unsigned long)_data.length]]; + XCTAssertTrue([_data isEqualToData:[_module resolveURL:[components URL]]]); +} + +- (void)testRemove +{ + XCTAssertNotNil([_module resolve:_blobId offset:0 size:_data.length]); + [_module remove:_blobId]; + XCTAssertNil([_module resolve:_blobId offset:0 size:_data.length]); +} + +- (void)testCreateFromParts +{ + NSDictionary *blobData = @{ + @"blobId": _blobId, + @"offset": @0, + @"size": @(_data.length), + }; + NSDictionary *blob = @{ + @"data": blobData, + @"type": @"blob", + }; + NSString *stringData = @"i \u2665 dogs"; + NSDictionary *string = @{ + @"data": stringData, + @"type": @"string", + }; + NSString *resultId = [NSUUID UUID].UUIDString; + NSArray *parts = @[blob, string]; + + [_module createFromParts:parts withId:resultId]; + + NSMutableData *expectedData = [NSMutableData new]; + [expectedData appendData:_data]; + [expectedData appendData:[stringData dataUsingEncoding:NSUTF8StringEncoding]]; + + NSData *result = [_module resolve:resultId offset:0 size:expectedData.length]; + + XCTAssertTrue([expectedData isEqualToData:result]); +} + +@end diff --git a/React.podspec b/React.podspec index b0b946a4708aa2..dd7ed70bb21715 100644 --- a/React.podspec +++ b/React.podspec @@ -166,7 +166,7 @@ Pod::Spec.new do |s| s.subspec "RCTBlob" do |ss| ss.dependency "React/Core" - ss.source_files = "Libraries/Blob/*.{h,m}" + ss.source_files = "Libraries/Blob/*.{h,m,mm}" ss.preserve_paths = "Libraries/Blob/*.js" end diff --git a/ReactAndroid/src/main/java/com/facebook/react/modules/blob/BUCK b/ReactAndroid/src/main/java/com/facebook/react/modules/blob/BUCK index fc4b0159cec67c..d2f8490be8893d 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/modules/blob/BUCK +++ b/ReactAndroid/src/main/java/com/facebook/react/modules/blob/BUCK @@ -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"), ], ) diff --git a/ReactAndroid/src/main/java/com/facebook/react/modules/blob/BlobModule.java b/ReactAndroid/src/main/java/com/facebook/react/modules/blob/BlobModule.java index e213bb749dd75f..7e1c4954af8b7e 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/modules/blob/BlobModule.java +++ b/ReactAndroid/src/main/java/com/facebook/react/modules/blob/BlobModule.java @@ -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.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 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 void onMessage(ByteString bytes, WritableMap params) { - byte[] data = bytes.toByteArray(); + @Override + public WritableMap fetch(Uri uri) throws IOException { + byte[] data = getBytesFromUri(uri); - WritableMap blob = Arguments.createMap(); + WritableMap blob = Arguments.createMap(); + blob.putString("blobId", store(data)); + blob.putInt("offset", 0); + blob.putInt("size", data.length); + blob.putString("type", getMimeTypeFromUri(uri)); - blob.putString("blobId", store(data)); - blob.putInt("offset", 0); - blob.putInt("size", data.length); + // Needed for files + blob.putString("name", getNameFromUri(uri)); + blob.putDouble("lastModified", getLastModifiedFromUri(uri)); - params.putMap("data", blob); - params.putString("type", "blob"); + 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"); + } + 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 String getName() { } @Override - @Nullable - public Map getConstants() { + public @Nullable Map 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 Map getConstants() { return null; } - return MapBuilder.of( - "BLOB_URI_SCHEME", "content", "BLOB_URI_HOST", resources.getString(resourceId)); + return MapBuilder.of( + "BLOB_URI_SCHEME", "content", "BLOB_URI_HOST", resources.getString(resourceId)); } public String store(byte[] data) { @@ -96,8 +186,7 @@ public void remove(String blobId) { 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 byte[] resolve(Uri uri) { 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 byte[] resolve(String blobId, int offset, int size) { 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 addWebSocketHandler(final int id) { + getWebSocketModule().setContentHandler(id, mWebSocketContentHandler); } @ReactMethod - public void disableBlobSupport(final int id) { + 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 void sendBlob(ReadableMap blob, int id) { @ReactMethod public void createFromParts(ReadableArray parts, String blobId) { int totalBlobSize = 0; - ArrayList partList = new ArrayList<>(parts.size()); + ArrayList 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); } diff --git a/ReactAndroid/src/main/java/com/facebook/react/modules/blob/FileReaderModule.java b/ReactAndroid/src/main/java/com/facebook/react/modules/blob/FileReaderModule.java new file mode 100644 index 00000000000000..d8cd3c73e63e91 --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/modules/blob/FileReaderModule.java @@ -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); + } + } +} diff --git a/ReactAndroid/src/main/java/com/facebook/react/modules/network/NetworkingModule.java b/ReactAndroid/src/main/java/com/facebook/react/modules/network/NetworkingModule.java index fc7c7e18f64403..b94abebc0633c5 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/modules/network/NetworkingModule.java +++ b/ReactAndroid/src/main/java/com/facebook/react/modules/network/NetworkingModule.java @@ -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.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 @@ @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 mRequestIds; + private final List mRequestBodyHandlers = new ArrayList<>(); + private final List mUriHandlers = new ArrayList<>(); + private final List mResponseHandlers = new ArrayList<>(); private boolean mShuttingDown; /* package */ NetworkingModule( @@ -154,6 +203,34 @@ public void onCatalystInstanceDestroy() { 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 void sendRequest( 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 void onProgress(long bytesWritten, long contentLength, boolean done) { 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 void onResponse(Call call, Response response) throws IOException { 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")) { diff --git a/ReactAndroid/src/main/java/com/facebook/react/modules/network/ResponseUtil.java b/ReactAndroid/src/main/java/com/facebook/react/modules/network/ResponseUtil.java index 5b589e195b0ccc..cd5aabff0fdceb 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/modules/network/ResponseUtil.java +++ b/ReactAndroid/src/main/java/com/facebook/react/modules/network/ResponseUtil.java @@ -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 static void onDataReceived( 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, diff --git a/ReactAndroid/src/main/java/com/facebook/react/shell/MainReactPackage.java b/ReactAndroid/src/main/java/com/facebook/react/shell/MainReactPackage.java index 7605e21f989274..73b478ea030b03 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/shell/MainReactPackage.java +++ b/ReactAndroid/src/main/java/com/facebook/react/shell/MainReactPackage.java @@ -30,6 +30,7 @@ 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 NativeModule get() { return new BlobModule(context); } }), + ModuleSpec.nativeModuleSpec( + FileReaderModule.class, + new Provider() { + @Override + public NativeModule get() { + return new FileReaderModule(context); + } + }), ModuleSpec.nativeModuleSpec( AsyncStorageModule.class, new Provider() { diff --git a/ReactAndroid/src/test/java/com/facebook/react/modules/BUCK b/ReactAndroid/src/test/java/com/facebook/react/modules/BUCK index 6dbf90b6523bd7..0b76706fc61280 100644 --- a/ReactAndroid/src/test/java/com/facebook/react/modules/BUCK +++ b/ReactAndroid/src/test/java/com/facebook/react/modules/BUCK @@ -26,6 +26,7 @@ rn_robolectric_test( react_native_target("java/com/facebook/react/common/network:network"), react_native_target("java/com/facebook/react/devsupport:interfaces"), react_native_target("java/com/facebook/react/jstasks:jstasks"), + react_native_target("java/com/facebook/react/modules/blob:blob"), react_native_target("java/com/facebook/react/modules/camera:camera"), react_native_target("java/com/facebook/react/modules/clipboard:clipboard"), react_native_target("java/com/facebook/react/modules/common:common"), diff --git a/ReactAndroid/src/test/java/com/facebook/react/modules/blob/BlobModuleTest.java b/ReactAndroid/src/test/java/com/facebook/react/modules/blob/BlobModuleTest.java new file mode 100644 index 00000000000000..4339dcd5e4b3db --- /dev/null +++ b/ReactAndroid/src/test/java/com/facebook/react/modules/blob/BlobModuleTest.java @@ -0,0 +1,159 @@ +/** + * 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.net.Uri; + +import com.facebook.react.bridge.Arguments; +import com.facebook.react.bridge.JavaOnlyArray; +import com.facebook.react.bridge.JavaOnlyMap; +import com.facebook.react.bridge.ReactTestHelper; + +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mockito; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.stubbing.Answer; +import org.powermock.api.mockito.PowerMockito; +import org.powermock.core.classloader.annotations.PowerMockIgnore; +import org.powermock.core.classloader.annotations.PrepareForTest; +import org.powermock.modules.junit4.rule.PowerMockRule; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import java.nio.ByteBuffer; +import java.nio.charset.Charset; +import java.util.Arrays; +import java.util.Random; +import java.util.UUID; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + +@PrepareForTest({Arguments.class}) +@RunWith(RobolectricTestRunner.class) +@PowerMockIgnore({"org.mockito.*", "org.robolectric.*", "android.*"}) +@Config(manifest = Config.NONE) +public class BlobModuleTest { + + private byte[] mBytes; + private String mBlobId; + private BlobModule mBlobModule; + + @Rule + public PowerMockRule rule = new PowerMockRule(); + + @Before + public void prepareModules() throws Exception { + PowerMockito.mockStatic(Arguments.class); + Mockito.when(Arguments.createMap()).thenAnswer(new Answer() { + @Override + public Object answer(InvocationOnMock invocation) throws Throwable { + return new JavaOnlyMap(); + } + }); + + mBytes = new byte[120]; + new Random().nextBytes(mBytes); + + mBlobModule = new BlobModule(ReactTestHelper.createCatalystContextForTest()); + mBlobId = mBlobModule.store(mBytes); + } + + @After + public void cleanUp() { + mBlobModule.remove(mBlobId); + } + + @Test + public void testResolve() { + assertArrayEquals(mBytes, mBlobModule.resolve(mBlobId, 0, mBytes.length)); + byte[] expectedRange = Arrays.copyOfRange(mBytes, 30, mBytes.length); + assertArrayEquals(expectedRange, mBlobModule.resolve(mBlobId, 30, mBytes.length - 30)); + } + + @Test + public void testResolveUri() { + Uri uri = new Uri.Builder() + .appendPath(mBlobId) + .appendQueryParameter("offset", "0") + .appendQueryParameter("size", String.valueOf(mBytes.length)) + .build(); + + assertArrayEquals(mBytes, mBlobModule.resolve(uri)); + } + + @Test + public void testResolveMap() { + JavaOnlyMap blob = new JavaOnlyMap(); + blob.putString("blobId", mBlobId); + blob.putInt("offset", 0); + blob.putInt("size", mBytes.length); + + assertArrayEquals(mBytes, mBlobModule.resolve(blob)); + } + + @Test + public void testRemove() { + assertNotNull(mBlobModule.resolve(mBlobId, 0, mBytes.length)); + + mBlobModule.remove(mBlobId); + + assertNull(mBlobModule.resolve(mBlobId, 0, mBytes.length)); + } + + @Test + public void testCreateFromParts() { + String id = UUID.randomUUID().toString(); + + JavaOnlyMap blobData = new JavaOnlyMap(); + blobData.putString("blobId", mBlobId); + blobData.putInt("offset", 0); + blobData.putInt("size", mBytes.length); + JavaOnlyMap blob = new JavaOnlyMap(); + blob.putMap("data", blobData); + blob.putString("type", "blob"); + + String stringData = "i \u2665 dogs"; + byte[] stringBytes = stringData.getBytes(Charset.forName("UTF-8")); + JavaOnlyMap string = new JavaOnlyMap(); + string.putString("data", stringData); + string.putString("type", "string"); + + JavaOnlyArray parts = new JavaOnlyArray(); + parts.pushMap(blob); + parts.pushMap(string); + + mBlobModule.createFromParts(parts, id); + + int resultSize = mBytes.length + stringBytes.length; + + byte[] result = mBlobModule.resolve(id, 0, resultSize); + + ByteBuffer buffer = ByteBuffer.allocate(resultSize); + buffer.put(mBytes); + buffer.put(stringBytes); + + assertArrayEquals(result, buffer.array()); + } + + @Test + public void testRelease() { + assertNotNull(mBlobModule.resolve(mBlobId, 0, mBytes.length)); + + mBlobModule.release(mBlobId); + + assertNull(mBlobModule.resolve(mBlobId, 0, mBytes.length)); + } +}