@react-native-firebase/app 25.1.0 → 26.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 (150) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/RNFBApp.podspec +7 -13
  3. package/android/build.gradle +25 -6
  4. package/android/src/reactnative/java/io/invertase/firebase/app/{ReactNativeFirebaseAppModule.java → NativeRNFBTurboApp.java} +81 -45
  5. package/android/src/reactnative/java/io/invertase/firebase/app/ReactNativeFirebaseAppPackage.java +3 -3
  6. package/android/src/reactnative/java/io/invertase/firebase/app/ReactNativeFirebaseVersion.java +2 -2
  7. package/android/src/reactnative/java/io/invertase/firebase/app/generated/java/com/facebook/fbreact/specs/NativeRNFBTurboAppSpec.java +136 -0
  8. package/android/src/reactnative/java/io/invertase/firebase/app/generated/java/com/facebook/fbreact/specs/NativeRNFBTurboUtilsSpec.java +94 -0
  9. package/android/src/reactnative/java/io/invertase/firebase/app/generated/jni/CMakeLists.txt +36 -0
  10. package/android/src/reactnative/java/io/invertase/firebase/app/generated/jni/RNFBAppTurboModules-generated.cpp +170 -0
  11. package/android/src/reactnative/java/io/invertase/firebase/app/generated/jni/RNFBAppTurboModules.h +39 -0
  12. package/android/src/reactnative/java/io/invertase/firebase/app/generated/jni/react/renderer/components/RNFBAppTurboModules/RNFBAppTurboModulesJSI-generated.cpp +187 -0
  13. package/android/src/reactnative/java/io/invertase/firebase/app/generated/jni/react/renderer/components/RNFBAppTurboModules/RNFBAppTurboModulesJSI.h +606 -0
  14. package/android/src/reactnative/java/io/invertase/firebase/common/RCTConvertFirebase.java +3 -3
  15. package/android/src/reactnative/java/io/invertase/firebase/common/ReactNativeFirebaseEventEmitter.java +475 -20
  16. package/android/src/reactnative/java/io/invertase/firebase/common/ReactNativeFirebaseMeta.java +8 -0
  17. package/android/src/reactnative/java/io/invertase/firebase/common/ReactNativeFirebasePreferences.java +8 -0
  18. package/android/src/reactnative/java/io/invertase/firebase/utils/{ReactNativeFirebaseUtilsModule.java → NativeRNFBTurboUtils.java} +57 -40
  19. package/android/src/test/java/io/invertase/firebase/app/NativeRNFBTurboAppTest.java +400 -0
  20. package/android/src/test/java/io/invertase/firebase/common/ReactNativeFirebaseEventEmitterTest.java +1823 -0
  21. package/app.plugin.js +1 -1
  22. package/dist/module/FirebaseApp.js +0 -9
  23. package/dist/module/FirebaseApp.js.map +1 -1
  24. package/dist/module/common/index.js +0 -645
  25. package/dist/module/common/index.js.map +1 -1
  26. package/dist/module/common/unitTestUtils.js +18 -54
  27. package/dist/module/common/unitTestUtils.js.map +1 -1
  28. package/dist/module/index.js +0 -1
  29. package/dist/module/index.js.map +1 -1
  30. package/dist/module/internal/RNFBNativeEventEmitter.js +12 -6
  31. package/dist/module/internal/RNFBNativeEventEmitter.js.map +1 -1
  32. package/dist/module/internal/constants.js +2 -2
  33. package/dist/module/internal/constants.js.map +1 -1
  34. package/dist/module/internal/index.js +1 -1
  35. package/dist/module/internal/index.js.map +1 -1
  36. package/dist/module/internal/nativeModuleAndroidIos.js +50 -10
  37. package/dist/module/internal/nativeModuleAndroidIos.js.map +1 -1
  38. package/dist/module/internal/nativeModuleWeb.js +12 -4
  39. package/dist/module/internal/nativeModuleWeb.js.map +1 -1
  40. package/dist/module/internal/registry/app.js +20 -12
  41. package/dist/module/internal/registry/app.js.map +1 -1
  42. package/dist/module/internal/registry/modular.js +73 -0
  43. package/dist/module/internal/registry/modular.js.map +1 -0
  44. package/dist/module/internal/registry/nativeModule.js +116 -95
  45. package/dist/module/internal/registry/nativeModule.js.map +1 -1
  46. package/dist/module/modular.js +44 -37
  47. package/dist/module/modular.js.map +1 -1
  48. package/dist/module/types/app.js +0 -3
  49. package/dist/module/types/app.js.map +1 -1
  50. package/dist/module/types/internal.js +0 -2
  51. package/dist/module/utils/UtilsStatics.js +2 -2
  52. package/dist/module/utils/UtilsStatics.js.map +1 -1
  53. package/dist/module/utils/index.js +21 -17
  54. package/dist/module/utils/index.js.map +1 -1
  55. package/dist/module/version.js +1 -1
  56. package/dist/typescript/lib/FirebaseApp.d.ts +1 -2
  57. package/dist/typescript/lib/FirebaseApp.d.ts.map +1 -1
  58. package/dist/typescript/lib/common/index.d.ts +0 -8
  59. package/dist/typescript/lib/common/index.d.ts.map +1 -1
  60. package/dist/typescript/lib/common/unitTestUtils.d.ts +1 -4
  61. package/dist/typescript/lib/common/unitTestUtils.d.ts.map +1 -1
  62. package/dist/typescript/lib/index.d.ts +0 -1
  63. package/dist/typescript/lib/index.d.ts.map +1 -1
  64. package/dist/typescript/lib/internal/NativeFirebaseError.d.ts +1 -1
  65. package/dist/typescript/lib/internal/NativeFirebaseError.d.ts.map +1 -1
  66. package/dist/typescript/lib/internal/RNFBNativeEventEmitter.d.ts.map +1 -1
  67. package/dist/typescript/lib/internal/constants.d.ts +2 -3
  68. package/dist/typescript/lib/internal/constants.d.ts.map +1 -1
  69. package/dist/typescript/lib/internal/index.d.ts +1 -1
  70. package/dist/typescript/lib/internal/index.d.ts.map +1 -1
  71. package/dist/typescript/lib/internal/nativeModuleAndroidIos.d.ts +2 -6
  72. package/dist/typescript/lib/internal/nativeModuleAndroidIos.d.ts.map +1 -1
  73. package/dist/typescript/lib/internal/nativeModuleWeb.d.ts.map +1 -1
  74. package/dist/typescript/lib/internal/registry/app.d.ts +2 -2
  75. package/dist/typescript/lib/internal/registry/app.d.ts.map +1 -1
  76. package/dist/typescript/lib/internal/registry/modular.d.ts +22 -0
  77. package/dist/typescript/lib/internal/registry/modular.d.ts.map +1 -0
  78. package/dist/typescript/lib/internal/registry/nativeModule.d.ts +3 -11
  79. package/dist/typescript/lib/internal/registry/nativeModule.d.ts.map +1 -1
  80. package/dist/typescript/lib/modular.d.ts +21 -7
  81. package/dist/typescript/lib/modular.d.ts.map +1 -1
  82. package/dist/typescript/lib/types/app.d.ts +4 -4
  83. package/dist/typescript/lib/types/app.d.ts.map +1 -1
  84. package/dist/typescript/lib/types/internal.d.ts +1 -33
  85. package/dist/typescript/lib/types/internal.d.ts.map +1 -1
  86. package/dist/typescript/lib/utils/index.d.ts +7 -3
  87. package/dist/typescript/lib/utils/index.d.ts.map +1 -1
  88. package/dist/typescript/lib/version.d.ts +1 -1
  89. package/firebase-schema.json +5 -1
  90. package/ios/RNFBApp/RNFBAppModule.h +1 -3
  91. package/ios/RNFBApp/{RNFBAppModule.m → RNFBAppModule.mm} +74 -77
  92. package/ios/RNFBApp/RNFBUtilsModule.h +1 -3
  93. package/ios/RNFBApp/{RNFBUtilsModule.m → RNFBUtilsModule.mm} +68 -25
  94. package/ios/RNFBApp/RNFBVersion.m +1 -1
  95. package/ios/RNFBApp.xcodeproj/project.pbxproj +12 -12
  96. package/ios/generated/RCTAppDependencyProvider.h +25 -0
  97. package/ios/generated/RCTAppDependencyProvider.mm +55 -0
  98. package/ios/generated/RCTModuleProviders.h +16 -0
  99. package/ios/generated/RCTModuleProviders.mm +52 -0
  100. package/ios/generated/RCTModulesConformingToProtocolsProvider.h +18 -0
  101. package/ios/generated/RCTModulesConformingToProtocolsProvider.mm +33 -0
  102. package/ios/generated/RCTThirdPartyComponentsProvider.h +16 -0
  103. package/ios/generated/RCTThirdPartyComponentsProvider.mm +23 -0
  104. package/ios/generated/RCTUnstableModulesRequiringMainQueueSetupProvider.h +14 -0
  105. package/ios/generated/RCTUnstableModulesRequiringMainQueueSetupProvider.mm +19 -0
  106. package/ios/generated/RNFBAppTurboModules/RNFBAppTurboModules-generated.mm +210 -0
  107. package/ios/generated/RNFBAppTurboModules/RNFBAppTurboModules.h +399 -0
  108. package/ios/generated/RNFBAppTurboModulesJSI-generated.cpp +187 -0
  109. package/ios/generated/RNFBAppTurboModulesJSI.h +606 -0
  110. package/ios/generated/ReactAppDependencyProvider.podspec +34 -0
  111. package/ios/generated/ReactCodegen.podspec +111 -0
  112. package/lib/FirebaseApp.ts +1 -10
  113. package/lib/common/index.ts +0 -742
  114. package/lib/common/unitTestUtils.ts +17 -73
  115. package/lib/index.ts +0 -1
  116. package/lib/internal/NativeFirebaseError.ts +1 -1
  117. package/lib/internal/RNFBNativeEventEmitter.ts +12 -6
  118. package/lib/internal/constants.ts +3 -25
  119. package/lib/internal/global.d.ts +0 -4
  120. package/lib/internal/index.ts +1 -1
  121. package/lib/internal/nativeModuleAndroidIos.ts +69 -12
  122. package/lib/internal/nativeModuleWeb.ts +11 -5
  123. package/lib/internal/registry/app.ts +24 -22
  124. package/lib/internal/registry/modular.ts +97 -0
  125. package/lib/internal/registry/nativeModule.ts +136 -107
  126. package/lib/modular.ts +47 -72
  127. package/lib/types/app.ts +6 -11
  128. package/lib/types/internal.ts +1 -46
  129. package/lib/utils/UtilsStatics.ts +2 -2
  130. package/lib/utils/index.ts +24 -18
  131. package/lib/version.ts +1 -1
  132. package/package.json +41 -3
  133. package/plugin/build/app.plugin.d.ts +2 -0
  134. package/plugin/build/app.plugin.js +6 -0
  135. package/plugin/src/app.plugin.ts +3 -0
  136. package/plugin/tsconfig.tsbuildinfo +1 -1
  137. package/react-native.config.js +2 -0
  138. package/specs/NativeRNFBTurboApp.ts +57 -0
  139. package/specs/NativeRNFBTurboUtils.ts +34 -0
  140. package/typedoc.json +1 -2
  141. package/dist/module/internal/registry/namespace.js +0 -262
  142. package/dist/module/internal/registry/namespace.js.map +0 -1
  143. package/dist/module/namespaced.js +0 -25
  144. package/dist/module/namespaced.js.map +0 -1
  145. package/dist/typescript/lib/internal/registry/namespace.d.ts +0 -26
  146. package/dist/typescript/lib/internal/registry/namespace.d.ts.map +0 -1
  147. package/dist/typescript/lib/namespaced.d.ts +0 -5
  148. package/dist/typescript/lib/namespaced.d.ts.map +0 -1
  149. package/lib/internal/registry/namespace.ts +0 -338
  150. package/lib/namespaced.ts +0 -24
