diff --git a/packages/storage/android/src/main/java/io/invertase/firebase/storage/ReactNativeFirebaseStorageCommon.java b/packages/storage/android/src/main/java/io/invertase/firebase/storage/ReactNativeFirebaseStorageCommon.java index d988c85e..021832ac 100644 --- a/packages/storage/android/src/main/java/io/invertase/firebase/storage/ReactNativeFirebaseStorageCommon.java +++ b/packages/storage/android/src/main/java/io/invertase/firebase/storage/ReactNativeFirebaseStorageCommon.java @@ -27,9 +27,11 @@ import android.webkit.MimeTypeMap; import com.facebook.react.bridge.Arguments; import com.facebook.react.bridge.Promise; import com.facebook.react.bridge.ReadableMap; +import com.facebook.react.bridge.WritableArray; import com.facebook.react.bridge.WritableMap; import com.google.firebase.storage.StorageException; import com.google.firebase.storage.StorageMetadata; +import com.google.firebase.storage.StorageReference; import com.google.firebase.storage.StorageTask; import java.util.Map; @@ -194,6 +196,27 @@ class ReactNativeFirebaseStorageCommon { return metadata; } + static WritableMap getListResultAsMap(ListResult listResult) { + WritableMap map = Arguments.createMap(); + map.putString("nextPageToken", listResult.getPageToken()); + + WritableArray items = Arguments.createArray(); + WritableArray prefixes = Arguments.createArray(); + + for (StorageReference reference : listResult.getItems()) { + items.pushString(reference.getPath()); + } + + for (StorageReference reference : listResult.getPrefixes()) { + prefixes.pushString(reference.getPath()); + } + + map.putArray("items", items); + map.putArray("prefixes", prefixes); + + return map; + } + static String[] getExceptionCodeAndMessage(@Nullable Exception exception) { String code = STATUS_UNKNOWN; String message = "An unknown error has occurred."; diff --git a/packages/storage/android/src/main/java/io/invertase/firebase/storage/ReactNativeFirebaseStorageModule.java b/packages/storage/android/src/main/java/io/invertase/firebase/storage/ReactNativeFirebaseStorageModule.java index 5e4999d8..4c423783 100644 --- a/packages/storage/android/src/main/java/io/invertase/firebase/storage/ReactNativeFirebaseStorageModule.java +++ b/packages/storage/android/src/main/java/io/invertase/firebase/storage/ReactNativeFirebaseStorageModule.java @@ -22,10 +22,13 @@ import android.net.Uri; import android.os.Build; import android.os.Environment; +import com.facebook.react.bridge.Arguments; import com.facebook.react.bridge.Promise; import com.facebook.react.bridge.ReactApplicationContext; import com.facebook.react.bridge.ReactMethod; import com.facebook.react.bridge.ReadableMap; +import com.facebook.react.bridge.WritableArray; +import com.facebook.react.bridge.WritableMap; import com.google.android.gms.tasks.Task; import com.google.firebase.FirebaseApp; import com.google.firebase.storage.FirebaseStorage; @@ -41,6 +44,7 @@ import java.util.Objects; import io.invertase.firebase.common.ReactNativeFirebaseModule; import static io.invertase.firebase.storage.ReactNativeFirebaseStorageCommon.buildMetadataFromMap; +import static io.invertase.firebase.storage.ReactNativeFirebaseStorageCommon.getListResultAsMap; import static io.invertase.firebase.storage.ReactNativeFirebaseStorageCommon.getMetadataAsMap; import static io.invertase.firebase.storage.ReactNativeFirebaseStorageCommon.isExternalStorageWritable; import static io.invertase.firebase.storage.ReactNativeFirebaseStorageCommon.promiseRejectStorageException; @@ -113,6 +117,41 @@ public class ReactNativeFirebaseStorageModule extends ReactNativeFirebaseModule }); } + @ReactMethod + public void list(String appName, String url, ReadableMap listOptions, Promise promise) { + StorageReference reference = getReferenceFromUrl(url, appName); + Task list; + + int maxResults = listOptions.getInt("maxResults"); + + if (listOptions.hasKey("pageToken")) { + String pageToken = listOptions.getString("pageToken"); + list = reference.list(maxResults, pageToken); + } else { + list = reference.list(maxResults); + } + + list.addOnCompleteListener(getExecutor(), task -> { + if (task.isSuccessful()) { + promise.resolve(getListResultAsMap(task.getResult())); + } else { + promiseRejectStorageException(promise, task.getException()); + } + }); + } + + @ReactMethod + public void listAll(String appName, String url, Promise promise) { + StorageReference reference = getReferenceFromUrl(url, appName); + reference.listAll().addOnCompleteListener(getExecutor(), task -> { + if (task.isSuccessful()) { + promise.resolve(getListResultAsMap(task.getResult())); + } else { + promiseRejectStorageException(promise, task.getException()); + } + }); + } + /** * @url https://firebase.google.com/docs/reference/js/firebase.storage.Reference#updateMetadata */ diff --git a/packages/storage/e2e/StorageReference.e2e.js b/packages/storage/e2e/StorageReference.e2e.js index 4ff3243a..b1ab60e6 100644 --- a/packages/storage/e2e/StorageReference.e2e.js +++ b/packages/storage/e2e/StorageReference.e2e.js @@ -221,6 +221,133 @@ describe('storage() -> StorageReference', () => { }); }); + describe('list', () => { + it('should return list results', async () => { + const storageReference = firebase.storage().ref('/'); + const result = await storageReference.list(); + + result.should.have.property('nextPageToken'); + + result.items.should.be.Array(); + result.items.length.should.be.greaterThan(0); + result.items.constructor.name.should.eql('StorageListResult'); + + result.prefixes.should.be.Array(); + result.prefixes.length.should.be.greaterThan(0); + result.prefixes.constructor.name.should.eql('StorageListResult'); + }); + + it('throws if options is not an object', () => { + try { + const storageReference = firebase.storage().ref('/'); + storageReference.list(123); + return Promise.reject(new Error('Did not throw')); + } catch (error) { + error.message.should.containEql("'options' expected an object value"); + return Promise.resolve(); + } + }); + + describe('maxResults', () => { + it('should limit with maxResults are passed', async () => { + const storageReference = firebase.storage().ref('/'); + const result = await storageReference.list({ + maxResults: 1, + }); + + result.nextPageToken.should.be.String(); + + result.items.should.be.Array(); + result.items.length.should.eql(1); + result.items.constructor.name.should.eql('StorageListResult'); + + result.prefixes.should.be.Array(); + // todo length? + }); + + it('throws if maxResults is not a number', () => { + try { + const storageReference = firebase.storage().ref('/'); + storageReference.list({ + maxResults: '123', + }); + return Promise.reject(new Error('Did not throw')); + } catch (error) { + error.message.should.containEql("'options.maxResults' expected a number value"); + return Promise.resolve(); + } + }); + + it('throws if maxResults is not a valid number', () => { + try { + const storageReference = firebase.storage().ref('/'); + storageReference.list({ + maxResults: 2000, + }); + return Promise.reject(new Error('Did not throw')); + } catch (error) { + error.message.should.containEql("''options.maxResults' expected a number value between 1-1000"); + return Promise.resolve(); + } + }); + }); + + describe('pageToken', () => { + it('throws if pageToken is not a string', () => { + try { + const storageReference = firebase.storage().ref('/'); + storageReference.list({ + pageToken: 123, + }); + return Promise.reject(new Error('Did not throw')); + } catch (error) { + error.message.should.containEql("'options.pageToken' expected a string value"); + return Promise.resolve(); + } + }); + + it('should return and use a page token', async () => { + const storageReference = firebase.storage().ref('/'); + const result1 = await storageReference.list({ + maxResults: 1, + }); + + const item1 = result1[0].path; + + const result2 = await storageReference.list({ + maxResults: 1, + pageToken: result1.nextPageToken, + }); + + const item2 = result2[0].path; + + if (item1 === item2) { + throw new Error("Expected item results to be different."); + } + }); + }); + + }); + + describe('listAll', () => { + it('should return all results', async () => { + const storageReference = firebase.storage().ref('/'); + const result = await storageReference.listAll(); + + should.eql(result.nextPageToken, null); + + result.items.should.be.Array(); + result.items.length.should.be.greaterThan(0); + result.items.constructor.name.should.eql('StorageListResult'); + + result.prefixes.should.be.Array(); + result.prefixes.length.should.be.greaterThan(0); + result.prefixes.constructor.name.should.eql('StorageListResult'); + }); + + + }); + describe('updateMetadata', () => { it('should return the updated metadata for a file', async () => { const storageReference = firebase.storage().ref('/writeOnly.jpeg'); diff --git a/packages/storage/lib/StorageListResult.js b/packages/storage/lib/StorageListResult.js new file mode 100644 index 00000000..7c951395 --- /dev/null +++ b/packages/storage/lib/StorageListResult.js @@ -0,0 +1,43 @@ +/* eslint-disable no-console */ +/* + * Copyright (c) 2016-present Invertase Limited & Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this library except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +// To avoid React Native require cycle warnings +let StorageReference = null; +export function provideStorageReferenceClass(storageReference) { + StorageReference = storageReference; +} + +export default class StorageListResult { + constructor(storage, nativeData) { + this._nextPageToken = nativeData.nextPageToken || null; + this._items = nativeData.items.map(path => new StorageReference(storage, path)); + this._prefixes = nativeData.items.map(path => new StorageReference(storage, path)); + } + + get items() { + return this._items; + } + + get nextPageToken() { + return this._nextPageToken; + } + + get prefixes() { + return this._prefixes; + } +} diff --git a/packages/storage/lib/StorageReference.js b/packages/storage/lib/StorageReference.js index 029d72c2..789a9c8e 100644 --- a/packages/storage/lib/StorageReference.js +++ b/packages/storage/lib/StorageReference.js @@ -24,12 +24,13 @@ import { isUndefined, getDataUrlParts, pathLastComponent, - ReferenceBase, + ReferenceBase, isObject, hasOwnProperty, isNumber, } from '@react-native-firebase/common'; import { validateMetadata } from './utils'; import StorageStatics from './StorageStatics'; import StorageUploadTask from './StorageUploadTask'; import StorageDownloadTask from './StorageDownloadTask'; +import StorageListResult, { provideStorageReferenceClass } from './StorageListResult'; export default class StorageReference extends ReferenceBase { constructor(storage, path) { @@ -112,6 +113,64 @@ export default class StorageReference extends ReferenceBase { return this._storage.native.getMetadata(this.toString()); } + /** + * @url https://firebase.google.com/docs/reference/js/firebase.storage.Reference#list + */ + list(options) { + if (!isUndefined(options) && !isObject(options)) { + throw new Error( + "firebase.storage.StorageReference.list(*) 'options' expected an object value.", + ); + } + + const listOptions = { + maxResults: 1000, + }; + + + if (hasOwnProperty(options, 'maxResults')) { + if (!isNumber(options.maxResults)) { + throw new Error( + "firebase.storage.StorageReference.list(*) 'options.maxResults' expected a number value.", + ); + } + + // todo integer check + + if (options.maxResults < 1 || options.maxResults > 1000) { + throw new Error( + "firebase.storage.StorageReference.list(*) 'options.maxResults' expected a number value between 1-1000.", + ); + } + + listOptions.maxResults = options.maxResults; + } + + if (options.pageToken) { + if (!isString(options.pageToken)) { + throw new Error( + "firebase.storage.StorageReference.list(*) 'options.pageToken' expected a string value.", + ); + } + + listOptions.pageToken = options.pageToken; + } + + + return this._storage.native + .list(options) + .then(data => new StorageListResult(this._storage, data)); + } + + /** + * @url https://firebase.google.com/docs/reference/js/firebase.storage.Reference#listAll + */ + listAll() { + return this._storage.native + .listAll() + .then(data => new StorageListResult(this._storage, data)); + } + /** * @url https://firebase.google.com/docs/reference/js/firebase.storage.Reference#put */ @@ -244,3 +303,5 @@ export default class StorageReference extends ReferenceBase { ); } } + +provideStorageReferenceClass(StorageReference); diff --git a/packages/storage/lib/index.d.ts b/packages/storage/lib/index.d.ts index afc94ee8..72df9859 100644 --- a/packages/storage/lib/index.d.ts +++ b/packages/storage/lib/index.d.ts @@ -531,6 +531,49 @@ export namespace Storage { */ getMetadata(): Promise; + /** + * List items (files) and prefixes (folders) under this storage reference. + * + * List API is only available for Firebase Rules Version 2. + * + * GCS is a key-blob store. Firebase Storage imposes the semantic of '/' delimited folder structure. + * Refer to GCS's List API if you want to learn more. + * + * To adhere to Firebase Rules's Semantics, Firebase Storage does not support objects whose paths + * end with "/" or contain two consecutive "/"s. Firebase Storage List API will filter these unsupported objects. + * list() may fail if there are too many unsupported objects in the bucket. + * + * #### Example + * + * ```js + * const ref = firebase.storage().ref('/'); + * const results = await ref.list({ + * maxResults: 30, + * }); + * ``` + * + * @param options An optional ListOptions interface. + */ + list(options?: ListOptions): Promise; + + /** + * List all items (files) and prefixes (folders) under this storage reference. + * + * This is a helper method for calling list() repeatedly until there are no more results. The default pagination size is 1000. + * + * Note: The results may not be consistent if objects are changed while this operation is running. + * + * Warning: `listAll` may potentially consume too many resources if there are too many results. + * + * #### Example + * + * ```js + * const ref = firebase.storage().ref('/'); + * const results = await ref.listAll(); + * ``` + */ + listAll(): Promise; + /** * Puts a file from local disk onto the storage bucket. * @@ -870,6 +913,43 @@ export namespace Storage { error?: NativeFirebaseError; } + /** + * The options `list()` accepts. + */ + export interface ListOptions { + /** + * If set, limits the total number of `prefixes` and `items` to return. The default and maximum maxResults is 1000. + */ + maxResults?: number; + + /** + * The `nextPageToken` from a previous call to `list()`. If provided, listing is resumed from the previous position. + */ + pageToken?: string; + } + + /** + * Result returned by `list()`. + */ + export interface ListResult { + /** + * Objects in this directory. You can call `getMetadate()` and `getDownloadUrl()` on them. + */ + items: Reference[]; + + /** + * If set, there might be more results for this list. Use this token to resume the list. + */ + nextPageToken: string | null; + + /** + * References to prefixes (sub-folders). You can call `list()` on them to get its contents. + * + * Folders are implicit based on '/' in the object paths. For example, if a bucket has two objects '/a/b/1' and '/a/b/2', list('/a') will return '/a/b' as a prefix. + */ + prefixes: Reference[]; + } + /** * The Cloud Storage service is available for the default app, a given app or a specific storage bucket. *