@react-native-firebase/database 26.4.0 → 27.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.
Files changed (67) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/RNFBDatabase.podspec +8 -2
  3. package/android/build.gradle +5 -0
  4. package/android/src/main/java/io/invertase/firebase/database/RNFBDatabaseKeepSyncedRegistry.java +106 -0
  5. package/android/src/main/java/io/invertase/firebase/database/RNFBDatabaseListenerRegistry.kt +133 -0
  6. package/android/src/main/java/io/invertase/firebase/database/RNFBDatabaseQueryRegistry.kt +76 -0
  7. package/android/src/main/java/io/invertase/firebase/database/RNFBDatabaseTransactionRegistry.kt +67 -0
  8. package/android/src/main/java/io/invertase/firebase/database/UniversalFirebaseDatabaseOnDisconnectModule.java +102 -9
  9. package/android/src/reactnative/java/io/invertase/firebase/database/NativeRNFBTurboDatabaseOnDisconnect.java +6 -11
  10. package/android/src/reactnative/java/io/invertase/firebase/database/NativeRNFBTurboDatabaseQuery.java +93 -2
  11. package/android/src/reactnative/java/io/invertase/firebase/database/generated/java/com/facebook/fbreact/specs/NativeRNFBTurboDatabaseQuerySpec.java +4 -0
  12. package/android/src/reactnative/java/io/invertase/firebase/database/generated/jni/RNFBDatabaseTurboModules-generated.cpp +6 -0
  13. package/android/src/reactnative/java/io/invertase/firebase/database/generated/jni/react/renderer/components/RNFBDatabaseTurboModules/RNFBDatabaseTurboModulesJSI.h +12 -0
  14. package/android/src/test/java/io/invertase/firebase/database/RNFBDatabaseKeepSyncedRegistryTest.java +174 -0
  15. package/android/src/test/java/io/invertase/firebase/database/RNFBDatabaseListenerRegistryTest.java +31 -0
  16. package/android/src/test/java/io/invertase/firebase/database/RNFBDatabaseQueryRegistryTest.java +55 -0
  17. package/android/src/test/java/io/invertase/firebase/database/UniversalFirebaseDatabaseOnDisconnectCancelErrorTest.java +137 -0
  18. package/dist/module/DatabaseQuery.js +52 -1
  19. package/dist/module/DatabaseQuery.js.map +1 -1
  20. package/dist/module/DatabaseSyncTree.js +1 -1
  21. package/dist/module/DatabaseSyncTree.js.map +1 -1
  22. package/dist/module/index.js +1 -2
  23. package/dist/module/index.js.map +1 -1
  24. package/dist/module/modular/query.js +1 -1
  25. package/dist/module/modular/query.js.map +1 -1
  26. package/dist/module/version.js +1 -1
  27. package/dist/module/web/RNFBDatabaseModule.js +7 -0
  28. package/dist/module/web/RNFBDatabaseModule.js.map +1 -1
  29. package/dist/typescript/lib/DatabaseQuery.d.ts +25 -0
  30. package/dist/typescript/lib/DatabaseQuery.d.ts.map +1 -1
  31. package/dist/typescript/lib/index.d.ts +1 -1
  32. package/dist/typescript/lib/types/internal.d.ts +2 -0
  33. package/dist/typescript/lib/types/internal.d.ts.map +1 -1
  34. package/dist/typescript/lib/version.d.ts +1 -1
  35. package/dist/typescript/lib/web/RNFBDatabaseModule.d.ts.map +1 -1
  36. package/ios/RNFBDatabase/RNFBDatabaseListenerRegistry.m +24 -17
  37. package/ios/RNFBDatabase/RNFBDatabaseListenerRegistryStorage.swift +81 -0
  38. package/ios/RNFBDatabase/RNFBDatabaseNullSentinelDecoder.h +36 -0
  39. package/ios/RNFBDatabase/RNFBDatabaseNullSentinelDecoder.m +48 -0
  40. package/ios/RNFBDatabase/RNFBDatabaseOnDisconnectHelper.m +29 -26
  41. package/ios/RNFBDatabase/RNFBDatabaseQueryHelper.h +7 -0
  42. package/ios/RNFBDatabase/RNFBDatabaseQueryHelper.m +20 -0
  43. package/ios/RNFBDatabase/RNFBDatabaseQueryModule.mm +14 -0
  44. package/ios/RNFBDatabase/RNFBDatabaseQueryRegistry.m +25 -51
  45. package/ios/RNFBDatabase/RNFBDatabaseQueryRegistryStorage.swift +104 -0
  46. package/ios/RNFBDatabase/RNFBDatabaseReferenceHelper.m +14 -12
  47. package/ios/RNFBDatabase/RNFBDatabaseTransactionHelper.m +3 -1
  48. package/ios/RNFBDatabase.xcodeproj/project.pbxproj +12 -0
  49. package/ios/RNFBDatabaseUnitTests/RNFBDatabaseListenerRegistryStorageTests.swift +86 -0
  50. package/ios/RNFBDatabaseUnitTests/RNFBDatabaseNullSentinelDecodeTests.m +118 -0
  51. package/ios/RNFBDatabaseUnitTests/RNFBDatabaseQueryRegistryStorageTests.swift +181 -0
  52. package/ios/RNFBDatabaseUnitTests/RNFBDatabaseUnitTests.xcodeproj/project.pbxproj +42 -0
  53. package/ios/generated/RNFBDatabaseTurboModules/RNFBDatabaseTurboModules-generated.mm +7 -0
  54. package/ios/generated/RNFBDatabaseTurboModules/RNFBDatabaseTurboModules.h +6 -0
  55. package/ios/generated/RNFBDatabaseTurboModulesJSI.h +12 -0
  56. package/lib/DatabaseQuery.ts +56 -0
  57. package/lib/DatabaseSyncTree.ts +1 -1
  58. package/lib/index.ts +1 -1
  59. package/lib/modular/query.ts +1 -1
  60. package/lib/types/internal.ts +2 -0
  61. package/lib/version.ts +1 -1
  62. package/lib/web/RNFBDatabaseModule.ts +13 -0
  63. package/package.json +4 -4
  64. package/specs/NativeRNFBTurboDatabaseQuery.ts +1 -0
  65. package/android/src/main/java/io/invertase/firebase/database/RNFBDatabaseListenerRegistry.java +0 -117
  66. package/android/src/main/java/io/invertase/firebase/database/RNFBDatabaseQueryRegistry.java +0 -78
  67. package/android/src/main/java/io/invertase/firebase/database/RNFBDatabaseTransactionRegistry.java +0 -71
