cordova-plugin-firebasex-firestore 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,754 @@
1
+ package org.apache.cordova.firebasex;
2
+
3
+ import android.util.Log;
4
+
5
+ import androidx.annotation.NonNull;
6
+ import androidx.annotation.Nullable;
7
+
8
+ import com.google.android.gms.tasks.OnCompleteListener;
9
+ import com.google.android.gms.tasks.OnFailureListener;
10
+ import com.google.android.gms.tasks.OnSuccessListener;
11
+ import com.google.android.gms.tasks.Task;
12
+ import com.google.firebase.Timestamp;
13
+ import com.google.firebase.firestore.CollectionReference;
14
+ import com.google.firebase.firestore.DocumentChange;
15
+ import com.google.firebase.firestore.DocumentReference;
16
+ import com.google.firebase.firestore.DocumentSnapshot;
17
+ import com.google.firebase.firestore.EventListener;
18
+ import com.google.firebase.firestore.FirebaseFirestore;
19
+ import com.google.firebase.firestore.FirebaseFirestoreException;
20
+ import com.google.firebase.firestore.ListenerRegistration;
21
+ import com.google.firebase.firestore.MetadataChanges;
22
+ import com.google.firebase.firestore.Query;
23
+ import com.google.firebase.firestore.QueryDocumentSnapshot;
24
+ import com.google.firebase.firestore.QuerySnapshot;
25
+ import com.google.firebase.firestore.Query.Direction;
26
+ import com.google.gson.Gson;
27
+ import com.google.gson.reflect.TypeToken;
28
+
29
+ import org.apache.cordova.CallbackContext;
30
+ import org.apache.cordova.CordovaPlugin;
31
+ import org.apache.cordova.PluginResult;
32
+ import org.json.JSONArray;
33
+ import org.json.JSONException;
34
+ import org.json.JSONObject;
35
+
36
+ import java.lang.reflect.Type;
37
+ import java.util.Date;
38
+ import java.util.HashMap;
39
+ import java.util.Map;
40
+ import java.util.Objects;
41
+ import java.util.Random;
42
+ import java.util.Set;
43
+
44
+ /**
45
+ * Cordova plugin for Cloud Firestore on Android.
46
+ *
47
+ * <p>Provides CRUD operations on documents and collections, compound queries
48
+ * with where/orderBy/startAt/endAt/limit filters, and real-time snapshot
49
+ * listeners with change tracking.
50
+ *
51
+ * @see <a href="https://firebase.google.com/docs/firestore">Cloud Firestore</a>
52
+ */
53
+ public class FirebasexFirestorePlugin extends CordovaPlugin {
54
+
55
+ /** Log tag for all messages from this plugin. */
56
+ private static final String TAG = "FirebasexFirestore";
57
+
58
+ /** Cloud Firestore instance. */
59
+ private FirebaseFirestore firestore;
60
+
61
+ /** Registry of active snapshot listeners keyed by generated string IDs. */
62
+ private Map<String, ListenerRegistration> firestoreListeners = new HashMap<String, ListenerRegistration>();
63
+
64
+ /** Gson instance for JSON-Map conversions. */
65
+ private Gson gson = new Gson();
66
+
67
+ /** Initialises the plugin and obtains the Firestore instance. */
68
+ @Override
69
+ protected void pluginInitialize() {
70
+ Log.d(TAG, "pluginInitialize");
71
+ firestore = FirebaseFirestore.getInstance();
72
+ }
73
+
74
+ /**
75
+ * Dispatches Cordova actions to plugin methods.
76
+ *
77
+ * <p>Supported actions: addDocumentToFirestoreCollection, setDocumentInFirestoreCollection,
78
+ * updateDocumentInFirestoreCollection, deleteDocumentFromFirestoreCollection,
79
+ * documentExistsInFirestoreCollection, fetchDocumentInFirestoreCollection,
80
+ * fetchFirestoreCollection, listenToDocumentInFirestoreCollection,
81
+ * listenToFirestoreCollection, removeFirestoreListener.
82
+ */
83
+ @Override
84
+ public boolean execute(String action, JSONArray args, CallbackContext callbackContext) throws JSONException {
85
+ switch (action) {
86
+ case "addDocumentToFirestoreCollection":
87
+ this.addDocumentToFirestoreCollection(args, callbackContext);
88
+ return true;
89
+ case "setDocumentInFirestoreCollection":
90
+ this.setDocumentInFirestoreCollection(args, callbackContext);
91
+ return true;
92
+ case "updateDocumentInFirestoreCollection":
93
+ this.updateDocumentInFirestoreCollection(args, callbackContext);
94
+ return true;
95
+ case "deleteDocumentFromFirestoreCollection":
96
+ this.deleteDocumentFromFirestoreCollection(args, callbackContext);
97
+ return true;
98
+ case "documentExistsInFirestoreCollection":
99
+ this.documentExistsInFirestoreCollection(args, callbackContext);
100
+ return true;
101
+ case "fetchDocumentInFirestoreCollection":
102
+ this.fetchDocumentInFirestoreCollection(args, callbackContext);
103
+ return true;
104
+ case "fetchFirestoreCollection":
105
+ this.fetchFirestoreCollection(args, callbackContext);
106
+ return true;
107
+ case "listenToDocumentInFirestoreCollection":
108
+ this.listenToDocumentInFirestoreCollection(args, callbackContext);
109
+ return true;
110
+ case "listenToFirestoreCollection":
111
+ this.listenToFirestoreCollection(args, callbackContext);
112
+ return true;
113
+ case "removeFirestoreListener":
114
+ this.removeFirestoreListener(args, callbackContext);
115
+ return true;
116
+ default:
117
+ return false;
118
+ }
119
+ }
120
+
121
+ /** Converts a boolean to an integer for Cordova plugin results (1 = true, 0 = false). */
122
+ private int conformBooleanForPluginResult(boolean value) {
123
+ return value ? 1 : 0;
124
+ }
125
+
126
+ /** Sends a JSON result to the JS callback while keeping the callback alive for further events. */
127
+ private void sendPluginResultAndKeepCallback(JSONObject result, CallbackContext callbackContext) {
128
+ PluginResult pluginResult = new PluginResult(PluginResult.Status.OK, result);
129
+ pluginResult.setKeepCallback(true);
130
+ callbackContext.sendPluginResult(pluginResult);
131
+ }
132
+
133
+ /** Generates a random numeric ID string for listener registration. */
134
+ private String generateId() {
135
+ Random r = new Random();
136
+ return Integer.toString(r.nextInt(1000 + 1));
137
+ }
138
+
139
+ // Firestore data conversion helpers
140
+
141
+ /** Deserialises a JSON string into a Map using Gson. */
142
+ private Map<String, Object> jsonStringToMap(String jsonString) throws JSONException {
143
+ Type type = new TypeToken<Map<String, Object>>() {}.getType();
144
+ return gson.fromJson(jsonString, type);
145
+ }
146
+
147
+ /** Sanitises a Firestore data map and converts it to a JSONObject. */
148
+ private JSONObject mapFirestoreDataToJsonObject(Map<String, Object> map) throws JSONException {
149
+ map = sanitiseFirestoreHashMap(map);
150
+ return mapToJsonObject(map);
151
+ }
152
+
153
+ /**
154
+ * Recursively sanitises Firestore-specific types in a HashMap.
155
+ * Converts {@link DocumentReference} values to their path strings.
156
+ */
157
+ private Map<String, Object> sanitiseFirestoreHashMap(Map<String, Object> map) {
158
+ Set<String> keys = map.keySet();
159
+ for (String key : keys) {
160
+ Object value = map.get(key);
161
+ if (value instanceof DocumentReference) {
162
+ map.put(key, ((DocumentReference) value).getPath());
163
+ } else if (value instanceof HashMap) {
164
+ map.put(key, sanitiseFirestoreHashMap((Map<String, Object>) value));
165
+ }
166
+ }
167
+ return map;
168
+ }
169
+
170
+ /** Converts a Map to a JSONObject via Gson serialisation. */
171
+ private JSONObject mapToJsonObject(Map<String, Object> map) throws JSONException {
172
+ String jsonString = gson.toJson(map);
173
+ return new JSONObject(jsonString);
174
+ }
175
+
176
+ // Firestore listener management
177
+
178
+ /**
179
+ * Saves a listener registration and returns a generated ID.
180
+ *
181
+ * @param listenerRegistration the Firestore listener to store
182
+ * @return the generated string ID for later removal
183
+ */
184
+ private String saveFirestoreListener(ListenerRegistration listenerRegistration) {
185
+ String id = this.generateId();
186
+ this.firestoreListeners.put(id, listenerRegistration);
187
+ return id;
188
+ }
189
+
190
+ /**
191
+ * Removes and detaches a Firestore listener by its ID.
192
+ *
193
+ * @param id the listener ID
194
+ * @return {@code true} if a listener was found and removed
195
+ */
196
+ private boolean removeFirestoreListenerById(String id) {
197
+ boolean removed = false;
198
+ if (this.firestoreListeners.containsKey(id)) {
199
+ ListenerRegistration listenerRegistration = this.firestoreListeners.get(id);
200
+ if (listenerRegistration != null) {
201
+ listenerRegistration.remove();
202
+ }
203
+ this.firestoreListeners.remove(id);
204
+ removed = true;
205
+ }
206
+ return removed;
207
+ }
208
+
209
+ // Query filter helpers
210
+
211
+ /**
212
+ * Applies an array of filter definitions to a Firestore query.
213
+ *
214
+ * <p>Supported filter types:
215
+ * <ul>
216
+ * <li>{@code ["where", fieldName, operator, value, type]} — <, >, <=, >=, ==, array-contains</li>
217
+ * <li>{@code ["orderBy", fieldName, direction]} — direction: "asc" or "desc"</li>
218
+ * <li>{@code ["startAt", value, type]} / {@code ["endAt", value, type]}</li>
219
+ * <li>{@code ["limit", count]}</li>
220
+ * </ul>
221
+ *
222
+ * @param filters the JSON array of filter arrays
223
+ * @param query the base query to apply filters to
224
+ * @return the filtered query
225
+ */
226
+ private Query applyFiltersToFirestoreCollectionQuery(JSONArray filters, Query query) throws JSONException {
227
+ for (int i = 0; i < filters.length(); i++) {
228
+ JSONArray filter = filters.getJSONArray(i);
229
+ switch (filter.getString(0)) {
230
+ case "where":
231
+ String fieldName = filter.getString(1);
232
+ String operator = filter.getString(2);
233
+ switch (operator) {
234
+ case "<":
235
+ query = query.whereLessThan(fieldName, getFilterValueAsType(filter, 3, 4));
236
+ break;
237
+ case ">":
238
+ query = query.whereGreaterThan(fieldName, getFilterValueAsType(filter, 3, 4));
239
+ break;
240
+ case "<=":
241
+ query = query.whereLessThanOrEqualTo(fieldName, getFilterValueAsType(filter, 3, 4));
242
+ break;
243
+ case ">=":
244
+ query = query.whereGreaterThanOrEqualTo(fieldName, getFilterValueAsType(filter, 3, 4));
245
+ break;
246
+ case "array-contains":
247
+ query = query.whereArrayContains(fieldName, getFilterValueAsType(filter, 3, 4));
248
+ break;
249
+ default:
250
+ query = query.whereEqualTo(fieldName, getFilterValueAsType(filter, 3, 4));
251
+ }
252
+ break;
253
+ case "orderBy":
254
+ Direction direction = Direction.ASCENDING;
255
+ if (Objects.equals(filter.getString(2), new String("desc"))) {
256
+ direction = Direction.DESCENDING;
257
+ }
258
+ query = query.orderBy(filter.getString(1), direction);
259
+ break;
260
+ case "startAt":
261
+ query = query.startAt(getFilterValueAsType(filter, 1, 2));
262
+ break;
263
+ case "endAt":
264
+ query = query.endAt(getFilterValueAsType(filter, 1, 2));
265
+ break;
266
+ case "limit":
267
+ query = query.limit(filter.getLong(1));
268
+ break;
269
+ }
270
+ }
271
+ return query;
272
+ }
273
+
274
+ /**
275
+ * Extracts a typed value from a filter array for use in Firestore queries.
276
+ * Supports types: boolean, integer, double, long, and string (default).
277
+ *
278
+ * @param filter the filter array
279
+ * @param valueIndex index of the value element
280
+ * @param typeIndex index of the type element
281
+ * @return the typed value
282
+ */
283
+ private Object getFilterValueAsType(JSONArray filter, int valueIndex, int typeIndex) throws JSONException {
284
+ Object typedValue;
285
+ String type = "string";
286
+ if (!filter.isNull(typeIndex)) {
287
+ type = filter.getString(typeIndex);
288
+ }
289
+
290
+ switch (type) {
291
+ case "boolean":
292
+ typedValue = filter.getBoolean(valueIndex);
293
+ break;
294
+ case "integer":
295
+ typedValue = filter.getInt(valueIndex);
296
+ break;
297
+ case "double":
298
+ typedValue = filter.getDouble(valueIndex);
299
+ break;
300
+ case "long":
301
+ typedValue = filter.getLong(valueIndex);
302
+ break;
303
+ default:
304
+ typedValue = filter.getString(valueIndex);
305
+ }
306
+
307
+ return typedValue;
308
+ }
309
+
310
+ // CRUD operations
311
+
312
+ /**
313
+ * Adds a new document with an auto-generated ID to a collection.
314
+ * If timestamp is true, adds {@code created} and {@code lastUpdate} Timestamp fields.
315
+ * Returns the generated document ID on success.
316
+ */
317
+ private void addDocumentToFirestoreCollection(JSONArray args, CallbackContext callbackContext) throws JSONException {
318
+ cordova.getThreadPool().execute(new Runnable() {
319
+ public void run() {
320
+ try {
321
+ String jsonDoc = args.getString(0);
322
+ String collection = args.getString(1);
323
+ boolean timestamp = args.getBoolean(2);
324
+
325
+ Map<String, Object> docData = jsonStringToMap(jsonDoc);
326
+
327
+ if (timestamp) {
328
+ docData.put("created", new Timestamp(new Date()));
329
+ docData.put("lastUpdate", new Timestamp(new Date()));
330
+ }
331
+
332
+ firestore.collection(collection)
333
+ .add(docData)
334
+ .addOnSuccessListener(new OnSuccessListener<DocumentReference>() {
335
+ @Override
336
+ public void onSuccess(DocumentReference documentReference) {
337
+ callbackContext.success(documentReference.getId());
338
+ }
339
+ })
340
+ .addOnFailureListener(new OnFailureListener() {
341
+ @Override
342
+ public void onFailure(@NonNull Exception e) {
343
+ FirebasexCorePlugin.handleExceptionWithContext(e, callbackContext);
344
+ }
345
+ });
346
+ } catch (Exception e) {
347
+ FirebasexCorePlugin.handleExceptionWithContext(e, callbackContext);
348
+ }
349
+ }
350
+ });
351
+ }
352
+
353
+ /**
354
+ * Creates or overwrites a document with a specific ID in a collection.
355
+ * If timestamp is true, adds a {@code lastUpdate} Timestamp field.
356
+ */
357
+ private void setDocumentInFirestoreCollection(JSONArray args, CallbackContext callbackContext) throws JSONException {
358
+ cordova.getThreadPool().execute(new Runnable() {
359
+ public void run() {
360
+ try {
361
+ String documentId = args.getString(0);
362
+ String jsonDoc = args.getString(1);
363
+ String collection = args.getString(2);
364
+ boolean timestamp = args.getBoolean(3);
365
+
366
+ Map<String, Object> docData = jsonStringToMap(jsonDoc);
367
+
368
+ if (timestamp) {
369
+ docData.put("lastUpdate", new Timestamp(new Date()));
370
+ }
371
+
372
+ firestore.collection(collection).document(documentId)
373
+ .set(docData)
374
+ .addOnSuccessListener(new OnSuccessListener<Void>() {
375
+ @Override
376
+ public void onSuccess(Void aVoid) {
377
+ callbackContext.success();
378
+ }
379
+ })
380
+ .addOnFailureListener(new OnFailureListener() {
381
+ @Override
382
+ public void onFailure(@NonNull Exception e) {
383
+ FirebasexCorePlugin.handleExceptionWithContext(e, callbackContext);
384
+ }
385
+ });
386
+ } catch (Exception e) {
387
+ FirebasexCorePlugin.handleExceptionWithContext(e, callbackContext);
388
+ }
389
+ }
390
+ });
391
+ }
392
+
393
+ /**
394
+ * Updates specific fields of an existing document. Fails if the document does not exist.
395
+ * If timestamp is true, updates the {@code lastUpdate} Timestamp field.
396
+ */
397
+ private void updateDocumentInFirestoreCollection(JSONArray args, CallbackContext callbackContext) throws JSONException {
398
+ cordova.getThreadPool().execute(new Runnable() {
399
+ public void run() {
400
+ try {
401
+ String documentId = args.getString(0);
402
+ String jsonDoc = args.getString(1);
403
+ String collection = args.getString(2);
404
+ boolean timestamp = args.getBoolean(3);
405
+
406
+ Map<String, Object> docData = jsonStringToMap(jsonDoc);
407
+
408
+ if (timestamp) {
409
+ docData.put("lastUpdate", new Timestamp(new Date()));
410
+ }
411
+
412
+ firestore.collection(collection).document(documentId)
413
+ .update(docData)
414
+ .addOnSuccessListener(new OnSuccessListener<Void>() {
415
+ @Override
416
+ public void onSuccess(Void aVoid) {
417
+ callbackContext.success();
418
+ }
419
+ })
420
+ .addOnFailureListener(new OnFailureListener() {
421
+ @Override
422
+ public void onFailure(@NonNull Exception e) {
423
+ FirebasexCorePlugin.handleExceptionWithContext(e, callbackContext);
424
+ }
425
+ });
426
+ } catch (Exception e) {
427
+ FirebasexCorePlugin.handleExceptionWithContext(e, callbackContext);
428
+ }
429
+ }
430
+ });
431
+ }
432
+
433
+ /** Deletes a document from a collection by its ID. */
434
+ private void deleteDocumentFromFirestoreCollection(JSONArray args, CallbackContext callbackContext) throws JSONException {
435
+ cordova.getThreadPool().execute(new Runnable() {
436
+ public void run() {
437
+ try {
438
+ String documentId = args.getString(0);
439
+ String collection = args.getString(1);
440
+
441
+ firestore.collection(collection).document(documentId)
442
+ .delete()
443
+ .addOnSuccessListener(new OnSuccessListener<Void>() {
444
+ @Override
445
+ public void onSuccess(Void aVoid) {
446
+ callbackContext.success();
447
+ }
448
+ })
449
+ .addOnFailureListener(new OnFailureListener() {
450
+ @Override
451
+ public void onFailure(@NonNull Exception e) {
452
+ FirebasexCorePlugin.handleExceptionWithContext(e, callbackContext);
453
+ }
454
+ });
455
+ } catch (Exception e) {
456
+ FirebasexCorePlugin.handleExceptionWithContext(e, callbackContext);
457
+ }
458
+ }
459
+ });
460
+ }
461
+
462
+ /** Checks whether a document exists in a collection. Returns 1 or 0. */
463
+ private void documentExistsInFirestoreCollection(JSONArray args, CallbackContext callbackContext) throws JSONException {
464
+ cordova.getThreadPool().execute(new Runnable() {
465
+ public void run() {
466
+ try {
467
+ String documentId = args.getString(0);
468
+ String collection = args.getString(1);
469
+
470
+ firestore.collection(collection).document(documentId)
471
+ .get()
472
+ .addOnCompleteListener(new OnCompleteListener<DocumentSnapshot>() {
473
+ @Override
474
+ public void onComplete(@NonNull Task<DocumentSnapshot> task) {
475
+ try {
476
+ if (task.isSuccessful()) {
477
+ DocumentSnapshot document = task.getResult();
478
+ callbackContext.success(conformBooleanForPluginResult(document != null && document.getData() != null));
479
+ } else {
480
+ Exception e = task.getException();
481
+ if (e != null) {
482
+ FirebasexCorePlugin.handleExceptionWithContext(e, callbackContext);
483
+ }
484
+ }
485
+ } catch (Exception e) {
486
+ FirebasexCorePlugin.handleExceptionWithContext(e, callbackContext);
487
+ }
488
+ }
489
+ })
490
+ .addOnFailureListener(new OnFailureListener() {
491
+ @Override
492
+ public void onFailure(@NonNull Exception e) {
493
+ FirebasexCorePlugin.handleExceptionWithContext(e, callbackContext);
494
+ }
495
+ });
496
+ } catch (Exception e) {
497
+ FirebasexCorePlugin.handleExceptionWithContext(e, callbackContext);
498
+ }
499
+ }
500
+ });
501
+ }
502
+
503
+ /** Fetches a single document by ID and returns its data as a JSON object. */
504
+ private void fetchDocumentInFirestoreCollection(JSONArray args, CallbackContext callbackContext) throws JSONException {
505
+ cordova.getThreadPool().execute(new Runnable() {
506
+ public void run() {
507
+ try {
508
+ String documentId = args.getString(0);
509
+ String collection = args.getString(1);
510
+
511
+ firestore.collection(collection).document(documentId)
512
+ .get()
513
+ .addOnCompleteListener(new OnCompleteListener<DocumentSnapshot>() {
514
+ @Override
515
+ public void onComplete(@NonNull Task<DocumentSnapshot> task) {
516
+ try {
517
+ if (task.isSuccessful()) {
518
+ DocumentSnapshot document = task.getResult();
519
+ if (document != null && document.getData() != null) {
520
+ JSONObject jsonDoc = mapFirestoreDataToJsonObject(document.getData());
521
+ callbackContext.success(jsonDoc);
522
+ } else {
523
+ callbackContext.error("No document found in collection");
524
+ }
525
+ } else {
526
+ Exception e = task.getException();
527
+ if (e != null) {
528
+ FirebasexCorePlugin.handleExceptionWithContext(e, callbackContext);
529
+ }
530
+ }
531
+ } catch (Exception e) {
532
+ FirebasexCorePlugin.handleExceptionWithContext(e, callbackContext);
533
+ }
534
+ }
535
+ })
536
+ .addOnFailureListener(new OnFailureListener() {
537
+ @Override
538
+ public void onFailure(@NonNull Exception e) {
539
+ FirebasexCorePlugin.handleExceptionWithContext(e, callbackContext);
540
+ }
541
+ });
542
+ } catch (Exception e) {
543
+ FirebasexCorePlugin.handleExceptionWithContext(e, callbackContext);
544
+ }
545
+ }
546
+ });
547
+ }
548
+
549
+ // Collection fetch and query
550
+
551
+ /**
552
+ * Fetches all documents in a collection, optionally filtered.
553
+ * Returns a JSON object mapping document IDs to their data.
554
+ */
555
+ private void fetchFirestoreCollection(JSONArray args, CallbackContext callbackContext) throws JSONException {
556
+ cordova.getThreadPool().execute(new Runnable() {
557
+ public void run() {
558
+ try {
559
+ String collection = args.getString(0);
560
+ JSONArray filters = args.getJSONArray(1);
561
+ Query query = firestore.collection(collection);
562
+
563
+ if (filters != null) {
564
+ query = applyFiltersToFirestoreCollectionQuery(filters, query);
565
+ }
566
+
567
+ query.get()
568
+ .addOnCompleteListener(new OnCompleteListener<QuerySnapshot>() {
569
+ @Override
570
+ public void onComplete(@NonNull Task<QuerySnapshot> task) {
571
+ try {
572
+ if (task.isSuccessful()) {
573
+ JSONObject jsonDocs = new JSONObject();
574
+ for (QueryDocumentSnapshot document : task.getResult()) {
575
+ jsonDocs.put(document.getId(), mapFirestoreDataToJsonObject(document.getData()));
576
+ }
577
+ callbackContext.success(jsonDocs);
578
+ } else {
579
+ FirebasexCorePlugin.handleExceptionWithContext(task.getException(), callbackContext);
580
+ }
581
+ } catch (Exception e) {
582
+ FirebasexCorePlugin.handleExceptionWithContext(e, callbackContext);
583
+ }
584
+ }
585
+ });
586
+ } catch (Exception e) {
587
+ FirebasexCorePlugin.handleExceptionWithContext(e, callbackContext);
588
+ }
589
+ }
590
+ });
591
+ }
592
+
593
+ // Listeners
594
+
595
+ /**
596
+ * Registers a real-time listener on a single document.
597
+ *
598
+ * <p>First sends an {@code {eventType: "id", id: ...}} result with the listener ID,
599
+ * then sends {@code {eventType: "change", snapshot: ..., source: "local"|"remote", fromCache: ...}}
600
+ * on each document change. Uses keepCallback to maintain the callback.
601
+ */
602
+ private void listenToDocumentInFirestoreCollection(JSONArray args, CallbackContext callbackContext) throws JSONException {
603
+ cordova.getThreadPool().execute(new Runnable() {
604
+ public void run() {
605
+ try {
606
+ String documentId = args.getString(0);
607
+ String collection = args.getString(1);
608
+ boolean includeMetadata = args.getBoolean(2);
609
+
610
+ ListenerRegistration registration = firestore.collection(collection).document(documentId)
611
+ .addSnapshotListener(includeMetadata ? MetadataChanges.INCLUDE : MetadataChanges.EXCLUDE, new EventListener<DocumentSnapshot>() {
612
+ @Override
613
+ public void onEvent(@Nullable DocumentSnapshot snapshot,
614
+ @Nullable FirebaseFirestoreException e3) {
615
+ try {
616
+ if (e3 == null) {
617
+ JSONObject document = new JSONObject();
618
+ document.put("eventType", "change");
619
+
620
+ String source = snapshot != null && snapshot.getMetadata().hasPendingWrites() ? "local" : "remote";
621
+ document.put("source", source);
622
+ document.put("fromCache", snapshot.getMetadata().isFromCache());
623
+
624
+ if (snapshot != null && snapshot.exists()) {
625
+ JSONObject jsonDoc = mapFirestoreDataToJsonObject(snapshot.getData());
626
+ document.put("snapshot", jsonDoc);
627
+ }
628
+ sendPluginResultAndKeepCallback(document, callbackContext);
629
+ } else {
630
+ FirebasexCorePlugin.handleExceptionWithContext(e3, callbackContext);
631
+ }
632
+ } catch (Exception e2) {
633
+ FirebasexCorePlugin.handleExceptionWithContext(e2, callbackContext);
634
+ }
635
+ }
636
+ });
637
+
638
+ String id = saveFirestoreListener(registration);
639
+ JSONObject jsResult = new JSONObject();
640
+ jsResult.put("eventType", "id");
641
+ jsResult.put("id", id);
642
+ sendPluginResultAndKeepCallback(jsResult, callbackContext);
643
+ } catch (Exception e1) {
644
+ FirebasexCorePlugin.handleExceptionWithContext(e1, callbackContext);
645
+ }
646
+ }
647
+ });
648
+ }
649
+
650
+ /**
651
+ * Registers a real-time listener on an entire collection, optionally filtered.
652
+ *
653
+ * <p>First sends the listener ID, then sends change events. Each document change
654
+ * includes its type ("new", "modified", "removed"), snapshot data, source, and fromCache flag.
655
+ */
656
+ private void listenToFirestoreCollection(JSONArray args, CallbackContext callbackContext) throws JSONException {
657
+ cordova.getThreadPool().execute(new Runnable() {
658
+ public void run() {
659
+ try {
660
+ String collection = args.getString(0);
661
+ JSONArray filters = null;
662
+ if (!args.isNull(1)) {
663
+ filters = args.getJSONArray(1);
664
+ }
665
+ boolean includeMetadata = args.getBoolean(2);
666
+
667
+ Query query = firestore.collection(collection);
668
+
669
+ if (filters != null) {
670
+ query = applyFiltersToFirestoreCollectionQuery(filters, query);
671
+ }
672
+
673
+ ListenerRegistration registration = query
674
+ .addSnapshotListener(includeMetadata ? MetadataChanges.INCLUDE : MetadataChanges.EXCLUDE, new EventListener<QuerySnapshot>() {
675
+ @Override
676
+ public void onEvent(@Nullable QuerySnapshot snapshots,
677
+ @Nullable FirebaseFirestoreException e3) {
678
+ try {
679
+ if (e3 == null) {
680
+ JSONObject jsResult = new JSONObject();
681
+ jsResult.put("eventType", "change");
682
+
683
+ JSONObject documents = new JSONObject();
684
+ boolean hasDocuments = false;
685
+ for (DocumentChange dc : snapshots.getDocumentChanges()) {
686
+ hasDocuments = true;
687
+ JSONObject document = new JSONObject();
688
+
689
+ switch (dc.getType()) {
690
+ case ADDED:
691
+ document.put("type", "new");
692
+ break;
693
+ case MODIFIED:
694
+ document.put("type", "modified");
695
+ break;
696
+ case REMOVED:
697
+ document.put("type", "removed");
698
+ break;
699
+ default:
700
+ document.put("type", "metadata");
701
+ }
702
+
703
+ QueryDocumentSnapshot documentSnapshot = dc.getDocument();
704
+ document.put("snapshot", mapFirestoreDataToJsonObject(documentSnapshot.getData()));
705
+ document.put("source", documentSnapshot.getMetadata().hasPendingWrites() ? "local" : "remote");
706
+ document.put("fromCache", documentSnapshot.getMetadata().isFromCache());
707
+
708
+ documents.put(documentSnapshot.getId(), document);
709
+ }
710
+ if (hasDocuments) {
711
+ jsResult.put("documents", documents);
712
+ }
713
+ sendPluginResultAndKeepCallback(jsResult, callbackContext);
714
+ } else {
715
+ FirebasexCorePlugin.handleExceptionWithContext(e3, callbackContext);
716
+ }
717
+ } catch (Exception e2) {
718
+ FirebasexCorePlugin.handleExceptionWithContext(e2, callbackContext);
719
+ }
720
+ }
721
+ });
722
+
723
+ String id = saveFirestoreListener(registration);
724
+ JSONObject jsResult = new JSONObject();
725
+ jsResult.put("eventType", "id");
726
+ jsResult.put("id", id);
727
+ sendPluginResultAndKeepCallback(jsResult, callbackContext);
728
+
729
+ } catch (Exception e1) {
730
+ FirebasexCorePlugin.handleExceptionWithContext(e1, callbackContext);
731
+ }
732
+ }
733
+ });
734
+ }
735
+
736
+ /** Removes a previously registered Firestore snapshot listener by its ID. */
737
+ private void removeFirestoreListener(JSONArray args, CallbackContext callbackContext) throws JSONException {
738
+ cordova.getThreadPool().execute(new Runnable() {
739
+ public void run() {
740
+ try {
741
+ String id = args.getString(0);
742
+ boolean removed = removeFirestoreListenerById(id);
743
+ if (removed) {
744
+ callbackContext.success();
745
+ } else {
746
+ callbackContext.error("Listener ID not found");
747
+ }
748
+ } catch (Exception e1) {
749
+ FirebasexCorePlugin.handleExceptionWithContext(e1, callbackContext);
750
+ }
751
+ }
752
+ });
753
+ }
754
+ }