@react-native-firebase/crashlytics 27.0.0 → 27.1.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 (22) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/android/build.gradle +1 -0
  3. package/android/proguard-rules.pro +20 -0
  4. package/android/src/main/java/io/invertase/firebase/crashlytics/NativeRNFBTurboCrashlytics.java +11 -4
  5. package/android/src/main/java/io/invertase/firebase/crashlytics/ReactNativeFirebaseCrashlyticsFatalExit.java +117 -0
  6. package/android/src/main/java/io/invertase/firebase/crashlytics/ReactNativeFirebaseCrashlyticsInternal.java +80 -0
  7. package/android/src/test/java/io/invertase/firebase/crashlytics/ReactNativeFirebaseCrashlyticsFatalExitBarrierTest.java +122 -0
  8. package/android/src/test/java/io/invertase/firebase/crashlytics/ReactNativeFirebaseCrashlyticsFatalExitTest.java +189 -0
  9. package/android/src/test/java/io/invertase/firebase/crashlytics/ReactNativeFirebaseCrashlyticsInternalTest.java +199 -0
  10. package/dist/module/version.js +1 -1
  11. package/dist/typescript/lib/index.d.ts +1 -1
  12. package/dist/typescript/lib/version.d.ts +1 -1
  13. package/ios/RNFBCrashlytics/RNFBCrashlyticsHelper.h +40 -0
  14. package/ios/RNFBCrashlytics/RNFBCrashlyticsHelper.m +73 -0
  15. package/ios/RNFBCrashlytics/RNFBCrashlyticsModule.mm +21 -7
  16. package/ios/RNFBCrashlyticsUnitTests/HostStubs/Firebase/FIRCrashlyticsHostStub.m +66 -0
  17. package/ios/RNFBCrashlyticsUnitTests/HostStubs/Firebase/Firebase.h +9 -0
  18. package/ios/RNFBCrashlyticsUnitTests/RNFBCrashlyticsFatalExitTests.m +268 -0
  19. package/ios/RNFBCrashlyticsUnitTests/RNFBCrashlyticsUnitTests.xcodeproj/project.pbxproj +4 -0
  20. package/ios_config.sh +14 -4
  21. package/lib/version.ts +1 -1
  22. package/package.json +4 -4