@@ -29,6 +29,7 @@ import com.google.firebase.database.*;
29
29
  import io.invertase.firebase.common.RNFBHandleCollisionException;
30
30
  import io.invertase.firebase.common.ReactNativeFirebaseEventEmitter;
31
31
  import io.invertase.firebase.common.ReactNativeFirebaseModule;
32
+ import io.invertase.firebase.common.UniversalFirebasePreferences;
32
33
  import java.util.Objects;
33
34
  import javax.annotation.Nonnull;
34
35
  import javax.annotation.Nullable;
@@ -374,6 +375,88 @@ public class NativeRNFBTurboDatabaseQuery extends NativeRNFBTurboDatabaseQuerySp
374
375
  }
375
376
  }
376
377
 
378
+ // Static because the SDK keeps keepSynced registrations across a JS reload.
379
+ private static final RNFBDatabaseKeepSyncedRegistry keepSyncedRegistry =
380
+ new RNFBDatabaseKeepSyncedRegistry();
381
+
382
+ // reference.toString() is the full URL, so a null dbURL and the default URL give the same key.
383
+ private static String locationKey(String app, DatabaseReference reference) {
384
+ return app + "|" + reference;
385
+ }
386
+
387
+ private static final java.util.regex.Pattern PERMISSION_DENIED_REASON =
388
+ java.util.regex.Pattern.compile(
389
+ "permission.?denied", java.util.regex.Pattern.CASE_INSENSITIVE);
390
+
391
+ /**
392
+ * Single request/response read backing modular get(). Offline, Query.get() waits for the
393
+ * connection, like once(). It uses once() instead when:
394
+ *
395
+ * <ul>
396
+ * <li>disk persistence is on: Query.get() then resolves from disk after 3s, even online;
397
+ * <li>RTDB debug logging is on: a get in flight when the socket drops is resent on reconnect
398
+ * only because PersistentConnectionImpl.sendGet's early return sits inside {@code if
399
+ * (logger.logsDebug())};
400
+ * <li>the app keeps anything at this location synced (see RNFBDatabaseKeepSyncedRegistry).
401
+ * </ul>
402
+ */
403
+ @Override
404
+ public void get(String app, String dbURL, String path, ReadableArray modifiers, Promise promise) {
405
+ DatabaseReference reference = getDatabaseForApp(app, dbURL).getReference(path);
406
+ ReactNativeFirebaseDatabaseQuery databaseQuery = getDatabaseQueryInstance(reference, modifiers);
407
+
408
+ UniversalFirebasePreferences preferences = UniversalFirebasePreferences.getSharedInstance();
409
+ String location = locationKey(app, reference);
410
+ if (modifiers.size() > 0
411
+ || preferences.getBooleanValue(UniversalDatabaseStatics.DATABASE_PERSISTENCE_ENABLED, false)
412
+ || preferences.getBooleanValue(UniversalDatabaseStatics.DATABASE_LOGGING_ENABLED, false)
413
+ || !keepSyncedRegistry.beginNativeGet(location)) {
414
+ addOnceValueEventListener(databaseQuery, promise);
415
+ return;
416
+ }
417
+
418
+ databaseQuery
419
+ .query
420
+ .get()
421
+ .addOnCompleteListener(
422
+ getTask -> {
423
+ keepSyncedRegistry.endNativeGet(location);
424
+ if (!getTask.isSuccessful()) {
425
+ Exception exception = getTask.getException();
426
+ // Query.get() surfaces a server error as a plain Exception carrying only the
427
+ // reply's reason text. Map a denial to the error once() gives, so callers see the
428
+ // same code and DatabaseQuery._get need not re-ask the server.
429
+ if (exception != null
430
+ && exception.getMessage() != null
431
+ && PERMISSION_DENIED_REASON.matcher(exception.getMessage()).find()) {
432
+ rejectPromiseDatabaseException(
433
+ promise,
434
+ new UniversalDatabaseException(
435
+ DatabaseError.PERMISSION_DENIED, exception.getMessage(), exception));
436
+ return;
437
+ }
438
+ ReactNativeFirebaseModule.rejectPromiseWithExceptionMap(promise, exception);
439
+ return;
440
+ }
441
+ DataSnapshot dataSnapshot = getTask.getResult();
442
+ // Serialise off the main thread, as addOnceValueEventListener does.
443
+ try {
444
+ Tasks.call(turboSupport.getExecutor(), () -> snapshotToMap(dataSnapshot))
445
+ .addOnCompleteListener(
446
+ task -> {
447
+ if (task.isSuccessful()) {
448
+ promise.resolve(task.getResult());
449
+ } else {
450
+ ReactNativeFirebaseModule.rejectPromiseWithExceptionMap(
451
+ promise, task.getException());
452
+ }
453
+ });
454
+ } catch (java.util.concurrent.RejectedExecutionException e) {
455
+ ReactNativeFirebaseModule.rejectPromiseWithExceptionMap(promise, e);
456
+ }
457
+ });
458
+ }
459
+
377
460
  @Override