@@ -0,0 +1,1823 @@
1
+ package io.invertase.firebase.common;
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.assertNull;
23
+ import static org.junit.Assert.assertTrue;
24
+ import static org.mockito.ArgumentMatchers.anyString;
25
+ import static org.mockito.ArgumentMatchers.eq;
26
+ import static org.mockito.Mockito.doThrow;
27
+ import static org.mockito.Mockito.mock;
28
+ import static org.mockito.Mockito.mockStatic;
29
+ import static org.mockito.Mockito.never;
30
+ import static org.mockito.Mockito.verify;
31
+ import static org.mockito.Mockito.when;
32
+
33
+ import android.app.Application;
34
+ import android.content.Context;
35
+ import androidx.annotation.Nullable;
36
+ import com.facebook.react.ReactApplication;
37
+ import com.facebook.react.ReactHost;
38
+ import com.facebook.react.ReactInstanceManager;
39
+ import com.facebook.react.ReactNativeHost;
40
+ import com.facebook.react.bridge.Arguments;
41
+ import com.facebook.react.bridge.ReactContext;
42
+ import com.facebook.react.bridge.WritableMap;
43
+ import com.facebook.react.modules.core.DeviceEventManagerModule;
44
+ import io.invertase.firebase.app.ReactNativeFirebaseApp;
45
+ import io.invertase.firebase.interfaces.NativeEvent;
46
+ import java.lang.ref.WeakReference;
47
+ import java.lang.reflect.Constructor;
48
+ import java.lang.reflect.Field;
49
+ import java.lang.reflect.Method;
50
+ import java.util.HashMap;
51
+ import java.util.concurrent.atomic.AtomicBoolean;
52
+ import org.junit.Before;
53
+ import org.junit.Test;
54
+ import org.junit.runner.RunWith;
55
+ import org.mockito.MockedStatic;
56
+ import org.robolectric.RobolectricTestRunner;
57
+ import org.robolectric.annotation.Config;
58
+ import org.robolectric.shadows.ShadowLooper;
59
+
60
+ /**
61
+ * JVM coverage for {@link ReactNativeFirebaseEventEmitter} ReactContext generation overlap.
62
+ *
63
+ * <p>Uses Robolectric for {@link android.os.Handler}/{@link android.os.Looper} and Mockito for RN
64
+ * host/context doubles. The contracts under test are Java state-machine behaviour and do not
65
+ * require the Detox/Jet harness.
66
+ */
67
+ @RunWith(RobolectricTestRunner.class)
68
+ @Config(sdk = 34, application = ReactNativeFirebaseEventEmitterTest.HostApplication.class)
69
+ public class ReactNativeFirebaseEventEmitterTest {
70
+
71
+ private static final String EVENT = "auth_state_changed";
72
+
73
+ private HostApplication application;
74
+ private ReactHost reactHost;
75
+ private ReactContext staleContext;
76
+ private ReactContext liveContext;
77
+ private DeviceEventManagerModule.RCTDeviceEventEmitter staleJsEmitter;
78
+ private DeviceEventManagerModule.RCTDeviceEventEmitter liveJsEmitter;
79
+ private WritableMap eventBody;
80
+
81
+ public static class HostApplication extends Application implements ReactApplication {
82
+ ReactHost reactHost;
83
+ ReactNativeHost reactNativeHost;
84
+
85
+ @Override
86
+ public ReactNativeHost getReactNativeHost() {
87
+ if (reactNativeHost == null) {
88
+ throw new RuntimeException("bridgeless-only test host");
89
+ }
90
+ return reactNativeHost;
91
+ }
92
+
93
+ @Override
94
+ public ReactHost getReactHost() {
95
+ return reactHost;
96
+ }
97
+ }
98
+
99
+ @Before
100
+ public void setUp() throws Exception {
101
+ application = (HostApplication) org.robolectric.RuntimeEnvironment.getApplication();
102
+ reactHost = mock(ReactHost.class);
103
+ application.reactHost = reactHost;
104
+ application.reactNativeHost = null;
105
+
106
+ staleContext = mock(ReactContext.class);
107
+ liveContext = mock(ReactContext.class);
108
+ staleJsEmitter = mock(DeviceEventManagerModule.RCTDeviceEventEmitter.class);
109
+ liveJsEmitter = mock(DeviceEventManagerModule.RCTDeviceEventEmitter.class);
110
+ eventBody = mock(WritableMap.class);
111
+
112
+ when(staleContext.getApplicationContext()).thenReturn(application);
113
+ when(liveContext.getApplicationContext()).thenReturn(application);
114
+ // Bridgeless stale contexts still report an active instance via host-level state.
115
+ when(staleContext.hasActiveReactInstance()).thenReturn(true);
116
+ when(liveContext.hasActiveReactInstance()).thenReturn(true);
117
+ when(staleContext.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class))
118
+ .thenReturn(staleJsEmitter);
119
+ when(liveContext.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class))
120
+ .thenReturn(liveJsEmitter);
121
+
122
+ resetSharedInstance();
123
+ }
124
+
125
+ /**
126
+ * Emit must target the host's current ReactContext, not a previously attached generation that no
127
+ * longer hosts JS listeners.
128
+ */
129
+ @Test
130
+ public void emit_deliversToHostCurrentContext_whenAttachedContextIsStale() {
131
+ when(reactHost.getCurrentReactContext()).thenReturn(staleContext);
132
+
133
+ ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance();
134
+ emitter.attachReactContext(staleContext);
135
+ ShadowLooper.idleMainLooper();
136
+
137
+ when(reactHost.getCurrentReactContext()).thenReturn(liveContext);
138
+
139
+ emitter.notifyJsReady(true);
140
+ ShadowLooper.idleMainLooper();
141
+ emitter.addListener(EVENT);
142
+ ShadowLooper.idleMainLooper();
143
+
144
+ emitter.sendEvent(event(EVENT, eventBody));
145
+ ShadowLooper.idleMainLooper();
146
+
147
+ verify(liveJsEmitter).emit(eq("rnfb_" + EVENT), eq(eventBody));
148
+ verify(staleJsEmitter, never()).emit(anyString(), eq(eventBody));
149
+ }
150
+
151
+ /**
152
+ * When the host cannot name a current context (reload gap), a late attach from a dying generation
153
+ * must not displace the live attachment, and a subsequent detach of that dying context must not
154
+ * clear live listener / jsReady state.
155
+ */
156
+ @Test
157
+ public void attachDetach_preservesLiveListeners_whenHostCurrentNullAndStaleOverlaps()
158
+ throws Exception {
159
+ when(reactHost.getCurrentReactContext()).thenReturn(liveContext);
160
+
161
+ ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance();
162
+ emitter.attachReactContext(liveContext);
163
+ ShadowLooper.idleMainLooper();
164
+
165
+ emitter.notifyJsReady(true);
166
+ ShadowLooper.idleMainLooper();
167
+ emitter.addListener(EVENT);
168
+ ShadowLooper.idleMainLooper();
169
+
170
+ assertEquals(1, listenerCount(emitter, EVENT));
171
+ assertTrue(jsReady(emitter));
172
+
173
+ when(reactHost.getCurrentReactContext()).thenReturn(null);
174
+ emitter.attachReactContext(staleContext);
175
+ ShadowLooper.idleMainLooper();
176
+
177
+ emitter.detachReactContext(staleContext);
178
+ ShadowLooper.idleMainLooper();
179
+
180
+ assertEquals(
181
+ "live jsListeners must survive late stale attach+detach when host current is null",
182
+ 1,
183
+ listenerCount(emitter, EVENT));
184
+ assertTrue(
185
+ "jsReady must survive late stale attach+detach when host current is null",
186
+ jsReady(emitter));
187
+ assertEquals(
188
+ "attached context must remain the live generation", liveContext, attachedContext(emitter));
189
+ assertDiagnostics(emitter, /* jsReady= */ true, liveContext, /* current= */ null);
190
+ }
191
+
192
+ /**
193
+ * Live module initialize while the host still names the dying context must converge: pending
194
+ * attach is promoted on dying detach, listeners registered by the live JS survive, and emit
195
+ * prefers the promoted attachment over the lagging host current.
196
+ */
197
+ @Test
198
+ public void attachDetachEmit_convergesToLiveContext_whenHostLagsOnDyingGeneration()
199
+ throws Exception {
200
+ when(reactHost.getCurrentReactContext()).thenReturn(staleContext);
201
+
202
+ ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance();
203
+ emitter.attachReactContext(staleContext);
204
+ ShadowLooper.idleMainLooper();
205
+
206
+ emitter.attachReactContext(liveContext);
207
+ ShadowLooper.idleMainLooper();
208
+ assertEquals(staleContext, attachedContext(emitter));
209
+ assertEquals(liveContext, pendingContext(emitter));
210
+
211
+ emitter.notifyJsReady(true);
212
+ ShadowLooper.idleMainLooper();
213
+ emitter.addListener(EVENT);
214
+ ShadowLooper.idleMainLooper();
215
+
216
+ emitter.detachReactContext(staleContext);
217
+ ShadowLooper.idleMainLooper();
218
+
219
+ assertEquals(liveContext, attachedContext(emitter));
220
+ assertEquals(1, listenerCount(emitter, EVENT));
221
+ assertTrue(jsReady(emitter));
222
+
223
+ // Host still names dying generation; emit must not deliver into it.
224
+ emitter.sendEvent(event(EVENT, eventBody));
225
+ ShadowLooper.idleMainLooper();
226
+
227
+ verify(liveJsEmitter).emit(eq("rnfb_" + EVENT), eq(eventBody));
228
+ verify(staleJsEmitter, never()).emit(anyString(), eq(eventBody));
229
+
230
+ assertDiagnostics(emitter, /* jsReady= */ true, liveContext, staleContext);
231
+ }
232
+
233
+ /**
234
+ * After the live generation has converged ahead of the host, a late attach of the lagging host
235
+ * current must not overwrite the live attachment.
236
+ */
237
+ @Test
238
+ public void attach_rejectsHostLagContext_afterLiveGenerationPromoted() throws Exception {
239
+ when(reactHost.getCurrentReactContext()).thenReturn(staleContext);
240
+
241
+ ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance();
242
+ emitter.attachReactContext(staleContext);
243
+ ShadowLooper.idleMainLooper();
244
+ emitter.attachReactContext(liveContext);
245
+ ShadowLooper.idleMainLooper();
246
+ emitter.notifyJsReady(true);
247
+ emitter.addListener(EVENT);
248
+ ShadowLooper.idleMainLooper();
249
+ emitter.detachReactContext(staleContext);
250
+ ShadowLooper.idleMainLooper();
251
+
252
+ emitter.attachReactContext(staleContext);
253
+ ShadowLooper.idleMainLooper();
254
+
255
+ assertEquals(liveContext, attachedContext(emitter));
256
+ assertEquals(1, listenerCount(emitter, EVENT));
257
+ assertTrue(jsReady(emitter));
258
+ }
259
+
260
+ /**
261
+ * When the host catches up to a pending live context before the dying generation detaches,
262
+ * attachment converges without wiping listener registrations already made by live JS.
263
+ */
264
+ @Test
265
+ public void attach_convergesPending_whenHostCatchesUpBeforeDyingDetach() throws Exception {
266
+ when(reactHost.getCurrentReactContext()).thenReturn(staleContext);
267
+
268
+ ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance();
269
+ emitter.attachReactContext(staleContext);
270
+ ShadowLooper.idleMainLooper();
271
+ emitter.attachReactContext(liveContext);
272
+ ShadowLooper.idleMainLooper();
273
+
274
+ emitter.notifyJsReady(true);
275
+ emitter.addListener(EVENT);
276
+ ShadowLooper.idleMainLooper();
277
+
278
+ when(reactHost.getCurrentReactContext()).thenReturn(liveContext);
279
+ emitter.attachReactContext(liveContext);
280
+ ShadowLooper.idleMainLooper();
281
+
282
+ assertEquals(liveContext, attachedContext(emitter));
283
+ assertEquals(1, listenerCount(emitter, EVENT));
284
+ assertTrue(jsReady(emitter));
285
+ assertEquals(null, pendingContext(emitter));
286
+
287
+ emitter.sendEvent(event(EVENT, eventBody));
288
+ ShadowLooper.idleMainLooper();
289
+ verify(liveJsEmitter).emit(eq("rnfb_" + EVENT), eq(eventBody));
290
+ }
291
+
292
+ /**
293
+ * A confirmed host switch onto a new context resets listener and jsReady accounting that belonged
294
+ * to the previous generation.
295
+ */
296
+ @Test
297
+ public void attach_resetsListenerState_onHostConfirmedContextSwitch() throws Exception {
298
+ when(reactHost.getCurrentReactContext()).thenReturn(staleContext);
299
+
300
+ ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance();
301
+ emitter.attachReactContext(staleContext);
302
+ ShadowLooper.idleMainLooper();
303
+ emitter.notifyJsReady(true);
304
+ emitter.addListener(EVENT);
305
+ ShadowLooper.idleMainLooper();
306
+
307
+ when(reactHost.getCurrentReactContext()).thenReturn(liveContext);
308
+ emitter.attachReactContext(liveContext);
309
+ ShadowLooper.idleMainLooper();
310
+
311
+ assertEquals(liveContext, attachedContext(emitter));
312
+ assertEquals(0, listenerCount(emitter, EVENT));
313
+ assertFalse(jsReady(emitter));
314
+ assertDiagnostics(emitter, /* jsReady= */ false, liveContext, liveContext);
315
+ }
316
+
317
+ /**
318
+ * Dying detach after the pointer already moved to the live generation is a no-op for listener
319
+ * state.
320
+ */
321
+ @Test
322
+ public void detach_isNoOp_whenAttachedPointerAlreadyMoved() throws Exception {
323
+ when(reactHost.getCurrentReactContext()).thenReturn(liveContext);
324
+
325
+ ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance();
326
+ emitter.attachReactContext(staleContext);
327
+ ShadowLooper.idleMainLooper();
328
+ // Force live attach while host already names live (confirmed switch).
329
+ emitter.attachReactContext(liveContext);
330
+ ShadowLooper.idleMainLooper();
331
+ emitter.notifyJsReady(true);
332
+ emitter.addListener(EVENT);
333
+ ShadowLooper.idleMainLooper();
334
+
335
+ emitter.detachReactContext(staleContext);
336
+ ShadowLooper.idleMainLooper();
337
+
338
+ assertEquals(liveContext, attachedContext(emitter));
339
+ assertEquals(1, listenerCount(emitter, EVENT));
340
+ assertTrue(jsReady(emitter));
341
+ }
342
+
343
+ /**
344
+ * When ReactHost is present but reports a null current context, resolution falls through to
345
+ * ReactNativeHost so hybrid hosts still participate in arbitration.
346
+ */
347
+ @Test
348
+ public void hostResolution_fallsThroughToReactNativeHost_whenReactHostCurrentNull()
349
+ throws Exception {
350
+ ReactNativeHost reactNativeHost = mock(ReactNativeHost.class);
351
+ ReactInstanceManager instanceManager = mock(ReactInstanceManager.class);
352
+ application.reactNativeHost = reactNativeHost;
353
+ when(reactHost.getCurrentReactContext()).thenReturn(null);
354
+ when(reactNativeHost.hasInstance()).thenReturn(true);
355
+ when(reactNativeHost.getReactInstanceManager()).thenReturn(instanceManager);
356
+ when(instanceManager.getCurrentReactContext()).thenReturn(liveContext);
357
+
358
+ ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance();
359
+ emitter.attachReactContext(staleContext);
360
+ ShadowLooper.idleMainLooper();
361
+ emitter.notifyJsReady(true);
362
+ emitter.addListener(EVENT);
363
+ ShadowLooper.idleMainLooper();
364
+
365
+ // Confirmed switch via bridge host fallthrough.
366
+ emitter.attachReactContext(liveContext);
367
+ ShadowLooper.idleMainLooper();
368
+
369
+ assertEquals(liveContext, attachedContext(emitter));
370
+ assertEquals(0, listenerCount(emitter, EVENT));
371
+ assertFalse(jsReady(emitter));
372
+ assertDiagnostics(emitter, /* jsReady= */ false, liveContext, liveContext);
373
+ }
374
+
375
+ /**
376
+ * Emit prefers the pending live context while the host/attached pointer still names the dying
377
+ * generation, without waiting for dying detach.
378
+ */
379
+ @Test
380
+ public void emit_deliversToPendingLiveContext_whileHostStillOnDyingGeneration() {
381
+ when(reactHost.getCurrentReactContext()).thenReturn(staleContext);
382
+
383
+ ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance();
384
+ emitter.attachReactContext(staleContext);
385
+ ShadowLooper.idleMainLooper();
386
+ emitter.attachReactContext(liveContext);
387
+ ShadowLooper.idleMainLooper();
388
+
389
+ emitter.notifyJsReady(true);
390
+ emitter.addListener(EVENT);
391
+ ShadowLooper.idleMainLooper();
392
+
393
+ emitter.sendEvent(event(EVENT, eventBody));
394
+ ShadowLooper.idleMainLooper();
395
+
396
+ verify(liveJsEmitter).emit(eq("rnfb_" + EVENT), eq(eventBody));
397
+ verify(staleJsEmitter, never()).emit(anyString(), eq(eventBody));
398
+ }
399
+
400
+ /**
401
+ * Dying same-context re-attach (resume) while the host still names the dying generation must not
402
+ * wipe a different pending live replacement — otherwise dying detach later resets listeners for
403
+ * the rest of the session (#8374-class).
404
+ */
405
+ @Test
406
+ public void attach_preservesPending_whenDyingSameContextReattachWhileHostLags() throws Exception {
407
+ when(reactHost.getCurrentReactContext()).thenReturn(staleContext);
408
+
409
+ ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance();
410
+ emitter.attachReactContext(staleContext);
411
+ ShadowLooper.idleMainLooper();
412
+ emitter.attachReactContext(liveContext);
413
+ ShadowLooper.idleMainLooper();
414
+
415
+ assertEquals(staleContext, attachedContext(emitter));
416
+ assertEquals(liveContext, pendingContext(emitter));
417
+
418
+ emitter.notifyJsReady(true);
419
+ emitter.addListener(EVENT);
420
+ ShadowLooper.idleMainLooper();
421
+ assertEquals(1, listenerCount(emitter, EVENT));
422
+
423
+ // Dying generation resume / re-attach while host still names it.
424
+ emitter.attachReactContext(staleContext);
425
+ ShadowLooper.idleMainLooper();
426
+
427
+ assertEquals(
428
+ "pending live replacement must survive dying same-context re-attach",
429
+ liveContext,
430
+ pendingContext(emitter));
431
+ assertEquals(staleContext, attachedContext(emitter));
432
+ assertEquals(staleContext, hostLagContext(emitter));
433
+ assertEquals(1, listenerCount(emitter, EVENT));
434
+ assertTrue(jsReady(emitter));
435
+
436
+ emitter.detachReactContext(staleContext);
437
+ ShadowLooper.idleMainLooper();
438
+ assertEquals(liveContext, attachedContext(emitter));
439
+ assertEquals(1, listenerCount(emitter, EVENT));
440
+ assertTrue(jsReady(emitter));
441
+
442
+ emitter.sendEvent(event(EVENT, eventBody));
443
+ ShadowLooper.idleMainLooper();
444
+ verify(liveJsEmitter).emit(eq("rnfb_" + EVENT), eq(eventBody));
445
+ verify(staleJsEmitter, never()).emit(anyString(), eq(eventBody));
446
+ }
447
+
448
+ /**
449
+ * Pending-only invalidate must cancel the pending slot rather than early-returning on the
450
+ * attached-identity check, and must restore the surviving attached generation's listener /
451
+ * jsReady snapshot taken when pending first entered — otherwise the dying JS (which already
452
+ * registered once) leaves the attached runtime deaf for the rest of the session.
453
+ */
454
+ @Test
455
+ public void detach_restoresAttachedListeners_whenPendingOnlyCancelled() throws Exception {
456
+ ReactContext doomedPending = mock(ReactContext.class);
457
+ DeviceEventManagerModule.RCTDeviceEventEmitter doomedJsEmitter =
458
+ mock(DeviceEventManagerModule.RCTDeviceEventEmitter.class);
459
+ when(doomedPending.getApplicationContext()).thenReturn(application);
460
+ when(doomedPending.hasActiveReactInstance()).thenReturn(true);
461
+ when(doomedPending.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class))
462
+ .thenReturn(doomedJsEmitter);
463
+
464
+ when(reactHost.getCurrentReactContext()).thenReturn(staleContext);
465
+
466
+ ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance();
467
+ emitter.attachReactContext(staleContext);
468
+ ShadowLooper.idleMainLooper();
469
+
470
+ // Attached generation registers before pending enters.
471
+ emitter.notifyJsReady(true);
472
+ emitter.addListener(EVENT);
473
+ ShadowLooper.idleMainLooper();
474
+ assertEquals(1, listenerCount(emitter, EVENT));
475
+ assertTrue(jsReady(emitter));
476
+
477
+ emitter.attachReactContext(doomedPending);
478
+ ShadowLooper.idleMainLooper();
479
+
480
+ assertEquals(doomedPending, pendingContext(emitter));
481
+ assertEquals(staleContext, attachedContext(emitter));
482
+ assertEquals(
483
+ "pending entry resets live map so events queue until pending JS re-registers",
484
+ 0,
485
+ listenerCount(emitter, EVENT));
486
+ assertFalse(jsReady(emitter));
487
+
488
+ // Pending JS registers its own accounting (must be discarded on cancel, not kept).
489
+ emitter.notifyJsReady(true);
490
+ emitter.addListener(EVENT);
491
+ ShadowLooper.idleMainLooper();
492
+ assertEquals(1, listenerCount(emitter, EVENT));
493
+
494
+ emitter.detachReactContext(doomedPending);
495
+ ShadowLooper.idleMainLooper();
496
+
497
+ assertEquals(null, pendingContext(emitter));
498
+ assertEquals(null, hostLagContext(emitter));
499
+ assertEquals(staleContext, attachedContext(emitter));
500
+ assertEquals(
501
+ "pending-only cancel must restore attached generation listener accounting",
502
+ 1,
503
+ listenerCount(emitter, EVENT));
504
+ assertTrue("pending-only cancel must restore attached generation jsReady", jsReady(emitter));
505
+
506
+ emitter.sendEvent(event(EVENT, eventBody));
507
+ ShadowLooper.idleMainLooper();
508
+ verify(staleJsEmitter).emit(eq("rnfb_" + EVENT), eq(eventBody));
509
+ verify(doomedJsEmitter, never()).emit(anyString(), eq(eventBody));
510
+ }
511
+
512
+ /**
513
+ * Events raised while pending entry wiped attached listeners are queued. Pending-only cancel must
514
+ * restore accounting and flush the queue — attached JS will not re-register.
515
+ */
516
+ @Test
517
+ public void detach_flushesQueuedEvents_whenPendingOnlyCancelledAfterQueueDuringWipe()
518
+ throws Exception {
519
+ ReactContext doomedPending = mock(ReactContext.class);
520
+ DeviceEventManagerModule.RCTDeviceEventEmitter doomedJsEmitter =
521
+ mock(DeviceEventManagerModule.RCTDeviceEventEmitter.class);
522
+ when(doomedPending.getApplicationContext()).thenReturn(application);
523
+ when(doomedPending.hasActiveReactInstance()).thenReturn(true);
524
+ when(doomedPending.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class))
525
+ .thenReturn(doomedJsEmitter);
526
+
527
+ when(reactHost.getCurrentReactContext()).thenReturn(staleContext);
528
+
529
+ ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance();
530
+ emitter.attachReactContext(staleContext);
531
+ ShadowLooper.idleMainLooper();
532
+
533
+ emitter.notifyJsReady(true);
534
+ emitter.addListener(EVENT);
535
+ ShadowLooper.idleMainLooper();
536
+
537
+ emitter.attachReactContext(doomedPending);
538
+ ShadowLooper.idleMainLooper();
539
+ assertEquals(0, listenerCount(emitter, EVENT));
540
+ assertFalse(jsReady(emitter));
541
+
542
+ // Queued during the wiped pending window (messaging-style / #8374-class).
543
+ emitter.sendEvent(event(EVENT, eventBody));
544
+ ShadowLooper.idleMainLooper();
545
+ assertEquals(1, queuedCount(emitter));
546
+ verify(staleJsEmitter, never()).emit(anyString(), eq(eventBody));
547
+ verify(doomedJsEmitter, never()).emit(anyString(), eq(eventBody));
548
+
549
+ emitter.detachReactContext(doomedPending);
550
+ ShadowLooper.idleMainLooper();
551
+
552
+ assertEquals(0, queuedCount(emitter));
553
+ verify(staleJsEmitter).emit(eq("rnfb_" + EVENT), eq(eventBody));
554
+ verify(doomedJsEmitter, never()).emit(anyString(), eq(eventBody));
555
+ }
556
+
557
+ /**
558
+ * Same pending identity re-attach while the host still names the dying generation (async
559
+ * onHostResume / NativeRNFBTurboApp) must not resetListenerState — otherwise live JS
560
+ * registrations made after the first pending entry are wiped for the rest of the session
561
+ * (#8374-class).
562
+ */
563
+ @Test
564
+ public void attach_preservesPendingListeners_whenSamePendingReattachWhileHostLags()
565
+ throws Exception {
566
+ when(reactHost.getCurrentReactContext()).thenReturn(staleContext);
567
+
568
+ ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance();
569
+ emitter.attachReactContext(staleContext);
570
+ ShadowLooper.idleMainLooper();
571
+ emitter.attachReactContext(liveContext);
572
+ ShadowLooper.idleMainLooper();
573
+
574
+ assertEquals(staleContext, attachedContext(emitter));
575
+ assertEquals(liveContext, pendingContext(emitter));
576
+
577
+ emitter.notifyJsReady(true);
578
+ emitter.addListener(EVENT);
579
+ ShadowLooper.idleMainLooper();
580
+ assertEquals(1, listenerCount(emitter, EVENT));
581
+ assertTrue(jsReady(emitter));
582
+
583
+ // Same pending identity re-attach while host still lags (e.g. onHostResume).
584
+ emitter.attachReactContext(liveContext);
585
+ ShadowLooper.idleMainLooper();
586
+
587
+ assertEquals(liveContext, pendingContext(emitter));
588
+ assertEquals(staleContext, attachedContext(emitter));
589
+ assertEquals(staleContext, hostLagContext(emitter));
590
+ assertEquals(
591
+ "same-pending re-attach must not wipe listeners registered after first pending entry",
592
+ 1,
593
+ listenerCount(emitter, EVENT));
594
+ assertTrue(
595
+ "same-pending re-attach must not clear jsReady registered after first pending entry",
596
+ jsReady(emitter));
597
+
598
+ emitter.detachReactContext(staleContext);
599
+ ShadowLooper.idleMainLooper();
600
+ assertEquals(liveContext, attachedContext(emitter));
601
+ assertEquals(1, listenerCount(emitter, EVENT));
602
+ assertTrue(jsReady(emitter));
603
+
604
+ emitter.sendEvent(event(EVENT, eventBody));
605
+ ShadowLooper.idleMainLooper();
606
+ verify(liveJsEmitter).emit(eq("rnfb_" + EVENT), eq(eventBody));
607
+ verify(staleJsEmitter, never()).emit(anyString(), eq(eventBody));
608
+ }
609
+
610
+ /**
611
+ * Detach of a superseded (non-current) pending identity must not wipe listener / jsReady state
612
+ * owned by a newer live pending candidate.
613
+ */
614
+ @Test
615
+ public void detach_doesNotClearListeners_whenSupersededPendingDetaches() throws Exception {
616
+ ReactContext middlePending = mock(ReactContext.class);
617
+ DeviceEventManagerModule.RCTDeviceEventEmitter middleJsEmitter =
618
+ mock(DeviceEventManagerModule.RCTDeviceEventEmitter.class);
619
+ when(middlePending.getApplicationContext()).thenReturn(application);
620
+ when(middlePending.hasActiveReactInstance()).thenReturn(true);
621
+ when(middlePending.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class))
622
+ .thenReturn(middleJsEmitter);
623
+
624
+ when(reactHost.getCurrentReactContext()).thenReturn(staleContext);
625
+
626
+ ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance();
627
+ emitter.attachReactContext(staleContext);
628
+ ShadowLooper.idleMainLooper();
629
+ emitter.attachReactContext(middlePending);
630
+ ShadowLooper.idleMainLooper();
631
+ emitter.attachReactContext(liveContext);
632
+ ShadowLooper.idleMainLooper();
633
+
634
+ emitter.notifyJsReady(true);
635
+ emitter.addListener(EVENT);
636
+ ShadowLooper.idleMainLooper();
637
+ assertEquals(liveContext, pendingContext(emitter));
638
+ assertEquals(1, listenerCount(emitter, EVENT));
639
+
640
+ emitter.detachReactContext(middlePending);
641
+ ShadowLooper.idleMainLooper();
642
+
643
+ assertEquals(liveContext, pendingContext(emitter));
644
+ assertEquals(1, listenerCount(emitter, EVENT));
645
+ assertTrue(jsReady(emitter));
646
+ }
647
+
648
+ /**
649
+ * A third overlapping candidate while the host still names the dying attached generation is
650
+ * last-writer-wins: pending becomes the newest candidate and prior pending listener accounting is
651
+ * reset so the newest JS re-registers.
652
+ */
653
+ @Test
654
+ public void attach_pendingLastWriterWins_whenThirdOverlappingCandidateArrives() throws Exception {
655
+ ReactContext middlePending = mock(ReactContext.class);
656
+ DeviceEventManagerModule.RCTDeviceEventEmitter middleJsEmitter =
657
+ mock(DeviceEventManagerModule.RCTDeviceEventEmitter.class);
658
+ when(middlePending.getApplicationContext()).thenReturn(application);
659
+ when(middlePending.hasActiveReactInstance()).thenReturn(true);
660
+ when(middlePending.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class))
661
+ .thenReturn(middleJsEmitter);
662
+
663
+ when(reactHost.getCurrentReactContext()).thenReturn(staleContext);
664
+
665
+ ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance();
666
+ emitter.attachReactContext(staleContext);
667
+ ShadowLooper.idleMainLooper();
668
+ emitter.attachReactContext(middlePending);
669
+ ShadowLooper.idleMainLooper();
670
+
671
+ emitter.notifyJsReady(true);
672
+ emitter.addListener(EVENT);
673
+ ShadowLooper.idleMainLooper();
674
+ assertEquals(middlePending, pendingContext(emitter));
675
+ assertEquals(1, listenerCount(emitter, EVENT));
676
+
677
+ // Third overlapping generation while host still on dying attached.
678
+ emitter.attachReactContext(liveContext);
679
+ ShadowLooper.idleMainLooper();
680
+
681
+ assertEquals(liveContext, pendingContext(emitter));
682
+ assertEquals(staleContext, attachedContext(emitter));
683
+ assertEquals(staleContext, hostLagContext(emitter));
684
+ assertEquals(
685
+ "last-writer-wins pending must reset prior pending listener accounting",
686
+ 0,
687
+ listenerCount(emitter, EVENT));
688
+ assertFalse(jsReady(emitter));
689
+
690
+ emitter.notifyJsReady(true);
691
+ emitter.addListener(EVENT);
692
+ ShadowLooper.idleMainLooper();
693
+
694
+ // Detach of superseded middle pending must not clear the newer pending.
695
+ emitter.detachReactContext(middlePending);
696
+ ShadowLooper.idleMainLooper();
697
+ assertEquals(liveContext, pendingContext(emitter));
698
+
699
+ emitter.detachReactContext(staleContext);
700
+ ShadowLooper.idleMainLooper();
701
+ assertEquals(liveContext, attachedContext(emitter));
702
+ assertEquals(1, listenerCount(emitter, EVENT));
703
+
704
+ emitter.sendEvent(event(EVENT, eventBody));
705
+ ShadowLooper.idleMainLooper();
706
+ verify(liveJsEmitter).emit(eq("rnfb_" + EVENT), eq(eventBody));
707
+ verify(middleJsEmitter, never()).emit(anyString(), eq(eventBody));
708
+ verify(staleJsEmitter, never()).emit(anyString(), eq(eventBody));
709
+ }
710
+
711
+ /**
712
+ * When something is already attached and the host names a third context, an attach candidate that
713
+ * is neither host-current nor the previous attachment is ignored (fail-closed).
714
+ */
715
+ @Test
716
+ public void attach_ignoresNonCurrentCandidate_whenHostAlreadyMovedToThirdContext()
717
+ throws Exception {
718
+ ReactContext thirdContext = mock(ReactContext.class);
719
+ when(thirdContext.getApplicationContext()).thenReturn(application);
720
+ when(thirdContext.hasActiveReactInstance()).thenReturn(true);
721
+
722
+ when(reactHost.getCurrentReactContext()).thenReturn(staleContext);
723
+ ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance();
724
+ emitter.attachReactContext(staleContext);
725
+ ShadowLooper.idleMainLooper();
726
+
727
+ when(reactHost.getCurrentReactContext()).thenReturn(liveContext);
728
+ // Candidate is neither host current (live) nor previous attached (stale).
729
+ emitter.attachReactContext(thirdContext);
730
+ ShadowLooper.idleMainLooper();
731
+
732
+ assertEquals(staleContext, attachedContext(emitter));
733
+ assertNull(pendingContext(emitter));
734
+ }
735
+
736
+ /**
737
+ * Detach of the sole attached generation with no pending replacement clears attachment, listener
738
+ * accounting, and convergence slots while intentionally retaining queued events.
739
+ */
740
+ @Test
741
+ public void detach_clearsAttachedState_whenNoPendingReplacement() throws Exception {
742
+ when(reactHost.getCurrentReactContext()).thenReturn(liveContext);
743
+ ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance();
744
+ emitter.attachReactContext(liveContext);
745
+ ShadowLooper.idleMainLooper();
746
+ emitter.notifyJsReady(true);
747
+ emitter.addListener(EVENT);
748
+ ShadowLooper.idleMainLooper();
749
+
750
+ emitter.detachReactContext(liveContext);
751
+ ShadowLooper.idleMainLooper();
752
+
753
+ assertNull(attachedContext(emitter));
754
+ assertNull(pendingContext(emitter));
755
+ assertNull(hostLagContext(emitter));
756
+ assertEquals(0, listenerCount(emitter, EVENT));
757
+ assertFalse(jsReady(emitter));
758
+ }
759
+
760
+ /** Repeated addListener for the same event increments the per-event count. */
761
+ @Test
762
+ public void addListener_incrementsExistingEventCount() throws Exception {
763
+ when(reactHost.getCurrentReactContext()).thenReturn(liveContext);
764
+ ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance();
765
+ emitter.attachReactContext(liveContext);
766
+ ShadowLooper.idleMainLooper();
767
+
768
+ emitter.addListener(EVENT);
769
+ emitter.addListener(EVENT);
770
+ ShadowLooper.idleMainLooper();
771
+
772
+ assertEquals(2, listenerCount(emitter, EVENT));
773
+ }
774
+
775
+ /** removeListener(all=false) with count &gt; 1 decrements rather than removing the key. */
776
+ @Test
777
+ public void removeListener_decrementsWhenMultipleRemain() throws Exception {
778
+ when(reactHost.getCurrentReactContext()).thenReturn(liveContext);
779
+ ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance();
780
+ emitter.attachReactContext(liveContext);
781
+ ShadowLooper.idleMainLooper();
782
+
783
+ emitter.addListener(EVENT);
784
+ emitter.addListener(EVENT);
785
+ emitter.removeListener(EVENT, false);
786
+ ShadowLooper.idleMainLooper();
787
+
788
+ assertEquals(1, listenerCount(emitter, EVENT));
789
+ }
790
+
791
+ /** removeListener removes the event key when the last registration is dropped. */
792
+ @Test
793
+ public void removeListener_removesKey_whenLastRegistrationDropped() throws Exception {
794
+ when(reactHost.getCurrentReactContext()).thenReturn(liveContext);
795
+ ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance();
796
+ emitter.attachReactContext(liveContext);
797
+ ShadowLooper.idleMainLooper();
798
+
799
+ emitter.addListener(EVENT);
800
+ emitter.removeListener(EVENT, false);
801
+ ShadowLooper.idleMainLooper();
802
+
803
+ assertEquals(0, listenerCount(emitter, EVENT));
804
+ }
805
+
806
+ /** removeListener(all=true) removes the key even when multiple registrations remain. */
807
+ @Test
808
+ public void removeListener_removesKey_whenAllFlagTrue() throws Exception {
809
+ when(reactHost.getCurrentReactContext()).thenReturn(liveContext);
810
+ ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance();
811
+ emitter.attachReactContext(liveContext);
812
+ ShadowLooper.idleMainLooper();
813
+
814
+ emitter.addListener(EVENT);
815
+ emitter.addListener(EVENT);
816
+ emitter.removeListener(EVENT, true);
817
+ ShadowLooper.idleMainLooper();
818
+
819
+ assertEquals(0, listenerCount(emitter, EVENT));
820
+ }
821
+
822
+ /**
823
+ * Same-context re-attach while the host already names that context clears residual convergence
824
+ * slots (e.g. leftover hostLag) when there is no different pending replacement.
825
+ */
826
+ @Test
827
+ public void attach_clearsConvergence_whenSameContextReattachAndHostCurrent() throws Exception {
828
+ when(reactHost.getCurrentReactContext()).thenReturn(liveContext);
829
+ ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance();
830
+ emitter.attachReactContext(liveContext);
831
+ ShadowLooper.idleMainLooper();
832
+
833
+ setWeakRefField(emitter, "hostLagReactContext", staleContext);
834
+ assertEquals(staleContext, hostLagContext(emitter));
835
+
836
+ emitter.attachReactContext(liveContext);
837
+ ShadowLooper.idleMainLooper();
838
+
839
+ assertNull(hostLagContext(emitter));
840
+ assertNull(pendingContext(emitter));
841
+ assertEquals(liveContext, attachedContext(emitter));
842
+ }
843
+
844
+ /**
845
+ * Events queued before a matching listener exists are drained by sendQueuedEvents once the
846
+ * listener is registered.
847
+ */
848
+ @Test
849
+ public void sendQueuedEvents_drainsAndEmits_whenListenerRegistersAfterQueue() {
850
+ when(reactHost.getCurrentReactContext()).thenReturn(liveContext);
851
+ ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance();
852
+ emitter.attachReactContext(liveContext);
853
+ ShadowLooper.idleMainLooper();
854
+ emitter.notifyJsReady(true);
855
+ ShadowLooper.idleMainLooper();
856
+
857
+ emitter.sendEvent(event(EVENT, eventBody));
858
+ ShadowLooper.idleMainLooper();
859
+ verify(liveJsEmitter, never()).emit(anyString(), eq(eventBody));
860
+
861
+ emitter.addListener(EVENT);
862
+ ShadowLooper.idleMainLooper();
863
+
864
+ verify(liveJsEmitter).emit(eq("rnfb_" + EVENT), eq(eventBody));
865
+ }
866
+
867
+ /** Emit aborts immediately when jsReady is still false. */
868
+ @Test
869
+ public void emit_returnsFalse_whenJsReadyFalse() {
870
+ when(reactHost.getCurrentReactContext()).thenReturn(liveContext);
871
+ ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance();
872
+ emitter.attachReactContext(liveContext);
873
+ ShadowLooper.idleMainLooper();
874
+ emitter.addListener(EVENT);
875
+ ShadowLooper.idleMainLooper();
876
+
877
+ emitter.sendEvent(event(EVENT, eventBody));
878
+ ShadowLooper.idleMainLooper();
879
+
880
+ verify(liveJsEmitter, never()).emit(anyString(), eq(eventBody));
881
+ }
882
+
883
+ /**
884
+ * Emit falls back to the pending context hint when the attached WeakReference has already cleared
885
+ * (dying generation GC'd) while a pending replacement remains.
886
+ */
887
+ @Test
888
+ public void emit_usesPendingHint_whenAttachedWeakReferenceCleared() throws Exception {
889
+ when(reactHost.getCurrentReactContext()).thenReturn(staleContext);
890
+ ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance();
891
+ emitter.attachReactContext(staleContext);
892
+ ShadowLooper.idleMainLooper();
893
+ emitter.attachReactContext(liveContext);
894
+ ShadowLooper.idleMainLooper();
895
+
896
+ emitter.notifyJsReady(true);
897
+ emitter.addListener(EVENT);
898
+ ShadowLooper.idleMainLooper();
899
+
900
+ setWeakRefField(emitter, "attachedReactContext", null);
901
+
902
+ emitter.sendEvent(event(EVENT, eventBody));
903
+ ShadowLooper.idleMainLooper();
904
+
905
+ verify(liveJsEmitter).emit(eq("rnfb_" + EVENT), eq(eventBody));
906
+ }
907
+
908
+ /**
909
+ * If jsReady flips false between the unlocked host resolve and the second monitor entry, emit
910
+ * must abort rather than delivering.
911
+ */
912
+ @Test
913
+ public void emit_returnsFalse_whenJsReadyClearedDuringHostResolve() throws Exception {
914
+ when(reactHost.getCurrentReactContext()).thenReturn(liveContext);
915
+ ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance();
916
+ emitter.attachReactContext(liveContext);
917
+ ShadowLooper.idleMainLooper();
918
+ emitter.notifyJsReady(true);
919
+ emitter.addListener(EVENT);
920
+ ShadowLooper.idleMainLooper();
921
+
922
+ AtomicBoolean cleared = new AtomicBoolean(false);
923
+ when(reactHost.getCurrentReactContext())
924
+ .thenAnswer(
925
+ invocation -> {
926
+ if (cleared.compareAndSet(false, true)) {
927
+ Field field = ReactNativeFirebaseEventEmitter.class.getDeclaredField("jsReady");
928
+ field.setAccessible(true);
929
+ field.setBoolean(emitter, false);
930
+ }
931
+ return liveContext;
932
+ });
933
+
934
+ emitter.sendEvent(event(EVENT, eventBody));
935
+ ShadowLooper.idleMainLooper();
936
+
937
+ verify(liveJsEmitter, never()).emit(anyString(), eq(eventBody));
938
+ }
939
+
940
+ /** Emit refuses delivery when the resolved context reports no active React instance. */
941
+ @Test
942
+ public void emit_returnsFalse_whenResolvedContextHasNoActiveInstance() {
943
+ when(reactHost.getCurrentReactContext()).thenReturn(liveContext);
944
+ when(liveContext.hasActiveReactInstance()).thenReturn(false);
945
+
946
+ ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance();
947
+ emitter.attachReactContext(liveContext);
948
+ ShadowLooper.idleMainLooper();
949
+ emitter.notifyJsReady(true);
950
+ emitter.addListener(EVENT);
951
+ ShadowLooper.idleMainLooper();
952
+
953
+ emitter.sendEvent(event(EVENT, eventBody));
954
+ ShadowLooper.idleMainLooper();
955
+
956
+ verify(liveJsEmitter, never()).emit(anyString(), eq(eventBody));
957
+ }
958
+
959
+ /** Emit catches DeviceEventEmitter failures and returns false rather than propagating. */
960
+ @Test
961
+ public void emit_returnsFalse_whenDeviceEventEmitterThrows() {
962
+ when(reactHost.getCurrentReactContext()).thenReturn(liveContext);
963
+ doThrow(new RuntimeException("emit failed"))
964
+ .when(liveJsEmitter)
965
+ .emit(anyString(), eq(eventBody));
966
+
967
+ ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance();
968
+ emitter.attachReactContext(liveContext);
969
+ ShadowLooper.idleMainLooper();
970
+ emitter.notifyJsReady(true);
971
+ emitter.addListener(EVENT);
972
+ ShadowLooper.idleMainLooper();
973
+
974
+ emitter.sendEvent(event(EVENT, eventBody));
975
+ ShadowLooper.idleMainLooper();
976
+
977
+ verify(liveJsEmitter).emit(eq("rnfb_" + EVENT), eq(eventBody));
978
+ }
979
+
980
+ /**
981
+ * While a pending replacement owns listeners, an unrelated third host current must not receive
982
+ * events (that runtime has no RNFB JS subscribers). Emit prefers pending so delivery is not
983
+ * silently lost without re-queue.
984
+ */
985
+ @Test
986
+ public void emit_deliversToPending_whenHostMovedToUnrelatedThirdContext() throws Exception {
987
+ ReactContext thirdContext = mock(ReactContext.class);
988
+ DeviceEventManagerModule.RCTDeviceEventEmitter thirdJsEmitter =
989
+ mock(DeviceEventManagerModule.RCTDeviceEventEmitter.class);
990
+ when(thirdContext.getApplicationContext()).thenReturn(application);
991
+ when(thirdContext.hasActiveReactInstance()).thenReturn(true);
992
+ when(thirdContext.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class))
993
+ .thenReturn(thirdJsEmitter);
994
+
995
+ when(reactHost.getCurrentReactContext()).thenReturn(staleContext);
996
+ ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance();
997
+ emitter.attachReactContext(staleContext);
998
+ ShadowLooper.idleMainLooper();
999
+ emitter.attachReactContext(liveContext);
1000
+ ShadowLooper.idleMainLooper();
1001
+
1002
+ emitter.notifyJsReady(true);
1003
+ emitter.addListener(EVENT);
1004
+ ShadowLooper.idleMainLooper();
1005
+
1006
+ when(reactHost.getCurrentReactContext()).thenReturn(thirdContext);
1007
+ emitter.sendEvent(event(EVENT, eventBody));
1008
+ ShadowLooper.idleMainLooper();
1009
+
1010
+ verify(liveJsEmitter).emit(eq("rnfb_" + EVENT), eq(eventBody));
1011
+ verify(thirdJsEmitter, never()).emit(anyString(), eq(eventBody));
1012
+ verify(staleJsEmitter, never()).emit(anyString(), eq(eventBody));
1013
+ }
1014
+
1015
+ /**
1016
+ * After pending-only cancel following a host advance to an unrelated third context, restored
1017
+ * listeners belong to the surviving attached generation. Emit must not prefer that unattached
1018
+ * hostCurrent (silent delivery into a runtime with no RNFB JS subscribers).
1019
+ */
1020
+ @Test
1021
+ public void emit_deliversToRestoredAttached_afterPendingCancelWhenHostOnUnrelatedThird()
1022
+ throws Exception {
1023
+ ReactContext thirdContext = mock(ReactContext.class);
1024
+ DeviceEventManagerModule.RCTDeviceEventEmitter thirdJsEmitter =
1025
+ mock(DeviceEventManagerModule.RCTDeviceEventEmitter.class);
1026
+ when(thirdContext.getApplicationContext()).thenReturn(application);
1027
+ when(thirdContext.hasActiveReactInstance()).thenReturn(true);
1028
+ when(thirdContext.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class))
1029
+ .thenReturn(thirdJsEmitter);
1030
+
1031
+ when(reactHost.getCurrentReactContext()).thenReturn(staleContext);
1032
+ ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance();
1033
+ emitter.attachReactContext(staleContext);
1034
+ ShadowLooper.idleMainLooper();
1035
+
1036
+ emitter.notifyJsReady(true);
1037
+ emitter.addListener(EVENT);
1038
+ ShadowLooper.idleMainLooper();
1039
+
1040
+ emitter.attachReactContext(liveContext);
1041
+ ShadowLooper.idleMainLooper();
1042
+ assertEquals(liveContext, pendingContext(emitter));
1043
+
1044
+ when(reactHost.getCurrentReactContext()).thenReturn(thirdContext);
1045
+ emitter.detachReactContext(liveContext);
1046
+ ShadowLooper.idleMainLooper();
1047
+
1048
+ assertEquals(null, pendingContext(emitter));
1049
+ assertEquals(staleContext, attachedContext(emitter));
1050
+ assertEquals(1, listenerCount(emitter, EVENT));
1051
+ assertTrue(jsReady(emitter));
1052
+
1053
+ emitter.sendEvent(event(EVENT, eventBody));
1054
+ ShadowLooper.idleMainLooper();
1055
+
1056
+ verify(staleJsEmitter).emit(eq("rnfb_" + EVENT), eq(eventBody));
1057
+ verify(thirdJsEmitter, never()).emit(anyString(), eq(eventBody));
1058
+ verify(liveJsEmitter, never()).emit(anyString(), eq(eventBody));
1059
+ }
1060
+
1061
+ /**
1062
+ * A subsequent acceptAttachedContext of the host's live generation clears restored-attached emit
1063
+ * affinity so delivery follows the new attached runtime.
1064
+ */
1065
+ @Test
1066
+ public void emit_prefersNewlyAttachedHost_afterRestoredAffinityClearedByAccept()
1067
+ throws Exception {
1068
+ ReactContext thirdContext = mock(ReactContext.class);
1069
+ DeviceEventManagerModule.RCTDeviceEventEmitter thirdJsEmitter =
1070
+ mock(DeviceEventManagerModule.RCTDeviceEventEmitter.class);
1071
+ when(thirdContext.getApplicationContext()).thenReturn(application);
1072
+ when(thirdContext.hasActiveReactInstance()).thenReturn(true);
1073
+ when(thirdContext.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class))
1074
+ .thenReturn(thirdJsEmitter);
1075
+
1076
+ when(reactHost.getCurrentReactContext()).thenReturn(staleContext);
1077
+ ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance();
1078
+ emitter.attachReactContext(staleContext);
1079
+ ShadowLooper.idleMainLooper();
1080
+
1081
+ emitter.notifyJsReady(true);
1082
+ emitter.addListener(EVENT);
1083
+ ShadowLooper.idleMainLooper();
1084
+
1085
+ emitter.attachReactContext(liveContext);
1086
+ ShadowLooper.idleMainLooper();
1087
+
1088
+ when(reactHost.getCurrentReactContext()).thenReturn(thirdContext);
1089
+ emitter.detachReactContext(liveContext);
1090
+ ShadowLooper.idleMainLooper();
1091
+
1092
+ // Host generation attaches for real — affinity must clear.
1093
+ emitter.attachReactContext(thirdContext);
1094
+ ShadowLooper.idleMainLooper();
1095
+ assertEquals(thirdContext, attachedContext(emitter));
1096
+
1097
+ emitter.notifyJsReady(true);
1098
+ emitter.addListener(EVENT);
1099
+ ShadowLooper.idleMainLooper();
1100
+
1101
+ emitter.sendEvent(event(EVENT, eventBody));
1102
+ ShadowLooper.idleMainLooper();
1103
+
1104
+ verify(thirdJsEmitter).emit(eq("rnfb_" + EVENT), eq(eventBody));
1105
+ verify(staleJsEmitter, never()).emit(anyString(), eq(eventBody));
1106
+ }
1107
+
1108
+ /**
1109
+ * With no pending slot and a null host current, emit delivers into the still-attached context.
1110
+ */
1111
+ @Test
1112
+ public void emit_deliversToAttached_whenHostCurrentNullAndNoPending() {
1113
+ when(reactHost.getCurrentReactContext()).thenReturn(liveContext);
1114
+ ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance();
1115
+ emitter.attachReactContext(liveContext);
1116
+ ShadowLooper.idleMainLooper();
1117
+ emitter.notifyJsReady(true);
1118
+ emitter.addListener(EVENT);
1119
+ ShadowLooper.idleMainLooper();
1120
+
1121
+ when(reactHost.getCurrentReactContext()).thenReturn(null);
1122
+ emitter.sendEvent(event(EVENT, eventBody));
1123
+ ShadowLooper.idleMainLooper();
1124
+
1125
+ verify(liveJsEmitter).emit(eq("rnfb_" + EVENT), eq(eventBody));
1126
+ }
1127
+
1128
+ /**
1129
+ * A posted tryConverge that runs after the pending slot was cancelled is a no-op (pendingHint
1130
+ * null).
1131
+ */
1132
+ @Test
1133
+ public void tryConverge_noOps_whenPendingCancelledBeforePostedRunnableRuns() throws Exception {
1134
+ when(reactHost.getCurrentReactContext()).thenReturn(staleContext);
1135
+ ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance();
1136
+ emitter.attachReactContext(staleContext);
1137
+ ShadowLooper.idleMainLooper();
1138
+
1139
+ // Enters pending and posts tryConverge; do not idle yet.
1140
+ emitter.attachReactContext(liveContext);
1141
+ assertEquals(liveContext, pendingContext(emitter));
1142
+
1143
+ // Cancel pending before the posted converge runs.
1144
+ emitter.detachReactContext(liveContext);
1145
+ assertNull(pendingContext(emitter));
1146
+
1147
+ ShadowLooper.idleMainLooper();
1148
+ assertEquals(staleContext, attachedContext(emitter));
1149
+ }
1150
+
1151
+ /**
1152
+ * Host catch-up between posting tryConverge and running it promotes pending without a second
1153
+ * attach call.
1154
+ */
1155
+ @Test
1156
+ public void tryConverge_promotesPending_whenHostCatchesUpBeforePostedRunnable() throws Exception {
1157
+ when(reactHost.getCurrentReactContext()).thenReturn(staleContext);
1158
+ ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance();
1159
+ emitter.attachReactContext(staleContext);
1160
+ ShadowLooper.idleMainLooper();
1161
+
1162
+ emitter.attachReactContext(liveContext);
1163
+ assertEquals(liveContext, pendingContext(emitter));
1164
+ assertEquals(staleContext, attachedContext(emitter));
1165
+
1166
+ when(reactHost.getCurrentReactContext()).thenReturn(liveContext);
1167
+ ShadowLooper.idleMainLooper();
1168
+
1169
+ assertEquals(liveContext, attachedContext(emitter));
1170
+ assertNull(pendingContext(emitter));
1171
+ assertNull(hostLagContext(emitter));
1172
+ }
1173
+
1174
+ /**
1175
+ * When attached already equals pending at converge time, clear convergence state without
1176
+ * re-accepting attachment.
1177
+ */
1178
+ @Test
1179
+ public void tryConverge_clearsStateOnly_whenAttachedAlreadyEqualsPending() throws Exception {
1180
+ when(reactHost.getCurrentReactContext()).thenReturn(liveContext);
1181
+ ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance();
1182
+ emitter.attachReactContext(liveContext);
1183
+ ShadowLooper.idleMainLooper();
1184
+
1185
+ setWeakRefField(emitter, "pendingReactContext", liveContext);
1186
+ setWeakRefField(emitter, "hostLagReactContext", staleContext);
1187
+
1188
+ Method converge =
1189
+ ReactNativeFirebaseEventEmitter.class.getDeclaredMethod(
1190
+ "tryConvergePendingReactContextLocked", ReactContext.class);
1191
+ converge.setAccessible(true);
1192
+ boolean changed = (boolean) converge.invoke(emitter, liveContext);
1193
+
1194
+ assertFalse(changed);
1195
+ assertNull(pendingContext(emitter));
1196
+ assertNull(hostLagContext(emitter));
1197
+ assertEquals(liveContext, attachedContext(emitter));
1198
+ }
1199
+
1200
+ /** Brownfield hosts that are not {@link ReactApplication} resolve host current as null. */
1201
+ @Test
1202
+ public void hostResolution_returnsNull_whenApplicationContextIsNotReactApplication()
1203
+ throws Exception {
1204
+ Application plainApp = mock(Application.class);
1205
+ when(plainApp.getApplicationContext()).thenReturn(plainApp);
1206
+ ReactContext brownfieldContext = mock(ReactContext.class);
1207
+ when(brownfieldContext.getApplicationContext()).thenReturn(plainApp);
1208
+
1209
+ Method resolve =
1210
+ ReactNativeFirebaseEventEmitter.class.getDeclaredMethod(
1211
+ "getCurrentReactContextFromHost", ReactContext.class);
1212
+ resolve.setAccessible(true);
1213
+ assertNull(resolve.invoke(null, brownfieldContext));
1214
+ }
1215
+
1216
+ /** ReactHost getter failures are swallowed; resolution may still fall through. */
1217
+ @Test
1218
+ public void hostResolution_swallowsReactHostRuntimeException() throws Exception {
1219
+ Application reactApp =
1220
+ mock(
1221
+ Application.class,
1222
+ org.mockito.Mockito.withSettings().extraInterfaces(ReactApplication.class));
1223
+ ReactApplication asReactApplication = (ReactApplication) reactApp;
1224
+ when(reactApp.getApplicationContext()).thenReturn(reactApp);
1225
+ when(asReactApplication.getReactHost()).thenThrow(new RuntimeException("reactHost broken"));
1226
+ when(asReactApplication.getReactNativeHost())
1227
+ .thenThrow(new RuntimeException("bridgeless-only test host"));
1228
+
1229
+ ReactContext hint = mock(ReactContext.class);
1230
+ when(hint.getApplicationContext()).thenReturn(reactApp);
1231
+
1232
+ Method resolve =
1233
+ ReactNativeFirebaseEventEmitter.class.getDeclaredMethod(
1234
+ "getCurrentReactContextFromHost", ReactContext.class);
1235
+ resolve.setAccessible(true);
1236
+ assertNull(resolve.invoke(null, hint));
1237
+ }
1238
+
1239
+ /** When ReactHost is absent, ReactNativeHost failures are logged and null current is returned. */
1240
+ @Test
1241
+ public void hostResolution_swallowsReactNativeHostFailure_whenReactHostAbsent() throws Exception {
1242
+ application.reactHost = null;
1243
+ ReactNativeHost rnHost = mock(ReactNativeHost.class);
1244
+ application.reactNativeHost = rnHost;
1245
+ when(rnHost.hasInstance()).thenThrow(new RuntimeException("native host broken"));
1246
+
1247
+ Method resolve =
1248
+ ReactNativeFirebaseEventEmitter.class.getDeclaredMethod(
1249
+ "getCurrentReactContextFromHost", ReactContext.class);
1250
+ resolve.setAccessible(true);
1251
+ assertNull(resolve.invoke(null, liveContext));
1252
+ }
1253
+
1254
+ /**
1255
+ * When ReactHost is present but reports a null current and ReactNativeHost has no instance,
1256
+ * resolution returns the null ReactHost current (hybrid fallthrough with nothing on the bridge).
1257
+ */
1258
+ @Test
1259
+ public void hostResolution_returnsNullFromReactHost_whenNativeHostHasNoInstance()
1260
+ throws Exception {
1261
+ when(reactHost.getCurrentReactContext()).thenReturn(null);
1262
+ ReactNativeHost rnHost = mock(ReactNativeHost.class);
1263
+ application.reactNativeHost = rnHost;
1264
+ when(rnHost.hasInstance()).thenReturn(false);
1265
+
1266
+ Method resolve =
1267
+ ReactNativeFirebaseEventEmitter.class.getDeclaredMethod(
1268
+ "getCurrentReactContextFromHost", ReactContext.class);
1269
+ resolve.setAccessible(true);
1270
+ assertNull(resolve.invoke(null, liveContext));
1271
+ }
1272
+
1273
+ /**
1274
+ * When ReactHost is present but reports a null current, a subsequent ReactNativeHost failure must
1275
+ * still return the null ReactHost current.
1276
+ */
1277
+ @Test
1278
+ public void hostResolution_returnsNullFromReactHost_whenNativeHostFailsAfterNullCurrent()
1279
+ throws Exception {
1280
+ when(reactHost.getCurrentReactContext()).thenReturn(null);
1281
+ ReactNativeHost rnHost = mock(ReactNativeHost.class);
1282
+ application.reactNativeHost = rnHost;
1283
+ when(rnHost.hasInstance()).thenThrow(new RuntimeException("native host broken"));
1284
+
1285
+ Method resolve =
1286
+ ReactNativeFirebaseEventEmitter.class.getDeclaredMethod(
1287
+ "getCurrentReactContextFromHost", ReactContext.class);
1288
+ resolve.setAccessible(true);
1289
+ assertNull(resolve.invoke(null, liveContext));
1290
+ }
1291
+
1292
+ /**
1293
+ * Fallback {@link ReactNativeFirebaseApp#getApplicationContext()} path when the context hint is
1294
+ * null still participates in host resolution (and brownfield null when unset).
1295
+ */
1296
+ @Test
1297
+ public void hostResolution_usesReactNativeFirebaseAppFallback_whenContextHintNull()
1298
+ throws Exception {
1299
+ Context previous = ReactNativeFirebaseApp.getApplicationContext();
1300
+ try {
1301
+ ReactNativeFirebaseApp.setApplicationContext(null);
1302
+ Method resolve =
1303
+ ReactNativeFirebaseEventEmitter.class.getDeclaredMethod(
1304
+ "getCurrentReactContextFromHost", ReactContext.class);
1305
+ resolve.setAccessible(true);
1306
+ assertNull(resolve.invoke(null, new Object[] {null}));
1307
+ } finally {
1308
+ ReactNativeFirebaseApp.setApplicationContext(previous);
1309
+ }
1310
+ }
1311
+
1312
+ /**
1313
+ * Same-context re-attach while the host names a different generation must not clear convergence
1314
+ * (dying hostLag with a different pending must keep waiting).
1315
+ */
1316
+ @Test
1317
+ public void attach_doesNotClearConvergence_whenSameContextReattachAndHostCurrentDiffers()
1318
+ throws Exception {
1319
+ when(reactHost.getCurrentReactContext()).thenReturn(staleContext);
1320
+ ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance();
1321
+ emitter.attachReactContext(staleContext);
1322
+ ShadowLooper.idleMainLooper();
1323
+ emitter.attachReactContext(liveContext);
1324
+ ShadowLooper.idleMainLooper();
1325
+
1326
+ assertEquals(liveContext, pendingContext(emitter));
1327
+ assertEquals(staleContext, hostLagContext(emitter));
1328
+
1329
+ // Host moved onto live, but re-attach of dying stale must not clear pending.
1330
+ when(reactHost.getCurrentReactContext()).thenReturn(liveContext);
1331
+ emitter.attachReactContext(staleContext);
1332
+ ShadowLooper.idleMainLooper();
1333
+
1334
+ assertEquals(liveContext, pendingContext(emitter));
1335
+ assertEquals(staleContext, attachedContext(emitter));
1336
+ assertEquals(staleContext, hostLagContext(emitter));
1337
+ }
1338
+
1339
+ /**
1340
+ * Same-context re-attach of the pending identity while the host has caught up clears residual
1341
+ * convergence (pending == reactContext arm of the compound guard).
1342
+ */
1343
+ @Test
1344
+ public void attach_clearsConvergence_whenSamePendingReattachAndHostCurrent() throws Exception {
1345
+ when(reactHost.getCurrentReactContext()).thenReturn(staleContext);
1346
+ ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance();
1347
+ emitter.attachReactContext(staleContext);
1348
+ ShadowLooper.idleMainLooper();
1349
+ emitter.attachReactContext(liveContext);
1350
+ ShadowLooper.idleMainLooper();
1351
+
1352
+ // Promote pending to attached while leaving pending slot set (host catch-up path normally
1353
+ // clears; force the same-context re-attach compound pending == reactContext).
1354
+ setWeakRefField(emitter, "attachedReactContext", liveContext);
1355
+ setWeakRefField(emitter, "pendingReactContext", liveContext);
1356
+ setWeakRefField(emitter, "hostLagReactContext", staleContext);
1357
+ when(reactHost.getCurrentReactContext()).thenReturn(liveContext);
1358
+
1359
+ emitter.attachReactContext(liveContext);
1360
+ ShadowLooper.idleMainLooper();
1361
+
1362
+ assertNull(pendingContext(emitter));
1363
+ assertNull(hostLagContext(emitter));
1364
+ assertEquals(liveContext, attachedContext(emitter));
1365
+ }
1366
+
1367
+ /** removeListener is a no-op when the event was never registered. */
1368
+ @Test
1369
+ public void removeListener_isNoOp_whenEventNeverRegistered() throws Exception {
1370
+ when(reactHost.getCurrentReactContext()).thenReturn(liveContext);
1371
+ ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance();
1372
+ emitter.attachReactContext(liveContext);
1373
+ ShadowLooper.idleMainLooper();
1374
+
1375
+ emitter.removeListener(EVENT, false);
1376
+ ShadowLooper.idleMainLooper();
1377
+
1378
+ assertEquals(0, listenerCount(emitter, EVENT));
1379
+ }
1380
+
1381
+ /**
1382
+ * sendQueuedEvents leaves queued events whose names have no current listener (does not remove
1383
+ * them while draining matching ones).
1384
+ */
1385
+ @Test
1386
+ public void sendQueuedEvents_leavesUnmatchedQueuedEvents() throws Exception {
1387
+ when(reactHost.getCurrentReactContext()).thenReturn(liveContext);
1388
+ ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance();
1389
+ emitter.attachReactContext(liveContext);
1390
+ ShadowLooper.idleMainLooper();
1391
+ emitter.notifyJsReady(true);
1392
+ ShadowLooper.idleMainLooper();
1393
+
1394
+ WritableMap otherBody = mock(WritableMap.class);
1395
+ emitter.sendEvent(event(EVENT, eventBody));
1396
+ emitter.sendEvent(event("auth_id_token_changed", otherBody));
1397
+ ShadowLooper.idleMainLooper();
1398
+
1399
+ emitter.addListener(EVENT);
1400
+ ShadowLooper.idleMainLooper();
1401
+
1402
+ verify(liveJsEmitter).emit(eq("rnfb_" + EVENT), eq(eventBody));
1403
+ verify(liveJsEmitter, never()).emit(eq("rnfb_auth_id_token_changed"), eq(otherBody));
1404
+
1405
+ Field queued = ReactNativeFirebaseEventEmitter.class.getDeclaredField("queuedEvents");
1406
+ queued.setAccessible(true);
1407
+ @SuppressWarnings("unchecked")
1408
+ java.util.List<NativeEvent> remaining = (java.util.List<NativeEvent>) queued.get(emitter);
1409
+ assertEquals(1, remaining.size());
1410
+ assertEquals("auth_id_token_changed", remaining.get(0).getEventName());
1411
+ }
1412
+
1413
+ /**
1414
+ * Emit returns false (and re-queues) when resolveEmitContext yields null — no attached, no
1415
+ * pending, and a null host current.
1416
+ */
1417
+ @Test
1418
+ public void emit_returnsFalse_whenEmitContextNull() throws Exception {
1419
+ when(reactHost.getCurrentReactContext()).thenReturn(null);
1420
+ ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance();
1421
+ emitter.notifyJsReady(true);
1422
+ emitter.addListener(EVENT);
1423
+ ShadowLooper.idleMainLooper();
1424
+
1425
+ emitter.sendEvent(event(EVENT, eventBody));
1426
+ ShadowLooper.idleMainLooper();
1427
+
1428
+ verify(liveJsEmitter, never()).emit(anyString(), eq(eventBody));
1429
+ verify(staleJsEmitter, never()).emit(anyString(), eq(eventBody));
1430
+
1431
+ Field queued = ReactNativeFirebaseEventEmitter.class.getDeclaredField("queuedEvents");
1432
+ queued.setAccessible(true);
1433
+ @SuppressWarnings("unchecked")
1434
+ java.util.List<NativeEvent> remaining = (java.util.List<NativeEvent>) queued.get(emitter);
1435
+ assertEquals(1, remaining.size());
1436
+ }
1437
+
1438
+ /** With no pending and a null attached pointer, emit prefers the non-null host current. */
1439
+ @Test
1440
+ public void emit_deliversToHostCurrent_whenAttachedNullAndNoPending() throws Exception {
1441
+ when(reactHost.getCurrentReactContext()).thenReturn(liveContext);
1442
+ ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance();
1443
+ emitter.attachReactContext(liveContext);
1444
+ ShadowLooper.idleMainLooper();
1445
+ emitter.notifyJsReady(true);
1446
+ emitter.addListener(EVENT);
1447
+ ShadowLooper.idleMainLooper();
1448
+
1449
+ // Clear attached after host can still resolve via FirebaseApp fallback + ReactHost.
1450
+ Context previous = ReactNativeFirebaseApp.getApplicationContext();
1451
+ try {
1452
+ ReactNativeFirebaseApp.setApplicationContext(application);
1453
+ setWeakRefField(emitter, "attachedReactContext", null);
1454
+ setWeakRefField(emitter, "pendingReactContext", null);
1455
+
1456
+ emitter.sendEvent(event(EVENT, eventBody));
1457
+ ShadowLooper.idleMainLooper();
1458
+
1459
+ verify(liveJsEmitter).emit(eq("rnfb_" + EVENT), eq(eventBody));
1460
+ } finally {
1461
+ ReactNativeFirebaseApp.setApplicationContext(previous);
1462
+ }
1463
+ }
1464
+
1465
+ /**
1466
+ * With no pending, a lagging host current that matches hostLag prefers the live attached
1467
+ * generation over that dying host ref.
1468
+ */
1469
+ @Test
1470
+ public void emit_prefersAttached_whenHostLagMatchesHostCurrentAndNoPending() throws Exception {
1471
+ when(reactHost.getCurrentReactContext()).thenReturn(liveContext);
1472
+ ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance();
1473
+ emitter.attachReactContext(liveContext);
1474
+ ShadowLooper.idleMainLooper();
1475
+ emitter.notifyJsReady(true);
1476
+ emitter.addListener(EVENT);
1477
+ ShadowLooper.idleMainLooper();
1478
+
1479
+ setWeakRefField(emitter, "hostLagReactContext", staleContext);
1480
+ when(reactHost.getCurrentReactContext()).thenReturn(staleContext);
1481
+
1482
+ emitter.sendEvent(event(EVENT, eventBody));
1483
+ ShadowLooper.idleMainLooper();
1484
+
1485
+ verify(liveJsEmitter).emit(eq("rnfb_" + EVENT), eq(eventBody));
1486
+ verify(staleJsEmitter, never()).emit(anyString(), eq(eventBody));
1487
+ }
1488
+
1489
+ /**
1490
+ * Pending-only cancel with a cleared snapshot falls back to resetListenerState (defensive path if
1491
+ * snapshot accounting was discarded while pending still lived).
1492
+ */
1493
+ @Test
1494
+ public void detach_resetsListeners_whenPendingOnlyCancelledWithoutSnapshot() throws Exception {
1495
+ when(reactHost.getCurrentReactContext()).thenReturn(staleContext);
1496
+ ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance();
1497
+ emitter.attachReactContext(staleContext);
1498
+ ShadowLooper.idleMainLooper();
1499
+ emitter.attachReactContext(liveContext);
1500
+ ShadowLooper.idleMainLooper();
1501
+
1502
+ emitter.notifyJsReady(true);
1503
+ emitter.addListener(EVENT);
1504
+ ShadowLooper.idleMainLooper();
1505
+
1506
+ Field snapshot =
1507
+ ReactNativeFirebaseEventEmitter.class.getDeclaredField("attachedListenersSnapshot");
1508
+ snapshot.setAccessible(true);
1509
+ snapshot.set(emitter, null);
1510
+
1511
+ emitter.detachReactContext(liveContext);
1512
+ ShadowLooper.idleMainLooper();
1513
+
1514
+ assertNull(pendingContext(emitter));
1515
+ assertEquals(staleContext, attachedContext(emitter));
1516
+ assertEquals(0, listenerCount(emitter, EVENT));
1517
+ assertFalse(jsReady(emitter));
1518
+ }
1519
+
1520
+ /**
1521
+ * Last-writer-wins pending replace reuses the first attached snapshot so a later pending-only
1522
+ * cancel still restores the original attached generation.
1523
+ */
1524
+ @Test
1525
+ public void detach_restoresOriginalAttachedSnapshot_afterLastWriterWinsPendingReplace()
1526
+ throws Exception {
1527
+ ReactContext middlePending = mock(ReactContext.class);
1528
+ when(middlePending.getApplicationContext()).thenReturn(application);
1529
+ when(middlePending.hasActiveReactInstance()).thenReturn(true);
1530
+
1531
+ when(reactHost.getCurrentReactContext()).thenReturn(staleContext);
1532
+ ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance();
1533
+ emitter.attachReactContext(staleContext);
1534
+ ShadowLooper.idleMainLooper();
1535
+
1536
+ emitter.notifyJsReady(true);
1537
+ emitter.addListener(EVENT);
1538
+ ShadowLooper.idleMainLooper();
1539
+
1540
+ emitter.attachReactContext(middlePending);
1541
+ ShadowLooper.idleMainLooper();
1542
+ emitter.attachReactContext(liveContext);
1543
+ ShadowLooper.idleMainLooper();
1544
+
1545
+ assertEquals(liveContext, pendingContext(emitter));
1546
+ assertEquals(0, listenerCount(emitter, EVENT));
1547
+
1548
+ emitter.detachReactContext(liveContext);
1549
+ ShadowLooper.idleMainLooper();
1550
+
1551
+ assertEquals(staleContext, attachedContext(emitter));
1552
+ assertEquals(1, listenerCount(emitter, EVENT));
1553
+ assertTrue(jsReady(emitter));
1554
+ }
1555
+
1556
+ /** Detach of attached when pending identity equals attached clears attachment (no promote). */
1557
+ @Test
1558
+ public void detach_clearsAttached_whenPendingIdentityEqualsAttached() throws Exception {
1559
+ when(reactHost.getCurrentReactContext()).thenReturn(liveContext);
1560
+ ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance();
1561
+ emitter.attachReactContext(liveContext);
1562
+ ShadowLooper.idleMainLooper();
1563
+ emitter.notifyJsReady(true);
1564
+ emitter.addListener(EVENT);
1565
+ ShadowLooper.idleMainLooper();
1566
+
1567
+ setWeakRefField(emitter, "pendingReactContext", liveContext);
1568
+
1569
+ emitter.detachReactContext(liveContext);
1570
+ ShadowLooper.idleMainLooper();
1571
+
1572
+ assertNull(attachedContext(emitter));
1573
+ assertNull(pendingContext(emitter));
1574
+ assertEquals(0, listenerCount(emitter, EVENT));
1575
+ }
1576
+
1577
+ /**
1578
+ * getListenersMap prefers the pending context as the host-resolution hint while a pending
1579
+ * candidate exists.
1580
+ */
1581
+ @Test
1582
+ public void getListenersMap_usesPendingHint_whenPendingExists() throws Exception {
1583
+ when(reactHost.getCurrentReactContext()).thenReturn(staleContext);
1584
+ ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance();
1585
+ emitter.attachReactContext(staleContext);
1586
+ ShadowLooper.idleMainLooper();
1587
+ emitter.attachReactContext(liveContext);
1588
+ ShadowLooper.idleMainLooper();
1589
+
1590
+ assertEquals(liveContext, pendingContext(emitter));
1591
+
1592
+ WritableMap writableMap = mock(WritableMap.class);
1593
+ WritableMap eventsMap = mock(WritableMap.class);
1594
+ try (MockedStatic<Arguments> arguments = mockStatic(Arguments.class)) {
1595
+ arguments.when(Arguments::createMap).thenReturn(writableMap, eventsMap);
1596
+ emitter.getListenersMap();
1597
+ verify(writableMap).putInt("attachedContextHash", System.identityHashCode(staleContext));
1598
+ // Host still names dying attached; current hash follows host, not pending hint alone.
1599
+ verify(writableMap).putInt("currentContextHash", System.identityHashCode(staleContext));
1600
+ }
1601
+ }
1602
+
1603
+ /**
1604
+ * With no pending, a non-null hostLag that does not match host current does not divert emit away
1605
+ * from the host-preferred path (hostLag != hostCurrent arm).
1606
+ */
1607
+ @Test
1608
+ public void emit_prefersHostCurrent_whenHostLagSetButDoesNotMatchHost() throws Exception {
1609
+ when(reactHost.getCurrentReactContext()).thenReturn(liveContext);
1610
+ ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance();
1611
+ emitter.attachReactContext(staleContext);
1612
+ ShadowLooper.idleMainLooper();
1613
+ emitter.notifyJsReady(true);
1614
+ emitter.addListener(EVENT);
1615
+ ShadowLooper.idleMainLooper();
1616
+
1617
+ setWeakRefField(emitter, "hostLagReactContext", staleContext);
1618
+ when(reactHost.getCurrentReactContext()).thenReturn(liveContext);
1619
+
1620
+ emitter.sendEvent(event(EVENT, eventBody));
1621
+ ShadowLooper.idleMainLooper();
1622
+
1623
+ verify(liveJsEmitter).emit(eq("rnfb_" + EVENT), eq(eventBody));
1624
+ verify(staleJsEmitter, never()).emit(anyString(), eq(eventBody));
1625
+ }
1626
+
1627
+ /**
1628
+ * Hybrid fallthrough: ReactHost reports null current and getReactNativeHost returns null — the
1629
+ * null ReactHost current stands.
1630
+ */
1631
+ @Test
1632
+ public void hostResolution_returnsNull_whenReactNativeHostNullAfterNullReactHostCurrent()
1633
+ throws Exception {
1634
+ Application reactApp =
1635
+ mock(
1636
+ Application.class,
1637
+ org.mockito.Mockito.withSettings().extraInterfaces(ReactApplication.class));
1638
+ ReactApplication asReactApplication = (ReactApplication) reactApp;
1639
+ when(reactApp.getApplicationContext()).thenReturn(reactApp);
1640
+ ReactHost host = mock(ReactHost.class);
1641
+ when(asReactApplication.getReactHost()).thenReturn(host);
1642
+ when(host.getCurrentReactContext()).thenReturn(null);
1643
+ when(asReactApplication.getReactNativeHost()).thenReturn(null);
1644
+
1645
+ ReactContext hint = mock(ReactContext.class);
1646
+ when(hint.getApplicationContext()).thenReturn(reactApp);
1647
+
1648
+ Method resolve =
1649
+ ReactNativeFirebaseEventEmitter.class.getDeclaredMethod(
1650
+ "getCurrentReactContextFromHost", ReactContext.class);
1651
+ resolve.setAccessible(true);
1652
+ assertNull(resolve.invoke(null, hint));
1653
+ }
1654
+
1655
+ /**
1656
+ * acceptAttachedContext is a no-op when the candidate is already the attached identity (guards
1657
+ * the inner identity check; production callers only invoke it on a change).
1658
+ */
1659
+ @Test
1660
+ public void acceptAttachedContext_noOps_whenCandidateAlreadyAttached() throws Exception {
1661
+ when(reactHost.getCurrentReactContext()).thenReturn(liveContext);
1662
+ ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance();
1663
+ emitter.attachReactContext(liveContext);
1664
+ ShadowLooper.idleMainLooper();
1665
+ emitter.notifyJsReady(true);
1666
+ emitter.addListener(EVENT);
1667
+ ShadowLooper.idleMainLooper();
1668
+
1669
+ Method accept =
1670
+ ReactNativeFirebaseEventEmitter.class.getDeclaredMethod(
1671
+ "acceptAttachedContext", ReactContext.class, boolean.class);
1672
+ accept.setAccessible(true);
1673
+ accept.invoke(emitter, liveContext, true);
1674
+
1675
+ assertEquals(liveContext, attachedContext(emitter));
1676
+ assertEquals(1, listenerCount(emitter, EVENT));
1677
+ assertTrue(jsReady(emitter));
1678
+ }
1679
+
1680
+ /**
1681
+ * Diagnostic map exposes jsReady and context identity hashes used for on-device diagnosis. {@link
1682
+ * Arguments#createMap()} is mocked so the JVM test does not need native SoLoader.
1683
+ */
1684
+ @Test
1685
+ public void getListenersMap_exposesJsReadyAndContextIdentityHashes() {
1686
+ when(reactHost.getCurrentReactContext()).thenReturn(liveContext);
1687
+
1688
+ ReactNativeFirebaseEventEmitter emitter = ReactNativeFirebaseEventEmitter.getSharedInstance();
1689
+ emitter.attachReactContext(liveContext);
1690
+ ShadowLooper.idleMainLooper();
1691
+ emitter.notifyJsReady(true);
1692
+ emitter.addListener(EVENT);
1693
+ ShadowLooper.idleMainLooper();
1694
+
1695
+ WritableMap writableMap = mock(WritableMap.class);
1696
+ WritableMap eventsMap = mock(WritableMap.class);
1697
+
1698
+ try (MockedStatic<Arguments> arguments = mockStatic(Arguments.class)) {
1699
+ arguments.when(Arguments::createMap).thenReturn(writableMap, eventsMap);
1700
+
1701
+ WritableMap result = emitter.getListenersMap();
1702
+ assertEquals(writableMap, result);
1703
+
1704
+ verify(writableMap).putInt("listeners", 1);
1705
+ verify(writableMap).putInt(eq("queued"), eq(0));
1706
+ verify(writableMap).putBoolean("jsReady", true);
1707
+ verify(writableMap).putInt("attachedContextHash", System.identityHashCode(liveContext));
1708
+ verify(writableMap).putInt("currentContextHash", System.identityHashCode(liveContext));
1709
+ verify(eventsMap).putInt(EVENT, 1);
1710
+ verify(writableMap).putMap("events", eventsMap);
1711
+ }
1712
+ }
1713
+
1714
+ private static NativeEvent event(String name, WritableMap body) {
1715
+ return new NativeEvent() {
1716
+ @Override
1717
+ public String getEventName() {
1718
+ return name;
1719
+ }
1720
+
1721
+ @Override
1722
+ public WritableMap getEventBody() {
1723
+ return body;
1724
+ }
1725
+
1726
+ @Override
1727
+ public String getFirebaseAppName() {
1728
+ return "[DEFAULT]";
1729
+ }
1730
+ };
1731
+ }
1732
+
1733
+ private static void resetSharedInstance() throws Exception {
1734
+ Constructor<ReactNativeFirebaseEventEmitter> ctor =
1735
+ ReactNativeFirebaseEventEmitter.class.getDeclaredConstructor();
1736
+ ctor.setAccessible(true);
1737
+ ReactNativeFirebaseEventEmitter fresh = ctor.newInstance();
1738
+
1739
+ Field shared = ReactNativeFirebaseEventEmitter.class.getDeclaredField("sharedInstance");
1740
+ shared.setAccessible(true);
1741
+ shared.set(null, fresh);
1742
+ }
1743
+
1744
+ @SuppressWarnings("unchecked")
1745
+ private static int listenerCount(ReactNativeFirebaseEventEmitter emitter, String eventName)
1746
+ throws Exception {
1747
+ Field field = ReactNativeFirebaseEventEmitter.class.getDeclaredField("jsListeners");
1748
+ field.setAccessible(true);
1749
+ HashMap<String, Integer> listeners = (HashMap<String, Integer>) field.get(emitter);
1750
+ Integer count = listeners.get(eventName);
1751
+ return count == null ? 0 : count;
1752
+ }
1753
+
1754
+ @SuppressWarnings("unchecked")
1755
+ private static int queuedCount(ReactNativeFirebaseEventEmitter emitter) throws Exception {
1756
+ Field field = ReactNativeFirebaseEventEmitter.class.getDeclaredField("queuedEvents");
1757
+ field.setAccessible(true);
1758
+ return ((java.util.List<NativeEvent>) field.get(emitter)).size();
1759
+ }
1760
+
1761
+ private static boolean jsReady(ReactNativeFirebaseEventEmitter emitter) throws Exception {
1762
+ Field field = ReactNativeFirebaseEventEmitter.class.getDeclaredField("jsReady");
1763
+ field.setAccessible(true);
1764
+ return field.getBoolean(emitter);
1765
+ }
1766
+
1767
+ @SuppressWarnings("unchecked")
1768
+ private static ReactContext attachedContext(ReactNativeFirebaseEventEmitter emitter)
1769
+ throws Exception {
1770
+ Field field = ReactNativeFirebaseEventEmitter.class.getDeclaredField("attachedReactContext");
1771
+ field.setAccessible(true);
1772
+ WeakReference<ReactContext> ref = (WeakReference<ReactContext>) field.get(emitter);
1773
+ return ref.get();
1774
+ }
1775
+
1776
+ @SuppressWarnings("unchecked")
1777
+ private static ReactContext pendingContext(ReactNativeFirebaseEventEmitter emitter)
1778
+ throws Exception {
1779
+ Field field = ReactNativeFirebaseEventEmitter.class.getDeclaredField("pendingReactContext");
1780
+ field.setAccessible(true);
1781
+ WeakReference<ReactContext> ref = (WeakReference<ReactContext>) field.get(emitter);
1782
+ return ref.get();
1783
+ }
1784
+
1785
+ @SuppressWarnings("unchecked")
1786
+ private static ReactContext hostLagContext(ReactNativeFirebaseEventEmitter emitter)
1787
+ throws Exception {
1788
+ Field field = ReactNativeFirebaseEventEmitter.class.getDeclaredField("hostLagReactContext");
1789
+ field.setAccessible(true);
1790
+ WeakReference<ReactContext> ref = (WeakReference<ReactContext>) field.get(emitter);
1791
+ return ref.get();
1792
+ }
1793
+
1794
+ private static void setWeakRefField(
1795
+ ReactNativeFirebaseEventEmitter emitter, String fieldName, @Nullable ReactContext value)
1796
+ throws Exception {
1797
+ Field field = ReactNativeFirebaseEventEmitter.class.getDeclaredField(fieldName);
1798
+ field.setAccessible(true);
1799
+ field.set(emitter, new WeakReference<>(value));
1800
+ }
1801
+
1802
+ /**
1803
+ * Asserts the same diagnostic identities {@link
1804
+ * ReactNativeFirebaseEventEmitter#getListenersMap()} exposes (jsReady / attached / host current
1805
+ * hashes) without calling {@code Arguments.createMap()}, which requires native SoLoader.
1806
+ */
1807
+ private static void assertDiagnostics(
1808
+ ReactNativeFirebaseEventEmitter emitter,
1809
+ boolean expectedJsReady,
1810
+ @Nullable ReactContext expectedAttached,
1811
+ @Nullable ReactContext expectedCurrent)
1812
+ throws Exception {
1813
+ assertEquals(expectedJsReady, jsReady(emitter));
1814
+ assertEquals(expectedAttached, attachedContext(emitter));
1815
+
1816
+ Method resolve =
1817
+ ReactNativeFirebaseEventEmitter.class.getDeclaredMethod(
1818
+ "getCurrentReactContextFromHost", ReactContext.class);
1819
+ resolve.setAccessible(true);
1820
+ ReactContext current = (ReactContext) resolve.invoke(null, expectedAttached);
1821
+ assertEquals(expectedCurrent, current);
1822
+ }
1823
+ }