@@ -0,0 +1,199 @@
1
+ package io.invertase.firebase.crashlytics;
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
+ import static org.junit.Assert.assertEquals;
20
+ import static org.junit.Assert.assertFalse;
21
+ import static org.junit.Assert.assertNotNull;
22
+ import static org.junit.Assert.assertThrows;
23
+ import static org.junit.Assert.assertTrue;
24
+ import static org.mockito.Mockito.mock;
25
+
26
+ import com.google.android.gms.tasks.Task;
27
+ import com.google.android.gms.tasks.Tasks;
28
+ import com.google.firebase.crashlytics.FirebaseCrashlytics;
29
+ import com.google.firebase.crashlytics.internal.common.CrashlyticsCore;
30
+ import com.google.firebase.crashlytics.internal.concurrency.CrashlyticsWorker;
31
+ import com.google.firebase.crashlytics.internal.concurrency.CrashlyticsWorkers;
32
+ import java.lang.reflect.Constructor;
33
+ import java.lang.reflect.Field;
34
+ import java.util.concurrent.Callable;
35
+ import java.util.concurrent.CountDownLatch;
36
+ import java.util.concurrent.ExecutorService;
37
+ import java.util.concurrent.Executors;
38
+ import java.util.concurrent.TimeUnit;
39
+ import java.util.concurrent.atomic.AtomicBoolean;
40
+ import org.junit.After;
41
+ import org.junit.Test;
42
+ import org.junit.runner.RunWith;
43
+ import org.robolectric.RobolectricTestRunner;
44
+
45
+ /**
46
+ * JVM coverage for {@link ReactNativeFirebaseCrashlyticsInternal} against the real Crashlytics
47
+ * worker classes. Robolectric supplies the Android runtime the SDK classes load against;
48
+ * AndroidTest-AD-1. {@code Tasks.await} must not run on the (Robolectric) main thread, so waits
49
+ * happen on a pool thread.
50
+ */
51
+ @RunWith(RobolectricTestRunner.class)
52
+ public class ReactNativeFirebaseCrashlyticsInternalTest {
53
+ private final ExecutorService pool = Executors.newCachedThreadPool();
54
+
55
+ @After
56
+ public void tearDown() {
57
+ pool.shutdownNow();
58
+ }
59
+
60
+ /**
61
+ * A FirebaseCrashlytics whose core uses {@code workers}. Built by reflection because the real
62
+ * graph is impractical in a unit test: {@code CrashlyticsCore}'s only constructor takes ten SDK
63
+ * collaborators (FirebaseApp, file store, native component, ...) and {@code
64
+ * FirebaseCrashlytics}'s constructor is private. So a mock core gets its {@code private final}
65
+ * workers field set.
66
+ */
67
+ static FirebaseCrashlytics crashlyticsWith(CrashlyticsWorkers workers) throws Exception {
68
+ CrashlyticsCore core = mock(CrashlyticsCore.class);
69
+ Field field = CrashlyticsCore.class.getDeclaredField("crashlyticsWorkers");
70
+ field.setAccessible(true);
71
+ field.set(core, workers);
72
+ return crashlyticsWithCore(core);
73
+ }
74
+
75
+ static FirebaseCrashlytics crashlyticsWithCore(CrashlyticsCore core) throws Exception {
76
+ Constructor<FirebaseCrashlytics> constructor =
77
+ FirebaseCrashlytics.class.getDeclaredConstructor(CrashlyticsCore.class);
78
+ constructor.setAccessible(true);
79
+ return constructor.newInstance(core);
80
+ }
81
+
82
+ private <T> T awaitOnPool(Task<T> task) throws Exception {
83
+ return pool.submit(() -> Tasks.await(task, 5, TimeUnit.SECONDS)).get(10, TimeUnit.SECONDS);
84
+ }
85
+
86
+ @Test
87
+ public void barrier_completesAfterAnEventWriteQueuedByAnEarlierCommonTask() throws Exception {
88
+ CrashlyticsWorkers workers = new CrashlyticsWorkers(pool, pool);
89
+ CountDownLatch barrierCreated = new CountDownLatch(1);
90
+ AtomicBoolean eventWritten = new AtomicBoolean(false);
91
+
92
+ // Same shape as CrashlyticsCore.logException → SessionReportingCoordinator.persistEvent: a
93
+ // common task that queues the actual write on diskWrite.
94
+ workers.common.submit(
95
+ () -> {
96
+ try {
97
+ barrierCreated.await(5, TimeUnit.SECONDS);
98
+ } catch (InterruptedException e) {
99
+ throw new IllegalStateException(e);
100
+ }
101
+ workers.diskWrite.submit(
102
+ () -> {
103
+ try {
104
+ Thread.sleep(150);
105
+ } catch (InterruptedException e) {
106
+ throw new IllegalStateException(e);
107
+ }
108
+ eventWritten.set(true);
109
+ });
110
+ });
111
+
112
+ Task<?> barrier =
113
+ ReactNativeFirebaseCrashlyticsInternal.pendingEventWritesBarrier(crashlyticsWith(workers));
114
+ assertFalse(barrier.isComplete());
115
+ barrierCreated.countDown();
116
+
117
+ awaitOnPool(barrier);
118
+ assertTrue(eventWritten.get());
119
+ }
120
+
121
+ /** Every member the barrier reflects exists on the SDK version these tests compile against. */
122
+ @Test
123
+ public void reflectedMembers_existOnTheRealSdkClasses() throws Exception {
124
+ assertNotNull(
125
+ FirebaseCrashlytics.class.getDeclaredField(
126
+ ReactNativeFirebaseCrashlyticsInternal.CORE_FIELD));
127
+ assertNotNull(
128
+ CrashlyticsCore.class.getDeclaredField(
129
+ ReactNativeFirebaseCrashlyticsInternal.WORKERS_FIELD));
130
+ assertNotNull(
131
+ CrashlyticsWorkers.class.getDeclaredField(
132
+ ReactNativeFirebaseCrashlyticsInternal.COMMON_FIELD));
133
+ assertNotNull(
134
+ CrashlyticsWorkers.class.getDeclaredField(
135
+ ReactNativeFirebaseCrashlyticsInternal.DISK_WRITE_FIELD));
136
+ assertNotNull(
137
+ CrashlyticsWorker.class.getMethod(
138
+ ReactNativeFirebaseCrashlyticsInternal.SUBMIT_TASK_METHOD, Callable.class));
139
+ assertNotNull(
140
+ CrashlyticsWorker.class.getMethod(
141
+ ReactNativeFirebaseCrashlyticsInternal.SUBMIT_METHOD, Runnable.class));
142
+ }
143
+
144
+ @Test
145
+ public void barrier_nullCore_throws() throws Exception {
146
+ FirebaseCrashlytics crashlytics = crashlyticsWithCore(null);
147
+ NullPointerException thrown =
148
+ assertThrows(
149
+ NullPointerException.class,
150
+ () -> ReactNativeFirebaseCrashlyticsInternal.pendingEventWritesBarrier(crashlytics));
151
+ assertEquals("core", thrown.getMessage());
152
+ }
153
+
154
+ @Test
155
+ public void barrier_unsetWorkersField_throws() throws Exception {
156
+ FirebaseCrashlytics crashlytics = crashlyticsWithCore(mock(CrashlyticsCore.class));
157
+ NullPointerException thrown =
158
+ assertThrows(
159
+ NullPointerException.class,
160
+ () -> ReactNativeFirebaseCrashlyticsInternal.pendingEventWritesBarrier(crashlytics));
161
+ assertEquals("crashlyticsWorkers", thrown.getMessage());
162
+ }
163
+
164
+ @SuppressWarnings("unused")
165
+ private static class FieldOnBase {
166
+ private final Object workers = "from base";
167
+ }
168
+
169
+ private static final class SubclassWithoutTheField extends FieldOnBase {}
170
+
171
+ @Test
172
+ public void read_findsAFieldDeclaredOnASuperclass() throws Exception {
173
+ assertEquals(
174
+ "from base",
175
+ ReactNativeFirebaseCrashlyticsInternal.read(new SubclassWithoutTheField(), "workers"));
176
+ }
177
+
178
+ @Test
179
+ public void read_missingField_throwsNoSuchField() {
180
+ NoSuchFieldException thrown =
181
+ assertThrows(
182
+ NoSuchFieldException.class,
183
+ () -> ReactNativeFirebaseCrashlyticsInternal.read(new Object(), "crashlyticsWorkers"));
184
+ assertEquals("crashlyticsWorkers", thrown.getMessage());
185
+ }
186
+
187
+ @SuppressWarnings("unused")
188
+ private static final class WorkersWithoutSubmitMethods {
189
+ private final Object common = new Object();
190
+ private final Object diskWrite = new Object();
191
+ }
192
+
193
+ @Test
194
+ public void barrierOn_missingWorkerMethod_throwsNoSuchMethod() {
195
+ assertThrows(
196
+ NoSuchMethodException.class,
197
+ () -> ReactNativeFirebaseCrashlyticsInternal.barrierOn(new WorkersWithoutSubmitMethods()));
198
+ }
199
+ }
@@ -1,5 +1,5 @@
1
1
  "use strict";
