diff --git a/packages/cloud_firestore/cloud_firestore/example/.flutter-plugins-dependencies b/packages/cloud_firestore/cloud_firestore/example/.flutter-plugins-dependencies new file mode 100644 index 000000000000..7b5ad896f47b --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/example/.flutter-plugins-dependencies @@ -0,0 +1 @@ +{"_info":"// This is a generated file; do not edit or check into version control.","dependencyGraph":[{"name":"cloud_firestore","dependencies":["firebase_core"]},{"name":"firebase_core","dependencies":[]}]} \ No newline at end of file diff --git a/packages/cloud_firestore/cloud_firestore/lib/cloud_firestore.dart b/packages/cloud_firestore/cloud_firestore/lib/cloud_firestore.dart index aef386e77d26..ae680dd994f0 100755 --- a/packages/cloud_firestore/cloud_firestore/lib/cloud_firestore.dart +++ b/packages/cloud_firestore/cloud_firestore/lib/cloud_firestore.dart @@ -9,6 +9,8 @@ import 'dart:convert'; import 'dart:typed_data'; import 'dart:ui' show hashValues, hashList; +import 'package:cloud_firestore_platform_interface/cloud_firestore_platform_interface.dart'; + import 'package:collection/collection.dart'; import 'package:firebase_core/firebase_core.dart'; import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer; diff --git a/packages/cloud_firestore/cloud_firestore/lib/src/document_reference.dart b/packages/cloud_firestore/cloud_firestore/lib/src/document_reference.dart index 413cf41be0be..ad66ca77e956 100644 --- a/packages/cloud_firestore/cloud_firestore/lib/src/document_reference.dart +++ b/packages/cloud_firestore/cloud_firestore/lib/src/document_reference.dart @@ -48,14 +48,11 @@ class DocumentReference { /// If [merge] is true, the provided data will be merged into an /// existing document instead of overwriting. Future setData(Map data, {bool merge = false}) { - return Firestore.channel.invokeMethod( - 'DocumentReference#setData', - { - 'app': firestore.app.name, - 'path': path, - 'data': data, - 'options': {'merge': merge}, - }, + return Firestore.platform.setDocumentReferenceData( + firestore.app.name, + path: path, + data: data, + options: {'merge': merge}, ); } @@ -66,13 +63,10 @@ class DocumentReference { /// /// If no document exists yet, the update will fail. Future updateData(Map data) { - return Firestore.channel.invokeMethod( - 'DocumentReference#updateData', - { - 'app': firestore.app.name, - 'path': path, - 'data': data, - }, + return Firestore.platform.updateDocumentReferenceData( + firestore.app.name, + path: path, + data: data, ); } @@ -81,14 +75,12 @@ class DocumentReference { /// If no document exists, the read will return null. Future get({Source source = Source.serverAndCache}) async { final Map data = - await Firestore.channel.invokeMapMethod( - 'DocumentReference#get', - { - 'app': firestore.app.name, - 'path': path, - 'source': _getSourceString(source), - }, + await Firestore.platform.getDocumentReference( + firestore.app.name, + path: path, + source: _getSourceString(source), ); + return DocumentSnapshot._( data['path'], _asStringKeyedMap(data['data']), @@ -100,9 +92,9 @@ class DocumentReference { /// Deletes the document referred to by this [DocumentReference]. Future delete() { - return Firestore.channel.invokeMethod( - 'DocumentReference#delete', - {'app': firestore.app.name, 'path': path}, + return Firestore.platform.deleteDocumentReference( + firestore.app.name, + path: path, ); } @@ -118,34 +110,19 @@ class DocumentReference { // TODO(jackson): Reduce code duplication with [Query] Stream snapshots({bool includeMetadataChanges = false}) { assert(includeMetadataChanges != null); - Future _handle; - // It's fine to let the StreamController be garbage collected once all the - // subscribers have cancelled; this analyzer warning is safe to ignore. - StreamController controller; // ignore: close_sinks - controller = StreamController.broadcast( - onListen: () { - _handle = Firestore.channel.invokeMethod( - 'DocumentReference#addSnapshotListener', - { - 'app': firestore.app.name, - 'path': path, - 'includeMetadataChanges': includeMetadataChanges, - }, - ).then((dynamic result) => result); - _handle.then((int handle) { - Firestore._documentObservers[handle] = controller; - }); - }, - onCancel: () { - _handle.then((int handle) async { - await Firestore.channel.invokeMethod( - 'removeListener', - {'handle': handle}, - ); - Firestore._documentObservers.remove(handle); - }); - }, - ); - return controller.stream; + + return Firestore.platform + .getDocumentReferenceSnapshots( + firestore.app.name, + path: path, + includeMetadataChanges: includeMetadataChanges, + ) + .map((dynamic data) => DocumentSnapshot._( + data['path'], + _asStringKeyedMap(data['data']), + SnapshotMetadata._(data['metadata']['hasPendingWrites'], + data['metadata']['isFromCache']), + firestore, + )); } } diff --git a/packages/cloud_firestore/cloud_firestore/lib/src/firestore.dart b/packages/cloud_firestore/cloud_firestore/lib/src/firestore.dart index 5c164ed1e122..2ce8b6a7de53 100644 --- a/packages/cloud_firestore/cloud_firestore/lib/src/firestore.dart +++ b/packages/cloud_firestore/cloud_firestore/lib/src/firestore.dart @@ -10,28 +10,6 @@ part of cloud_firestore; class Firestore { Firestore({FirebaseApp app}) : app = app ?? FirebaseApp.instance { if (_initialized) return; - channel.setMethodCallHandler((MethodCall call) async { - if (call.method == 'QuerySnapshot') { - final QuerySnapshot snapshot = QuerySnapshot._(call.arguments, this); - _queryObservers[call.arguments['handle']].add(snapshot); - } else if (call.method == 'DocumentSnapshot') { - final DocumentSnapshot snapshot = DocumentSnapshot._( - call.arguments['path'], - _asStringKeyedMap(call.arguments['data']), - SnapshotMetadata._(call.arguments['metadata']['hasPendingWrites'], - call.arguments['metadata']['isFromCache']), - this, - ); - _documentObservers[call.arguments['handle']].add(snapshot); - } else if (call.method == 'DoTransaction') { - final int transactionId = call.arguments['transactionId']; - final Transaction transaction = Transaction(transactionId, this); - final dynamic result = - await _transactionHandlers[transactionId](transaction); - await transaction._finish(); - return result; - } - }); _initialized = true; } @@ -46,20 +24,7 @@ class Firestore { static bool _initialized = false; @visibleForTesting - static const MethodChannel channel = MethodChannel( - 'plugins.flutter.io/cloud_firestore', - StandardMethodCodec(FirestoreMessageCodec()), - ); - - static final Map> _queryObservers = - >{}; - - static final Map> _documentObservers = - >{}; - - static final Map _transactionHandlers = - {}; - static int _transactionHandlerId = 0; + static CloudFirestorePlatform platform = CloudFirestorePlatform.instance; @override bool operator ==(dynamic o) => o is Firestore && o.app == app; @@ -123,26 +88,31 @@ class Firestore { {Duration timeout = const Duration(seconds: 5)}) async { assert(timeout.inMilliseconds > 0, 'Transaction timeout must be more than 0 milliseconds'); - final int transactionId = _transactionHandlerId++; - _transactionHandlers[transactionId] = transactionHandler; - final Map result = await channel - .invokeMapMethod( - 'Firestore#runTransaction', { - 'app': app.name, - 'transactionId': transactionId, - 'transactionTimeout': timeout.inMilliseconds - }); + + // Wrap the incoming [TransactionHandler] into something that can be passed + // to the Platform implementation. + final PlatformTransactionHandler handler = (int transactionId) async { + Transaction transaction = Transaction(transactionId, this); + final dynamic result = await transactionHandler(transaction); + await transaction._finish(); + return result; + }; + + final Map result = await platform.runTransaction( + app.name, + transactionHandler: handler, + transactionTimeout: timeout.inMilliseconds, + ); return result ?? {}; } @deprecated Future enablePersistence(bool enable) async { assert(enable != null); - await channel - .invokeMethod('Firestore#enablePersistence', { - 'app': app.name, - 'enable': enable, - }); + await platform.enablePersistence( + app.name, + enable: enable, + ); } Future settings( @@ -150,12 +120,10 @@ class Firestore { String host, bool sslEnabled, int cacheSizeBytes}) async { - await channel.invokeMethod('Firestore#settings', { - 'app': app.name, - 'persistenceEnabled': persistenceEnabled, - 'host': host, - 'sslEnabled': sslEnabled, - 'cacheSizeBytes': cacheSizeBytes, - }); + await platform.settings(app.name, + persistenceEnabled: persistenceEnabled, + host: host, + sslEnabled: sslEnabled, + cacheSizeBytes: cacheSizeBytes); } } diff --git a/packages/cloud_firestore/cloud_firestore/lib/src/query.dart b/packages/cloud_firestore/cloud_firestore/lib/src/query.dart index 2da85859ee5a..170835ad07ec 100644 --- a/packages/cloud_firestore/cloud_firestore/lib/src/query.dart +++ b/packages/cloud_firestore/cloud_firestore/lib/src/query.dart @@ -52,37 +52,16 @@ class Query { // TODO(jackson): Reduce code duplication with [DocumentReference] Stream snapshots({bool includeMetadataChanges = false}) { assert(includeMetadataChanges != null); - Future _handle; - // It's fine to let the StreamController be garbage collected once all the - // subscribers have cancelled; this analyzer warning is safe to ignore. - StreamController controller; // ignore: close_sinks - controller = StreamController.broadcast( - onListen: () { - _handle = Firestore.channel.invokeMethod( - 'Query#addSnapshotListener', - { - 'app': firestore.app.name, - 'path': _path, - 'isCollectionGroup': _isCollectionGroup, - 'parameters': _parameters, - 'includeMetadataChanges': includeMetadataChanges, - }, - ).then((dynamic result) => result); - _handle.then((int handle) { - Firestore._queryObservers[handle] = controller; - }); - }, - onCancel: () { - _handle.then((int handle) async { - await Firestore.channel.invokeMethod( - 'removeListener', - {'handle': handle}, - ); - Firestore._queryObservers.remove(handle); - }); - }, - ); - return controller.stream; + + return Firestore.platform + .getQuerySnapshots( + firestore.app.name, + path: _path, + isCollectionGroup: _isCollectionGroup, + parameters: _parameters, + includeMetadataChanges: includeMetadataChanges, + ) + .map((dynamic data) => QuerySnapshot._(data, firestore)); } /// Fetch the documents for this query @@ -90,15 +69,12 @@ class Query { {Source source = Source.serverAndCache}) async { assert(source != null); final Map data = - await Firestore.channel.invokeMapMethod( - 'Query#getDocuments', - { - 'app': firestore.app.name, - 'path': _path, - 'isCollectionGroup': _isCollectionGroup, - 'parameters': _parameters, - 'source': _getSourceString(source), - }, + await Firestore.platform.getQueryDocuments( + firestore.app.name, + path: _path, + isCollectionGroup: _isCollectionGroup, + parameters: _parameters, + source: _getSourceString(source), ); return QuerySnapshot._(data, firestore); } diff --git a/packages/cloud_firestore/cloud_firestore/lib/src/transaction.dart b/packages/cloud_firestore/cloud_firestore/lib/src/transaction.dart index d13e3f19f9b2..c65c8ba77e04 100644 --- a/packages/cloud_firestore/cloud_firestore/lib/src/transaction.dart +++ b/packages/cloud_firestore/cloud_firestore/lib/src/transaction.dart @@ -23,12 +23,12 @@ class Transaction { } Future _get(DocumentReference documentReference) async { - final Map result = await Firestore.channel - .invokeMapMethod('Transaction#get', { - 'app': _firestore.app.name, - 'transactionId': _transactionId, - 'path': documentReference.path, - }); + final Map result = await Firestore.platform.getTransaction( + _firestore.app.name, + transactionId: _transactionId, + path: documentReference.path, + ); + if (result != null) { return DocumentSnapshot._( documentReference.path, @@ -52,12 +52,11 @@ class Transaction { } Future _delete(DocumentReference documentReference) async { - return Firestore.channel - .invokeMethod('Transaction#delete', { - 'app': _firestore.app.name, - 'transactionId': _transactionId, - 'path': documentReference.path, - }); + return Firestore.platform.deleteTransaction( + _firestore.app.name, + transactionId: _transactionId, + path: documentReference.path, + ); } /// Updates fields in the document referred to by [documentReference]. @@ -74,13 +73,10 @@ class Transaction { Future _update( DocumentReference documentReference, Map data) async { - return Firestore.channel - .invokeMethod('Transaction#update', { - 'app': _firestore.app.name, - 'transactionId': _transactionId, - 'path': documentReference.path, - 'data': data, - }); + return Firestore.platform.updateTransaction(_firestore.app.name, + transactionId: _transactionId, + path: documentReference.path, + data: data); } /// Writes to the document referred to by the provided [DocumentReference]. @@ -98,12 +94,11 @@ class Transaction { Future _set( DocumentReference documentReference, Map data) async { - return Firestore.channel - .invokeMethod('Transaction#set', { - 'app': _firestore.app.name, - 'transactionId': _transactionId, - 'path': documentReference.path, - 'data': data, - }); + return Firestore.platform.setTransaction( + _firestore.app.name, + transactionId: _transactionId, + path: documentReference.path, + data: data, + ); } } diff --git a/packages/cloud_firestore/cloud_firestore/lib/src/write_batch.dart b/packages/cloud_firestore/cloud_firestore/lib/src/write_batch.dart index 6a41ee7bfec3..e3ced8aff2f5 100644 --- a/packages/cloud_firestore/cloud_firestore/lib/src/write_batch.dart +++ b/packages/cloud_firestore/cloud_firestore/lib/src/write_batch.dart @@ -12,8 +12,7 @@ part of cloud_firestore; /// nor can it be committed again. class WriteBatch { WriteBatch._(this._firestore) - : _handle = Firestore.channel.invokeMethod( - 'WriteBatch#create', {'app': _firestore.app.name}); + : _handle = Firestore.platform.createWriteBatch(_firestore.app.name); final Firestore _firestore; Future _handle; @@ -29,8 +28,7 @@ class WriteBatch { if (!_committed) { _committed = true; await Future.wait(_actions); - await Firestore.channel.invokeMethod( - 'WriteBatch#commit', {'handle': await _handle}); + await Firestore.platform.commitWriteBatch(handle: await _handle); } else { throw StateError("This batch has already been committed."); } @@ -41,13 +39,10 @@ class WriteBatch { if (!_committed) { _handle.then((dynamic handle) { _actions.add( - Firestore.channel.invokeMethod( - 'WriteBatch#delete', - { - 'app': _firestore.app.name, - 'handle': handle, - 'path': document.path, - }, + Firestore.platform.deleteWriteBatch( + _firestore.app.name, + handle: handle, + path: document.path, ), ); }); @@ -68,15 +63,12 @@ class WriteBatch { if (!_committed) { _handle.then((dynamic handle) { _actions.add( - Firestore.channel.invokeMethod( - 'WriteBatch#setData', - { - 'app': _firestore.app.name, - 'handle': handle, - 'path': document.path, - 'data': data, - 'options': {'merge': merge}, - }, + Firestore.platform.setWriteBatchData( + _firestore.app.name, + handle: handle, + path: document.path, + data: data, + options: {'merge': merge}, ), ); }); @@ -93,14 +85,11 @@ class WriteBatch { if (!_committed) { _handle.then((dynamic handle) { _actions.add( - Firestore.channel.invokeMethod( - 'WriteBatch#updateData', - { - 'app': _firestore.app.name, - 'handle': handle, - 'path': document.path, - 'data': data, - }, + Firestore.platform.updateWriteBatchData( + _firestore.app.name, + handle: handle, + path: document.path, + data: data, ), ); }); diff --git a/packages/cloud_firestore/cloud_firestore/pubspec.yaml b/packages/cloud_firestore/cloud_firestore/pubspec.yaml index 6c756675bdc9..1383cf71a1e9 100755 --- a/packages/cloud_firestore/cloud_firestore/pubspec.yaml +++ b/packages/cloud_firestore/cloud_firestore/pubspec.yaml @@ -13,6 +13,8 @@ flutter: pluginClass: CloudFirestorePlugin dependencies: + cloud_firestore_platform_interface: + path: ../cloud_firestore_platform_interface flutter: sdk: flutter meta: "^1.0.5" diff --git a/packages/cloud_firestore/cloud_firestore_platform_interface/CHANGELOG.md b/packages/cloud_firestore/cloud_firestore_platform_interface/CHANGELOG.md new file mode 100644 index 000000000000..6fadda91b380 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore_platform_interface/CHANGELOG.md @@ -0,0 +1,3 @@ +## 1.0.0 + +- Initial open-source release. diff --git a/packages/cloud_firestore/cloud_firestore_platform_interface/LICENSE b/packages/cloud_firestore/cloud_firestore_platform_interface/LICENSE new file mode 100644 index 000000000000..000b4618d2bd --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore_platform_interface/LICENSE @@ -0,0 +1,27 @@ +// Copyright 2017 The Chromium Authors. All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are +// met: +// +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// * Redistributions in binary form must reproduce the above +// copyright notice, this list of conditions and the following disclaimer +// in the documentation and/or other materials provided with the +// distribution. +// * Neither the name of Google Inc. nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/packages/cloud_firestore/cloud_firestore_platform_interface/README.md b/packages/cloud_firestore/cloud_firestore_platform_interface/README.md new file mode 100644 index 000000000000..f354005e41c6 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore_platform_interface/README.md @@ -0,0 +1,26 @@ +# cloud_firestore_platform_interface + +A common platform interface for the [`cloud_firestore`][1] plugin. + +This interface allows platform-specific implementations of the `cloud_firestore` +plugin, as well as the plugin itself, to ensure they are supporting the +same interface. + +# Usage + +To implement a new platform-specific implementation of `cloud_firestore`, extend +[`CloudFirestorePlatform`][2] with an implementation that performs the +platform-specific behavior, and when you register your plugin, set the default +`CloudFirestorePlatform` by calling +`CloudFirestorePlatform.instance = MyCloudFirestorePlatform()`. + +# Note on breaking changes + +Strongly prefer non-breaking changes (such as adding a method to the interface) +over breaking changes for this package. + +See https://flutter.dev/go/platform-interface-breaking-changes for a discussion +on why a less-clean interface is preferable to a breaking change. + +[1]: ../cloud_firestore +[2]: lib/cloud_firestore_platform_interface.dart diff --git a/packages/cloud_firestore/cloud_firestore_platform_interface/lib/cloud_firestore_platform_interface.dart b/packages/cloud_firestore/cloud_firestore_platform_interface/lib/cloud_firestore_platform_interface.dart new file mode 100644 index 000000000000..48d6374aca1b --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore_platform_interface/lib/cloud_firestore_platform_interface.dart @@ -0,0 +1,236 @@ +// Copyright 2019 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:meta/meta.dart' show required, visibleForTesting; + +import 'src/method_channel_cloud_firestore.dart'; + +import 'src/types.dart'; + +export 'src/types.dart'; + +/// The interface that implementations of `cloud_firestore` must extend. +/// +/// Platform implementations should extend this class rather than implement it +/// as `cloud_firestore` does not consider newly added methods to be breaking +/// changes. Extending this class (using `extends`) ensures that the subclass +/// will get the default implementation, while platform implementations that +/// `implements` this interface will be broken by newly added +/// [CloudFirestorePlatform] methods. +abstract class CloudFirestorePlatform { + /// Only mock implementations should set this to `true`. + /// + /// Mockito mocks implement this class with `implements` which is forbidden + /// (see class docs). This property provides a backdoor for mocks to skip the + /// verification that the class isn't implemented with `implements`. + @visibleForTesting + bool get isMock => false; + + /// The default instance of [CloudFirestorePlatform] to use. + /// + /// Platform-specific plugins should override this with their own class + /// that extends [CloudFirestorePlatform] when they register themselves. + /// + /// Defaults to [MethodChannelCloudFirestore]. + static CloudFirestorePlatform get instance => _instance; + + static CloudFirestorePlatform _instance = MethodChannelCloudFirestore(); + + // TODO(amirh): Extract common platform interface logic. + // https://github.com/flutter/flutter/issues/43368 + static set instance(CloudFirestorePlatform instance) { + if (!instance.isMock) { + try { + instance._verifyProvidesDefaultImplementations(); + } on NoSuchMethodError catch (_) { + throw AssertionError( + 'Platform interfaces must not be implemented with `implements`'); + } + } + _instance = instance; + } + + /// This method ensures that [CloudFirestorePlatform] isn't implemented with `implements`. + /// + /// See class docs for more details on why using `implements` to implement + /// [CloudFirestorePlatform] is forbidden. + /// + /// This private method is called by the [instance] setter, which should fail + /// if the provided instance is a class implemented with `implements`. + void _verifyProvidesDefaultImplementations() {} + + // Actual API + // Global + /// Removes any listener by its handle. + /// All handles must be unique across al types of listeners. + Future removeListener(int handle) async { + throw UnimplementedError('removeListener() is not implemented'); + } + + // Firestore + Future enablePersistence(String app, {@required bool enable}) async { + throw UnimplementedError('enablePersistence() is not implemented'); + } + + Future settings( + String app, { + bool persistenceEnabled, + String host, + bool sslEnabled, + int cacheSizeBytes, + }) async { + throw UnimplementedError('settings() is not implemented'); + } + + Future> runTransaction( + String app, { + @required PlatformTransactionHandler transactionHandler, + int transactionTimeout, + }) async { + throw UnimplementedError('runTransaction() is not implemented'); + } + + // Document Reference + Future setDocumentReferenceData( + String app, { + @required String path, + Map data, + // TODO: Type https://firebase.google.com/docs/reference/js/firebase.firestore.SetOptions.html + Map options, + }) async { + throw UnimplementedError('setDocumentReferenceData() is not implemented'); + } + + Future updateDocumentReferenceData( + String app, { + @required String path, + Map data, + }) async { + throw UnimplementedError( + 'updateDocumentReferenceData() is not implemented'); + } + + // TODO: Type this return + Future> getDocumentReference( + String app, { + @required String path, + @required String source, + }) async { + throw UnimplementedError('getDocumentReference() is not implemented'); + } + + Future deleteDocumentReference( + String app, { + @required String path, + }) async { + throw UnimplementedError('deleteDocumentReference() is not implemented'); + } + + Stream getDocumentReferenceSnapshots( + String app, { + @required String path, + bool includeMetadataChanges, + }) { + throw UnimplementedError( + 'addDocumentReferenceSnapshotListener() is not implemented'); + } + + // Query + Stream getQuerySnapshots( + String app, { + @required String path, + bool isCollectionGroup, + Map parameters, + bool includeMetadataChanges, + }) { + throw UnimplementedError('getQuerySnapshots() is not implemented'); + } + + //TODO: Type this return + Future> getQueryDocuments( + String app, { + @required String path, + bool isCollectionGroup, + Map parameters, + String source, + }) async { + throw UnimplementedError('getQueryDocuments() is not implemented'); + } + + // Transaction + // TODO: Type this return + Future> getTransaction( + String app, { + @required String path, + @required int transactionId, + }) async { + throw UnimplementedError('getTransaction() is not implemented'); + } + + Future deleteTransaction( + String app, { + @required String path, + @required int transactionId, + }) async { + throw UnimplementedError('deleteTransaction() is not implemented'); + } + + Future updateTransaction( + String app, { + @required String path, + @required int transactionId, + Map data, + }) async { + throw UnimplementedError('updateTransaction() is not implemented'); + } + + Future setTransaction( + String app, { + @required String path, + @required int transactionId, + Map data, + }) async { + throw UnimplementedError('setTransaction() is not implemented'); + } + + // Write Batch + Future createWriteBatch(String app) async { + throw UnimplementedError('createWriteBatch() is not implemented'); + } + + Future commitWriteBatch({ + @required dynamic handle, + }) async { + throw UnimplementedError('commitWriteBatch() is not implemented'); + } + + Future deleteWriteBatch( + String app, { + @required dynamic handle, + @required String path, + }) async { + throw UnimplementedError('deleteWriteBatch() is not implemented'); + } + + Future setWriteBatchData( + String app, { + @required dynamic handle, + @required String path, + Map data, + Map options, + }) async { + throw UnimplementedError('setWriteBatchData() is not implemented'); + } + + Future updateWriteBatchData( + String app, { + @required dynamic handle, + @required String path, + Map data, + }) async { + throw UnimplementedError('updateWriteBatchData() is not implemented'); + } +} diff --git a/packages/cloud_firestore/cloud_firestore_platform_interface/lib/src/method_channel_cloud_firestore.dart b/packages/cloud_firestore/cloud_firestore_platform_interface/lib/src/method_channel_cloud_firestore.dart new file mode 100644 index 000000000000..3cfd914aa523 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore_platform_interface/lib/src/method_channel_cloud_firestore.dart @@ -0,0 +1,439 @@ +// Copyright 2019 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:flutter/services.dart'; +import 'package:meta/meta.dart' show visibleForTesting, required; + +import '../cloud_firestore_platform_interface.dart'; + +class MethodChannelCloudFirestore extends CloudFirestorePlatform { + MethodChannelCloudFirestore() { + channel.setMethodCallHandler(_handlePlatformCall); + } + + @visibleForTesting + static const MethodChannel channel = MethodChannel( + 'plugins.flutter.io/cloud_firestore', + ); + + // Platform calls + Future _handlePlatformCall(MethodCall call) async { + switch (call.method) { + case 'QuerySnapshot': + return _handleQuerySnapshot(call); + break; + case 'DocumentSnapshot': + return _handleDocumentSnapshot(call); + break; + case 'DoTransaction': + return _handleDoTransaction(call); + break; + } + } + + // Global + @override + Future removeListener(int handle) { + return channel.invokeMethod( + 'removeListener', + {'handle': handle}, + ); + } + + // Firestore + @override + Future enablePersistence(String app, {bool enable = true}) { + return channel + .invokeMethod('Firestore#enablePersistence', { + 'app': app, + 'enable': enable, + }); + } + + @override + Future settings( + String app, { + bool persistenceEnabled, + String host, + bool sslEnabled, + int cacheSizeBytes, + }) { + return channel.invokeMethod('Firestore#settings', { + 'app': app, + 'persistenceEnabled': persistenceEnabled, + 'host': host, + 'sslEnabled': sslEnabled, + 'cacheSizeBytes': cacheSizeBytes, + }); + } + + // Transaction data + static final Map _transactionHandlers = + {}; + static int _transactionHandlerId = 0; + + @override + Future> runTransaction( + String app, { + @required PlatformTransactionHandler transactionHandler, + int transactionTimeout, + }) async { + // The [transactionHandler] will be used by the [_handleDoTransaction] method later + final int transactionId = _transactionHandlerId++; + _transactionHandlers[transactionId] = transactionHandler; + + return channel.invokeMapMethod( + 'Firestore#runTransaction', { + 'app': app, + 'transactionId': transactionId, + 'transactionTimeout': transactionTimeout + }); + } + + Future _handleDoTransaction(MethodCall call) async { + final int transactionId = call.arguments['transactionId']; + // Retrieve the handler passed to [runTransaction]... + final PlatformTransactionHandler transactionHandler = + _transactionHandlers[transactionId]; + _transactionHandlers.remove(transactionId); + // Delegate handling to it + return await transactionHandler(transactionId); + } + + // Document Reference + @override + Future setDocumentReferenceData( + String app, { + @required String path, + // TODO: Type SetOptions: https://firebase.google.com/docs/reference/js/firebase.firestore.SetOptions.html + Map options, + Map data = const {}, + }) { + return channel.invokeMethod( + 'DocumentReference#setData', + { + 'app': app, + 'path': path, + 'data': data, + 'options': options, + }, + ); + } + + @override + Future updateDocumentReferenceData( + String app, { + @required String path, + Map data = const {}, + }) { + return channel.invokeMethod( + 'DocumentReference#updateData', + { + 'app': app, + 'path': path, + 'data': data, + }, + ); + } + + // TODO: Type this return + @override + Future> getDocumentReference( + String app, { + @required String path, + @required String source, + }) { + return channel.invokeMapMethod( + 'DocumentReference#get', + { + 'app': app, + 'path': path, + 'source': source, + }, + ); + } + + @override + Future deleteDocumentReference( + String app, { + @required String path, + }) { + return channel.invokeMethod( + 'DocumentReference#delete', + {'app': app, 'path': path}, + ); + } + + Future _addDocumentReferenceSnapshotListener( + String app, { + @required String path, + bool includeMetadataChanges, + }) { + return channel.invokeMethod( + 'DocumentReference#addSnapshotListener', + { + 'app': app, + 'path': path, + 'includeMetadataChanges': includeMetadataChanges, + }, + ); + } + + static final Map> _documentObservers = + >{}; + + // This method is very similar to getQuerySnapshots. Extract common logic? + @override + Stream getDocumentReferenceSnapshots( + String app, { + @required String path, + bool includeMetadataChanges, + }) { + assert(includeMetadataChanges != null); + Future _handle; + // It's fine to let the StreamController be garbage collected once all the + // subscribers have cancelled; this analyzer warning is safe to ignore. + StreamController controller; // ignore: close_sinks + controller = StreamController.broadcast( + onListen: () { + _handle = _addDocumentReferenceSnapshotListener( + app, + path: path, + includeMetadataChanges: includeMetadataChanges, + ).then((dynamic result) => result); + _handle.then((int handle) { + _documentObservers[handle] = controller; + }); + }, + onCancel: () { + _handle.then((int handle) async { + await removeListener(handle); + _documentObservers.remove(handle); + }); + }, + ); + return controller.stream; + } + + void _handleDocumentSnapshot(MethodCall call) { + final int handle = call.arguments['handle']; + _queryObservers[handle].add(call.arguments); + } + + // Query + Future _addQuerySnapshotListener( + String app, { + @required String path, + bool isCollectionGroup, + Map parameters, + bool includeMetadataChanges, + }) { + return channel.invokeMethod( + 'Query#addSnapshotListener', + { + 'app': app, + 'path': path, + 'isCollectionGroup': isCollectionGroup, + 'parameters': parameters, + 'includeMetadataChanges': includeMetadataChanges, + }, + ); + } + + static final Map> _queryObservers = + >{}; + + @override + Stream getQuerySnapshots( + String app, { + @required String path, + bool isCollectionGroup, + Map parameters, + bool includeMetadataChanges, + }) { + assert(includeMetadataChanges != null); + Future _handle; + // It's fine to let the StreamController be garbage collected once all the + // subscribers have cancelled; this analyzer warning is safe to ignore. + StreamController controller; // ignore: close_sinks + controller = StreamController.broadcast( + onListen: () { + _handle = _addQuerySnapshotListener( + app, + path: path, + isCollectionGroup: isCollectionGroup, + parameters: parameters, + includeMetadataChanges: includeMetadataChanges, + ).then((dynamic result) => result); + _handle.then((int handle) { + _queryObservers[handle] = controller; + }); + }, + onCancel: () { + _handle.then((int handle) async { + await removeListener(handle); + _queryObservers.remove(handle); + }); + }, + ); + return controller.stream; + } + + // Broadcast the QuerySnapshot data + void _handleQuerySnapshot(MethodCall call) { + final int handle = call.arguments['handle']; + _queryObservers[handle].add(call.arguments); + } + + //TODO: Type this return + @override + Future> getQueryDocuments( + String app, { + @required String path, + bool isCollectionGroup, + Map parameters, + String source, + }) { + return channel.invokeMapMethod( + 'Query#getDocuments', + { + 'app': app, + 'path': path, + 'isCollectionGroup': isCollectionGroup, + 'parameters': parameters, + 'source': source, + }, + ); + } + + // Transaction + // TODO: Type this return + @override + Future> getTransaction( + String app, { + @required String path, + @required int transactionId, + }) { + return channel + .invokeMapMethod('Transaction#get', { + 'app': app, + 'transactionId': transactionId, + 'path': path, + }); + } + + @override + Future deleteTransaction( + String app, { + @required String path, + @required int transactionId, + }) { + return channel.invokeMethod('Transaction#delete', { + 'app': app, + 'transactionId': transactionId, + 'path': path, + }); + } + + @override + Future updateTransaction( + String app, { + @required String path, + @required int transactionId, + Map data, + }) { + return channel.invokeMethod('Transaction#update', { + 'app': app, + 'transactionId': transactionId, + 'path': path, + 'data': data, + }); + } + + @override + Future setTransaction( + String app, { + @required String path, + @required int transactionId, + Map data, + }) { + return channel.invokeMethod('Transaction#set', { + 'app': app, + 'transactionId': transactionId, + 'path': path, + 'data': data, + }); + } + + // Write Batch + @override + Future createWriteBatch(String app) { + return channel.invokeMethod( + 'WriteBatch#create', {'app': app}); + } + + @override + Future commitWriteBatch({ + @required dynamic handle, + }) { + return channel.invokeMethod( + 'WriteBatch#commit', {'handle': handle}); + } + + @override + Future deleteWriteBatch( + String app, { + @required dynamic handle, + @required String path, + }) { + return channel.invokeMethod( + 'WriteBatch#delete', + { + 'app': app, + 'handle': handle, + 'path': path, + }, + ); + } + + @override + Future setWriteBatchData( + String app, { + @required dynamic handle, + @required String path, + Map data, + Map options, + }) { + return channel.invokeMethod( + 'WriteBatch#setData', + { + 'app': app, + 'handle': handle, + 'path': path, + 'data': data, + 'options': options, + }, + ); + } + + @override + Future updateWriteBatchData( + String app, { + @required dynamic handle, + @required String path, + Map data, + }) { + return channel.invokeMethod( + 'WriteBatch#updateData', + { + 'app': app, + 'handle': handle, + 'path': path, + 'data': data, + }, + ); + } +} diff --git a/packages/cloud_firestore/cloud_firestore_platform_interface/lib/src/types.dart b/packages/cloud_firestore/cloud_firestore_platform_interface/lib/src/types.dart new file mode 100644 index 000000000000..2746b6008f64 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore_platform_interface/lib/src/types.dart @@ -0,0 +1,3 @@ +typedef Future PlatformTransactionHandler(int transactionId); + +// Data required to initialize a snapshot diff --git a/packages/cloud_firestore/cloud_firestore_platform_interface/pubspec.yaml b/packages/cloud_firestore/cloud_firestore_platform_interface/pubspec.yaml new file mode 100644 index 000000000000..7aa80cb809a1 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore_platform_interface/pubspec.yaml @@ -0,0 +1,22 @@ +name: cloud_firestore_platform_interface +description: A common platform interface for the cloud_firestore plugin. +author: Flutter Team +homepage: https://github.com/FirebaseExtended/flutterfire/tree/master/packages/cloud_firestore/cloud_firestore_platform_interface +# NOTE: We strongly prefer non-breaking changes, even at the expense of a +# less-clean API. See https://flutter.dev/go/platform-interface-breaking-changes +version: 1.0.0 + +dependencies: + flutter: + sdk: flutter + meta: ^1.0.5 + quiver: ">=2.0.0 <3.0.0" + +dev_dependencies: + flutter_test: + sdk: flutter + mockito: ^4.1.1 + +environment: + sdk: ">=2.0.0-dev.28.0 <3.0.0" + flutter: ">=1.9.1+hotfix.5 <2.0.0" diff --git a/packages/cloud_firestore/cloud_firestore_platform_interface/test/cloud_firestore_platform_interface_test.dart b/packages/cloud_firestore/cloud_firestore_platform_interface/test/cloud_firestore_platform_interface_test.dart new file mode 100644 index 000000000000..fbbc5a0189a0 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore_platform_interface/test/cloud_firestore_platform_interface_test.dart @@ -0,0 +1,42 @@ +// Copyright 2019 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:cloud_firestore_platform_interface/cloud_firestore_platform_interface.dart'; +import 'package:cloud_firestore_platform_interface/src/method_channel_cloud_firestore.dart'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('$CloudFirestorePlatform', () { + test('$MethodChannelCloudFirestore is the default instance', () { + expect( + CloudFirestorePlatform.instance, isA()); + }); + + test('Cannot be implemented with `implements`', () { + expect(() { + CloudFirestorePlatform.instance = ImplementsCloudFirestorePlatform(); + }, throwsAssertionError); + }); + + test('Can be extended', () { + CloudFirestorePlatform.instance = ExtendsCloudFirestorePlatform(); + }); + + test('Can be mocked with `implements`', () { + final ImplementsCloudFirestorePlatform mock = + ImplementsCloudFirestorePlatform(); + when(mock.isMock).thenReturn(true); + CloudFirestorePlatform.instance = mock; + }); + }); +} + +class ImplementsCloudFirestorePlatform extends Mock + implements CloudFirestorePlatform {} + +class ExtendsCloudFirestorePlatform extends CloudFirestorePlatform {} diff --git a/packages/cloud_firestore/cloud_firestore_platform_interface/test/method_channel_firebase_core_test.dart b/packages/cloud_firestore/cloud_firestore_platform_interface/test/method_channel_firebase_core_test.dart new file mode 100644 index 000000000000..a67e26790d2a --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore_platform_interface/test/method_channel_firebase_core_test.dart @@ -0,0 +1,1223 @@ +// Copyright 2019 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:cloud_firestore_platform_interface/src/method_channel_cloud_firestore.dart'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('$MethodChannelCloudFirestore', () { + int mockHandleId = 0; + + String appName = 'testApp'; + + MethodChannelCloudFirestore platform = MethodChannelCloudFirestore(); + + final List log = []; + + const Map kMockDocumentSnapshotData = { + '1': 2 + }; + const Map kMockSnapshotMetadata = { + "hasPendingWrites": false, + "isFromCache": false, + }; + + setUp(() async { + mockHandleId = 0; + + MethodChannelCloudFirestore.channel + .setMockMethodCallHandler((MethodCall methodCall) async { + log.add(methodCall); + switch (methodCall.method) { + case 'Query#addSnapshotListener': + final int handle = mockHandleId++; + // Wait before sending a message back. + // Otherwise the first request didn't have the time to finish. + Future.delayed(Duration.zero).then((_) { + // TODO(hterkelsen): Remove this when defaultBinaryMessages is in stable. + // https://github.com/flutter/flutter/issues/33446 + // ignore: deprecated_member_use + BinaryMessages.handlePlatformMessage( + MethodChannelCloudFirestore.channel.name, + MethodChannelCloudFirestore.channel.codec.encodeMethodCall( + MethodCall('QuerySnapshot', { + 'app': appName, + 'handle': handle, + 'paths': ["${methodCall.arguments['path']}/0"], + 'documents': [kMockDocumentSnapshotData], + 'metadatas': >[kMockSnapshotMetadata], + 'metadata': kMockSnapshotMetadata, + 'documentChanges': [ + { + 'oldIndex': -1, + 'newIndex': 0, + 'type': 'DocumentChangeType.added', + 'document': kMockDocumentSnapshotData, + 'metadata': kMockSnapshotMetadata, + }, + ], + }), + ), + (_) {}, + ); + }); + return handle; + case 'DocumentReference#addSnapshotListener': + final int handle = mockHandleId++; + // Wait before sending a message back. + // Otherwise the first request didn't have the time to finish. + Future.delayed(Duration.zero).then((_) { + // TODO(hterkelsen): Remove this when defaultBinaryMessages is in stable. + // https://github.com/flutter/flutter/issues/33446 + // ignore: deprecated_member_use + BinaryMessages.handlePlatformMessage( + MethodChannelCloudFirestore.channel.name, + MethodChannelCloudFirestore.channel.codec.encodeMethodCall( + MethodCall('DocumentSnapshot', { + 'handle': handle, + 'path': methodCall.arguments['path'], + 'data': kMockDocumentSnapshotData, + 'metadata': kMockSnapshotMetadata, + }), + ), + (_) {}, + ); + }); + return handle; + case 'Query#getDocuments': + return { + 'paths': ["${methodCall.arguments['path']}/0"], + 'documents': [kMockDocumentSnapshotData], + 'metadatas': >[kMockSnapshotMetadata], + 'metadata': kMockSnapshotMetadata, + 'documentChanges': [ + { + 'oldIndex': -1, + 'newIndex': 0, + 'type': 'DocumentChangeType.added', + 'document': kMockDocumentSnapshotData, + 'metadata': kMockSnapshotMetadata, + }, + ], + }; + case 'DocumentReference#setData': + return true; + case 'DocumentReference#get': + if (methodCall.arguments['path'] == 'foo/bar') { + return { + 'path': 'foo/bar', + 'data': {'key1': 'val1'}, + 'metadata': kMockSnapshotMetadata, + }; + } else if (methodCall.arguments['path'] == 'foo/notExists') { + return { + 'path': 'foo/notExists', + 'data': null, + 'metadata': kMockSnapshotMetadata, + }; + } + throw PlatformException(code: 'UNKNOWN_PATH'); + case 'Firestore#runTransaction': + return {'1': 3}; + case 'Transaction#get': + if (methodCall.arguments['path'] == 'foo/bar') { + return { + 'path': 'foo/bar', + 'data': {'key1': 'val1'}, + 'metadata': kMockSnapshotMetadata, + }; + } else if (methodCall.arguments['path'] == 'foo/notExists') { + return { + 'path': 'foo/notExists', + 'data': null, + 'metadata': kMockSnapshotMetadata, + }; + } + throw PlatformException(code: 'UNKNOWN_PATH'); + case 'Transaction#set': + return null; + case 'Transaction#update': + return null; + case 'Transaction#delete': + return null; + case 'WriteBatch#create': + return 1; + default: + return null; + } + }); + log.clear(); + }); + + test('settings', () async { + await platform.settings( + appName, + persistenceEnabled: true, + host: null, + sslEnabled: true, + cacheSizeBytes: 500000, + ); + + expect(log, [ + isMethodCall('Firestore#settings', arguments: { + 'app': appName, + 'persistenceEnabled': true, + 'host': null, + 'sslEnabled': true, + 'cacheSizeBytes': 500000, + }), + ]); + }); + + group('Transaction', () { + test('runTransaction', () async { + final Map result = await platform.runTransaction( + appName, + transactionHandler: (int id) async {}, + transactionTimeout: 3000); + + expect(log, [ + isMethodCall('Firestore#runTransaction', arguments: { + 'app': appName, + 'transactionId': 0, + 'transactionTimeout': 3000 + }), + ]); + expect(result, equals({'1': 3})); + }); + + test('get', () async { + await platform.getTransaction(appName, + path: 'foo/bar', transactionId: 0); + + expect(log, [ + isMethodCall('Transaction#get', arguments: { + 'app': appName, + 'transactionId': 0, + 'path': 'foo/bar', + }) + ]); + }); + + test('get notExists', () async { + await platform.getTransaction(appName, + path: 'foo/notExists', transactionId: 0); + + expect(log, [ + isMethodCall('Transaction#get', arguments: { + 'app': appName, + 'transactionId': 0, + 'path': 'foo/notExists', + }) + ]); + }); + + test('delete', () async { + await platform.deleteTransaction(appName, + path: 'foo/bar', transactionId: 0); + + expect(log, [ + isMethodCall('Transaction#delete', arguments: { + 'app': appName, + 'transactionId': 0, + 'path': 'foo/bar', + }) + ]); + }); + + test('set', () async { + await platform.setTransaction( + appName, + path: 'foo/bar', + transactionId: 0, + data: {'key1': 'val1', 'key2': 'val2'}, + ); + + expect(log, [ + isMethodCall('Transaction#set', arguments: { + 'app': appName, + 'transactionId': 0, + 'path': 'foo/bar', + 'data': {'key1': 'val1', 'key2': 'val2'} + }) + ]); + }); + + group('CollectionsReference', () { + test('listen', () async { + final dynamic snapshot = await platform.getQuerySnapshots( + appName, + path: 'foo', + includeMetadataChanges: true, + isCollectionGroup: false, + parameters: { + 'where': >[], + 'orderBy': >[], + }, + ).first; + final dynamic document = snapshot.documents[0]; + + expect(document.documentID, equals('0')); + expect(document.reference.path, equals('foo/0')); + expect(document.data, equals(kMockDocumentSnapshotData)); + + // Flush the async removeListener call + await Future.delayed(Duration.zero); + expect(log, [ + isMethodCall( + 'Query#addSnapshotListener', + arguments: { + 'app': appName, + 'path': 'foo', + 'isCollectionGroup': false, + 'parameters': { + 'where': >[], + 'orderBy': >[], + }, + 'includeMetadataChanges': true, + }, + ), + isMethodCall( + 'removeListener', + arguments: {'handle': 0}, + ), + ]); + }); + }); + + group('DocumentReference', () { + test('listen', () async { + final dynamic snapshot = await platform + .getDocumentReferenceSnapshots( + appName, + path: 'path/to/foo', + includeMetadataChanges: true, + ) + .first; + + expect(snapshot.documentID, equals('foo')); + expect(snapshot.reference.path, equals('path/to/foo')); + expect(snapshot.data, equals(kMockDocumentSnapshotData)); + // Flush the async removeListener call + await Future.delayed(Duration.zero); + expect( + log, + [ + isMethodCall( + 'DocumentReference#addSnapshotListener', + arguments: { + 'app': appName, + 'path': 'path/to/foo', + 'includeMetadataChanges': true, + }, + ), + isMethodCall( + 'removeListener', + arguments: {'handle': 0}, + ), + ], + ); + }); + test('set', () async { + await platform.setDocumentReferenceData( + appName, + path: 'foo/bar', + data: {'bazKey': 'quxValue'}, + options: {'merge': false}, + ); + + expect( + log, + [ + isMethodCall( + 'DocumentReference#setData', + arguments: { + 'app': appName, + 'path': 'foo/bar', + 'data': {'bazKey': 'quxValue'}, + 'options': {'merge': false}, + }, + ), + ], + ); + }); + test('merge set', () async { + await platform.setDocumentReferenceData( + appName, + path: 'foo/bar', + data: {'bazKey': 'quxValue'}, + options: {'merge': true}, + ); + + expect( + log, + [ + isMethodCall( + 'DocumentReference#setData', + arguments: { + 'app': appName, + 'path': 'foo/bar', + 'data': {'bazKey': 'quxValue'}, + 'options': {'merge': true}, + }, + ), + ], + ); + }); + test('update', () async { + await platform.updateDocumentReferenceData( + appName, + path: 'foo/bar', + data: {'bazKey': 'quxValue'}, + ); + + expect( + log, + [ + isMethodCall( + 'DocumentReference#updateData', + arguments: { + 'app': appName, + 'path': 'foo/bar', + 'data': {'bazKey': 'quxValue'}, + }, + ), + ], + ); + }); + test('delete', () async { + await platform.deleteDocumentReference( + appName, + path: 'foo/bar', + ); + expect( + log, + equals([ + isMethodCall( + 'DocumentReference#delete', + arguments: { + 'app': appName, + 'path': 'foo/bar', + }, + ), + ]), + ); + }); + test('get', () async { + final dynamic snapshot = await platform.getDocumentReference( + appName, + path: 'foo/bar', + source: 'cache', + ); + + expect( + log, + equals([ + isMethodCall( + 'DocumentReference#get', + arguments: { + 'app': appName, + 'path': 'foo/bar', + 'source': 'cache', + }, + ), + ]), + ); + log.clear(); + + expect(snapshot.reference.path, equals('foo/bar')); + expect(snapshot.data.containsKey('key1'), equals(true)); + expect(snapshot.data['key1'], equals('val1')); + expect(snapshot.exists, isTrue); + + final dynamic snapshot2 = await platform.getDocumentReference( + appName, + path: 'foo/notExists', + source: 'default', + ); + + expect(snapshot2.data, isNull); + expect(snapshot2.exists, isFalse); + expect( + log, + equals([ + isMethodCall( + 'DocumentReference#get', + arguments: { + 'app': appName, + 'path': 'foo/notExists', + 'source': 'default', + }, + ), + ]), + ); + + try { + await platform.getDocumentReference(appName, + path: 'baz', source: null); + } on PlatformException catch (e) { + expect(e.code, equals('UNKNOWN_PATH')); + } + }); + /* + test('collection', () async { + final CollectionReference colRef = + collectionReference.document('bar').collection('baz'); + expect(colRef.path, equals('foo/bar/baz')); + }); + test('parent', () async { + final CollectionReference colRef = + collectionReference.document('bar').collection('baz'); + expect(colRef.parent().documentID, equals('bar')); + }); + */ + }); +/* + group('Query', () { + test('getDocumentsFromCollection', () async { + QuerySnapshot snapshot = + await collectionReference.getDocuments(source: Source.server); + expect(snapshot.metadata.hasPendingWrites, + equals(kMockSnapshotMetadata['hasPendingWrites'])); + expect(snapshot.metadata.isFromCache, + equals(kMockSnapshotMetadata['isFromCache'])); + DocumentSnapshot document = snapshot.documents.first; + expect(document.documentID, equals('0')); + expect(document.reference.path, equals('foo/0')); + expect(document.data, equals(kMockDocumentSnapshotData)); + + // startAtDocument + snapshot = + await collectionReference.startAtDocument(document).getDocuments(); + document = snapshot.documents.first; + expect(document.documentID, equals('0')); + expect(document.reference.path, equals('foo/0')); + expect(document.data, equals(kMockDocumentSnapshotData)); + + // startAfterDocument + snapshot = await collectionReference + .startAfterDocument(document) + .getDocuments(); + document = snapshot.documents.first; + expect(document.documentID, equals('0')); + expect(document.reference.path, equals('foo/0')); + expect(document.data, equals(kMockDocumentSnapshotData)); + + // endAtDocument + snapshot = + await collectionReference.endAtDocument(document).getDocuments(); + document = snapshot.documents.first; + expect(document.documentID, equals('0')); + expect(document.reference.path, equals('foo/0')); + expect(document.data, equals(kMockDocumentSnapshotData)); + + // endBeforeDocument + snapshot = await collectionReference + .endBeforeDocument(document) + .getDocuments(); + document = snapshot.documents.first; + expect(document.documentID, equals('0')); + expect(document.reference.path, equals('foo/0')); + expect(document.data, equals(kMockDocumentSnapshotData)); + + // startAtDocument - endAtDocument + snapshot = await collectionReference + .startAtDocument(document) + .endAtDocument(document) + .getDocuments(); + document = snapshot.documents.first; + expect(document.documentID, equals('0')); + expect(document.reference.path, equals('foo/0')); + expect(document.data, equals(kMockDocumentSnapshotData)); + + expect( + log, + equals( + [ + isMethodCall( + 'Query#getDocuments', + arguments: { + 'app': app.name, + 'path': 'foo', + 'isCollectionGroup': false, + 'source': 'server', + 'parameters': { + 'where': >[], + 'orderBy': >[], + }, + }, + ), + isMethodCall( + 'Query#getDocuments', + arguments: { + 'app': app.name, + 'path': 'foo', + 'isCollectionGroup': false, + 'source': 'default', + 'parameters': { + 'where': >[], + 'orderBy': >[], + 'startAtDocument': { + 'id': '0', + 'path': 'foo/0', + 'data': kMockDocumentSnapshotData, + }, + }, + }, + ), + isMethodCall( + 'Query#getDocuments', + arguments: { + 'app': app.name, + 'path': 'foo', + 'isCollectionGroup': false, + 'source': 'default', + 'parameters': { + 'where': >[], + 'orderBy': >[], + 'startAfterDocument': { + 'id': '0', + 'path': 'foo/0', + 'data': kMockDocumentSnapshotData, + }, + }, + }, + ), + isMethodCall( + 'Query#getDocuments', + arguments: { + 'app': app.name, + 'path': 'foo', + 'isCollectionGroup': false, + 'source': 'default', + 'parameters': { + 'where': >[], + 'orderBy': >[], + 'endAtDocument': { + 'id': '0', + 'path': 'foo/0', + 'data': kMockDocumentSnapshotData, + }, + }, + }, + ), + isMethodCall( + 'Query#getDocuments', + arguments: { + 'app': app.name, + 'path': 'foo', + 'isCollectionGroup': false, + 'source': 'default', + 'parameters': { + 'where': >[], + 'orderBy': >[], + 'endBeforeDocument': { + 'id': '0', + 'path': 'foo/0', + 'data': kMockDocumentSnapshotData, + }, + }, + }, + ), + isMethodCall( + 'Query#getDocuments', + arguments: { + 'app': app.name, + 'path': 'foo', + 'isCollectionGroup': false, + 'source': 'default', + 'parameters': { + 'where': >[], + 'orderBy': >[], + 'startAtDocument': { + 'id': '0', + 'path': 'foo/0', + 'data': kMockDocumentSnapshotData, + }, + 'endAtDocument': { + 'id': '0', + 'path': 'foo/0', + 'data': kMockDocumentSnapshotData, + }, + }, + }, + ), + ], + ), + ); + }); + test('getDocumentsFromCollectionGroup', () async { + QuerySnapshot snapshot = await collectionGroupQuery.getDocuments(); + expect(snapshot.metadata.hasPendingWrites, + equals(kMockSnapshotMetadata['hasPendingWrites'])); + expect(snapshot.metadata.isFromCache, + equals(kMockSnapshotMetadata['isFromCache'])); + DocumentSnapshot document = snapshot.documents.first; + expect(document.documentID, equals('0')); + expect(document.reference.path, equals('bar/0')); + expect(document.data, equals(kMockDocumentSnapshotData)); + + // startAtDocument + snapshot = + await collectionGroupQuery.startAtDocument(document).getDocuments(); + document = snapshot.documents.first; + expect(document.documentID, equals('0')); + expect(document.reference.path, equals('bar/0')); + expect(document.data, equals(kMockDocumentSnapshotData)); + + // startAfterDocument + snapshot = await collectionGroupQuery + .startAfterDocument(document) + .getDocuments(); + document = snapshot.documents.first; + expect(document.documentID, equals('0')); + expect(document.reference.path, equals('bar/0')); + expect(document.data, equals(kMockDocumentSnapshotData)); + + // endAtDocument + snapshot = + await collectionGroupQuery.endAtDocument(document).getDocuments(); + document = snapshot.documents.first; + expect(document.documentID, equals('0')); + expect(document.reference.path, equals('bar/0')); + expect(document.data, equals(kMockDocumentSnapshotData)); + + // endBeforeDocument + snapshot = await collectionGroupQuery + .endBeforeDocument(document) + .getDocuments(); + document = snapshot.documents.first; + expect(document.documentID, equals('0')); + expect(document.reference.path, equals('bar/0')); + expect(document.data, equals(kMockDocumentSnapshotData)); + + // startAtDocument - endAtDocument + snapshot = await collectionGroupQuery + .startAtDocument(document) + .endAtDocument(document) + .getDocuments(); + document = snapshot.documents.first; + expect(document.documentID, equals('0')); + expect(document.reference.path, equals('bar/0')); + expect(document.data, equals(kMockDocumentSnapshotData)); + + expect( + log, + equals( + [ + isMethodCall( + 'Query#getDocuments', + arguments: { + 'app': app.name, + 'path': 'bar', + 'isCollectionGroup': true, + 'parameters': { + 'where': >[], + 'orderBy': >[], + }, + 'source': 'default', + }, + ), + isMethodCall( + 'Query#getDocuments', + arguments: { + 'app': app.name, + 'path': 'bar', + 'isCollectionGroup': true, + 'parameters': { + 'where': >[], + 'orderBy': >[], + 'startAtDocument': { + 'id': '0', + 'path': 'bar/0', + 'data': kMockDocumentSnapshotData, + }, + }, + 'source': 'default', + }, + ), + isMethodCall( + 'Query#getDocuments', + arguments: { + 'app': app.name, + 'path': 'bar', + 'isCollectionGroup': true, + 'parameters': { + 'where': >[], + 'orderBy': >[], + 'startAfterDocument': { + 'id': '0', + 'path': 'bar/0', + 'data': kMockDocumentSnapshotData, + }, + }, + 'source': 'default', + }, + ), + isMethodCall( + 'Query#getDocuments', + arguments: { + 'app': app.name, + 'path': 'bar', + 'isCollectionGroup': true, + 'parameters': { + 'where': >[], + 'orderBy': >[], + 'endAtDocument': { + 'id': '0', + 'path': 'bar/0', + 'data': kMockDocumentSnapshotData, + }, + }, + 'source': 'default', + }, + ), + isMethodCall( + 'Query#getDocuments', + arguments: { + 'app': app.name, + 'path': 'bar', + 'isCollectionGroup': true, + 'source': 'default', + 'parameters': { + 'where': >[], + 'orderBy': >[], + 'endBeforeDocument': { + 'id': '0', + 'path': 'bar/0', + 'data': kMockDocumentSnapshotData, + }, + }, + 'source': 'default', + }, + ), + isMethodCall( + 'Query#getDocuments', + arguments: { + 'app': app.name, + 'path': 'bar', + 'isCollectionGroup': true, + 'source': 'default', + 'parameters': { + 'where': >[], + 'orderBy': >[], + 'startAtDocument': { + 'id': '0', + 'path': 'bar/0', + 'data': kMockDocumentSnapshotData, + }, + 'endAtDocument': { + 'id': '0', + 'path': 'bar/0', + 'data': kMockDocumentSnapshotData, + }, + }, + 'source': 'default', + }, + ), + ], + ), + ); + }); + + test('FieldPath', () async { + await collectionReference + .where(FieldPath.documentId, isEqualTo: 'bar') + .getDocuments(); + expect( + log, + equals([ + isMethodCall( + 'Query#getDocuments', + arguments: { + 'app': app.name, + 'path': 'foo', + 'isCollectionGroup': false, + 'parameters': { + 'where': >[ + [FieldPath.documentId, '==', 'bar'], + ], + 'orderBy': >[], + }, + 'source': 'default', + }, + ), + ]), + ); + }); + test('orderBy assertions', () async { + // Can only order by the same field once. + expect(() { + firestore.collection('foo').orderBy('bar').orderBy('bar'); + }, throwsAssertionError); + // Cannot order by unsupported types. + expect(() { + firestore.collection('foo').orderBy(0); + }, throwsAssertionError); + // Parameters cannot be null. + expect(() { + firestore.collection('foo').orderBy(null); + }, throwsAssertionError); + expect(() { + firestore.collection('foo').orderBy('bar', descending: null); + }, throwsAssertionError); + + // Cannot order by document id when paginating with documents. + final DocumentReference documentReference = + firestore.document('foo/bar'); + final DocumentSnapshot snapshot = await documentReference.get(); + expect(() { + firestore + .collection('foo') + .startAfterDocument(snapshot) + .orderBy(FieldPath.documentId); + }, throwsAssertionError); + }); + test('document pagination FieldPath assertions', () async { + final DocumentReference documentReference = + firestore.document('foo/bar'); + final DocumentSnapshot snapshot = await documentReference.get(); + final Query query = + firestore.collection('foo').orderBy(FieldPath.documentId); + + expect(() { + query.startAfterDocument(snapshot); + }, throwsAssertionError); + expect(() { + query.startAtDocument(snapshot); + }, throwsAssertionError); + expect(() { + query.endAtDocument(snapshot); + }, throwsAssertionError); + expect(() { + query.endBeforeDocument(snapshot); + }, throwsAssertionError); + }); + }); + + group('FirestoreMessageCodec', () { + const MessageCodec codec = FirestoreMessageCodec(); + final DateTime testTime = DateTime(2015, 10, 30, 11, 16); + final Timestamp timestamp = Timestamp.fromDate(testTime); + test('should encode and decode simple messages', () { + _checkEncodeDecode(codec, testTime); + _checkEncodeDecode(codec, timestamp); + _checkEncodeDecode( + codec, const GeoPoint(37.421939, -122.083509)); + _checkEncodeDecode(codec, firestore.document('foo/bar')); + }); + test('should encode and decode composite message', () { + final List message = [ + testTime, + const GeoPoint(37.421939, -122.083509), + firestore.document('foo/bar'), + ]; + _checkEncodeDecode(codec, message); + }); + test('encode and decode blob', () { + final Uint8List bytes = Uint8List(4); + bytes[0] = 128; + final Blob message = Blob(bytes); + _checkEncodeDecode(codec, message); + }); + + test('encode and decode FieldValue', () { + _checkEncodeDecode(codec, FieldValue.arrayUnion([123])); + _checkEncodeDecode(codec, FieldValue.arrayRemove([123])); + _checkEncodeDecode(codec, FieldValue.delete()); + _checkEncodeDecode(codec, FieldValue.serverTimestamp()); + _checkEncodeDecode(codec, FieldValue.increment(1.0)); + _checkEncodeDecode(codec, FieldValue.increment(1)); + }); + + test('encode and decode FieldPath', () { + _checkEncodeDecode(codec, FieldPath.documentId); + }); + }); + + group('Timestamp', () { + test('is accurate for dates after epoch', () { + final DateTime date = DateTime.fromMillisecondsSinceEpoch(22501); + final Timestamp timestamp = Timestamp.fromDate(date); + + expect(timestamp.seconds, equals(22)); + expect(timestamp.nanoseconds, equals(501000000)); + }); + + test('is accurate for dates before epoch', () { + final DateTime date = DateTime.fromMillisecondsSinceEpoch(-1250); + final Timestamp timestamp = Timestamp.fromDate(date); + + expect(timestamp.seconds, equals(-2)); + expect(timestamp.nanoseconds, equals(750000000)); + }); + + test('creates equivalent timestamps regardless of factory', () { + const int kMilliseconds = 22501; + const int kMicroseconds = 22501000; + final DateTime date = + DateTime.fromMicrosecondsSinceEpoch(kMicroseconds); + + final Timestamp timestamp = Timestamp(22, 501000000); + final Timestamp milliTimestamp = + Timestamp.fromMillisecondsSinceEpoch(kMilliseconds); + final Timestamp microTimestamp = + Timestamp.fromMicrosecondsSinceEpoch(kMicroseconds); + final Timestamp dateTimestamp = Timestamp.fromDate(date); + + expect(timestamp, equals(milliTimestamp)); + expect(milliTimestamp, equals(microTimestamp)); + expect(microTimestamp, equals(dateTimestamp)); + }); + + test('correctly compares timestamps', () { + final Timestamp alpha = Timestamp.fromDate(DateTime(2017, 5, 11)); + final Timestamp beta1 = Timestamp.fromDate(DateTime(2018, 2, 19)); + final Timestamp beta2 = Timestamp.fromDate(DateTime(2018, 4, 2)); + final Timestamp beta3 = Timestamp.fromDate(DateTime(2018, 4, 20)); + final Timestamp preview = Timestamp.fromDate(DateTime(2018, 6, 20)); + final List inOrder = [ + alpha, + beta1, + beta2, + beta3, + preview + ]; + + final List timestamps = [ + beta2, + beta3, + alpha, + preview, + beta1 + ]; + timestamps.sort(); + expect(_deepEqualsList(timestamps, inOrder), isTrue); + }); + + test('rejects dates outside RFC 3339 range', () { + final List invalidDates = [ + DateTime.fromMillisecondsSinceEpoch(-70000000000000), + DateTime.fromMillisecondsSinceEpoch(300000000000000), + ]; + + invalidDates.forEach((DateTime date) { + expect(() => Timestamp.fromDate(date), throwsArgumentError); + }); + }); + }); + + group('WriteBatch', () { + test('set', () async { + final WriteBatch batch = firestore.batch(); + batch.setData( + collectionReference.document('bar'), + {'bazKey': 'quxValue'}, + ); + await batch.commit(); + expect( + log, + [ + isMethodCall('WriteBatch#create', arguments: { + 'app': app.name, + }), + isMethodCall( + 'WriteBatch#setData', + arguments: { + 'app': app.name, + 'handle': 1, + 'path': 'foo/bar', + 'data': {'bazKey': 'quxValue'}, + 'options': {'merge': false}, + }, + ), + isMethodCall( + 'WriteBatch#commit', + arguments: { + 'handle': 1, + }, + ), + ], + ); + }); + test('merge set', () async { + final WriteBatch batch = firestore.batch(); + batch.setData( + collectionReference.document('bar'), + {'bazKey': 'quxValue'}, + merge: true, + ); + await batch.commit(); + expect( + log, + [ + isMethodCall('WriteBatch#create', arguments: { + 'app': app.name, + }), + isMethodCall('WriteBatch#setData', arguments: { + 'app': app.name, + 'handle': 1, + 'path': 'foo/bar', + 'data': {'bazKey': 'quxValue'}, + 'options': {'merge': true}, + }), + isMethodCall( + 'WriteBatch#commit', + arguments: { + 'handle': 1, + }, + ), + ], + ); + }); + test('update', () async { + final WriteBatch batch = firestore.batch(); + batch.updateData( + collectionReference.document('bar'), + {'bazKey': 'quxValue'}, + ); + await batch.commit(); + expect( + log, + [ + isMethodCall( + 'WriteBatch#create', + arguments: { + 'app': app.name, + }, + ), + isMethodCall( + 'WriteBatch#updateData', + arguments: { + 'app': app.name, + 'handle': 1, + 'path': 'foo/bar', + 'data': {'bazKey': 'quxValue'}, + }, + ), + isMethodCall( + 'WriteBatch#commit', + arguments: { + 'handle': 1, + }, + ), + ], + ); + }); + test('delete', () async { + final WriteBatch batch = firestore.batch(); + batch.delete(collectionReference.document('bar')); + await batch.commit(); + expect( + log, + [ + isMethodCall( + 'WriteBatch#create', + arguments: { + 'app': app.name, + }, + ), + isMethodCall( + 'WriteBatch#delete', + arguments: { + 'app': app.name, + 'handle': 1, + 'path': 'foo/bar', + }, + ), + isMethodCall( + 'WriteBatch#commit', + arguments: { + 'handle': 1, + }, + ), + ], + ); + }); + }); + */ + }); + }); +} + +/* +void _checkEncodeDecode(MessageCodec codec, T message) { + final ByteData encoded = codec.encodeMessage(message); + final T decoded = codec.decodeMessage(encoded); + if (message == null) { + expect(encoded, isNull); + expect(decoded, isNull); + } else { + expect(_deepEquals(message, decoded), isTrue); + final ByteData encodedAgain = codec.encodeMessage(decoded); + expect( + encodedAgain.buffer.asUint8List(), + orderedEquals(encoded.buffer.asUint8List()), + ); + } +} + +bool _deepEquals(dynamic valueA, dynamic valueB) { + if (valueA is TypedData) + return valueB is TypedData && _deepEqualsTypedData(valueA, valueB); + if (valueA is List) return valueB is List && _deepEqualsList(valueA, valueB); + if (valueA is Map) return valueB is Map && _deepEqualsMap(valueA, valueB); + if (valueA is double && valueA.isNaN) return valueB is double && valueB.isNaN; + if (valueA is FieldValue) { + return valueB is FieldValue && _deepEqualsFieldValue(valueA, valueB); + } + if (valueA is FieldPath) + return valueB is FieldPath && valueA.type == valueB.type; + return valueA == valueB; +} + +bool _deepEqualsTypedData(TypedData valueA, TypedData valueB) { + if (valueA is ByteData) { + return valueB is ByteData && + _deepEqualsList( + valueA.buffer.asUint8List(), valueB.buffer.asUint8List()); + } + if (valueA is Uint8List) + return valueB is Uint8List && _deepEqualsList(valueA, valueB); + if (valueA is Int32List) + return valueB is Int32List && _deepEqualsList(valueA, valueB); + if (valueA is Int64List) + return valueB is Int64List && _deepEqualsList(valueA, valueB); + if (valueA is Float64List) + return valueB is Float64List && _deepEqualsList(valueA, valueB); + throw 'Unexpected typed data: $valueA'; +} + +bool _deepEqualsList(List valueA, List valueB) { + if (valueA.length != valueB.length) return false; + for (int i = 0; i < valueA.length; i++) { + if (!_deepEquals(valueA[i], valueB[i])) return false; + } + return true; +} + +bool _deepEqualsMap( + Map valueA, Map valueB) { + if (valueA.length != valueB.length) return false; + for (final dynamic key in valueA.keys) { + if (!valueB.containsKey(key) || !_deepEquals(valueA[key], valueB[key])) + return false; + } + return true; +} + +bool _deepEqualsFieldValue(FieldValue valueA, FieldValue valueB) { + if (valueA.type != valueB.type) return false; + if (valueA.value == null) return valueB.value == null; + if (valueA.value is List) return _deepEqualsList(valueA.value, valueB.value); + if (valueA.value is Map) return _deepEqualsMap(valueA.value, valueB.value); + return valueA.value == valueB.value; +} +*/