Skip to content

Commit 3615bd1

Browse files
Add Firestore snapshot listen options bindings (#126)
1 parent 69d272c commit 3615bd1

4 files changed

Lines changed: 324 additions & 0 deletions

File tree

source/Firebase/CloudFirestore/ApiDefinition.cs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,10 @@ interface DocumentReference
223223
// -(id<FIRListenerRegistration> _Nonnull)addSnapshotListenerWithIncludeMetadataChanges:(BOOL)includeMetadataChanges listener:(FIRDocumentSnapshotBlock _Nonnull)listener;
224224
[Export ("addSnapshotListenerWithIncludeMetadataChanges:listener:")]
225225
IListenerRegistration AddSnapshotListener (bool includeMetadataChanges, DocumentSnapshotHandler listener);
226+
227+
// -(id<FIRListenerRegistration> _Nonnull)addSnapshotListenerWithOptions:(FIRSnapshotListenOptions * _Nonnull)options listener:(FIRDocumentSnapshotBlock _Nonnull)listener;
228+
[Export ("addSnapshotListenerWithOptions:listener:")]
229+
IListenerRegistration AddSnapshotListener (SnapshotListenOptions options, DocumentSnapshotHandler listener);
226230
}
227231

228232
// @interface FIRDocumentSnapshot : NSObject
@@ -833,6 +837,33 @@ interface ListenerRegistration
833837
void Remove ();
834838
}
835839

840+
// @interface FIRSnapshotListenOptions : NSObject
841+
[DisableDefaultCtor]
842+
[BaseType (typeof (NSObject), Name = "FIRSnapshotListenOptions")]
843+
interface SnapshotListenOptions
844+
{
845+
// @property(nonatomic, readonly) FIRListenSource source;
846+
[Export ("source")]
847+
ListenSource Source { get; }
848+
849+
// @property(nonatomic, readonly) BOOL includeMetadataChanges;
850+
[Export ("includeMetadataChanges")]
851+
bool IncludeMetadataChanges { get; }
852+
853+
// - (instancetype _Nonnull)init __attribute__((objc_designated_initializer));
854+
[DesignatedInitializer]
855+
[Export ("init")]
856+
NativeHandle Constructor ();
857+
858+
// - (FIRSnapshotListenOptions * _Nonnull)optionsWithIncludeMetadataChanges:(BOOL)includeMetadataChanges;
859+
[Export ("optionsWithIncludeMetadataChanges:")]
860+
SnapshotListenOptions OptionsWithIncludeMetadataChanges (bool includeMetadataChanges);
861+
862+
// - (FIRSnapshotListenOptions * _Nonnull)optionsWithSource:(FIRListenSource)source;
863+
[Export ("optionsWithSource:")]
864+
SnapshotListenOptions OptionsWithSource (ListenSource source);
865+
}
866+
836867
// typedef void (^FIRQuerySnapshotBlock)(FIRQuerySnapshot * _Nullable, NSError * _Nullable);
837868
delegate void QuerySnapshotHandler ([NullAllowed] QuerySnapshot snapshot, [NullAllowed] NSError error);
838869

@@ -862,6 +893,10 @@ interface Query
862893
[Export ("addSnapshotListenerWithIncludeMetadataChanges:listener:")]
863894
IListenerRegistration AddSnapshotListener (bool includeMetadataChanges, QuerySnapshotHandler listener);
864895

896+
// -(id<FIRListenerRegistration> _Nonnull)addSnapshotListenerWithOptions:(FIRSnapshotListenOptions * _Nonnull)options listener:(FIRQuerySnapshotBlock _Nonnull)listener;
897+
[Export ("addSnapshotListenerWithOptions:listener:")]
898+
IListenerRegistration AddSnapshotListener (SnapshotListenOptions options, QuerySnapshotHandler listener);
899+
865900
// -(FIRQuery * _Nonnull)queryWhereFilter:(FIRFilter * _Nonnull)filter;
866901
[Export ("queryWhereFilter:")]
867902
Query FilteredBy (Filter filter);

source/Firebase/CloudFirestore/Enums.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,13 @@ public enum AggregateSource : ulong
5555
Server
5656
}
5757