2
2
 
3
3
  // Generated by genversion.
4
- export const version = '27.0.0';
4
+ export const version = '27.1.0';
5
5
  //# sourceMappingURL=version.js.map
@@ -1,7 +1,7 @@
1
1
  import type { FirebaseApp } from '@react-native-firebase/app';
2
2
  import './types/internal';
3
3
  import type { Crashlytics } from './types/crashlytics';
4
- export declare const SDK_VERSION = "27.0.0";
4
+ export declare const SDK_VERSION = "27.1.0";
5
5
  export declare function getCrashlytics(app?: FirebaseApp): Crashlytics;
6
6
  export declare function checkForUnsentReports(crashlytics: Crashlytics): Promise<boolean>;
7
7
  export declare function deleteUnsentReports(crashlytics: Crashlytics): Promise<void>;
@@ -1,2 +1,2 @@
1
- export declare const version = "27.0.0";
1
+ export declare const version = "27.1.0";
2
2
  //# sourceMappingURL=version.d.ts.map
@@ -56,3 +56,43 @@
56
56
  isUnhandledPromiseRejection:(BOOL)isUnhandledPromiseRejection;
57
57
 
58
58
  @end
59
+
60
+ /** Upper bound (seconds) a JS fatal waits for its record to save before the process exits. */
61
+ FOUNDATION_EXPORT const NSTimeInterval RNFBCrashlyticsFatalExitWatchdogTimeout;
62
+
63
+ /**
64
+ * Records a JS fatal, then terminates the process once the record has had a chance to save.
65
+ *
66
+ * `-[FIRCrashlytics recordExceptionModel:]` saves asynchronously: once Crashlytics' context-init
67
+ * promise is fulfilled, the save block is dispatched to the main queue. Terminating on the
68
+ * calling queue right after recording usually loses that save. Instead the terminator is
69
+ * enqueued on `terminateQueue` (the main queue in production), so it runs FIFO after the save
70
+ * block. A watchdog on `watchdogQueue` terminates after `watchdogTimeout` if `terminateQueue`
71
+ * is hung. The terminator runs at most once.
72
+ *
73
+ * Known limit: a JS fatal recorded before Crashlytics finished initialising queues its save as a
74
+ * promise observer instead of a main-queue block, so it can still be lost.
75
+ * The FIFO ordering also relies on FBLPromise's default dispatch queue being the main queue; if an
76
+ * app changes it, the save runs off the main queue and the terminator can run first, losing the
77
+ * report. The watchdog only covers a hung `terminateQueue`.
78
+ */
79
+ @interface RNFBCrashlyticsFatalExit : NSObject
80
+
81
+ /** Production instance: terminates with `exit(0)` on the main queue, 2 s watchdog. */
82
+ + (instancetype)shared;
83
+
84
+ - (instancetype)initWithTerminator:(dispatch_block_t)terminator
85
+ terminateQueue:(dispatch_queue_t)terminateQueue
86
+ watchdogQueue:(dispatch_queue_t)watchdogQueue
87
+ watchdogTimeout:(NSTimeInterval)watchdogTimeout NS_DESIGNATED_INITIALIZER;
88
+
89
+ - (instancetype)init NS_UNAVAILABLE;
90
+
91
+ /**
92
+ * Runs `record` synchronously, then schedules termination, even when `record` raises. Returns NO
93
+ * without running `record` when an earlier call already started terminating (a second fatal
94
+ * while waiting).
95
+ */
96
+ - (BOOL)recordThenTerminate:(dispatch_block_t)record;
97
+
98
+ @end
@@ -35,6 +35,8 @@
35
35
  #error "RNFBCrashlyticsDebuggerProbe Swift interface not found"