378
461
  public void on(String app, String dbURL, ReadableMap props) {
379
462
  String key = props.getString("key");
@@ -414,7 +497,15 @@ public class NativeRNFBTurboDatabaseQuery extends NativeRNFBTurboDatabaseQuerySp
414
497
  boolean enabled,
415
498
  Promise promise) {
416
499
  DatabaseReference reference = getDatabaseForApp(app, dbURL).getReference(path);
417
- getDatabaseQueryInstance(key, reference, modifiers).query.keepSynced(enabled);
418
- promise.resolve(null);
500
+ Query query = getDatabaseQueryInstance(key, reference, modifiers).query;
501
+ // Resolve only after the SDK apply runs (now, or after endNativeGet when deferred).
502
+ keepSyncedRegistry.keepSynced(
503
+ locationKey(app, reference),
504
+ key,
505
+ enabled,
506
+ () -> {
507
+ query.keepSynced(enabled);
508
+ promise.resolve(null);
509
+ });
419
510
  }
420
511
  }
@@ -38,6 +38,10 @@ public abstract class NativeRNFBTurboDatabaseQuerySpec extends ReactContextBaseJ
38
38
  @DoNotStrip
39
39
  public abstract void once(String app, String dbURL, String path, ReadableArray modifiers, String eventType, Promise promise);
40
40
 
41
+ @ReactMethod
42
+ @DoNotStrip
43
+ public abstract void get(String app, String dbURL, String path, ReadableArray modifiers, Promise promise);
44
+
41
45
  @ReactMethod
42
46
  @DoNotStrip
43
47
  public abstract void on(String app, String dbURL, ReadableMap props);
@@ -89,6 +89,11 @@ static facebook::jsi::Value __hostFunction_NativeRNFBTurboDatabaseQuerySpecJSI_o
89
89
  return static_cast<JavaTurboModule &>(turboModule).invokeJavaMethod(rt, PromiseKind, "once", "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lcom/facebook/react/bridge/ReadableArray;Ljava/lang/String;Lcom/facebook/react/bridge/Promise;)V", args, count, cachedMethodId);
90
90
  }
91
91
 