58+
[Native]
59+
public enum ListenSource : ulong
60+
{
61+
Default,
62+
Cache
63+
}
64+
5865
[Native]
5966
public enum LoadBundleTaskState : long
6067
{

tests/E2E/Firebase.Foundation/FirebaseFoundationE2E/FirebaseRuntimeDriftCases.cs

Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,12 @@
6666
using ObjCRuntime;
6767
#endif
6868

69+
#if ENABLE_RUNTIME_DRIFT_CASE_CLOUDFIRESTORE_SNAPSHOT_LISTEN_OPTIONS
70+
using Firebase.CloudFirestore;
71+
using Foundation;
72+
using ObjCRuntime;
73+
#endif
74+
6975
#if ENABLE_RUNTIME_DRIFT_CASE_CLOUDFUNCTIONS_USEFUNCTIONSEMULATORORIGIN
7076
using Firebase.CloudFunctions;
7177
using Foundation;
@@ -1299,6 +1305,271 @@ static void RequireCount(int actual, int expected, string label)
12991305
}
13001306
#endif
13011307

1308+
#if ENABLE_RUNTIME_DRIFT_CASE_CLOUDFIRESTORE_SNAPSHOT_LISTEN_OPTIONS
1309+
static async Task<string> VerifyCloudFirestoreSnapshotListenOptionsAsync()
1310+
{
1311+
const string addSnapshotListenerWithOptionsSelector = "addSnapshotListenerWithOptions:listener:";
1312+
const string optionsWithIncludeMetadataChangesSelector = "optionsWithIncludeMetadataChanges:";
1313+
const string optionsWithSourceSelector = "optionsWithSource:";
1314+
1315+
var constructor = typeof(SnapshotListenOptions).GetConstructor(Type.EmptyTypes);
1316+
if (constructor is null)
1317+
{
1318+
throw new InvalidOperationException(
1319+
$"Expected managed API '{typeof(SnapshotListenOptions).FullName}()' was not found.");
1320+
}
1321+
1322+
var includeMetadataChangesSignature = typeof(SnapshotListenOptions).GetMethod(
1323+
nameof(SnapshotListenOptions.OptionsWithIncludeMetadataChanges),
1324+
BindingFlags.Instance | BindingFlags.Public,
1325+
binder: null,
1326+
types: new[] { typeof(bool) },
1327+
modifiers: null);
1328+
if (includeMetadataChangesSignature?.ReturnType != typeof(SnapshotListenOptions))
1329+
{
1330+
throw new InvalidOperationException(
1331+
$"Expected managed API '{nameof(SnapshotListenOptions.OptionsWithIncludeMetadataChanges)}({typeof(bool).FullName})' " +
1332+
$"to return '{typeof(SnapshotListenOptions).FullName}' for selector '{optionsWithIncludeMetadataChangesSelector}', " +
1333+
$"observed '{includeMetadataChangesSignature?.ReturnType.FullName ?? "<missing>"}'.");
1334+
}
1335+
1336+
var sourceSignature = typeof(SnapshotListenOptions).GetMethod(
1337+
nameof(SnapshotListenOptions.OptionsWithSource),
1338+
BindingFlags.Instance | BindingFlags.Public,
1339+
binder: null,
1340+
types: new[] { typeof(ListenSource) },
1341+
modifiers: null);
1342+
if (sourceSignature?.ReturnType != typeof(SnapshotListenOptions))
1343+
{
1344+
throw new InvalidOperationException(
1345+
$"Expected managed API '{nameof(SnapshotListenOptions.OptionsWithSource)}({typeof(ListenSource).FullName})' " +
1346+
$"to return '{typeof(SnapshotListenOptions).FullName}' for selector '{optionsWithSourceSelector}', " +
1347+
$"observed '{sourceSignature?.ReturnType.FullName ?? "<missing>"}'.");
1348+
}
1349+
1350+
var documentListenerSignature = typeof(DocumentReference).GetMethod(
1351+
nameof(DocumentReference.AddSnapshotListener),
1352+
BindingFlags.Instance | BindingFlags.Public,
1353+
binder: null,
1354+
types: new[] { typeof(SnapshotListenOptions), typeof(DocumentSnapshotHandler) },
1355+
modifiers: null);
1356+
if (documentListenerSignature?.ReturnType != typeof(IListenerRegistration))
1357+
{
1358+
throw new InvalidOperationException(
1359+
$"Expected managed API '{typeof(DocumentReference).FullName}.{nameof(DocumentReference.AddSnapshotListener)}" +
1360+
$"({typeof(SnapshotListenOptions).FullName}, {typeof(DocumentSnapshotHandler).FullName})' to return " +
1361+
$"'{typeof(IListenerRegistration).FullName}' for selector '{addSnapshotListenerWithOptionsSelector}', " +
1362+
$"observed '{documentListenerSignature?.ReturnType.FullName ?? "<missing>"}'.");
1363+
}
1364+
1365+
var queryListenerSignature = typeof(Query).GetMethod(
1366+
nameof(Query.AddSnapshotListener),
1367+
BindingFlags.Instance | BindingFlags.Public,
1368+
binder: null,
1369+
types: new[] { typeof(SnapshotListenOptions), typeof(QuerySnapshotHandler) },
1370+
modifiers: null);
1371+
if (queryListenerSignature?.ReturnType != typeof(IListenerRegistration))
1372+
{
1373+
throw new InvalidOperationException(
1374+
$"Expected managed API '{typeof(Query).FullName}.{nameof(Query.AddSnapshotListener)}" +
1375+
$"({typeof(SnapshotListenOptions).FullName}, {typeof(QuerySnapshotHandler).FullName})' to return " +
1376+
$"'{typeof(IListenerRegistration).FullName}' for selector '{addSnapshotListenerWithOptionsSelector}', " +
1377+
$"observed '{queryListenerSignature?.ReturnType.FullName ?? "<missing>"}'.");
1378+
}
1379+
1380+
var firestore = Firestore.SharedInstance;
1381+
if (firestore is null)
1382+
{
1383+
throw new InvalidOperationException("Firebase.CloudFirestore.Firestore.SharedInstance returned null after App.Configure().");
1384+
}
1385+
1386+
var collectionName = $"codex-snapshot-options-e2e-{Guid.NewGuid():N}";
1387+
var collection = firestore.GetCollection(collectionName);
1388+
if (collection is null)
1389+
{
1390+
throw new InvalidOperationException("Firebase.CloudFirestore.Firestore.GetCollection returned null.");
1391+
}
1392+
1393+
var document = collection.GetDocument("listener-target");
1394+
if (document is null)
1395+
{
1396+
throw new InvalidOperationException("Firebase.CloudFirestore.CollectionReference.GetDocument returned null.");
1397+
}
1398+
1399+
using var defaultOptions = new SnapshotListenOptions();
1400+
if (defaultOptions.Source != ListenSource.Default)
1401+
{
1402+
throw new InvalidOperationException(
1403+
$"New SnapshotListenOptions.Source returned '{defaultOptions.Source}', expected '{ListenSource.Default}'.");
1404+
}
1405+
1406+
if (defaultOptions.IncludeMetadataChanges)
1407+
{
1408+
throw new InvalidOperationException("New SnapshotListenOptions.IncludeMetadataChanges returned true, expected false.");
1409+
}
1410+
1411+
if (!defaultOptions.RespondsToSelector(new Selector(optionsWithIncludeMetadataChangesSelector)))
1412+
{
1413+
throw new InvalidOperationException(
1414+
$"Native FIRSnapshotListenOptions does not respond to expected selector '{optionsWithIncludeMetadataChangesSelector}'.");
1415+
}
1416+
1417+
if (!defaultOptions.RespondsToSelector(new Selector(optionsWithSourceSelector)))
1418+
{
1419+
throw new InvalidOperationException(
1420+
$"Native FIRSnapshotListenOptions does not respond to expected selector '{optionsWithSourceSelector}'.");
1421+
}
1422+
1423+
if (!document.RespondsToSelector(new Selector(addSnapshotListenerWithOptionsSelector)))
1424+
{
1425+
throw new InvalidOperationException(
1426+
$"Native FIRDocumentReference does not respond to expected selector '{addSnapshotListenerWithOptionsSelector}'.");
1427+
}
1428+
1429+
if (!collection.RespondsToSelector(new Selector(addSnapshotListenerWithOptionsSelector)))
1430+
{
1431+
throw new InvalidOperationException(
1432+
$"Native FIRQuery does not respond to expected selector '{addSnapshotListenerWithOptionsSelector}'.");
1433+
}
1434+
1435+
NSException? marshaledException = null;
1436+
MarshalObjectiveCExceptionMode? marshaledExceptionMode = null;
1437+
1438+
void OnMarshalObjectiveCException(object? sender, MarshalObjectiveCExceptionEventArgs args)
1439+
{
1440+
marshaledException ??= args.Exception;
1441+
marshaledExceptionMode ??= args.ExceptionMode;
1442+
}
1443+
1444+
Runtime.MarshalObjectiveCException += OnMarshalObjectiveCException;
1445+
IListenerRegistration? documentRegistration = null;
1446+
IListenerRegistration? queryRegistration = null;
1447+
var documentCallbackSource = new TaskCompletionSource<(DocumentSnapshot? Snapshot, NSError? Error)>(TaskCreationOptions.RunContinuationsAsynchronously);
1448+
var queryCallbackSource = new TaskCompletionSource<(QuerySnapshot? Snapshot, NSError? Error)>(TaskCreationOptions.RunContinuationsAsynchronously);
1449+
1450+
try
1451+
{
1452+
SnapshotListenOptions metadataOptions;
1453+
SnapshotListenOptions cacheOptions;
1454+
try
1455+
{
1456+
metadataOptions = defaultOptions.OptionsWithIncludeMetadataChanges(true);
1457+
cacheOptions = metadataOptions.OptionsWithSource(ListenSource.Cache);
1458+
1459+
if (metadataOptions is null)
1460+
{
1461+
throw new InvalidOperationException($"Selector '{optionsWithIncludeMetadataChangesSelector}' returned null.");
1462+
}
1463+
1464+
if (cacheOptions is null)
1465+
{
1466+
throw new InvalidOperationException($"Selector '{optionsWithSourceSelector}' returned null.");
1467+
}
1468+
1469+
if (!metadataOptions.IncludeMetadataChanges)
1470+
{
1471+
throw new InvalidOperationException(
1472+
$"Selector '{optionsWithIncludeMetadataChangesSelector}' returned options with IncludeMetadataChanges=false.");
1473+
}
1474+
1475+
if (cacheOptions.Source != ListenSource.Cache)
1476+
{
1477+
throw new InvalidOperationException(
1478+
$"Selector '{optionsWithSourceSelector}' returned options with Source='{cacheOptions.Source}', expected '{ListenSource.Cache}'.");
1479+
}
1480+
1481+
if (!cacheOptions.IncludeMetadataChanges)
1482+
{
1483+
throw new InvalidOperationException(
1484+
$"Selector '{optionsWithSourceSelector}' did not preserve IncludeMetadataChanges=true.");
1485+
}
1486+
1487+
documentRegistration = document.AddSnapshotListener(cacheOptions, (snapshot, error) =>
1488+
{
1489+
documentCallbackSource.TrySetResult((snapshot, error));
1490+
});
1491+
queryRegistration = collection.AddSnapshotListener(cacheOptions, (snapshot, error) =>
1492+
{
1493+
queryCallbackSource.TrySetResult((snapshot, error));
1494+
});
1495+
}
1496+
catch (ObjCException ex)
1497+
{
1498+
throw new InvalidOperationException(
1499+
$"Firestore snapshot listen option selectors should not throw after the missing bindings are added, but observed {ex.GetType().FullName}. " +
1500+
$"Selectors exercised: '{optionsWithIncludeMetadataChangesSelector}', '{optionsWithSourceSelector}', '{addSnapshotListenerWithOptionsSelector}'. " +
1501+
$"NSException.Name: {FormatDetail(marshaledException?.Name?.ToString())}. " +
1502+
$"NSException.Reason: {FormatDetail(marshaledException?.Reason)}. " +
1503+
$"Marshal mode: {FormatDetail(marshaledExceptionMode?.ToString())}.",
1504+
ex);
1505+
}
1506+
1507+
if (documentRegistration is null)
1508+
{
1509+
throw new InvalidOperationException(
1510+
$"DocumentReference selector '{addSnapshotListenerWithOptionsSelector}' returned null listener registration.");
1511+
}
1512+
1513+
if (queryRegistration is null)
1514+
{
1515+
throw new InvalidOperationException(
1516+
$"Query selector '{addSnapshotListenerWithOptionsSelector}' returned null listener registration.");
1517+
}
1518+
1519+
await Task.WhenAny(
1520+
Task.WhenAll(documentCallbackSource.Task, queryCallbackSource.Task),
1521+
Task.Delay(TimeSpan.FromMilliseconds(500)));
1522+
1523+
if (marshaledException is not null)
1524+
{
1525+
throw new InvalidOperationException(
1526+
$"Firestore snapshot listen option selectors completed, but Runtime.MarshalObjectiveCException captured unexpected NSException.Name '{marshaledException.Name}'. " +
1527+
$"Reason: {FormatDetail(marshaledException.Reason)}. Marshal mode: {FormatDetail(marshaledExceptionMode?.ToString())}.");
1528+
}
1529+
1530+
var documentCallbackDetail = documentCallbackSource.Task.IsCompletedSuccessfully
1531+
? FormatDocumentCallback(documentCallbackSource.Task.Result)
1532+
: "not observed before listener removal";
1533+
var queryCallbackDetail = queryCallbackSource.Task.IsCompletedSuccessfully
1534+
? FormatQueryCallback(queryCallbackSource.Task.Result)
1535+
: "not observed before listener removal";
1536+
1537+
return
1538+
$"Firestore snapshot listen option APIs crossed the native selector boundary. " +
1539+
$"Options: Source={ListenSource.Cache}, IncludeMetadataChanges=true. " +
1540+
$"Document registration type: {documentRegistration.GetType().FullName}. " +
1541+
$"Query registration type: {queryRegistration.GetType().FullName}. " +
1542+
$"Document callback: {documentCallbackDetail}. Query callback: {queryCallbackDetail}.";
1543+
}
1544+
finally
1545+
{
1546+
try
1547+
{
1548+
documentRegistration?.Remove();
1549+
}
1550+
finally
1551+
{
1552+
queryRegistration?.Remove();
1553+
Runtime.MarshalObjectiveCException -= OnMarshalObjectiveCException;
1554+
}
1555+
}
1556+
1557+
static string FormatDocumentCallback((DocumentSnapshot? Snapshot, NSError? Error) callback)
1558+
{
1559+
return callback.Error is null
1560+
? $"snapshot type {callback.Snapshot?.GetType().FullName ?? "<null>"}"
1561+
: $"Firebase error {FormatNSError(callback.Error)}";
1562+
}
1563+
1564+
static string FormatQueryCallback((QuerySnapshot? Snapshot, NSError? Error) callback)
1565+
{
1566+
return callback.Error is null
1567+
? $"snapshot type {callback.Snapshot?.GetType().FullName ?? "<null>"}"
1568+
: $"Firebase error {FormatNSError(callback.Error)}";
1569+
}
1570+
}
1571+
#endif
1572+
13021573
#if ENABLE_RUNTIME_DRIFT_CASE_CLOUDFIRESTORE_AGGREGATE_QUERY
13031574
static async Task<string> VerifyCloudFirestoreAggregateQueryAsync()
13041575
{

tests/E2E/Firebase.Foundation/runtime-drift-cases.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,17 @@
115115
}
116116
]
117117
},
118+
{
119+
"id": "cloudfirestore-snapshot-listen-options",
120+
"method": "VerifyCloudFirestoreSnapshotListenOptionsAsync",
121+
"bindingPackage": "AdamE.Firebase.iOS.CloudFirestore",
122+
"packages": [
123+
{
124+
"id": "AdamE.Firebase.iOS.CloudFirestore",
125+
"version": "12.6.0"
126+
}
127+
]
128+
},
118129
{
119130
"id": "cloudfunctions-usefunctionsemulatororigin",
120131
"method": "VerifyCloudFunctionsUseFunctionsEmulatorOriginAsync",

0 commit comments

Comments
 (0)