36
36
  #endif
37
37
 
38
+ #import <stdatomic.h>
39
+
38
40
  #import "RNFBCrashlyticsHelper.h"
39
41
 
40
42
  @implementation RNFBCrashlyticsHelper
@@ -107,3 +109,74 @@
107
109
  }
108
110
 
109
111
  @end
112
+
113
+ const NSTimeInterval RNFBCrashlyticsFatalExitWatchdogTimeout = 2.0;
114
+
115
+ @implementation RNFBCrashlyticsFatalExit {
116
+ dispatch_block_t _terminator;
117
+ dispatch_queue_t _terminateQueue;
118
+ dispatch_queue_t _watchdogQueue;
119
+ NSTimeInterval _watchdogTimeout;
120
+ atomic_bool _exiting;
121
+ atomic_bool _terminated;
122
+ }
123
+
124
+ + (instancetype)shared {
125
+ static RNFBCrashlyticsFatalExit *shared;
126
+ static dispatch_once_t once;
127
+ dispatch_once(&once, ^{
128
+ shared = [[RNFBCrashlyticsFatalExit alloc]
129
+ initWithTerminator:^{
130
+ // Coverage exception: ends the process, so no test can run it.
131
+ exit(0);
132
+ }
133
+ terminateQueue:dispatch_get_main_queue()
134
+ watchdogQueue:dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0)
135
+ watchdogTimeout:RNFBCrashlyticsFatalExitWatchdogTimeout];
136
+ });
137
+ return shared;
138
+ }
139
+
140
+ - (instancetype)initWithTerminator:(dispatch_block_t)terminator
141
+ terminateQueue:(dispatch_queue_t)terminateQueue
142
+ watchdogQueue:(dispatch_queue_t)watchdogQueue
143
+ watchdogTimeout:(NSTimeInterval)watchdogTimeout {
144
+ if (self = [super init]) {
145
+ _terminator = [terminator copy];
146
+ _terminateQueue = terminateQueue;
147
+ _watchdogQueue = watchdogQueue;
148
+ _watchdogTimeout = watchdogTimeout;
149
+ atomic_init(&_exiting, false);
150
+ atomic_init(&_terminated, false);
151
+ }
152
+ return self;
153
+ }
154
+
155
+ - (BOOL)recordThenTerminate:(dispatch_block_t)record {
156
+ if (atomic_exchange(&_exiting, true)) {
157
+ return NO;
158
+ }
159
+ @try {
160
+ record();
161
+ } @catch (NSException *exception) {
162
+ // The guard is already set, so terminate anyway rather than leave the app running with every
163
+ // later fatal ignored.
164
+ NSLog(@"RNFBCrashlytics: recording JS fatal failed: %@", exception);
165
+ }
166
+ dispatch_block_t terminateOnce = ^{
167
+ [self terminateOnce];
168
+ };
169
+ // FIFO behind the Crashlytics save block that recordExceptionModel: enqueued on main.
170
+ dispatch_async(_terminateQueue, terminateOnce);
171
+ dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(_watchdogTimeout * NSEC_PER_SEC)),
172
+ _watchdogQueue, terminateOnce);
173
+ return YES;
174
+ }
175
+
176
+ - (void)terminateOnce {
177
+ if (!atomic_exchange(&_terminated, true)) {
178
+ _terminator();
179
+ }
180
+ }
181
+
182
+ @end
@@ -97,10 +97,15 @@ RCT_EXPORT_MODULE(NativeRNFBTurboCrashlytics)
97
97
  RCTLog(
98
98
  @"Crashlytics - WARNING: Debugger detected. Crashlytics will not receive crash reports.");
