From f8210eb27b8b00e803bbb07a7b9e7d06ea89bfe3 Mon Sep 17 00:00:00 2001 From: Adam Essenmacher Date: Sat, 11 Apr 2026 17:29:19 -0400 Subject: [PATCH] Add Firestore snapshot listen options bindings --- .../Firebase/CloudFirestore/ApiDefinition.cs | 35 +++ source/Firebase/CloudFirestore/Enums.cs | 7 + .../FirebaseRuntimeDriftCases.cs | 271 ++++++++++++++++++ .../runtime-drift-cases.json | 11 + 4 files changed, 324 insertions(+) diff --git a/source/Firebase/CloudFirestore/ApiDefinition.cs b/source/Firebase/CloudFirestore/ApiDefinition.cs index 0ef81ebc..8bff004d 100644 --- a/source/Firebase/CloudFirestore/ApiDefinition.cs +++ b/source/Firebase/CloudFirestore/ApiDefinition.cs @@ -223,6 +223,10 @@ interface DocumentReference // -(id _Nonnull)addSnapshotListenerWithIncludeMetadataChanges:(BOOL)includeMetadataChanges listener:(FIRDocumentSnapshotBlock _Nonnull)listener; [Export ("addSnapshotListenerWithIncludeMetadataChanges:listener:")] IListenerRegistration AddSnapshotListener (bool includeMetadataChanges, DocumentSnapshotHandler listener); + + // -(id _Nonnull)addSnapshotListenerWithOptions:(FIRSnapshotListenOptions * _Nonnull)options listener:(FIRDocumentSnapshotBlock _Nonnull)listener; + [Export ("addSnapshotListenerWithOptions:listener:")] + IListenerRegistration AddSnapshotListener (SnapshotListenOptions options, DocumentSnapshotHandler listener); } // @interface FIRDocumentSnapshot : NSObject @@ -833,6 +837,33 @@ interface ListenerRegistration void Remove (); } + // @interface FIRSnapshotListenOptions : NSObject + [DisableDefaultCtor] + [BaseType (typeof (NSObject), Name = "FIRSnapshotListenOptions")] + interface SnapshotListenOptions + { + // @property(nonatomic, readonly) FIRListenSource source; + [Export ("source")] + ListenSource Source { get; } + + // @property(nonatomic, readonly) BOOL includeMetadataChanges; + [Export ("includeMetadataChanges")] + bool IncludeMetadataChanges { get; } + + // - (instancetype _Nonnull)init __attribute__((objc_designated_initializer)); + [DesignatedInitializer] + [Export ("init")] + NativeHandle Constructor (); + + // - (FIRSnapshotListenOptions * _Nonnull)optionsWithIncludeMetadataChanges:(BOOL)includeMetadataChanges; + [Export ("optionsWithIncludeMetadataChanges:")] + SnapshotListenOptions OptionsWithIncludeMetadataChanges (bool includeMetadataChanges); + + // - (FIRSnapshotListenOptions * _Nonnull)optionsWithSource:(FIRListenSource)source; + [Export ("optionsWithSource:")] + SnapshotListenOptions OptionsWithSource (ListenSource source); + } + // typedef void (^FIRQuerySnapshotBlock)(FIRQuerySnapshot * _Nullable, NSError * _Nullable); delegate void QuerySnapshotHandler ([NullAllowed] QuerySnapshot snapshot, [NullAllowed] NSError error); @@ -862,6 +893,10 @@ interface Query [Export ("addSnapshotListenerWithIncludeMetadataChanges:listener:")] IListenerRegistration AddSnapshotListener (bool includeMetadataChanges, QuerySnapshotHandler listener); + // -(id _Nonnull)addSnapshotListenerWithOptions:(FIRSnapshotListenOptions * _Nonnull)options listener:(FIRQuerySnapshotBlock _Nonnull)listener; + [Export ("addSnapshotListenerWithOptions:listener:")] + IListenerRegistration AddSnapshotListener (SnapshotListenOptions options, QuerySnapshotHandler listener); + // -(FIRQuery * _Nonnull)queryWhereFilter:(FIRFilter * _Nonnull)filter; [Export ("queryWhereFilter:")] Query FilteredBy (Filter filter); diff --git a/source/Firebase/CloudFirestore/Enums.cs b/source/Firebase/CloudFirestore/Enums.cs index 8ed570bb..ac8df43c 100644 --- a/source/Firebase/CloudFirestore/Enums.cs +++ b/source/Firebase/CloudFirestore/Enums.cs @@ -55,6 +55,13 @@ public enum AggregateSource : ulong Server } + [Native] + public enum ListenSource : ulong + { + Default, + Cache + } + [Native] public enum LoadBundleTaskState : long { diff --git a/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseRuntimeDriftCases.cs b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseRuntimeDriftCases.cs index e9d09afa..1a141bff 100644 --- a/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseRuntimeDriftCases.cs +++ b/tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseRuntimeDriftCases.cs @@ -66,6 +66,12 @@ using ObjCRuntime; #endif +#if ENABLE_RUNTIME_DRIFT_CASE_CLOUDFIRESTORE_SNAPSHOT_LISTEN_OPTIONS +using Firebase.CloudFirestore; +using Foundation; +using ObjCRuntime; +#endif + #if ENABLE_RUNTIME_DRIFT_CASE_CLOUDFUNCTIONS_USEFUNCTIONSEMULATORORIGIN using Firebase.CloudFunctions; using Foundation; @@ -1299,6 +1305,271 @@ static void RequireCount(int actual, int expected, string label) } #endif +#if ENABLE_RUNTIME_DRIFT_CASE_CLOUDFIRESTORE_SNAPSHOT_LISTEN_OPTIONS + static async Task VerifyCloudFirestoreSnapshotListenOptionsAsync() + { + const string addSnapshotListenerWithOptionsSelector = "addSnapshotListenerWithOptions:listener:"; + const string optionsWithIncludeMetadataChangesSelector = "optionsWithIncludeMetadataChanges:"; + const string optionsWithSourceSelector = "optionsWithSource:"; + + var constructor = typeof(SnapshotListenOptions).GetConstructor(Type.EmptyTypes); + if (constructor is null) + { + throw new InvalidOperationException( + $"Expected managed API '{typeof(SnapshotListenOptions).FullName}()' was not found."); + } + + var includeMetadataChangesSignature = typeof(SnapshotListenOptions).GetMethod( + nameof(SnapshotListenOptions.OptionsWithIncludeMetadataChanges), + BindingFlags.Instance | BindingFlags.Public, + binder: null, + types: new[] { typeof(bool) }, + modifiers: null); + if (includeMetadataChangesSignature?.ReturnType != typeof(SnapshotListenOptions)) + { + throw new InvalidOperationException( + $"Expected managed API '{nameof(SnapshotListenOptions.OptionsWithIncludeMetadataChanges)}({typeof(bool).FullName})' " + + $"to return '{typeof(SnapshotListenOptions).FullName}' for selector '{optionsWithIncludeMetadataChangesSelector}', " + + $"observed '{includeMetadataChangesSignature?.ReturnType.FullName ?? ""}'."); + } + + var sourceSignature = typeof(SnapshotListenOptions).GetMethod( + nameof(SnapshotListenOptions.OptionsWithSource), + BindingFlags.Instance | BindingFlags.Public, + binder: null, + types: new[] { typeof(ListenSource) }, + modifiers: null); + if (sourceSignature?.ReturnType != typeof(SnapshotListenOptions)) + { + throw new InvalidOperationException( + $"Expected managed API '{nameof(SnapshotListenOptions.OptionsWithSource)}({typeof(ListenSource).FullName})' " + + $"to return '{typeof(SnapshotListenOptions).FullName}' for selector '{optionsWithSourceSelector}', " + + $"observed '{sourceSignature?.ReturnType.FullName ?? ""}'."); + } + + var documentListenerSignature = typeof(DocumentReference).GetMethod( + nameof(DocumentReference.AddSnapshotListener), + BindingFlags.Instance | BindingFlags.Public, + binder: null, + types: new[] { typeof(SnapshotListenOptions), typeof(DocumentSnapshotHandler) }, + modifiers: null); + if (documentListenerSignature?.ReturnType != typeof(IListenerRegistration)) + { + throw new InvalidOperationException( + $"Expected managed API '{typeof(DocumentReference).FullName}.{nameof(DocumentReference.AddSnapshotListener)}" + + $"({typeof(SnapshotListenOptions).FullName}, {typeof(DocumentSnapshotHandler).FullName})' to return " + + $"'{typeof(IListenerRegistration).FullName}' for selector '{addSnapshotListenerWithOptionsSelector}', " + + $"observed '{documentListenerSignature?.ReturnType.FullName ?? ""}'."); + } + + var queryListenerSignature = typeof(Query).GetMethod( + nameof(Query.AddSnapshotListener), + BindingFlags.Instance | BindingFlags.Public, + binder: null, + types: new[] { typeof(SnapshotListenOptions), typeof(QuerySnapshotHandler) }, + modifiers: null); + if (queryListenerSignature?.ReturnType != typeof(IListenerRegistration)) + { + throw new InvalidOperationException( + $"Expected managed API '{typeof(Query).FullName}.{nameof(Query.AddSnapshotListener)}" + + $"({typeof(SnapshotListenOptions).FullName}, {typeof(QuerySnapshotHandler).FullName})' to return " + + $"'{typeof(IListenerRegistration).FullName}' for selector '{addSnapshotListenerWithOptionsSelector}', " + + $"observed '{queryListenerSignature?.ReturnType.FullName ?? ""}'."); + } + + var firestore = Firestore.SharedInstance; + if (firestore is null) + { + throw new InvalidOperationException("Firebase.CloudFirestore.Firestore.SharedInstance returned null after App.Configure()."); + } + + var collectionName = $"codex-snapshot-options-e2e-{Guid.NewGuid():N}"; + var collection = firestore.GetCollection(collectionName); + if (collection is null) + { + throw new InvalidOperationException("Firebase.CloudFirestore.Firestore.GetCollection returned null."); + } + + var document = collection.GetDocument("listener-target"); + if (document is null) + { + throw new InvalidOperationException("Firebase.CloudFirestore.CollectionReference.GetDocument returned null."); + } + + using var defaultOptions = new SnapshotListenOptions(); + if (defaultOptions.Source != ListenSource.Default) + { + throw new InvalidOperationException( + $"New SnapshotListenOptions.Source returned '{defaultOptions.Source}', expected '{ListenSource.Default}'."); + } + + if (defaultOptions.IncludeMetadataChanges) + { + throw new InvalidOperationException("New SnapshotListenOptions.IncludeMetadataChanges returned true, expected false."); + } + + if (!defaultOptions.RespondsToSelector(new Selector(optionsWithIncludeMetadataChangesSelector))) + { + throw new InvalidOperationException( + $"Native FIRSnapshotListenOptions does not respond to expected selector '{optionsWithIncludeMetadataChangesSelector}'."); + } + + if (!defaultOptions.RespondsToSelector(new Selector(optionsWithSourceSelector))) + { + throw new InvalidOperationException( + $"Native FIRSnapshotListenOptions does not respond to expected selector '{optionsWithSourceSelector}'."); + } + + if (!document.RespondsToSelector(new Selector(addSnapshotListenerWithOptionsSelector))) + { + throw new InvalidOperationException( + $"Native FIRDocumentReference does not respond to expected selector '{addSnapshotListenerWithOptionsSelector}'."); + } + + if (!collection.RespondsToSelector(new Selector(addSnapshotListenerWithOptionsSelector))) + { + throw new InvalidOperationException( + $"Native FIRQuery does not respond to expected selector '{addSnapshotListenerWithOptionsSelector}'."); + } + + NSException? marshaledException = null; + MarshalObjectiveCExceptionMode? marshaledExceptionMode = null; + + void OnMarshalObjectiveCException(object? sender, MarshalObjectiveCExceptionEventArgs args) + { + marshaledException ??= args.Exception; + marshaledExceptionMode ??= args.ExceptionMode; + } + + Runtime.MarshalObjectiveCException += OnMarshalObjectiveCException; + IListenerRegistration? documentRegistration = null; + IListenerRegistration? queryRegistration = null; + var documentCallbackSource = new TaskCompletionSource<(DocumentSnapshot? Snapshot, NSError? Error)>(TaskCreationOptions.RunContinuationsAsynchronously); + var queryCallbackSource = new TaskCompletionSource<(QuerySnapshot? Snapshot, NSError? Error)>(TaskCreationOptions.RunContinuationsAsynchronously); + + try + { + SnapshotListenOptions metadataOptions; + SnapshotListenOptions cacheOptions; + try + { + metadataOptions = defaultOptions.OptionsWithIncludeMetadataChanges(true); + cacheOptions = metadataOptions.OptionsWithSource(ListenSource.Cache); + + if (metadataOptions is null) + { + throw new InvalidOperationException($"Selector '{optionsWithIncludeMetadataChangesSelector}' returned null."); + } + + if (cacheOptions is null) + { + throw new InvalidOperationException($"Selector '{optionsWithSourceSelector}' returned null."); + } + + if (!metadataOptions.IncludeMetadataChanges) + { + throw new InvalidOperationException( + $"Selector '{optionsWithIncludeMetadataChangesSelector}' returned options with IncludeMetadataChanges=false."); + } + + if (cacheOptions.Source != ListenSource.Cache) + { + throw new InvalidOperationException( + $"Selector '{optionsWithSourceSelector}' returned options with Source='{cacheOptions.Source}', expected '{ListenSource.Cache}'."); + } + + if (!cacheOptions.IncludeMetadataChanges) + { + throw new InvalidOperationException( + $"Selector '{optionsWithSourceSelector}' did not preserve IncludeMetadataChanges=true."); + } + + documentRegistration = document.AddSnapshotListener(cacheOptions, (snapshot, error) => + { + documentCallbackSource.TrySetResult((snapshot, error)); + }); + queryRegistration = collection.AddSnapshotListener(cacheOptions, (snapshot, error) => + { + queryCallbackSource.TrySetResult((snapshot, error)); + }); + } + catch (ObjCException ex) + { + throw new InvalidOperationException( + $"Firestore snapshot listen option selectors should not throw after the missing bindings are added, but observed {ex.GetType().FullName}. " + + $"Selectors exercised: '{optionsWithIncludeMetadataChangesSelector}', '{optionsWithSourceSelector}', '{addSnapshotListenerWithOptionsSelector}'. " + + $"NSException.Name: {FormatDetail(marshaledException?.Name?.ToString())}. " + + $"NSException.Reason: {FormatDetail(marshaledException?.Reason)}. " + + $"Marshal mode: {FormatDetail(marshaledExceptionMode?.ToString())}.", + ex); + } + + if (documentRegistration is null) + { + throw new InvalidOperationException( + $"DocumentReference selector '{addSnapshotListenerWithOptionsSelector}' returned null listener registration."); + } + + if (queryRegistration is null) + { + throw new InvalidOperationException( + $"Query selector '{addSnapshotListenerWithOptionsSelector}' returned null listener registration."); + } + + await Task.WhenAny( + Task.WhenAll(documentCallbackSource.Task, queryCallbackSource.Task), + Task.Delay(TimeSpan.FromMilliseconds(500))); + + if (marshaledException is not null) + { + throw new InvalidOperationException( + $"Firestore snapshot listen option selectors completed, but Runtime.MarshalObjectiveCException captured unexpected NSException.Name '{marshaledException.Name}'. " + + $"Reason: {FormatDetail(marshaledException.Reason)}. Marshal mode: {FormatDetail(marshaledExceptionMode?.ToString())}."); + } + + var documentCallbackDetail = documentCallbackSource.Task.IsCompletedSuccessfully + ? FormatDocumentCallback(documentCallbackSource.Task.Result) + : "not observed before listener removal"; + var queryCallbackDetail = queryCallbackSource.Task.IsCompletedSuccessfully + ? FormatQueryCallback(queryCallbackSource.Task.Result) + : "not observed before listener removal"; + + return + $"Firestore snapshot listen option APIs crossed the native selector boundary. " + + $"Options: Source={ListenSource.Cache}, IncludeMetadataChanges=true. " + + $"Document registration type: {documentRegistration.GetType().FullName}. " + + $"Query registration type: {queryRegistration.GetType().FullName}. " + + $"Document callback: {documentCallbackDetail}. Query callback: {queryCallbackDetail}."; + } + finally + { + try + { + documentRegistration?.Remove(); + } + finally + { + queryRegistration?.Remove(); + Runtime.MarshalObjectiveCException -= OnMarshalObjectiveCException; + } + } + + static string FormatDocumentCallback((DocumentSnapshot? Snapshot, NSError? Error) callback) + { + return callback.Error is null + ? $"snapshot type {callback.Snapshot?.GetType().FullName ?? ""}" + : $"Firebase error {FormatNSError(callback.Error)}"; + } + + static string FormatQueryCallback((QuerySnapshot? Snapshot, NSError? Error) callback) + { + return callback.Error is null + ? $"snapshot type {callback.Snapshot?.GetType().FullName ?? ""}" + : $"Firebase error {FormatNSError(callback.Error)}"; + } + } +#endif + #if ENABLE_RUNTIME_DRIFT_CASE_CLOUDFIRESTORE_AGGREGATE_QUERY static async Task VerifyCloudFirestoreAggregateQueryAsync() { diff --git a/tests/E2E/Firebase.Foundation/runtime-drift-cases.json b/tests/E2E/Firebase.Foundation/runtime-drift-cases.json index 23aa1190..925ab906 100644 --- a/tests/E2E/Firebase.Foundation/runtime-drift-cases.json +++ b/tests/E2E/Firebase.Foundation/runtime-drift-cases.json @@ -115,6 +115,17 @@ } ] }, + { + "id": "cloudfirestore-snapshot-listen-options", + "method": "VerifyCloudFirestoreSnapshotListenOptionsAsync", + "bindingPackage": "AdamE.Firebase.iOS.CloudFirestore", + "packages": [ + { + "id": "AdamE.Firebase.iOS.CloudFirestore", + "version": "12.6.0" + } + ] + }, { "id": "cloudfunctions-usefunctionsemulatororigin", "method": "VerifyCloudFunctionsUseFunctionsEmulatorOriginAsync",