92
+ static facebook::jsi::Value __hostFunction_NativeRNFBTurboDatabaseQuerySpecJSI_get(facebook::jsi::Runtime& rt, TurboModule &turboModule, const facebook::jsi::Value* args, size_t count) {
93
+ static jmethodID cachedMethodId = nullptr;
94
+ return static_cast<JavaTurboModule &>(turboModule).invokeJavaMethod(rt, PromiseKind, "get", "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lcom/facebook/react/bridge/ReadableArray;Lcom/facebook/react/bridge/Promise;)V", args, count, cachedMethodId);
95
+ }
96
+
92
97
  static facebook::jsi::Value __hostFunction_NativeRNFBTurboDatabaseQuerySpecJSI_on(facebook::jsi::Runtime& rt, TurboModule &turboModule, const facebook::jsi::Value* args, size_t count) {
93
98
  static jmethodID cachedMethodId = nullptr;
94
99
  return static_cast<JavaTurboModule &>(turboModule).invokeJavaMethod(rt, VoidKind, "on", "(Ljava/lang/String;Ljava/lang/String;Lcom/facebook/react/bridge/ReadableMap;)V", args, count, cachedMethodId);
@@ -107,6 +112,7 @@ static facebook::jsi::Value __hostFunction_NativeRNFBTurboDatabaseQuerySpecJSI_k
107
112
  NativeRNFBTurboDatabaseQuerySpecJSI::NativeRNFBTurboDatabaseQuerySpecJSI(const JavaTurboModule::InitParams &params)
108
113
  : JavaTurboModule(params) {
109
114
  methodMap_["once"] = MethodMetadata {5, __hostFunction_NativeRNFBTurboDatabaseQuerySpecJSI_once};
115
+ methodMap_["get"] = MethodMetadata {4, __hostFunction_NativeRNFBTurboDatabaseQuerySpecJSI_get};
110
116
  methodMap_["on"] = MethodMetadata {3, __hostFunction_NativeRNFBTurboDatabaseQuerySpecJSI_on};
111
117
  methodMap_["off"] = MethodMetadata {2, __hostFunction_NativeRNFBTurboDatabaseQuerySpecJSI_off};
112
118
  methodMap_["keepSynced"] = MethodMetadata {6, __hostFunction_NativeRNFBTurboDatabaseQuerySpecJSI_keepSynced};
@@ -170,6 +170,7 @@ public:
170
170
  protected:
171
171
  NativeRNFBTurboDatabaseQueryCxxSpec(std::shared_ptr<CallInvoker> jsInvoker) : TurboModule(std::string{NativeRNFBTurboDatabaseQueryCxxSpec::kModuleName}, jsInvoker) {
172
172
  methodMap_["once"] = MethodMetadata {.argCount = 5, .invoker = __once};
173
+ methodMap_["get"] = MethodMetadata {.argCount = 4, .invoker = __get};
173
174
  methodMap_["on"] = MethodMetadata {.argCount = 3, .invoker = __on};
174
175
  methodMap_["off"] = MethodMetadata {.argCount = 2, .invoker = __off};
175
176
  methodMap_["keepSynced"] = MethodMetadata {.argCount = 6, .invoker = __keepSynced};
@@ -188,6 +189,17 @@ private:
188
189
  count <= 4 ? throw jsi::JSError(rt, "Expected argument in position 4 to be passed") : args[4].asString(rt));
189
190
  }
190
191
 
192
+ static jsi::Value __get(jsi::Runtime &rt, TurboModule &turboModule, const jsi::Value* args, size_t count) {
193
+ static_assert(
194
+ bridging::getParameterCount(&T::get) == 5,
195
+ "Expected get(...) to have 5 parameters");
196
+ return bridging::callFromJs<jsi::Value>(rt, &T::get, static_cast<NativeRNFBTurboDatabaseQueryCxxSpec*>(&turboModule)->jsInvoker_, static_cast<T*>(&turboModule),
197
+ count <= 0 ? throw jsi::JSError(rt, "Expected argument in position 0 to be passed") : args[0].asString(rt),
198
+ count <= 1 ? throw jsi::JSError(rt, "Expected argument in position 1 to be passed") : args[1].asString(rt),
199
+ count <= 2 ? throw jsi::JSError(rt, "Expected argument in position 2 to be passed") : args[2].asString(rt),
200
+ count <= 3 ? throw jsi::JSError(rt, "Expected argument in position 3 to be passed") : args[3].asObject(rt).asArray(rt));
201
+ }
202
+
191
203
  static jsi::Value __on(jsi::Runtime &rt, TurboModule &turboModule, const jsi::Value* args, size_t count) {
192
204
  static_assert(
193
205
  bridging::getParameterCount(&T::on) == 4,
@@ -0,0 +1,174 @@
1
+ package io.invertase.firebase.database;
2
+
3
+ /*
4
+ * Copyright (c) 2016-present Invertase Limited & Contributors
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this library except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ *
18
+ */
19
+
20
+ import static org.junit.Assert.assertEquals;
21
+ import static org.junit.Assert.assertFalse;
22
+ import static org.junit.Assert.assertTrue;
23
+
24
+ import java.util.ArrayList;
25
+ import java.util.Arrays;
26
+ import java.util.Collections;
27
+ import java.util.List;
28
+ import org.junit.Test;
29
+
30
+ public class RNFBDatabaseKeepSyncedRegistryTest {
31
+ private static final String LOCATION = "[DEFAULT]|https://db.example.com/users/1";
32
+ private static final String OTHER_LOCATION = "[DEFAULT]|https://db.example.com/users/2";
33
+
34
+ private final RNFBDatabaseKeepSyncedRegistry registry = new RNFBDatabaseKeepSyncedRegistry();
35
+ private final List<String> applied = new ArrayList<>();
36
+
37
+ private void keepSynced(String location, String queryKey, boolean enabled) {
38
+ registry.keepSynced(location, queryKey, enabled, () -> applied.add(queryKey + "=" + enabled));
39
+ }
40
+
41
+ @Test
42
+ public void beginNativeGet_nothingKeptSynced_returnsTrue() {
43
+ assertTrue(registry.beginNativeGet(LOCATION));
44
+ }
45
+
46
+ @Test
47
+ public void beginNativeGet_locationKeptSynced_returnsFalse() {
48
+ keepSynced(LOCATION, "plain", true);
49
+ assertFalse(registry.beginNativeGet(LOCATION));
50
+ }
51
+
52
+ @Test
53
+ public void beginNativeGet_queryAtLocationKeptSynced_returnsFalse() {
54
+ keepSynced(LOCATION, "limitToLast(1)", true);
55
+ assertFalse(registry.beginNativeGet(LOCATION));
56
+ }
57
+
58
+ @Test
59
+ public void beginNativeGet_otherLocationKeptSynced_returnsTrue() {
60
+ keepSynced(OTHER_LOCATION, "plain", true);
61
+ assertTrue(registry.beginNativeGet(LOCATION));
62
+ }
63
+
64
+ @Test
65
+ public void beginNativeGet_afterKeepSyncedFalse_returnsTrue() {
66
+ keepSynced(LOCATION, "plain", true);
67
+ keepSynced(LOCATION, "plain", false);
68
+ assertTrue(registry.beginNativeGet(LOCATION));
69
+ }
70
+
71
+ @Test
72
+ public void beginNativeGet_untilEveryQueryAtLocationIsCleared_returnsFalse() {
73
+ keepSynced(LOCATION, "plain", true);
74
+ keepSynced(LOCATION, "limitToLast(1)", true);
75
+ keepSynced(LOCATION, "plain", false);
76
+ assertFalse(registry.beginNativeGet(LOCATION));
77
+ keepSynced(LOCATION, "limitToLast(1)", false);
78
+ assertTrue(registry.beginNativeGet(LOCATION));
79
+ }
80
+
81
+ @Test
82
+ public void keepSynced_noNativeGetInFlight_appliesNow() {
83
+ keepSynced(LOCATION, "plain", true);
84
+ assertEquals(Collections.singletonList("plain=true"), applied);
85
+ }
86
+
87
+ @Test
88
+ public void keepSynced_disableWhenNothingKept_stillApplies() {
89
+ keepSynced(LOCATION, "plain", false);
90
+ assertEquals(Collections.singletonList("plain=false"), applied);
91
+ assertTrue(registry.beginNativeGet(LOCATION));
92
+ }
93
+
94
+ @Test
95
+ public void keepSynced_noNativeGetInFlight_runsApplyBeforeCallerCompletion() {
96
+ List<String> order = new ArrayList<>();
97
+ registry.keepSynced(
98
+ LOCATION,
99
+ "plain",
100
+ true,
101
+ () -> {
102
+ order.add("sdk");
103
+ order.add("promise");
104
+ });
105
+ assertEquals(Arrays.asList("sdk", "promise"), order);
106
+ }
107
+
108
+ @Test
109
+ public void keepSynced_nativeGetInFlight_waitsForItInCallOrder() {
110
+ registry.beginNativeGet(LOCATION);
111
+ keepSynced(LOCATION, "plain", true);
112
+ keepSynced(LOCATION, "limitToLast(1)", true);
113
+ keepSynced(LOCATION, "plain", false);
114
+ assertTrue(applied.isEmpty());
115
+
116
+ registry.endNativeGet(LOCATION);
117
+ assertEquals(Arrays.asList("plain=true", "limitToLast(1)=true", "plain=false"), applied);
118
+ }
119
+
120
+ @Test
121
+ public void keepSynced_nativeGetInFlight_runsEachApplyBeforeItsCallerCompletion() {
122
+ List<String> order = new ArrayList<>();
123
+ registry.beginNativeGet(LOCATION);
124
+ registry.keepSynced(
125
+ LOCATION,
126
+ "plain",
127
+ true,
128
+ () -> {
129
+ order.add("sdk-true");
130
+ order.add("promise-true");
131
+ });
132
+ registry.keepSynced(
133
+ LOCATION,
134
+ "plain",
135
+ false,
136
+ () -> {
137
+ order.add("sdk-false");
138
+ order.add("promise-false");
139
+ });
140
+ assertTrue(order.isEmpty());
141
+
142
+ registry.endNativeGet(LOCATION);
143
+ assertEquals(Arrays.asList("sdk-true", "promise-true", "sdk-false", "promise-false"), order);
144
+ }
145
+
146
+ @Test
147
+ public void keepSynced_nativeGetInFlight_makesLaterGetsUseOnce() {
148
+ registry.beginNativeGet(LOCATION);
149
+ keepSynced(LOCATION, "plain", true);
150
+ assertFalse(registry.beginNativeGet(LOCATION));
151
+ }
152
+
153
+ @Test
154
+ public void keepSynced_nativeGetInFlightAtOtherLocation_appliesNow() {
155
+ registry.beginNativeGet(OTHER_LOCATION);
156
+ keepSynced(LOCATION, "plain", true);
157
+ assertEquals(Collections.singletonList("plain=true"), applied);
158
+ }
159
+
160
+ @Test
161
+ public void endNativeGet_waitsForTheLastGetAtLocation() {
162
+ registry.beginNativeGet(LOCATION);
163
+ registry.beginNativeGet(LOCATION);
164
+ keepSynced(LOCATION, "plain", true);
165
+
166
+ registry.endNativeGet(LOCATION);
167
+ assertTrue(applied.isEmpty());
168
+ registry.endNativeGet(LOCATION);
169
+ assertEquals(Collections.singletonList("plain=true"), applied);
170
+
171
+ keepSynced(LOCATION, "plain", false);
172
+ assertEquals(Arrays.asList("plain=true", "plain=false"), applied);
173
+ }
174
+ }
@@ -109,6 +109,37 @@ public class RNFBDatabaseListenerRegistryTest {
109
109
  assertFalse(registry.hasListeners());
110
110
  }
111
111
 
112
+ @Test
113
+ public void getValue_nullKey_returnsNull_noNpe() {
114
+ RNFBDatabaseListenerRegistry registry = new RNFBDatabaseListenerRegistry();
115
+ assertNull(registry.getValue(null));
116
+ }
117
+
118
+ @Test
119
+ public void getChild_nullKey_returnsNull_noNpe() {
120
+ RNFBDatabaseListenerRegistry registry = new RNFBDatabaseListenerRegistry();
121
+ assertNull(registry.getChild(null));
122
+ }
123
+
124
+ @Test
125
+ public void takeValue_nullKey_returnsNull_noNpe() {
126
+ RNFBDatabaseListenerRegistry registry = new RNFBDatabaseListenerRegistry();
127
+ assertNull(registry.takeValue(null));
128
+ assertFalse(registry.hasListeners());
129
+ }
130
+
131
+ @Test
132
+ public void takeChild_nullKey_returnsNull_noNpe() {
133
+ RNFBDatabaseListenerRegistry registry = new RNFBDatabaseListenerRegistry();
134
+ assertNull(registry.takeChild(null));
135
+ }
136
+
137
+ @Test
138
+ public void hasEventListener_nullKey_returnsFalse_noNpe() {
139
+ RNFBDatabaseListenerRegistry registry = new RNFBDatabaseListenerRegistry();
140
+ assertFalse(registry.hasEventListener(null));
141
+ }
142
+
112
143
  @Test
113
144
  public void takeChild_missingKey_isNoOp() {
114
145
  RNFBDatabaseListenerRegistry registry = new RNFBDatabaseListenerRegistry();
@@ -209,6 +209,42 @@ public class RNFBDatabaseQueryRegistryTest {
209
209
  assertEquals(2, query.calls.get());
210
210
  }
211
211
 
212
+ /**
213
+ * Outside idle check saw the original query; before identity-take, a concurrent take+put replaced
214
+ * the mapping — takeIf predicate fails and the replacement stays registered.
215
+ */
216
+ @Test
217
+ public void takeIfIdle_identityMismatch_leavesReplacement() throws Exception {
218
+ RNFBDatabaseQueryRegistry registry = new RNFBDatabaseQueryRegistry();
219
+ GatedFakeQuery original = new GatedFakeQuery();
220
+ original.listeners = false;
221
+ registry.put("q", original);
222
+
223
+ FakeQuery replacement = new FakeQuery();
224
+ replacement.listeners = true;
225
+
226
+ CountDownLatch takeDone = new CountDownLatch(1);
227
+ Thread takeThread =
228
+ new Thread(
229
+ () -> {
230
+ try {
231
+ registry.takeIfIdle("q");
232
+ } finally {
233
+ takeDone.countDown();
234
+ }
235
+ });
236
+ takeThread.start();
237
+
238
+ assertTrue(original.hasListenersEntered.await(5, TimeUnit.SECONDS));
239
+ assertSame(original, registry.take("q"));
240
+ registry.put("q", replacement);
241
+ original.allowHasListenersReturn.countDown();
242
+ assertTrue(takeDone.await(5, TimeUnit.SECONDS));
243
+
244
+ assertSame(replacement, registry.get("q"));
245
+ assertEquals(0, replacement.removeCount);
246
+ }
247
+
212
248
  /**
213
249
  * After identity-take, listeners appear but a concurrent put claimed the slot — put-back fails;
214
250
  * orphan must clear listeners so SDK callbacks are not left outside the registry.
@@ -431,4 +467,23 @@ public class RNFBDatabaseQueryRegistryTest {
431
467
  registry.takeIfIdle("missing");
432
468
  assertNull(registry.get("missing"));
433
469
  }
470
+
471
+ @Test
472
+ public void get_nullKey_returnsNull_noNpe() {
473
+ RNFBDatabaseQueryRegistry registry = new RNFBDatabaseQueryRegistry();
474
+ assertNull(registry.get(null));
475
+ }
476
+
477
+ @Test
478
+ public void take_nullKey_returnsNull_noNpe() {
479
+ RNFBDatabaseQueryRegistry registry = new RNFBDatabaseQueryRegistry();
480
+ assertNull(registry.take(null));
481
+ }
482
+
483
+ @Test
484
+ public void takeIfIdle_nullKey_isNoOp() {
485
+ RNFBDatabaseQueryRegistry registry = new RNFBDatabaseQueryRegistry();
486
+ registry.takeIfIdle(null);
487
+ assertNull(registry.get(null));
488
+ }
434
489
  }
@@ -0,0 +1,137 @@
1
+ package io.invertase.firebase.database;
2
+
3
+ /*
4
+ * Copyright (c) 2016-present Invertase Limited & Contributors
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this library except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ *
18
+ */
19
+
20
+ import static org.junit.Assert.assertFalse;
21
+ import static org.junit.Assert.assertNotNull;
22
+ import static org.junit.Assert.assertTrue;
23
+ import static org.mockito.ArgumentMatchers.any;
24
+ import static org.mockito.ArgumentMatchers.anyString;
25
+ import static org.mockito.Mockito.doAnswer;
26
+ import static org.mockito.Mockito.mock;
27
+ import static org.mockito.Mockito.mockStatic;
28
+ import static org.mockito.Mockito.verify;
29
+ import static org.mockito.Mockito.when;
30
+
31
+ import android.content.Context;
32
+ import com.facebook.react.bridge.Promise;
33
+ import com.facebook.react.bridge.ReactApplicationContext;
34
+ import com.facebook.react.bridge.WritableMap;
35
+ import com.google.android.gms.tasks.Task;
36
+ import com.google.firebase.database.DatabaseError;
37
+ import com.google.firebase.database.DatabaseReference;
38
+ import com.google.firebase.database.FirebaseDatabase;
39
+ import com.google.firebase.database.OnDisconnect;
40
+ import org.junit.Test;
41
+ import org.junit.runner.RunWith;
42
+ import org.mockito.ArgumentCaptor;
43
+ import org.mockito.MockedStatic;
44
+ import org.robolectric.RobolectricTestRunner;
45
+ import org.robolectric.shadows.ShadowLooper;
46
+
47
+ /**
48
+ * onDisconnect.cancel() is not rejected by RTDB {@code .write: false} (e2e wire evidence in
49
+ * writePermissionDenied.e2e.js). Drive the CompletionListener error arm + TurboModule reject with a
50
+ * mocked Firebase OnDisconnect — AndroidTest-AD-1.
51
+ */
52
+ @RunWith(RobolectricTestRunner.class)
53
+ public class UniversalFirebaseDatabaseOnDisconnectCancelErrorTest {
54
+
55
+ private static void stubCancelWithPermissionDenied(
56
+ OnDisconnect onDisconnect, DatabaseReference ref) {
57
+ doAnswer(
58
+ invocation -> {
59
+ DatabaseReference.CompletionListener listener = invocation.getArgument(0);
60
+ listener.onComplete(DatabaseError.fromCode(DatabaseError.PERMISSION_DENIED), ref);
61
+ return null;
62
+ })
63
+ .when(onDisconnect)
64
+ .cancel(any(DatabaseReference.CompletionListener.class));
65
+ }
66
+
67
+ @Test
68
+ public void onDisconnectCancel_taskFailsWithUniversalDatabaseException() {
69
+ Context context = mock(Context.class);
70
+ FirebaseDatabase firebaseDatabase = mock(FirebaseDatabase.class);
71
+ DatabaseReference reference = mock(DatabaseReference.class);
72
+ OnDisconnect onDisconnect = mock(OnDisconnect.class);
73
+
74
+ when(firebaseDatabase.getReference("nope/foo")).thenReturn(reference);
75
+ when(reference.onDisconnect()).thenReturn(onDisconnect);
76
+ stubCancelWithPermissionDenied(onDisconnect, reference);
77
+
78
+ try (MockedStatic<UniversalFirebaseDatabaseCommon> common =
79
+ mockStatic(UniversalFirebaseDatabaseCommon.class)) {
80
+ common
81
+ .when(() -> UniversalFirebaseDatabaseCommon.getDatabaseForApp(anyString(), anyString()))
82
+ .thenReturn(firebaseDatabase);
83
+
84
+ UniversalFirebaseDatabaseOnDisconnectModule module =
85
+ new UniversalFirebaseDatabaseOnDisconnectModule(context, "DatabaseOnDisconnect");
86
+
87
+ Task<Void> task = module.onDisconnectCancel("app", "https://example.com", "nope/foo");
88
+
89
+ assertTrue(task.isComplete());
90
+ assertFalse(task.isSuccessful());
91
+ assertTrue(task.getException() instanceof UniversalDatabaseException);
92
+ UniversalDatabaseException databaseException =
93
+ (UniversalDatabaseException) task.getException();
94
+ assertTrue(databaseException.getCode().contains("permission-denied"));
95
+ }
96
+ }
97
+
98
+ @Test
99
+ public void turboOnDisconnectCancel_rejectsPromiseOnDatabaseError() {
100
+ ReactApplicationContext reactContext = mock(ReactApplicationContext.class);
101
+ when(reactContext.getApplicationContext()).thenReturn(reactContext);
102
+
103
+ FirebaseDatabase firebaseDatabase = mock(FirebaseDatabase.class);
104
+ DatabaseReference reference = mock(DatabaseReference.class);
105
+ OnDisconnect onDisconnect = mock(OnDisconnect.class);
106
+
107
+ when(firebaseDatabase.getReference("nope/foo")).thenReturn(reference);
108
+ when(reference.onDisconnect()).thenReturn(onDisconnect);
109
+ stubCancelWithPermissionDenied(onDisconnect, reference);
110
+
111
+ Promise promise = mock(Promise.class);
112
+ WritableMap userInfo = mock(WritableMap.class);
113
+
114
+ try (MockedStatic<UniversalFirebaseDatabaseCommon> common =
115
+ mockStatic(UniversalFirebaseDatabaseCommon.class);
116
+ MockedStatic<com.facebook.react.bridge.Arguments> arguments =
117
+ mockStatic(com.facebook.react.bridge.Arguments.class)) {
118
+ common
119
+ .when(() -> UniversalFirebaseDatabaseCommon.getDatabaseForApp(anyString(), anyString()))
120
+ .thenReturn(firebaseDatabase);
121
+ arguments.when(com.facebook.react.bridge.Arguments::createMap).thenReturn(userInfo);
122
+
123
+ NativeRNFBTurboDatabaseOnDisconnect turbo =
124
+ new NativeRNFBTurboDatabaseOnDisconnect(reactContext);
125
+ turbo.onDisconnectCancel("app", "https://example.com", "nope/foo", promise);
126
+
127
+ // addOnCompleteListener without an executor posts to the main looper.
128
+ ShadowLooper.idleMainLooper();
129
+
130
+ ArgumentCaptor<String> code = ArgumentCaptor.forClass(String.class);
131
+ ArgumentCaptor<String> message = ArgumentCaptor.forClass(String.class);
132
+ verify(promise).reject(code.capture(), message.capture(), any(WritableMap.class));
133
+ assertTrue(code.getValue().contains("permission-denied"));
134
+ assertNotNull(message.getValue());
135
+ }
136
+ }
137
+ }
@@ -17,10 +17,24 @@
17
17
  *
18
18
  */
19
19
 
20
- import { isBoolean, isFunction, isNull, isNumber, isObject, isString, isUndefined, pathIsEmpty, pathToUrlEncodedString, ReferenceBase } from '@react-native-firebase/app/dist/module/common';
20
+ import { isBoolean, isFunction, isIOS, isNull, isNumber, isObject, isString, isUndefined, pathIsEmpty, pathToUrlEncodedString, ReferenceBase } from '@react-native-firebase/app/dist/module/common';
21
21
  import DatabaseDataSnapshot from "./DatabaseDataSnapshot.js";
22
22
  import DatabaseSyncTree from "./DatabaseSyncTree.js";
23
23
  const eventTypes = ['value', 'child_added', 'child_changed', 'child_moved', 'child_removed'];
24
+
25
+ /** `.info/*` is client-local metadata that only the listener path serves. */
26
+ const INFO_PATH = /^\/?\.info(\/|$)/;
27
+
28
+ /**
29
+ * True when native/web get already produced an observer-shaped `database/*` code.
30
+ * Android Query.get() non-denial failures use SharedUtils.getExceptionMap → `database/unknown`;
31
+ * those still fall through to `once('value')`. Specific codes mean once already ran (or a
32
+ * denial was mapped to once's code) — do not ask the server again.
33
+ */
34
+ function isObserverShapedDatabaseError(error) {
35
+ const code = error?.code;
36
+ return typeof code === 'string' && code.startsWith('database/') && code !== 'database/unknown';
37
+ }
24
38
  let DatabaseReferenceClass = null;
25
39
  export function provideReferenceClass(databaseReference) {
26
40
  DatabaseReferenceClass = databaseReference;
@@ -180,6 +194,43 @@ export default class DatabaseQuery extends ReferenceBase {
180
194
  listeners += 1;
181
195
  return callback;
182
196
  }
197
+
198
+ /**
199
+ * One-shot read backing modular `get()`: on Android and web, the SDKs' single request/response
200
+ * read (`Query.get()`, web `get()`) instead of the listen -> data -> unlisten exchange
201
+ * `once('value')` performs. On iOS the native `get` is still the single-event observer; see
202
+ * `RNFBDatabaseQueryHelper get:`.
203
+ *
204
+ * Callers keep `once('value')` semantics, with one exception noted below:
205
+ * - iOS failures pass straight through (native get already is `once`).
206
+ * - On Android / web, an error whose code is already a specific `database/*` value (anything
207
+ * other than `database/unknown`) passes through: Android maps Query.get() permission denials
208
+ * to the same code `once` gives, and when native get already used `addOnceValueEventListener`
209
+ * (persistence, logging, keepSynced) its cancellations are those codes too — do not call
210
+ * `once('value')` again. Only a plain Query.get() failure is rejected as `database/unknown`
211
+ * (SharedUtils.getExceptionMap); that path still retries as `once('value')`.
212
+ * - Queries (any orderBy / limit / range modifier) use `once('value')`: `get()` parity for
213
+ * unindexed queries is unverified.
214
+ * - `.info/*` paths use `once('value')`: the one-shot reads go to the server tree, not the
215
+ * client-local `.info` tree.
216
+ *
217
+ * The exception: the one-shot read resolves with the server's reply as it was when the read was
218
+ * sent, where `once('value')` layered this client's pending writes over it. A local write made to
219
+ * an overlapping path while a `get()` is in flight is therefore not in its result, as with the
220
+ * web SDK's `get()`.
221
+ */
222
+ _get() {
223
+ const modifiers = this._modifiers._copy().toArray();
224
+ if (modifiers.length > 0 || INFO_PATH.test(this.path)) {
225
+ return this.once('value');
226
+ }
227
+ return this._database.native.get(this.path, modifiers).then(result => new DatabaseDataSnapshot(this.ref, result), error => {
228
+ if (isIOS || isObserverShapedDatabaseError(error)) {
229
+ return Promise.reject(error);
230
+ }
231
+ return this.once('value');
232
+ });
233
+ }
183
234
  once(eventType, successCallBack, failureCallbackOrContext, context) {
184
235
  if (!eventTypes.includes(eventType)) {
185
236
  throw new Error(`firebase.database().ref().once(*) 'eventType' must be one of ${eventTypes.join(', ')}.`);