diff --git a/android/src/main/java/io/invertase/firebase/firestore/FirestoreSerialize.java b/android/src/main/java/io/invertase/firebase/firestore/FirestoreSerialize.java index d35e44d5..90291179 100644 --- a/android/src/main/java/io/invertase/firebase/firestore/FirestoreSerialize.java +++ b/android/src/main/java/io/invertase/firebase/firestore/FirestoreSerialize.java @@ -1,14 +1,15 @@ package io.invertase.firebase.firestore; +import android.util.Base64; import android.util.Log; import com.facebook.react.bridge.Arguments; import com.facebook.react.bridge.ReadableArray; import com.facebook.react.bridge.ReadableMap; import com.facebook.react.bridge.ReadableMapKeySetIterator; -import com.facebook.react.bridge.ReadableType; import com.facebook.react.bridge.WritableArray; import com.facebook.react.bridge.WritableMap; +import com.google.firebase.firestore.Blob; import com.google.firebase.firestore.DocumentChange; import com.google.firebase.firestore.DocumentReference; import com.google.firebase.firestore.DocumentSnapshot; @@ -18,15 +19,11 @@ import com.google.firebase.firestore.FirebaseFirestore; import com.google.firebase.firestore.GeoPoint; import com.google.firebase.firestore.QuerySnapshot; -import java.text.DateFormat; -import java.text.ParseException; -import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.TimeZone; import io.invertase.firebase.Utils; @@ -214,6 +211,9 @@ public class FirestoreSerialize { } else if (value instanceof Date) { typeMap.putString("type", "date"); typeMap.putDouble("value", ((Date) value).getTime()); + } else if (value instanceof Blob) { + typeMap.putString("type", "blob"); + typeMap.putString("value", Base64.encodeToString(((Blob) value).toBytes(), Base64.NO_WRAP)); } else { Log.e(TAG, "buildTypeMap: Cannot convert object of type " + value.getClass()); typeMap.putString("type", "null"); @@ -266,6 +266,9 @@ public class FirestoreSerialize { } else if ("geopoint".equals(type)) { ReadableMap geoPoint = typeMap.getMap("value"); return new GeoPoint(geoPoint.getDouble("latitude"), geoPoint.getDouble("longitude")); + } else if ("blob".equals(type)) { + String base64String = typeMap.getString("value"); + return Blob.fromBytes(Base64.decode(base64String, Base64.NO_WRAP)); } else if ("date".equals(type)) { Double time = typeMap.getDouble("value"); return new Date(time.longValue()); diff --git a/bridge/e2e/firestore/blob.e2e.js b/bridge/e2e/firestore/blob.e2e.js new file mode 100644 index 00000000..380cdd64 --- /dev/null +++ b/bridge/e2e/firestore/blob.e2e.js @@ -0,0 +1,132 @@ +const testObject = { hello: 'world', testRunId }; +const testString = JSON.stringify(testObject); +const testBuffer = Buffer.from(testString); +const testBase64 = testBuffer.toString('base64'); + +const testObjectLarge = new Array(5000).fill(testObject); +const testStringLarge = JSON.stringify(testObjectLarge); +const testBufferLarge = Buffer.from(testStringLarge); +const testBase64Large = testBufferLarge.toString('base64'); + +// function sizeInKiloBytes(base64String) { +// return 4 * Math.ceil(base64String.length / 3) / 1000; +// } + +// console.log(sizeInKiloBytes(testBase64)); +// console.log(sizeInKiloBytes(testBase64Large)); + +/** ---------------- + * CLASS TESTS + * -----------------*/ +describe('firestore', () => { + it('should export Blob class on statics', async () => { + const { Blob } = firebase.firestore; + should.exist(Blob); + }); + + describe('Blob', () => { + it('.constructor() -> returns new instance of Blob', async () => { + const { Blob } = firebase.firestore; + const myBlob = new Blob(testStringLarge); + myBlob.should.be.instanceOf(Blob); + myBlob._binaryString.should.equal(testStringLarge); + myBlob.toBase64().should.equal(testBase64Large); + }); + + it('.fromBase64String() -> returns new instance of Blob', async () => { + const { Blob } = firebase.firestore; + const myBlob = Blob.fromBase64String(testBase64); + myBlob.should.be.instanceOf(Blob); + myBlob._binaryString.should.equal(testString); + should.deepEqual( + JSON.parse(myBlob._binaryString), + testObject, + 'Expected Blob _binaryString internals to serialize to json and match test object' + ); + }); + + it('.fromUint8Array() -> returns new instance of Blob', async () => { + const testUInt8Array = new Uint8Array(testBuffer); + const { Blob } = firebase.firestore; + const myBlob = Blob.fromUint8Array(testUInt8Array); + myBlob.should.be.instanceOf(Blob); + const json = JSON.parse(myBlob._binaryString); + json.hello.should.equal('world'); + }); + }); + + describe('Blob instance', () => { + it('.toString() -> returns string representation of blob instance', async () => { + const { Blob } = firebase.firestore; + const myBlob = Blob.fromBase64String(testBase64); + myBlob.should.be.instanceOf(Blob); + should.equal( + myBlob.toString().includes(testBase64), + true, + 'toString() should return a string that includes the base64' + ); + }); + + it('.toBase64() -> returns base64 string', async () => { + const { Blob } = firebase.firestore; + const myBlob = Blob.fromBase64String(testBase64); + myBlob.should.be.instanceOf(Blob); + myBlob.toBase64().should.equal(testBase64); + }); + + it('.toUint8Array() -> returns Uint8Array', async () => { + const { Blob } = firebase.firestore; + const myBlob = Blob.fromBase64String(testBase64); + const testUInt8Array = new Uint8Array(testBuffer); + const testUInt8Array2 = new Uint8Array(); + + myBlob.should.be.instanceOf(Blob); + should.deepEqual(myBlob.toUint8Array(), testUInt8Array); + should.notDeepEqual(myBlob.toUint8Array(), testUInt8Array2); + }); + }); +}); + +/** ---------------- + * USAGE TESTS + * -----------------*/ + +describe('firestore', () => { + describe('Blob', () => { + it('reads and writes small blobs', async () => { + const { Blob } = firebase.firestore; + + await firebase + .firestore() + .doc('blob-tests/small') + .set({ blobby: Blob.fromBase64String(testBase64) }); + + const snapshot = await firebase + .firestore() + .doc('blob-tests/small') + .get(); + + const blob = snapshot.data().blobby; + blob._binaryString.should.equal(testString); + blob.toBase64().should.equal(testBase64); + }); + + it('reads and writes large blobs', async () => { + const { Blob } = firebase.firestore; + + await firebase + .firestore() + .doc('blob-tests/large') + .set({ blobby: Blob.fromBase64String(testBase64Large) }); + + const snapshot = await firebase + .firestore() + .doc('blob-tests/large') + .get(); + + const blob = snapshot.data().blobby; + blob._binaryString.should.equal(testStringLarge); + blob.toBase64().should.equal(testBase64Large); + }); + }); +}); diff --git a/bridge/helpers/index.js b/bridge/helpers/index.js index fc08ea22..bc8dfabc 100644 --- a/bridge/helpers/index.js +++ b/bridge/helpers/index.js @@ -9,6 +9,38 @@ Object.defineProperty(global, 'firebase', { }, }); +// TODO move as part of bridge +const { Uint8Array } = global; +Object.defineProperty(global, 'Uint8Array', { + get() { + const { stack } = new Error(); + if ( + (stack.includes('Context.it') || stack.includes('Context.beforeEach')) && + global.bridge && + global.bridge.context + ) { + return bridge.context.window.Uint8Array; + } + return Uint8Array; + }, +}); + +// TODO move as part of bridge +const { Array } = global; +Object.defineProperty(global, 'Array', { + get() { + const { stack } = new Error(); + if ( + (stack.includes('Context.it') || stack.includes('Context.beforeEach')) && + global.bridge && + global.bridge.context + ) { + return bridge.context.window.Array; + } + return Array; + }, +}); + global.isObject = function isObject(item) { return item ? typeof item === 'object' && !Array.isArray(item) && item !== null @@ -30,11 +62,17 @@ global.randomString = (length, chars) => { } return result; }; - -global.firebaseAdmin = require('firebase-admin'); - global.testRunId = randomString(4, 'aA#'); +/** ------------------ + * Init WEB SDK + ---------------------*/ + +/** ------------------ + * Init ADMIN SDK + ---------------------*/ +global.firebaseAdmin = require('firebase-admin'); + firebaseAdmin.initializeApp({ credential: firebaseAdmin.credential.cert(require('./service-account')), databaseURL: 'https://rnfirebase-b9ad4.firebaseio.com', diff --git a/ios/RNFirebase/firestore/RNFirebaseFirestoreDocumentReference.m b/ios/RNFirebase/firestore/RNFirebaseFirestoreDocumentReference.m index 6fe78759..49646047 100644 --- a/ios/RNFirebase/firestore/RNFirebaseFirestoreDocumentReference.m +++ b/ios/RNFirebase/firestore/RNFirebaseFirestoreDocumentReference.m @@ -208,6 +208,10 @@ static NSMutableDictionary *_listeners; typeMap[@"type"] = @"number"; } typeMap[@"value"] = value; + } else if ([value isKindOfClass:[NSData class]]) { + typeMap[@"type"] = @"blob"; + NSData *blob = (NSData *)value; + typeMap[@"value"] = [blob base64EncodedStringWithOptions:0]; } else { // TODO: Log an error typeMap[@"type"] = @"null"; @@ -248,6 +252,8 @@ static NSMutableDictionary *_listeners; return [RNFirebaseFirestoreDocumentReference parseJSMap:firestore jsMap:value]; } else if ([type isEqualToString:@"reference"]) { return [firestore documentWithPath:value]; + } else if ([type isEqualToString:@"blob"]) { + return [[NSData alloc] initWithBase64EncodedString:(NSString *) value options:0]; } else if ([type isEqualToString:@"geopoint"]) { NSDictionary *geopoint = (NSDictionary*)value; NSNumber *latitude = geopoint[@"latitude"]; diff --git a/lib/modules/firestore/Blob.js b/lib/modules/firestore/Blob.js new file mode 100644 index 00000000..3d8149e9 --- /dev/null +++ b/lib/modules/firestore/Blob.js @@ -0,0 +1,85 @@ +import Base64 from './utils/Base64'; + +export default class Blob { + _binaryString: string; + + constructor(binaryString) { + this._binaryString = binaryString; + } + + /** + * Creates a new Blob from the given Base64 string + * + * @url https://firebase.google.com/docs/reference/js/firebase.firestore.Blob#.fromBase64String + * @param base64 string + */ + static fromBase64String(base64: string): Blob { + return new Blob(Base64.atob(base64)); + } + + /** + * Creates a new Blob from the given Uint8Array. + * + * @url https://firebase.google.com/docs/reference/js/firebase.firestore.Blob#.fromUint8Array + * @param array Array + */ + static fromUint8Array(array: Uint8Array): Blob { + if (!(array instanceof Uint8Array)) { + throw new Error( + 'firestore.Blob.fromUint8Array expects an instance of Uint8Array' + ); + } + + return new Blob( + Array.prototype.map + .call(array, (char: number) => String.fromCharCode(char)) + .join('') + ); + } + + /** + * Returns 'true' if this Blob is equal to the provided one. + * @url https://firebase.google.com/docs/reference/js/firebase.firestore.Blob#isEqual + * @param {*} blob Blob The Blob to compare against. Value must not be null. + * @returns boolean 'true' if this Blob is equal to the provided one. + */ + isEqual(blob: Blob): boolean { + if (!(blob instanceof Blob)) { + throw new Error('firestore.Blob.isEqual expects an instance of Blob'); + } + + return this._binaryString === blob._binaryString; + } + + /** + * Returns the bytes of a Blob as a Base64-encoded string. + * + * @url https://firebase.google.com/docs/reference/js/firebase.firestore.Blob#toBase64 + * @returns string The Base64-encoded string created from the Blob object. + */ + toBase64(): string { + return Base64.btoa(this._binaryString); + } + + /** + * Returns the bytes of a Blob in a new Uint8Array. + * + * @url https://firebase.google.com/docs/reference/js/firebase.firestore.Blob#toUint8Array + * @returns non-null Uint8Array The Uint8Array created from the Blob object. + */ + toUint8Array(): Uint8Array { + return new Uint8Array( + this._binaryString.split('').map(c => c.charCodeAt(0)) + ); + } + + /** + * Returns a string representation of this blob instance + * + * @returns {string} + * @memberof Blob + */ + toString(): string { + return `firestore.Blob(base64: ${this.toBase64()})`; + } +} diff --git a/lib/modules/firestore/index.js b/lib/modules/firestore/index.js index d0e6d60e..80fe4678 100644 --- a/lib/modules/firestore/index.js +++ b/lib/modules/firestore/index.js @@ -11,6 +11,7 @@ import DocumentReference from './DocumentReference'; import FieldPath from './FieldPath'; import FieldValue from './FieldValue'; import GeoPoint from './GeoPoint'; +import Blob from './Blob'; import Path from './Path'; import WriteBatch from './WriteBatch'; import TransactionHandler from './TransactionHandler'; @@ -258,6 +259,7 @@ export const statics = { FieldPath, FieldValue, GeoPoint, + Blob, enableLogging(enabled: boolean): void { // DEPRECATED: Remove method in v4.1.0 console.warn( diff --git a/lib/modules/firestore/types.js b/lib/modules/firestore/types.js index 6ed935dc..3dd1f4a6 100644 --- a/lib/modules/firestore/types.js +++ b/lib/modules/firestore/types.js @@ -42,6 +42,7 @@ export type NativeTypeMap = { | 'array' | 'boolean' | 'date' + | 'blob' | 'documentid' | 'fieldvalue' | 'geopoint' diff --git a/lib/modules/firestore/utils/Base64.js b/lib/modules/firestore/utils/Base64.js new file mode 100644 index 00000000..a9ef56a2 --- /dev/null +++ b/lib/modules/firestore/utils/Base64.js @@ -0,0 +1,68 @@ +// @flow +/* eslint-disable */ + +const CHARS = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='; + +export default { + /** + * window.btoa + */ + btoa(input: string = ''): string { + let map; + let i = 0; + let block = 0; + let output = ''; + + // eslint-disable-next-line + for ( + block = 0, i = 0, map = CHARS; + input.charAt(i | 0) || ((map = '='), i % 1); + output += map.charAt(63 & (block >> (8 - (i % 1) * 8))) + ) { + const charCode = input.charCodeAt((i += 3 / 4)); + + if (charCode > 0xff) { + throw new Error( + "'firestore.utils.btoa' failed: The string to be encoded contains characters outside of the Latin1 range." + ); + } + + block = (block << 8) | charCode; + } + + return output; + }, + + /** + * window.atob + */ + atob(input: string = ''): string { + let i = 0; + let bc = 0; + let bs = 0; + let buffer; + let output = ''; + + const str = input.replace(/=+$/, ''); + + if (str.length % 4 === 1) { + throw new Error( + "'firestore.utils.atob' failed: The string to be decoded is not correctly encoded." + ); + } + + // eslint-disable-next-line + for ( + bc = 0, bs = 0, i = 0; + (buffer = str.charAt(i++)); + ~buffer && ((bs = bc % 4 ? bs * 64 + buffer : buffer), bc++ % 4) + ? (output += String.fromCharCode(255 & (bs >> ((-2 * bc) & 6)))) + : 0 + ) { + buffer = CHARS.indexOf(buffer); + } + + return output; + }, +}; diff --git a/lib/modules/firestore/utils/serialize.js b/lib/modules/firestore/utils/serialize.js index 8ea8a5d7..79b0ad0c 100644 --- a/lib/modules/firestore/utils/serialize.js +++ b/lib/modules/firestore/utils/serialize.js @@ -3,6 +3,7 @@ */ import DocumentReference from '../DocumentReference'; +import Blob from '../Blob'; import { DOCUMENT_ID } from '../FieldPath'; import { DELETE_FIELD_VALUE, @@ -98,6 +99,11 @@ export const buildTypeMap = (value: any): NativeTypeMap | null => { type: 'date', value: value.getTime(), }; + } else if (value instanceof Blob) { + return { + type: 'blob', + value: value.toBase64(), + }; } return { type: 'object', @@ -156,6 +162,8 @@ const parseTypeMap = (firestore: Firestore, typeMap: NativeTypeMap): any => { return new GeoPoint(value.latitude, value.longitude); } else if (type === 'date') { return new Date(value); + } else if (type === 'blob') { + return Blob.fromBase64String(value); } console.warn(`Unknown data type received ${type}`); return value;