99
99
  }
100
- [self recordJavaScriptError:jsErrorDict];
101
-
102
- ELog(@"Crashlytics - Crash logged. Terminating app.");
103
- exit(0);
100
+ // Coverage exception: this branch ends the process, so neither XCTest nor e2e can observe it.
101
+ // Keep it a thin delegate; ordering, watchdog and the second-fatal guard live in
102
+ // RNFBCrashlyticsFatalExit (unit-tested). The promise is intentionally never resolved: that
103
+ // keeps the JS global handler suspended (no chaining to the previous handler, no
104
+ // fall-through) until termination.
105
+ ELog(@"Crashlytics - Logging crash and terminating app.");
106
+ [[RNFBCrashlyticsFatalExit shared]
107
+ recordThenTerminate:[self javaScriptErrorRecorder:jsErrorDict]];
108
+ return;
104
109
  } else {
105
110
  RCTLog(@"Crashlytics - INFO: crashlytics collection is not enabled, not crashing.");
106
111
  }
@@ -193,6 +198,13 @@ RCT_EXPORT_MODULE(NativeRNFBTurboCrashlytics)
193
198
  }
194
199
 
195
200
  - (void)recordJavaScriptError:(JS::NativeRNFBTurboCrashlytics::JavaScriptErrorObject &)jsErrorDict {
201
+ [self javaScriptErrorRecorder:jsErrorDict]();
202
+ }
203
+
204
+ // Converts the codegen struct to Foundation values up front, so the returned block captures no
205
+ // C++ references and can be handed to RNFBCrashlyticsFatalExit.
206
+ - (dispatch_block_t)javaScriptErrorRecorder:
207
+ (JS::NativeRNFBTurboCrashlytics::JavaScriptErrorObject &)jsErrorDict {
196
208
  NSString *message = jsErrorDict.message();
197
209
  auto stackFrames = jsErrorDict.frames();
198
210
  NSMutableArray *frames = [[NSMutableArray alloc] init];
@@ -206,9 +218,11 @@ RCT_EXPORT_MODULE(NativeRNFBTurboCrashlytics)
206
218
  }];
207
219
  }
208
220
 
209
- [RNFBCrashlyticsHelper recordJavaScriptErrorWithMessage:message
210
- frames:frames
211
- isUnhandledPromiseRejection:isUnhandledPromiseRejection];
221
+ return ^{
222
+ [RNFBCrashlyticsHelper recordJavaScriptErrorWithMessage:message
223
+ frames:frames
224
+ isUnhandledPromiseRejection:isUnhandledPromiseRejection];
225
+ };
212
226
  }
213
227
 
214
228
  @end
@@ -2,6 +2,7 @@
2
2
  * Host-only stub for macOS XCTest. Not shipped in the production pod.
3
3
  */
4
4
  #import <Firebase/Firebase.h>
5
+ #import <stdatomic.h>
5
6
 
6
7
  @implementation FIRStackFrame {
7
8
  NSString *_symbol;
@@ -63,11 +64,19 @@ static NSString *sUserIDValue;
63
64
  static NSUInteger sDeleteUnsentReportsCallCount;
64
65
  static NSUInteger sSendUnsentReportsCallCount;
65
66
  static FIRExceptionModel *sLastRecordedExceptionModel;
67
+ static NSMutableArray<FIRExceptionModel *> *sSavedExceptionModels;
68
+ static void (^sOnExceptionModelSaved)(FIRExceptionModel *);
69
+ static BOOL sContextInitPending;
70
+ static NSMutableArray<dispatch_block_t> *sPendingSaves;
71
+ // Bumped by resetTestState so saves enqueued by an earlier test are dropped.
72
+ static atomic_uint_fast64_t sSaveGeneration;
66
73
 
67
74
  + (void)initialize {
68
75
  if (self == [FIRCrashlytics class]) {
69
76
  sLoggedMessages = [NSMutableArray new];
70
77
  sCustomValues = [NSMutableDictionary new];
78
+ sSavedExceptionModels = [NSMutableArray new];
79
+ sPendingSaves = [NSMutableArray new];
71
80
  }
72
81
  }
73
82
 
@@ -150,6 +159,36 @@ static FIRExceptionModel *sLastRecordedExceptionModel;
150
159
  sLastRecordedExceptionModel = lastRecordedExceptionModel;
151
160
  }
152
161
 
162
+ + (NSArray<FIRExceptionModel *> *)savedExceptionModels {
163
+ return [sSavedExceptionModels copy];
164
+ }
165
+
166
+ + (void (^)(FIRExceptionModel *))onExceptionModelSaved {
167
+ return sOnExceptionModelSaved;
168
+ }
169
+
170
+ + (void)setOnExceptionModelSaved:(void (^)(FIRExceptionModel *))onExceptionModelSaved {
171
+ sOnExceptionModelSaved = [onExceptionModelSaved copy];
172
+ }
173
+
174
+ + (void)holdContextInit {
175
+ @synchronized(sPendingSaves) {
176
+ sContextInitPending = YES;
177
+ }
178
+ }
179
+
180
+ + (void)fulfillContextInit {
181
+ NSArray<dispatch_block_t> *saves;
182
+ @synchronized(sPendingSaves) {
183
+ sContextInitPending = NO;
184
+ saves = [sPendingSaves copy];
185
+ [sPendingSaves removeAllObjects];
186
+ }
187
+ for (dispatch_block_t save in saves) {
188
+ dispatch_async(dispatch_get_main_queue(), save);
189
+ }
190
+ }
191
+
153
192
  + (void)resetTestState {
154
193
  sDidCrashDuringPreviousExecutionValue = NO;
155
194
  sCheckForUnsentReportsValue = NO;
@@ -159,6 +198,13 @@ static FIRExceptionModel *sLastRecordedExceptionModel;
159
198
  sDeleteUnsentReportsCallCount = 0;
160
199
  sSendUnsentReportsCallCount = 0;
161
200
  sLastRecordedExceptionModel = nil;
201
+ [sSavedExceptionModels removeAllObjects];
202
+ sOnExceptionModelSaved = nil;
203
+ @synchronized(sPendingSaves) {
204
+ sContextInitPending = NO;
205
+ [sPendingSaves removeAllObjects];
206
+ }
207
+ atomic_fetch_add(&sSaveGeneration, 1);
162
208
  }
163
209
 
164
210
  - (void)checkForUnsentReportsWithCompletion:(void (^)(BOOL unsentReports))completion {
@@ -202,6 +248,26 @@ static FIRExceptionModel *sLastRecordedExceptionModel;
202
248
 
203
249
  - (void)recordExceptionModel:(FIRExceptionModel *)exceptionModel {
204
250
  sLastRecordedExceptionModel = exceptionModel;
251
+ // Mirrors FirebaseCrashlytics: once context init is fulfilled, the save block is dispatched to
252
+ // the main queue and writes synchronously when it runs. While init is pending, FBLPromise holds
253
+ // the block as an observer and dispatches it to the main queue only when the promise fulfills.
254
+ uint_fast64_t generation = atomic_load(&sSaveGeneration);
255
+ dispatch_block_t save = ^{
256
+ if (generation != atomic_load(&sSaveGeneration)) {
257
+ return;
258
+ }
259
+ [sSavedExceptionModels addObject:exceptionModel];
260
+ if (sOnExceptionModelSaved != nil) {
261
+ sOnExceptionModelSaved(exceptionModel);
262
+ }
263
+ };
264
+ @synchronized(sPendingSaves) {
265
+ if (sContextInitPending) {
266
+ [sPendingSaves addObject:save];
267
+ return;
268
+ }
269
+ }
270
+ dispatch_async(dispatch_get_main_queue(), save);
205
271
  }
206
272
 
207
273
  @end
@@ -41,6 +41,15 @@
41
41
  @property(class, nonatomic, assign) NSUInteger deleteUnsentReportsCallCount;
42
42
  @property(class, nonatomic, assign) NSUInteger sendUnsentReportsCallCount;
43
43
  @property(class, nonatomic, copy, nullable) FIRExceptionModel *lastRecordedExceptionModel;
44
+ /** Models whose main-queue save block has run (see recordExceptionModel:). */
45
+ @property(class, nonatomic, readonly, copy) NSArray<FIRExceptionModel *> *savedExceptionModels;
46
+ /** Called on the main queue right after a save block runs. */
47
+ @property(class, nonatomic, copy, nullable) void (^onExceptionModelSaved)(FIRExceptionModel *model);
48
+ /** Holds later saves like observers of a pending context-init promise, until fulfillContextInit. */
49
+ + (void)holdContextInit;
50
+
51
+ /** Dispatches the held saves to the main queue and stops holding new ones. */
52
+ + (void)fulfillContextInit;
44
53
 
45
54
  + (void)resetTestState;
46
55