Sfoglia il codice sorgente

Scoping out report callback API (#7503)

Sam Edson 5 anni fa
parent
commit
e769c91f4c

+ 3 - 0
Crashlytics/CHANGELOG.md

@@ -1,3 +1,6 @@
+# Unreleased
+- [added] Added a new API checkAndUpdateUnsentReportsWithCompletion for updating the crash report from the previous run of the app if, for example, the developer wants to implement a feedback dialog to ask end-users for more information. Unsent Crashlytics Reports have familiar methods like setting custom keys and logs.
+
 # v7.6.0
 - [fixed] Fixed an issue where some developers experienced a race condition involving binary image operations (#7459).
 

+ 5 - 0
Crashlytics/Crashlytics/Components/FIRCLSUserLogging.h

@@ -101,6 +101,11 @@ void FIRCLSUserLoggingWriteAndCheckABFiles(FIRCLSUserLoggingABStorage* storage,
 NSArray* FIRCLSUserLoggingStoredKeyValues(const char* path);
 
 OBJC_EXTERN void FIRCLSLog(NSString* format, ...) NS_FORMAT_FUNCTION(1, 2);
+OBJC_EXTERN void FIRCLSLogToStorage(FIRCLSUserLoggingABStorage* storage,
+                                    const char** activePath,
+                                    NSString* format,
+                                    ...) NS_FORMAT_FUNCTION(3, 4);
+
 #endif
 
 __END_DECLS

+ 30 - 9
Crashlytics/Crashlytics/Components/FIRCLSUserLogging.m

@@ -42,7 +42,9 @@ static void FIRCLSUserLoggingWriteKeysAndValues(NSDictionary *keysAndValues,
 static void FIRCLSUserLoggingCheckAndSwapABFiles(FIRCLSUserLoggingABStorage *storage,
                                                  const char **activePath,
                                                  off_t fileSize);
-void FIRCLSLogInternal(NSString *message);
+void FIRCLSLogInternal(FIRCLSUserLoggingABStorage *storage,
+                       const char **activePath,
+                       NSString *message);
 
 #pragma mark - Setup
 void FIRCLSUserLoggingInit(FIRCLSUserLoggingReadOnlyContext *roContext,
@@ -397,7 +399,26 @@ void FIRCLSLog(NSString *format, ...) {
   NSString *msg = [[NSString alloc] initWithFormat:format arguments:args];
   va_end(args);
 
-  FIRCLSLogInternal(msg);
+  FIRCLSUserLoggingABStorage *currentStorage = &_firclsContext.readonly->logging.logStorage;
+  const char **activePath = &_firclsContext.writable->logging.activeUserLogPath;
+  FIRCLSLogInternal(currentStorage, activePath, msg);
+}
+
+void FIRCLSLogToStorage(FIRCLSUserLoggingABStorage *storage,
+                        const char **activePath,
+                        NSString *format,
+                        ...) {
+  // If the format is nil do nothing just like NSLog.
+  if (!format) {
+    return;
+  }
+
+  va_list args;
+  va_start(args, format);
+  NSString *msg = [[NSString alloc] initWithFormat:format arguments:args];
+  va_end(args);
+
+  FIRCLSLogInternal(storage, activePath, msg);
 }
 
 #pragma mark - Properties
@@ -525,7 +546,9 @@ void FIRCLSLogInternalWrite(FIRCLSFile *file, NSString *message, uint64_t time)
   FIRCLSFileWriteSectionEnd(file);
 }
 
-void FIRCLSLogInternal(NSString *message) {
+void FIRCLSLogInternal(FIRCLSUserLoggingABStorage *storage,
+                       const char **activePath,
+                       NSString *message) {
   if (!message) {
     return;
   }
@@ -539,7 +562,7 @@ void FIRCLSLogInternal(NSString *message) {
   struct timeval te;
 
   NSUInteger messageLength = [message length];
-  int maxLogSize = _firclsContext.readonly->logging.logStorage.maxSize;
+  int maxLogSize = storage->maxSize;
 
   if (messageLength > maxLogSize) {
     FIRCLSWarningLog(
@@ -556,9 +579,7 @@ void FIRCLSLogInternal(NSString *message) {
 
   const uint64_t time = te.tv_sec * 1000LL + te.tv_usec / 1000;
 
-  FIRCLSUserLoggingWriteAndCheckABFiles(&_firclsContext.readonly->logging.logStorage,
-                                        &_firclsContext.writable->logging.activeUserLogPath,
-                                        ^(FIRCLSFile *file) {
-                                          FIRCLSLogInternalWrite(file, message, time);
-                                        });
+  FIRCLSUserLoggingWriteAndCheckABFiles(storage, activePath, ^(FIRCLSFile *file) {
+    FIRCLSLogInternalWrite(file, message, time);
+  });
 }

+ 40 - 6
Crashlytics/Crashlytics/Controllers/FIRCLSExistingReportManager.h

@@ -19,25 +19,59 @@ NS_ASSUME_NONNULL_BEGIN
 @class FIRCLSManagerData;
 @class FIRCLSReportUploader;
 @class FIRCLSDataCollectionToken;
+@class FIRCrashlyticsReport;
 
 @interface FIRCLSExistingReportManager : NSObject
 
+/**
+ * Returns the number of unsent reports on the device, ignoring empty reports in
+ * the active folder, and ignoring any reports in "processing" or "prepared".
+ *
+ * In the past, this would count reports in the processed or prepared
+ * folders. This has been changed because reports in those paths have already
+ * been cleared for upload, so there isn't any point in asking for permission
+ * or possibly spamming end-users if a report gets stuck.
+ *
+ * The tricky part is, customers will NOT be alerted in checkForUnsentReports
+ * for reports in these paths, but when they choose sendUnsentReports / enable data
+ * collection, reports in those directories will be re-managed. This should be ok and
+ * just an edge case because reports should only be in processing or prepared for a split second as
+ * they do on-device symbolication and get converted into a GDTEvent. After a report is handed off
+ * to GoogleDataTransport, it is uploaded regardless of Crashlytics data collection.
+ */
+@property(nonatomic, readonly) NSUInteger unsentReportsCount;
+
+/**
+ * This value needs to stay in sync with numUnsentReports, so if there is > 0 numUnsentReports,
+ * newestUnsentReport needs to return a value. Otherwise it needs to return null.
+ *
+ * FIRCLSContext needs to be initialized before the FIRCrashlyticsReport is instantiated.
+ */
+@property(nonatomic, readonly) FIRCrashlyticsReport *_Nullable newestUnsentReport;
+
 - (instancetype)initWithManagerData:(FIRCLSManagerData *)managerData
                      reportUploader:(FIRCLSReportUploader *)reportUploader;
 
 - (instancetype)init NS_UNAVAILABLE;
 + (instancetype)new NS_UNAVAILABLE;
 
-- (int)unsentReportsCountWithPreexisting:(NSArray<NSString *> *)paths;
+/**
+ * This is important to call once, early in startup, before the
+ * new report for this run of the app has been created. Any
+ * reports in ExistingReportManager will be uploaded or deleted
+ * and we don't want to do that for the current run of the app.
+ */
+- (void)collectExistingReports;
 
-- (void)deleteUnsentReportsWithPreexisting:(NSArray *)preexistingReportPaths;
+/**
+ * This is the side-effect of calling deleteUnsentReports, or collect_reports setting
+ * being false.
+ */
+- (void)deleteUnsentReports;
 
-- (void)processExistingReportPaths:(NSArray *)reportPaths
-               dataCollectionToken:(FIRCLSDataCollectionToken *)dataCollectionToken
+- (void)sendUnsentReportsWithToken:(FIRCLSDataCollectionToken *)dataCollectionToken
                           asUrgent:(BOOL)urgent;
 
-- (void)handleContentsInOtherReportingDirectoriesWithToken:(FIRCLSDataCollectionToken *)token;
-
 @end
 
 NS_ASSUME_NONNULL_END

+ 88 - 75
Crashlytics/Crashlytics/Controllers/FIRCLSExistingReportManager.m

@@ -17,9 +17,10 @@
 #import "Crashlytics/Crashlytics/Controllers/FIRCLSManagerData.h"
 #import "Crashlytics/Crashlytics/Controllers/FIRCLSReportUploader.h"
 #import "Crashlytics/Crashlytics/DataCollection/FIRCLSDataCollectionToken.h"
-#import "Crashlytics/Crashlytics/Helpers/FIRCLSLogger.h"
 #import "Crashlytics/Crashlytics/Models/FIRCLSFileManager.h"
 #import "Crashlytics/Crashlytics/Models/FIRCLSInternalReport.h"
+#import "Crashlytics/Crashlytics/Private/FIRCrashlyticsReport_Private.h"
+#import "Crashlytics/Crashlytics/Public/FirebaseCrashlytics/FIRCrashlyticsReport.h"
 
 @interface FIRCLSExistingReportManager ()
 
@@ -27,6 +28,12 @@
 @property(nonatomic, strong) NSOperationQueue *operationQueue;
 @property(nonatomic, strong) FIRCLSReportUploader *reportUploader;
 
+// This list of active reports excludes the brand new active report that will be created this run of
+// the app.
+@property(nonatomic, strong) NSArray *existingUnemptyActiveReportPaths;
+@property(nonatomic, strong) NSArray *processingReportPaths;
+@property(nonatomic, strong) NSArray *preparedReportPaths;
+
 @end
 
 @implementation FIRCLSExistingReportManager
@@ -45,40 +52,95 @@
   return self;
 }
 
-/**
- * Returns the number of unsent reports on the device, including the ones passed in.
- */
-- (int)unsentReportsCountWithPreexisting:(NSArray<NSString *> *)paths {
-  int count = [self countSubmittableAndDeleteUnsubmittableReportPaths:paths];
+NSInteger compareOlder(FIRCLSInternalReport *reportA,
+                       FIRCLSInternalReport *reportB,
+                       void *context) {
+  return [reportA.dateCreated compare:reportB.dateCreated];
+}
+
+- (void)collectExistingReports {
+  self.existingUnemptyActiveReportPaths =
+      [self getUnemptyExistingActiveReportsAndDeleteEmpty:self.fileManager.activePathContents];
+  self.processingReportPaths = self.fileManager.processingPathContents;
+  self.preparedReportPaths = self.fileManager.preparedPathContents;
+}
+
+- (FIRCrashlyticsReport *)newestUnsentReport {
+  if (self.unsentReportsCount <= 0) {
+    return nil;
+  }
+
+  NSMutableArray<NSString *> *allReportPaths =
+      [NSMutableArray arrayWithArray:self.existingUnemptyActiveReportPaths];
 
-  count += self.fileManager.processingPathContents.count;
-  count += self.fileManager.preparedPathContents.count;
-  return count;
+  NSMutableArray<FIRCLSInternalReport *> *validReports = [NSMutableArray array];
+  for (NSString *path in allReportPaths) {
+    FIRCLSInternalReport *_Nullable report = [FIRCLSInternalReport reportWithPath:path];
+    if (!report) {
+      continue;
+    }
+    [validReports addObject:report];
+  }
+
+  [validReports sortUsingFunction:compareOlder context:nil];
+
+  FIRCLSInternalReport *_Nullable internalReport = [validReports lastObject];
+  return [[FIRCrashlyticsReport alloc] initWithInternalReport:internalReport];
+}
+
+- (NSUInteger)unsentReportsCount {
+  // There are nuances about why we only count active reports.
+  // See the header comment for more information.
+  return self.existingUnemptyActiveReportPaths.count;
 }
 
-- (int)countSubmittableAndDeleteUnsubmittableReportPaths:(NSArray *)reportPaths {
-  int count = 0;
+- (NSArray *)getUnemptyExistingActiveReportsAndDeleteEmpty:(NSArray *)reportPaths {
+  NSMutableArray *unemptyReports = [NSMutableArray array];
   for (NSString *path in reportPaths) {
     FIRCLSInternalReport *report = [FIRCLSInternalReport reportWithPath:path];
-    if ([report needsToBeSubmitted]) {
-      count++;
+    if ([report hasAnyEvents]) {
+      [unemptyReports addObject:path];
     } else {
       [self.operationQueue addOperationWithBlock:^{
-        [self->_fileManager removeItemAtPath:path];
+        [self.fileManager removeItemAtPath:path];
       }];
     }
   }
-  return count;
+  return unemptyReports;
 }
 
-- (void)processExistingReportPaths:(NSArray *)reportPaths
-               dataCollectionToken:(FIRCLSDataCollectionToken *)dataCollectionToken
+- (void)sendUnsentReportsWithToken:(FIRCLSDataCollectionToken *)dataCollectionToken
                           asUrgent:(BOOL)urgent {
-  for (NSString *path in reportPaths) {
+  for (NSString *path in self.existingUnemptyActiveReportPaths) {
     [self processExistingActiveReportPath:path
                       dataCollectionToken:dataCollectionToken
                                  asUrgent:urgent];
   }
+
+  // deal with stuff in processing more carefully - do not process again
+  [self.operationQueue addOperationWithBlock:^{
+    for (NSString *path in self.processingReportPaths) {
+      FIRCLSInternalReport *report = [FIRCLSInternalReport reportWithPath:path];
+      [self.reportUploader prepareAndSubmitReport:report
+                              dataCollectionToken:dataCollectionToken
+                                         asUrgent:NO
+                                   withProcessing:NO];
+    }
+  }];
+
+  // Because this could happen quite a bit after the inital set of files was
+  // captured, some could be completed (deleted). So, just double-check to make sure
+  // the file still exists.
+  [self.operationQueue addOperationWithBlock:^{
+    for (NSString *path in self.preparedReportPaths) {
+      if (![[self.fileManager underlyingFileManager] fileExistsAtPath:path]) {
+        continue;
+      }
+      [self.reportUploader uploadPackagedReportAtPath:path
+                                  dataCollectionToken:dataCollectionToken
+                                             asUrgent:NO];
+    }
+  }];
 }
 
 - (void)processExistingActiveReportPath:(NSString *)path
@@ -86,10 +148,10 @@
                                asUrgent:(BOOL)urgent {
   FIRCLSInternalReport *report = [FIRCLSInternalReport reportWithPath:path];
 
-  // TODO: needsToBeSubmitted should really be called on the background queue.
-  if (![report needsToBeSubmitted]) {
+  // TODO: hasAnyEvents should really be called on the background queue.
+  if (![report hasAnyEvents]) {
     [self.operationQueue addOperationWithBlock:^{
-      [self->_fileManager removeItemAtPath:path];
+      [self.fileManager removeItemAtPath:path];
     }];
 
     return;
@@ -104,11 +166,6 @@
     return;
   }
 
-  [self submitReport:report dataCollectionToken:dataCollectionToken];
-}
-
-- (void)submitReport:(FIRCLSInternalReport *)report
-    dataCollectionToken:(FIRCLSDataCollectionToken *)dataCollectionToken {
   [self.operationQueue addOperationWithBlock:^{
     [self.reportUploader prepareAndSubmitReport:report
                             dataCollectionToken:dataCollectionToken
@@ -117,15 +174,12 @@
   }];
 }
 
-// This is the side-effect of calling deleteUnsentReports, or collect_reports setting
-// being false
-- (void)deleteUnsentReportsWithPreexisting:(NSArray *)preexistingReportPaths {
-  [self removeExistingReportPaths:preexistingReportPaths];
-  [self removeExistingReportPaths:self.fileManager.processingPathContents];
-  [self removeExistingReportPaths:self.fileManager.preparedPathContents];
-}
+- (void)deleteUnsentReports {
+  NSArray<NSString *> *reportPaths = @[];
+  reportPaths = [reportPaths arrayByAddingObjectsFromArray:self.existingUnemptyActiveReportPaths];
+  reportPaths = [reportPaths arrayByAddingObjectsFromArray:self.processingReportPaths];
+  reportPaths = [reportPaths arrayByAddingObjectsFromArray:self.preparedReportPaths];
 
-- (void)removeExistingReportPaths:(NSArray *)reportPaths {
   [self.operationQueue addOperationWithBlock:^{
     for (NSString *path in reportPaths) {
       [self.fileManager removeItemAtPath:path];
@@ -133,45 +187,4 @@
   }];
 }
 
-- (void)handleContentsInOtherReportingDirectoriesWithToken:(FIRCLSDataCollectionToken *)token {
-  [self handleExistingFilesInProcessingWithToken:token];
-  [self handleExistingFilesInPreparedWithToken:token];
-}
-
-- (void)handleExistingFilesInProcessingWithToken:(FIRCLSDataCollectionToken *)token {
-  NSArray *processingPaths = _fileManager.processingPathContents;
-
-  // deal with stuff in processing more carefully - do not process again
-  [self.operationQueue addOperationWithBlock:^{
-    for (NSString *path in processingPaths) {
-      FIRCLSInternalReport *report = [FIRCLSInternalReport reportWithPath:path];
-      [self.reportUploader prepareAndSubmitReport:report
-                              dataCollectionToken:token
-                                         asUrgent:NO
-                                   withProcessing:NO];
-    }
-  }];
-}
-
-- (void)handleExistingFilesInPreparedWithToken:(FIRCLSDataCollectionToken *)token {
-  NSArray *preparedPaths = self.fileManager.preparedPathContents;
-  [self.operationQueue addOperationWithBlock:^{
-    [self uploadPreexistingFiles:preparedPaths withToken:token];
-  }];
-}
-
-- (void)uploadPreexistingFiles:(NSArray *)files withToken:(FIRCLSDataCollectionToken *)token {
-  // Because this could happen quite a bit after the inital set of files was
-  // captured, some could be completed (deleted). So, just double-check to make sure
-  // the file still exists.
-
-  for (NSString *path in files) {
-    if (![[_fileManager underlyingFileManager] fileExistsAtPath:path]) {
-      continue;
-    }
-
-    [self.reportUploader uploadPackagedReportAtPath:path dataCollectionToken:token asUrgent:NO];
-  }
-}
-
 @end

+ 1 - 1
Crashlytics/Crashlytics/Controllers/FIRCLSReportManager.h

@@ -36,7 +36,7 @@ NS_ASSUME_NONNULL_BEGIN
 
 - (FBLPromise<NSNumber *> *)startWithProfilingMark:(FIRCLSProfileMark)mark;
 
-- (FBLPromise<NSNumber *> *)checkForUnsentReports;
+- (FBLPromise<FIRCrashlyticsReport *> *)checkForUnsentReports;
 - (FBLPromise *)sendUnsentReports;
 - (FBLPromise *)deleteUnsentReports;
 

+ 42 - 64
Crashlytics/Crashlytics/Controllers/FIRCLSReportManager.m

@@ -93,11 +93,6 @@ typedef NSNumber FIRCLSWrappedReportAction;
 }
 @end
 
-/**
- * This is a helper to make code using NSNumber for bools more readable.
- */
-typedef NSNumber FIRCLSWrappedBool;
-
 @interface FIRCLSReportManager () {
   FIRCLSFileManager *_fileManager;
   dispatch_queue_t _dispatchQueue;
@@ -106,7 +101,7 @@ typedef NSNumber FIRCLSWrappedBool;
 
   // A promise that will be resolved when unsent reports are found on the device, and
   // processReports: can be called to decide how to deal with them.
-  FBLPromise<FIRCLSWrappedBool *> *_unsentReportsAvailable;
+  FBLPromise<FIRCrashlyticsReport *> *_unsentReportsAvailable;
 
   // A promise that will be resolved when the user has provided an action that they want to perform
   // for all the unsent reports.
@@ -197,8 +192,8 @@ typedef NSNumber FIRCLSWrappedBool;
 //    2. The developer uses the processCrashReports API to indicate whether the report
 //       should be sent or deleted, at which point the promise will be resolved with the action.
 - (FBLPromise<FIRCLSWrappedReportAction *> *)waitForReportAction {
-  FIRCLSDebugLog(@"[Crashlytics:Crash] Notifying that unsent reports are available.");
-  [_unsentReportsAvailable fulfill:@YES];
+  FIRCrashlyticsReport *unsentReport = self.existingReportManager.newestUnsentReport;
+  [_unsentReportsAvailable fulfill:unsentReport];
 
   // If data collection gets enabled while we are waiting for an action, go ahead and send the
   // reports, and any subsequent explicit response will be ignored.
@@ -208,16 +203,16 @@ typedef NSNumber FIRCLSWrappedBool;
             return @(FIRCLSReportActionSend);
           }];
 
-  FIRCLSDebugLog(@"[Crashlytics:Crash] Waiting for send/deleteUnsentReports to be called.");
   // Wait for either the processReports callback to be called, or data collection to be enabled.
   return [FBLPromise race:@[ collectionEnabled, _reportActionProvided ]];
 }
 
-- (FBLPromise<FIRCLSWrappedBool *> *)checkForUnsentReports {
+- (FBLPromise<FIRCrashlyticsReport *> *)checkForUnsentReports {
   bool expectedCalled = NO;
   if (!atomic_compare_exchange_strong(&_checkForUnsentReportsCalled, &expectedCalled, YES)) {
-    FIRCLSErrorLog(@"checkForUnsentReports should only be called once per execution.");
-    return [FBLPromise resolvedWith:@NO];
+    FIRCLSErrorLog(@"Either checkForUnsentReports or checkAndUpdateUnsentReports should be called "
+                   @"once per execution.");
+    return [FBLPromise resolvedWith:nil];
   }
   return _unsentReportsAvailable;
 }
@@ -239,6 +234,10 @@ typedef NSNumber FIRCLSWrappedBool;
   NSTimeInterval currentTimestamp = [NSDate timeIntervalSinceReferenceDate];
   [self.settings reloadFromCacheWithGoogleAppID:self.googleAppID currentTimestamp:currentTimestamp];
 
+  // This needs to be called before the new report is created for
+  // this run of the app.
+  [self.existingReportManager collectExistingReports];
+
   if (![self validateAppIdentifiers]) {
     return [FBLPromise resolvedWith:@NO];
   }
@@ -251,9 +250,7 @@ typedef NSNumber FIRCLSWrappedBool;
     return [FBLPromise resolvedWith:@NO];
   }
 
-  // Grab existing reports
   BOOL launchFailure = [self.launchMarker checkForAndCreateLaunchMarker];
-  NSArray *preexistingReportPaths = _fileManager.activePathContents;
 
   FIRCLSInternalReport *report = [self setupCurrentReport:executionIdentifier];
   if (!report) {
@@ -282,56 +279,41 @@ typedef NSNumber FIRCLSWrappedBool;
 
     [self beginSettingsWithToken:dataCollectionToken];
 
-    [self beginReportUploadsWithToken:dataCollectionToken
-               preexistingReportPaths:preexistingReportPaths
-                         blockingSend:launchFailure];
+    [self beginReportUploadsWithToken:dataCollectionToken blockingSend:launchFailure];
 
     // If data collection is enabled, the SDK will not notify the user
     // when unsent reports are available, or respect Send / DeleteUnsentReports
-    [_unsentReportsAvailable fulfill:@NO];
+    [_unsentReportsAvailable fulfill:nil];
 
   } else {
     FIRCLSDebugLog(@"Automatic data collection is disabled.");
-
-    // TODO: This counting of the file system happens on the main thread. Now that some of the other
-    // work below has been made async and moved to the dispatch queue, maybe we can move this code
-    // to the dispatch queue as well.
-    int unsentReportsCount =
-        [self.existingReportManager unsentReportsCountWithPreexisting:preexistingReportPaths];
-    if (unsentReportsCount > 0) {
-      FIRCLSDebugLog(
-          @"[Crashlytics:Crash] %d unsent reports are available. Checking for upload permission.",
-          unsentReportsCount);
-      // Wait for an action to get sent, either from processReports: or automatic data collection.
-      promise = [[self waitForReportAction]
-          onQueue:_dispatchQueue
-             then:^id _Nullable(FIRCLSWrappedReportAction *_Nullable wrappedAction) {
-               // Process the actions for the reports on disk.
-               FIRCLSReportAction action = [wrappedAction reportActionValue];
-               if (action == FIRCLSReportActionSend) {
-                 FIRCLSDebugLog(@"Sending unsent reports.");
-                 FIRCLSDataCollectionToken *dataCollectionToken =
-                     [FIRCLSDataCollectionToken validToken];
-
-                 [self beginSettingsWithToken:dataCollectionToken];
-
-                 [self beginReportUploadsWithToken:dataCollectionToken
-                            preexistingReportPaths:preexistingReportPaths
-                                      blockingSend:NO];
-
-               } else if (action == FIRCLSReportActionDelete) {
-                 FIRCLSDebugLog(@"Deleting unsent reports.");
-                 [self.existingReportManager
-                     deleteUnsentReportsWithPreexisting:preexistingReportPaths];
-               } else {
-                 FIRCLSErrorLog(@"Unknown report action: %d", action);
-               }
-               return @(report != nil);
-             }];
-    } else {
-      FIRCLSDebugLog(@"[Crashlytics:Crash] There are no unsent reports.");
-      [_unsentReportsAvailable fulfill:@NO];
-    }
+    FIRCLSDebugLog(@"[Crashlytics:Crash] %d unsent reports are available. Waiting for "
+                   @"send/deleteUnsentReports to be called.",
+                   self.existingReportManager.unsentReportsCount);
+
+    // Wait for an action to get sent, either from processReports: or automatic data collection.
+    promise = [[self waitForReportAction]
+        onQueue:_dispatchQueue
+           then:^id _Nullable(FIRCLSWrappedReportAction *_Nullable wrappedAction) {
+             // Process the actions for the reports on disk.
+             FIRCLSReportAction action = [wrappedAction reportActionValue];
+             if (action == FIRCLSReportActionSend) {
+               FIRCLSDebugLog(@"Sending unsent reports.");
+               FIRCLSDataCollectionToken *dataCollectionToken =
+                   [FIRCLSDataCollectionToken validToken];
+
+               [self beginSettingsWithToken:dataCollectionToken];
+
+               [self beginReportUploadsWithToken:dataCollectionToken blockingSend:NO];
+
+             } else if (action == FIRCLSReportActionDelete) {
+               FIRCLSDebugLog(@"Deleting unsent reports.");
+               [self.existingReportManager deleteUnsentReports];
+             } else {
+               FIRCLSErrorLog(@"Unknown report action: %d", action);
+             }
+             return @(report != nil);
+           }];
   }
 
   if (report != nil) {
@@ -388,17 +370,13 @@ typedef NSNumber FIRCLSWrappedBool;
 }
 
 - (void)beginReportUploadsWithToken:(FIRCLSDataCollectionToken *)token
-             preexistingReportPaths:(NSArray *)preexistingReportPaths
                        blockingSend:(BOOL)blockingSend {
   if (self.settings.collectReportsEnabled) {
-    [self.existingReportManager processExistingReportPaths:preexistingReportPaths
-                                       dataCollectionToken:token
-                                                  asUrgent:blockingSend];
-    [self.existingReportManager handleContentsInOtherReportingDirectoriesWithToken:token];
+    [self.existingReportManager sendUnsentReportsWithToken:token asUrgent:blockingSend];
 
   } else {
     FIRCLSInfoLog(@"Collect crash reports is disabled");
-    [self.existingReportManager deleteUnsentReportsWithPreexisting:preexistingReportPaths];
+    [self.existingReportManager deleteUnsentReports];
   }
 }
 

+ 14 - 5
Crashlytics/Crashlytics/FIRCrashlytics.m

@@ -31,7 +31,6 @@
 #include "Crashlytics/Crashlytics/Helpers/FIRCLSProfiling.h"
 #include "Crashlytics/Crashlytics/Helpers/FIRCLSUtility.h"
 #import "Crashlytics/Crashlytics/Models/FIRCLSFileManager.h"
-#import "Crashlytics/Crashlytics/Models/FIRCLSReport_Private.h"
 #import "Crashlytics/Crashlytics/Models/FIRCLSSettings.h"
 #import "Crashlytics/Crashlytics/Settings/Models/FIRCLSApplicationIdentifierModel.h"
 
@@ -271,10 +270,20 @@ NSString *const FIRCLSGoogleTransportMappingID = @"1206";
 #pragma mark - API: Accessors
 
 - (void)checkForUnsentReportsWithCompletion:(void (^)(BOOL))completion {
-  [[self.reportManager checkForUnsentReports] then:^id _Nullable(NSNumber *_Nullable value) {
-    completion([value boolValue]);
-    return nil;
-  }];
+  [[self.reportManager checkForUnsentReports]
+      then:^id _Nullable(FIRCrashlyticsReport *_Nullable value) {
+        completion(value ? true : false);
+        return nil;
+      }];
+}
+
+- (void)checkAndUpdateUnsentReportsWithCompletion:
+    (void (^)(FIRCrashlyticsReport *_Nonnull))completion {
+  [[self.reportManager checkForUnsentReports]
+      then:^id _Nullable(FIRCrashlyticsReport *_Nullable value) {
+        completion(value);
+        return nil;
+      }];
 }
 
 - (void)sendUnsentReports {

+ 197 - 0
Crashlytics/Crashlytics/FIRCrashlyticsReport.m

@@ -0,0 +1,197 @@
+// Copyright 2021 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#import "Crashlytics/Crashlytics/Public/FirebaseCrashlytics/FIRCrashlyticsReport.h"
+
+#import "Crashlytics/Crashlytics/Components/FIRCLSContext.h"
+#import "Crashlytics/Crashlytics/Components/FIRCLSGlobals.h"
+#import "Crashlytics/Crashlytics/Helpers/FIRCLSLogger.h"
+#import "Crashlytics/Crashlytics/Models/FIRCLSInternalReport.h"
+
+@interface FIRCrashlyticsReport () {
+  NSString *_reportID;
+  NSDate *_dateCreated;
+  BOOL _hasCrash;
+
+  FIRCLSUserLoggingABStorage _logStorage;
+  const char *_activeLogPath;
+
+  uint32_t _internalKVCounter;
+  FIRCLSUserLoggingKVStorage _internalKVStorage;
+
+  uint32_t _userKVCounter;
+  FIRCLSUserLoggingKVStorage _userKVStorage;
+}
+
+@property(nonatomic, strong) FIRCLSInternalReport *internalReport;
+
+@end
+
+@implementation FIRCrashlyticsReport
+
+- (instancetype)initWithInternalReport:(FIRCLSInternalReport *)internalReport {
+  self = [super init];
+  if (!self) {
+    return nil;
+  }
+
+  _internalReport = internalReport;
+  _reportID = [[internalReport identifier] copy];
+  _dateCreated = [[internalReport dateCreated] copy];
+  _hasCrash = [internalReport isCrash];
+
+  _logStorage.maxSize = _firclsContext.readonly->logging.logStorage.maxSize;
+  _logStorage.maxEntries = _firclsContext.readonly->logging.logStorage.maxEntries;
+  _logStorage.restrictBySize = _firclsContext.readonly->logging.logStorage.restrictBySize;
+  _logStorage.entryCount = _firclsContext.readonly->logging.logStorage.entryCount;
+  _logStorage.aPath = [FIRCrashlyticsReport filesystemPathForContentFile:FIRCLSReportLogAFile
+                                                        inInternalReport:internalReport];
+  _logStorage.bPath = [FIRCrashlyticsReport filesystemPathForContentFile:FIRCLSReportLogBFile
+                                                        inInternalReport:internalReport];
+
+  _activeLogPath = _logStorage.aPath;
+
+  // TODO: correct kv accounting
+  // The internal report will have non-zero compacted and incremental keys. The right thing to do
+  // is count them, so we can kick off compactions/pruning at the right times. By
+  // setting this value to zero, we're allowing more entries to be made than there really
+  // should be. Not the end of the world, but we should do better eventually.
+  _internalKVCounter = 0;
+  _userKVCounter = 0;
+
+  _userKVStorage.maxCount = _firclsContext.readonly->logging.userKVStorage.maxCount;
+  _userKVStorage.maxIncrementalCount =
+      _firclsContext.readonly->logging.userKVStorage.maxIncrementalCount;
+  _userKVStorage.compactedPath =
+      [FIRCrashlyticsReport filesystemPathForContentFile:FIRCLSReportUserCompactedKVFile
+                                        inInternalReport:internalReport];
+  _userKVStorage.incrementalPath =
+      [FIRCrashlyticsReport filesystemPathForContentFile:FIRCLSReportUserIncrementalKVFile
+                                        inInternalReport:internalReport];
+
+  _internalKVStorage.maxCount = _firclsContext.readonly->logging.internalKVStorage.maxCount;
+  _internalKVStorage.maxIncrementalCount =
+      _firclsContext.readonly->logging.internalKVStorage.maxIncrementalCount;
+  _internalKVStorage.compactedPath =
+      [FIRCrashlyticsReport filesystemPathForContentFile:FIRCLSReportInternalCompactedKVFile
+                                        inInternalReport:internalReport];
+  _internalKVStorage.incrementalPath =
+      [FIRCrashlyticsReport filesystemPathForContentFile:FIRCLSReportInternalIncrementalKVFile
+                                        inInternalReport:internalReport];
+
+  return self;
+}
+
++ (const char *)filesystemPathForContentFile:(NSString *)contentFile
+                            inInternalReport:(FIRCLSInternalReport *)internalReport {
+  if (!internalReport) {
+    return nil;
+  }
+
+  // We need to be defensive because strdup will crash
+  // if given a nil.
+  NSString *objCString = [internalReport pathForContentFile:contentFile];
+  const char *fileSystemString = [objCString fileSystemRepresentation];
+  if (!objCString || !fileSystemString) {
+    return nil;
+  }
+
+  // Paths need to be duplicated because fileSystemRepresentation returns C strings
+  // that are freed outside of this context.
+  return strdup(fileSystemString);
+}
+
+- (BOOL)checkContextForMethod:(NSString *)methodName {
+  if (!FIRCLSContextIsInitialized()) {
+    FIRCLSErrorLog(@"%@ failed for FIRCrashlyticsReport because Crashlytics context isn't "
+                   @"initialized.",
+                   methodName);
+    return false;
+  }
+  return true;
+}
+
+#pragma mark - API: Getters
+
+- (NSString *)reportID {
+  return _reportID;
+}
+
+- (NSDate *)dateCreated {
+  return _dateCreated;
+}
+
+- (BOOL)hasCrash {
+  return _hasCrash;
+}
+
+#pragma mark - API: Logging
+
+- (void)log:(NSString *)msg {
+  if (![self checkContextForMethod:@"log:"]) {
+    return;
+  }
+
+  FIRCLSLogToStorage(&_logStorage, &_activeLogPath, @"%@", msg);
+}
+
+- (void)logWithFormat:(NSString *)format, ... {
+  if (![self checkContextForMethod:@"logWithFormat:"]) {
+    return;
+  }
+
+  va_list args;
+  va_start(args, format);
+  [self logWithFormat:format arguments:args];
+  va_end(args);
+}
+
+- (void)logWithFormat:(NSString *)format arguments:(va_list)args {
+  if (![self checkContextForMethod:@"logWithFormat:arguments:"]) {
+    return;
+  }
+
+  [self log:[[NSString alloc] initWithFormat:format arguments:args]];
+}
+
+#pragma mark - API: setUserID
+
+- (void)setUserID:(NSString *)userID {
+  if (![self checkContextForMethod:@"setUserID:"]) {
+    return;
+  }
+
+  FIRCLSUserLoggingRecordKeyValue(FIRCLSUserIdentifierKey, userID, &_internalKVStorage,
+                                  &_internalKVCounter);
+}
+
+#pragma mark - API: setCustomValue
+
+- (void)setCustomValue:(id)value forKey:(NSString *)key {
+  if (![self checkContextForMethod:@"setCustomValue:forKey:"]) {
+    return;
+  }
+
+  FIRCLSUserLoggingRecordKeyValue(key, value, &_userKVStorage, &_userKVCounter);
+}
+
+- (void)setCustomKeysAndValues:(NSDictionary *)keysAndValues {
+  if (![self checkContextForMethod:@"setCustomKeysAndValues:"]) {
+    return;
+  }
+
+  FIRCLSUserLoggingRecordKeysAndValues(keysAndValues, &_userKVStorage, &_userKVCounter);
+}
+
+@end

+ 1 - 1
Crashlytics/Crashlytics/Models/FIRCLSInternalReport.h

@@ -49,7 +49,7 @@ extern NSString *const FIRCLSReportUserCompactedKVFile;
 
 @property(nonatomic, copy, readonly) NSString *directoryName;
 @property(nonatomic, copy) NSString *path;
-@property(nonatomic, assign, readonly) BOOL needsToBeSubmitted;
+@property(nonatomic, assign, readonly) BOOL hasAnyEvents;
 
 // content paths
 @property(nonatomic, copy, readonly) NSString *binaryImagePath;

+ 4 - 4
Crashlytics/Crashlytics/Models/FIRCLSInternalReport.m

@@ -105,7 +105,7 @@ NSString *const FIRCLSReportUserCompactedKVFile = @"user_compacted_kv.clsrecord"
 }
 
 #pragma mark - Processing Methods
-- (BOOL)needsToBeSubmitted {
+- (BOOL)hasAnyEvents {
   NSArray *reportFiles = @[
     FIRCLSReportExceptionFile, FIRCLSReportSignalFile, FIRCLSReportCustomExceptionAFile,
     FIRCLSReportCustomExceptionBFile,
@@ -114,7 +114,7 @@ NSString *const FIRCLSReportUserCompactedKVFile = @"user_compacted_kv.clsrecord"
 #endif
     FIRCLSReportErrorAFile, FIRCLSReportErrorBFile
   ];
-  return [self checkExistenceOfAtLeastOnceFileInArray:reportFiles];
+  return [self checkExistenceOfAtLeastOneFileInArray:reportFiles];
 }
 
 // These are purposefully in order of precedence. If duplicate data exists
@@ -140,10 +140,10 @@ NSString *const FIRCLSReportUserCompactedKVFile = @"user_compacted_kv.clsrecord"
 
 - (BOOL)isCrash {
   NSArray *crashFiles = [FIRCLSInternalReport crashFileNames];
-  return [self checkExistenceOfAtLeastOnceFileInArray:crashFiles];
+  return [self checkExistenceOfAtLeastOneFileInArray:crashFiles];
 }
 
-- (BOOL)checkExistenceOfAtLeastOnceFileInArray:(NSArray *)files {
+- (BOOL)checkExistenceOfAtLeastOneFileInArray:(NSArray *)files {
   NSFileManager *manager = [NSFileManager defaultManager];
 
   for (NSString *fileName in files) {

+ 0 - 110
Crashlytics/Crashlytics/Models/FIRCLSReport.h

@@ -1,110 +0,0 @@
-// Copyright 2019 Google
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//      http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-#import <Foundation/Foundation.h>
-
-NS_ASSUME_NONNULL_BEGIN
-
-/**
- * The CLSCrashReport protocol is deprecated. See the CLSReport class and the CrashyticsDelegate
- * changes for details.
- **/
-@protocol FIRCLSCrashReport <NSObject>
-
-@property(nonatomic, copy, readonly) NSString *identifier;
-@property(nonatomic, copy, readonly) NSDictionary *customKeys;
-@property(nonatomic, copy, readonly) NSString *bundleVersion;
-@property(nonatomic, copy, readonly) NSString *bundleShortVersionString;
-@property(nonatomic, readonly, nullable) NSDate *crashedOnDate;
-@property(nonatomic, copy, readonly) NSString *OSVersion;
-@property(nonatomic, copy, readonly) NSString *OSBuildVersion;
-
-@end
-
-/**
- * The CLSReport exposes an interface to the phsyical report that Crashlytics has created. You can
- * use this class to get information about the event, and can also set some values after the
- * event has occurred.
- **/
-@interface FIRCLSReport : NSObject <FIRCLSCrashReport>
-
-- (instancetype)init NS_UNAVAILABLE;
-+ (instancetype)new NS_UNAVAILABLE;
-
-/**
- * Returns the session identifier for the report.
- **/
-@property(nonatomic, copy, readonly) NSString *identifier;
-
-/**
- * Returns the custom key value data for the report.
- **/
-@property(nonatomic, copy, readonly) NSDictionary *customKeys;
-
-/**
- * Returns the CFBundleVersion of the application that generated the report.
- **/
-@property(nonatomic, copy, readonly) NSString *bundleVersion;
-
-/**
- * Returns the CFBundleShortVersionString of the application that generated the report.
- **/
-@property(nonatomic, copy, readonly) NSString *bundleShortVersionString;
-
-/**
- * Returns the date that the report was created.
- **/
-@property(nonatomic, copy, readonly) NSDate *dateCreated;
-
-/**
- * Returns the os version that the application crashed on.
- **/
-@property(nonatomic, copy, readonly) NSString *OSVersion;
-
-/**
- * Returns the os build version that the application crashed on.
- **/
-@property(nonatomic, copy, readonly) NSString *OSBuildVersion;
-
-/**
- * Returns YES if the report contains any crash information, otherwise returns NO.
- **/
-@property(nonatomic, assign, readonly) BOOL isCrash;
-
-/**
- * You can use this method to set, after the event, additional custom keys. The rules
- * and semantics for this method are the same as those documented in FIRCrashlytics.h. Be aware
- * that the maximum size and count of custom keys is still enforced, and you can overwrite keys
- * and/or cause excess keys to be deleted by using this method.
- **/
-- (void)setObjectValue:(nullable id)value forKey:(NSString *)key;
-
-/**
- * Record an application-specific user identifier. See FIRCrashlytics.h for details.
- **/
-@property(nonatomic, copy, nullable) NSString *userIdentifier;
-
-/**
- * Record a user name. See FIRCrashlytics.h for details.
- **/
-@property(nonatomic, copy, nullable) NSString *userName;
-
-/**
- * Record a user email. See FIRCrashlytics.h for details.
- **/
-@property(nonatomic, copy, nullable) NSString *userEmail;
-
-@end
-
-NS_ASSUME_NONNULL_END

+ 0 - 241
Crashlytics/Crashlytics/Models/FIRCLSReport.m

@@ -1,241 +0,0 @@
-// Copyright 2019 Google
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//      http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-#import "Crashlytics/Crashlytics/Components/FIRCLSContext.h"
-#import "Crashlytics/Crashlytics/Components/FIRCLSGlobals.h"
-#import "Crashlytics/Crashlytics/Components/FIRCLSUserLogging.h"
-#import "Crashlytics/Crashlytics/Helpers/FIRCLSFile.h"
-#import "Crashlytics/Crashlytics/Models/FIRCLSInternalReport.h"
-#import "Crashlytics/Crashlytics/Models/FIRCLSReport_Private.h"
-
-@interface FIRCLSReport () {
-  FIRCLSInternalReport *_internalReport;
-  uint32_t _internalKVCounter;
-  uint32_t _userKVCounter;
-
-  NSString *_internalCompactedKVFile;
-  NSString *_internalIncrementalKVFile;
-  NSString *_userCompactedKVFile;
-  NSString *_userIncrementalKVFile;
-
-  BOOL _readOnly;
-
-  // cached values, to ensure that their contents remain valid
-  // even if the report is deleted
-  NSString *_identifer;
-  NSString *_bundleVersion;
-  NSString *_bundleShortVersionString;
-  NSDate *_dateCreated;
-  NSDate *_crashedOnDate;
-  NSString *_OSVersion;
-  NSString *_OSBuildVersion;
-  NSNumber *_isCrash;
-  NSDictionary *_customKeys;
-}
-
-@end
-
-@implementation FIRCLSReport
-
-- (instancetype)initWithInternalReport:(FIRCLSInternalReport *)report
-                          prefetchData:(BOOL)shouldPrefetch {
-  self = [super init];
-  if (!self) {
-    return nil;
-  }
-
-  _internalReport = report;
-
-  // TODO: correct kv accounting
-  // The internal report will have non-zero compacted and incremental keys. The right thing to do
-  // is count them, so we can kick off compactions/pruning at the right times. By
-  // setting this value to zero, we're allowing more entries to be made than there really
-  // should be. Not the end of the world, but we should do better eventually.
-  _internalKVCounter = 0;
-  _userKVCounter = 0;
-
-  _internalCompactedKVFile =
-      [self.internalReport pathForContentFile:FIRCLSReportInternalCompactedKVFile];
-  _internalIncrementalKVFile =
-      [self.internalReport pathForContentFile:FIRCLSReportInternalIncrementalKVFile];
-  _userCompactedKVFile = [self.internalReport pathForContentFile:FIRCLSReportUserCompactedKVFile];
-  _userIncrementalKVFile =
-      [self.internalReport pathForContentFile:FIRCLSReportUserIncrementalKVFile];
-
-  _readOnly = shouldPrefetch;
-
-  if (shouldPrefetch) {
-    _identifer = report.identifier;
-    _bundleVersion = report.bundleVersion;
-    _bundleShortVersionString = report.bundleShortVersionString;
-    _dateCreated = report.dateCreated;
-    _crashedOnDate = report.crashedOnDate;
-    _OSVersion = report.OSVersion;
-    _OSBuildVersion = report.OSBuildVersion;
-    _isCrash = [NSNumber numberWithBool:report.isCrash];
-
-    _customKeys = [self readCustomKeys];
-  }
-
-  return self;
-}
-
-- (instancetype)initWithInternalReport:(FIRCLSInternalReport *)report {
-  return [self initWithInternalReport:report prefetchData:NO];
-}
-
-#pragma mark - Helpers
-- (FIRCLSUserLoggingKVStorage)internalKVStorage {
-  FIRCLSUserLoggingKVStorage storage;
-
-  storage.maxCount = _firclsContext.readonly->logging.internalKVStorage.maxCount;
-  storage.maxIncrementalCount =
-      _firclsContext.readonly->logging.internalKVStorage.maxIncrementalCount;
-  storage.compactedPath = [_internalCompactedKVFile fileSystemRepresentation];
-  storage.incrementalPath = [_internalIncrementalKVFile fileSystemRepresentation];
-
-  return storage;
-}
-
-- (FIRCLSUserLoggingKVStorage)userKVStorage {
-  FIRCLSUserLoggingKVStorage storage;
-
-  storage.maxCount = _firclsContext.readonly->logging.userKVStorage.maxCount;
-  storage.maxIncrementalCount = _firclsContext.readonly->logging.userKVStorage.maxIncrementalCount;
-  storage.compactedPath = [_userCompactedKVFile fileSystemRepresentation];
-  storage.incrementalPath = [_userIncrementalKVFile fileSystemRepresentation];
-
-  return storage;
-}
-
-- (BOOL)canRecordNewValues {
-  return !_readOnly && FIRCLSContextIsInitialized();
-}
-
-- (void)recordValue:(id)value forInternalKey:(NSString *)key {
-  if (!self.canRecordNewValues) {
-    return;
-  }
-
-  FIRCLSUserLoggingKVStorage storage = [self internalKVStorage];
-
-  FIRCLSUserLoggingRecordKeyValue(key, value, &storage, &_internalKVCounter);
-}
-
-- (void)recordValue:(id)value forUserKey:(NSString *)key {
-  if (!self.canRecordNewValues) {
-    return;
-  }
-
-  FIRCLSUserLoggingKVStorage storage = [self userKVStorage];
-
-  FIRCLSUserLoggingRecordKeyValue(key, value, &storage, &_userKVCounter);
-}
-
-- (NSDictionary *)readCustomKeys {
-  FIRCLSUserLoggingKVStorage storage = [self userKVStorage];
-
-  // return decoded entries
-  return FIRCLSUserLoggingGetCompactedKVEntries(&storage, true);
-}
-
-#pragma mark - Metadata helpers
-
-- (NSString *)identifier {
-  if (!_identifer) {
-    _identifer = self.internalReport.identifier;
-  }
-
-  return _identifer;
-}
-
-- (NSDictionary *)customKeys {
-  if (!_customKeys) {
-    _customKeys = [self readCustomKeys];
-  }
-
-  return _customKeys;
-}
-
-- (NSString *)bundleVersion {
-  if (!_bundleVersion) {
-    _bundleVersion = self.internalReport.bundleVersion;
-  }
-
-  return _bundleVersion;
-}
-
-- (NSString *)bundleShortVersionString {
-  if (!_bundleShortVersionString) {
-    _bundleShortVersionString = self.internalReport.bundleShortVersionString;
-  }
-
-  return _bundleShortVersionString;
-}
-
-- (NSDate *)dateCreated {
-  if (!_dateCreated) {
-    _dateCreated = self.internalReport.dateCreated;
-  }
-
-  return _dateCreated;
-}
-
-// for compatibility with the CLSCrashReport Protocol
-- (NSDate *)crashedOnDate {
-  if (!_crashedOnDate) {
-    _crashedOnDate = self.internalReport.crashedOnDate;
-  }
-
-  return _crashedOnDate;
-}
-
-- (NSString *)OSVersion {
-  if (!_OSVersion) {
-    _OSVersion = self.internalReport.OSVersion;
-  }
-
-  return _OSVersion;
-}
-
-- (NSString *)OSBuildVersion {
-  if (!_OSBuildVersion) {
-    _OSBuildVersion = self.internalReport.OSBuildVersion;
-  }
-
-  return _OSBuildVersion;
-}
-
-- (BOOL)isCrash {
-  if (_isCrash == nil) {
-    _isCrash = [NSNumber numberWithBool:self.internalReport.isCrash];
-  }
-
-  return [_isCrash boolValue];
-}
-
-#pragma mark - Public Read/Write Methods
-- (void)setObjectValue:(id)value forKey:(NSString *)key {
-  [self recordValue:value forUserKey:key];
-}
-
-- (NSString *)userIdentifier {
-  return nil;
-}
-
-- (void)setUserIdentifier:(NSString *)userIdentifier {
-  [self recordValue:userIdentifier forInternalKey:FIRCLSUserIdentifierKey];
-}
-
-@end

+ 16 - 8
Crashlytics/Crashlytics/Models/FIRCLSReport_Private.h → Crashlytics/Crashlytics/Private/FIRCrashlyticsReport_Private.h

@@ -1,4 +1,4 @@
-// Copyright 2019 Google
+// Copyright 2021 Google LLC
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -12,16 +12,24 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-#import "Crashlytics/Crashlytics/Models/FIRCLSReport.h"
+#ifndef FIRCrashlyticsReport_Private_h
+#define FIRCrashlyticsReport_Private_h
 
-@class FIRCLSInternalReport;
+#import "Crashlytics/Crashlytics/Public/FirebaseCrashlytics/FIRCrashlyticsReport.h"
 
-@interface FIRCLSReport ()
+NS_ASSUME_NONNULL_BEGIN
 
-- (instancetype)initWithInternalReport:(FIRCLSInternalReport *)report
-                          prefetchData:(BOOL)shouldPrefetch NS_DESIGNATED_INITIALIZER;
-- (instancetype)initWithInternalReport:(FIRCLSInternalReport *)report;
+/**
+ * Internal initializer because this object is created by the SDK.
+ **/
+@interface FIRCrashlyticsReport (Private)
 
-@property(nonatomic, strong, readonly) FIRCLSInternalReport *internalReport;
+- (instancetype)initWithInternalReport:(FIRCLSInternalReport *)internalReport;
+
+@property(nonatomic, strong) FIRCLSInternalReport *internalReport;
 
 @end
+
+NS_ASSUME_NONNULL_END
+
+#endif /* FIRCrashlyticsReport_Private_h */

+ 29 - 0
Crashlytics/Crashlytics/Public/FirebaseCrashlytics/FIRCrashlytics.h

@@ -14,6 +14,7 @@
 
 #import <Foundation/Foundation.h>
 
+#import "FIRCrashlyticsReport.h"
 #import "FIRExceptionModel.h"
 
 #if __has_include(<Crashlytics/Crashlytics.h>)
@@ -179,6 +180,34 @@ NS_SWIFT_NAME(Crashlytics)
 - (void)checkForUnsentReportsWithCompletion:(void (^)(BOOL))completion
     NS_SWIFT_NAME(checkForUnsentReports(completion:));
 
+/**
+ * Determines whether there are any unsent crash reports cached on the device, then calls the given
+ * callback with a CrashlyticsReport object that you can use to update the unsent report.
+ * CrashlyticsReports have a lot of the familiar Crashlytics methods like setting custom keys and
+ * logs.
+ *
+ * The callback only executes if automatic data collection is disabled. You can use
+ * the callback to get one-time consent from a user upon a crash, and then call
+ * sendUnsentReports or deleteUnsentReports, depending on whether or not the user gives consent.
+ *
+ * Disable automatic collection by:
+ *  - Adding the FirebaseCrashlyticsCollectionEnabled: NO key to your App's Info.plist
+ *  - Calling [[FIRCrashlytics crashlytics] setCrashlyticsCollectionEnabled:NO] in your app
+ *  - Setting FIRApp's isDataCollectionDefaultEnabled to NO
+ *
+ * Not calling send/deleteUnsentReports will result in the report staying on disk, which means the
+ * same CrashlyticsReport can show up in multiple runs of the app. If you want avoid duplicates,
+ * ensure there was a crash on the last run of the app by checking the value of
+ * didCrashDuringPreviousExecution.
+ *
+ * @param completion The callback that's executed once Crashlytics finishes checking for unsent
+ * reports. The callback is called with the newest unsent Crashlytics Report, or nil if there are
+ * none cached on disk.
+ */
+- (void)checkAndUpdateUnsentReportsWithCompletion:
+    (void (^)(FIRCrashlyticsReport *_Nullable))completion
+    NS_SWIFT_NAME(checkAndUpdateUnsentReports(completion:));
+
 /**
  * Enqueues any unsent reports on the device to upload to Crashlytics.
  *

+ 108 - 0
Crashlytics/Crashlytics/Public/FirebaseCrashlytics/FIRCrashlyticsReport.h

@@ -0,0 +1,108 @@
+// Copyright 2021 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#import <Foundation/Foundation.h>
+
+NS_ASSUME_NONNULL_BEGIN
+
+/**
+ * The Firebase Crashlytics Report provides a way to read and write information
+ * to a past Crashlytics reports. A common use case is gathering end-user feedback
+ * on the next run of the app.
+ *
+ * The CrashlyticsReport should be modified before calling send/deleteUnsentReports.
+ */
+NS_SWIFT_NAME(CrashlyticsReport)
+@interface FIRCrashlyticsReport : NSObject
+
+/** :nodoc: */
+- (instancetype)init NS_UNAVAILABLE;
+
+/**
+ * Returns the unique ID for the Crashlytics report.
+ */
+@property(nonatomic, readonly) NSString *reportID;
+
+/**
+ * Returns the date that the report was created.
+ */
+@property(nonatomic, readonly) NSDate *dateCreated;
+
+/**
+ * Returns true when one of the events in the Crashlytics report is a crash.
+ */
+@property(nonatomic, readonly) BOOL hasCrash;
+
+/**
+ * Adds logging that is sent with your crash data. The logging does not appear  in the
+ * system.log and is only visible in the Crashlytics dashboard.
+ *
+ * @param msg Message to log
+ */
+- (void)log:(NSString *)msg;
+
+/**
+ * Adds logging that is sent with your crash data. The logging does not appear  in the
+ * system.log and is only visible in the Crashlytics dashboard.
+ *
+ * @param format Format of string
+ * @param ... A comma-separated list of arguments to substitute into format
+ */
+- (void)logWithFormat:(NSString *)format, ... NS_FORMAT_FUNCTION(1, 2);
+
+/**
+ * Adds logging that is sent with your crash data. The logging does not appear  in the
+ * system.log and is only visible in the Crashlytics dashboard.
+ *
+ * @param format Format of string
+ * @param args Arguments to substitute into format
+ */
+- (void)logWithFormat:(NSString *)format
+            arguments:(va_list)args NS_SWIFT_NAME(log(format:arguments:));
+
+/**
+ * Sets a custom key and value to be associated with subsequent fatal and non-fatal reports.
+ * When setting an object value, the object is converted to a string. This is
+ * typically done by calling "-[NSObject description]".
+ *
+ * @param value The value to be associated with the key
+ * @param key A unique key
+ */
+- (void)setCustomValue:(id)value forKey:(NSString *)key;
+
+/**
+ * Sets custom keys and values to be associated with subsequent fatal and non-fatal reports.
+ * The objects in the dictionary are converted to strings. This is
+ * typically done by calling "-[NSObject description]".
+ *
+ * @param keysAndValues The values to be associated with the corresponding keys
+ */
+- (void)setCustomKeysAndValues:(NSDictionary *)keysAndValues;
+
+/**
+ * Records a user ID (identifier) that's associated with subsequent fatal and non-fatal reports.
+ *
+ * If you want to associate a crash with a specific user, we recommend specifying an arbitrary
+ * string (e.g., a database, ID, hash, or other value that you can index and query, but is
+ * meaningless to a third-party observer). This allows you to facilitate responses for support
+ * requests and reach out to users for more information.
+ *
+ * @param userID An arbitrary user identifier string that associates a user to a record in your
+ * system.
+ */
+- (void)setUserID:(NSString *)userID;
+
+@end
+
+NS_ASSUME_NONNULL_END

+ 1 - 0
Crashlytics/Crashlytics/Public/FirebaseCrashlytics/FirebaseCrashlytics.h

@@ -15,5 +15,6 @@
  */
 
 #import "FIRCrashlytics.h"
+#import "FIRCrashlyticsReport.h"
 #import "FIRExceptionModel.h"
 #import "FIRStackFrame.h"

+ 4 - 5
Crashlytics/UnitTests/FIRCLSInternalReportTests.m

@@ -47,24 +47,23 @@
   NSString *customAPath = [report pathForContentFile:FIRCLSReportCustomExceptionAFile];
   NSString *customBPath = [report pathForContentFile:FIRCLSReportCustomExceptionBFile];
 
-  XCTAssertFalse(report.needsToBeSubmitted, @"metadata only should not need to be submitted");
+  XCTAssertFalse(report.hasAnyEvents, @"metadata only should not need to be submitted");
 
   [[NSFileManager defaultManager] createFileAtPath:customAPath
                                           contents:[NSData data]
                                         attributes:nil];
 
-  XCTAssert(report.needsToBeSubmitted, @"with the A file present, needs to be submitted");
+  XCTAssert(report.hasAnyEvents, @"with the A file present, needs to be submitted");
 
   [[NSFileManager defaultManager] createFileAtPath:customBPath
                                           contents:[NSData data]
                                         attributes:nil];
 
   // with A and B, also needs
-  XCTAssert(report.needsToBeSubmitted,
-            @"with both the A and B files present, needs to be submitted");
+  XCTAssert(report.hasAnyEvents, @"with both the A and B files present, needs to be submitted");
 
   XCTAssert([[NSFileManager defaultManager] removeItemAtPath:customAPath error:nil]);
-  XCTAssert(report.needsToBeSubmitted, @"with the B file present, needs to be submitted");
+  XCTAssert(report.hasAnyEvents, @"with the B file present, needs to be submitted");
 }
 
 @end

+ 41 - 24
Crashlytics/UnitTests/FIRCLSReportManagerTests.m

@@ -230,17 +230,15 @@
   XCTestExpectation *processReportsComplete =
       [[XCTestExpectation alloc] initWithDescription:@"processReports: complete"];
   __block BOOL reportsAvailable = NO;
-  [[[self.reportManager checkForUnsentReports] then:^id _Nullable(NSNumber *_Nullable value) {
-    reportsAvailable = [value boolValue];
-    if (!reportsAvailable) {
-      return nil;
-    }
-    if (send) {
-      return [self->_reportManager sendUnsentReports];
-    } else {
-      return [self->_reportManager deleteUnsentReports];
-    }
-  }] then:^id _Nullable(id _Nullable ignored) {
+  [[[self.reportManager checkForUnsentReports]
+      then:^id _Nullable(FIRCrashlyticsReport *_Nullable report) {
+        reportsAvailable = report ? true : false;
+        if (send) {
+          return [self->_reportManager sendUnsentReports];
+        } else {
+          return [self->_reportManager deleteUnsentReports];
+        }
+      }] then:^id _Nullable(id _Nullable ignored) {
     [processReportsComplete fulfill];
     return nil;
   }];
@@ -257,12 +255,15 @@
 }
 
 - (void)testExistingUnimportantReportOnStart {
-  // create a report and put it in place
+  // Create a report representing the last run and put it in place
   [self createActiveReport];
 
-  // Report should get deleted, and nothing else specials should happen.
+  // Report from the last run should get deleted, and a new
+  // one should be created for this run.
   [self startReportManager];
 
+  // If this is > 1 it means we're not cleaning up reports from previous runs.
+  // If this == 0, it means we're not creating new reports.
   XCTAssertEqual([[self contentsOfActivePath] count], 1);
 
   XCTAssertEqual([self.prepareAndSubmitReportArray count], 0);
@@ -273,10 +274,8 @@
   // create a report and put it in place
   [self createActiveReport];
 
-  // Report should get deleted, and nothing else specials should happen.
-  FBLPromise<NSNumber *> *promise = [self startReportManagerWithDataCollectionEnabled:NO];
-  // It should not be necessary to call processReports, since there are no reports.
-  [self waitForPromise:promise];
+  // Starting with data collection disabled should report in nothing changing
+  [self startReportManagerWithDataCollectionEnabled:NO];
 
   XCTAssertEqual([[self contentsOfActivePath] count], 1);
 
@@ -466,6 +465,11 @@
   XCTAssertEqualObjects(self.prepareAndSubmitReportArray[0][@"urgent"], @(NO));
 }
 
+/*
+ * This tests an edge case where there is a report in processing. For the purposes of unsent
+ * reports these are not shown to the developer, but they are uploaded / deleted upon
+ * calling send / delete.
+ */
 - (void)testFilesLeftInProcessingWithDataCollectionDisabled {
   // Put report in processing.
   FIRCLSInternalReport *report = [self createActiveReport];
@@ -479,10 +483,14 @@
                  @"Processing should still have the report");
   XCTAssertEqual([self.prepareAndSubmitReportArray count], 0);
 
-  [self processReports:YES];
+  // We don't expect reports here because we don't consider processing or prepared
+  // reports as unsent as they need to be marked for sending before being placed
+  // in those directories.
+  [self processReports:YES andExpectReports:NO];
 
   // We should not process reports left over in processing.
   XCTAssertEqual([[self contentsOfProcessingPath] count], 0, @"Processing should be cleared");
+  XCTAssertEqual([[self contentsOfPreparedPath] count], 0, @"Prepared should be cleared");
 
   XCTAssertEqual([self.prepareAndSubmitReportArray count], 1);
   XCTAssertEqualObjects(self.prepareAndSubmitReportArray[0][@"process"], @(NO));
@@ -493,7 +501,7 @@
   // Drop a phony multipart-mime file in here, with non-zero contents.
   XCTAssert([_fileManager createDirectoryAtPath:_fileManager.preparedPath]);
   NSString *path = [_fileManager.preparedPath stringByAppendingPathComponent:@"phony-report"];
-  path = [path stringByAppendingPathExtension:@".multipart-mime"];
+  path = [path stringByAppendingPathExtension:@"multipart-mime"];
 
   XCTAssertTrue([[_fileManager underlyingFileManager]
       createFileAtPath:path
@@ -502,7 +510,7 @@
 
   [self startReportManager];
 
-  // We should not process reports left over in prepared.
+  // Reports should be moved out of prepared
   XCTAssertEqual([[self contentsOfPreparedPath] count], 0, @"Prepared should be cleared");
 
   XCTAssertEqual([self.prepareAndSubmitReportArray count], 0);
@@ -510,11 +518,16 @@
   XCTAssertEqualObjects(self.uploadReportArray[0][@"path"], path);
 }
 
+/*
+ * This tests an edge case where there is a report in prepared. For the purposes of unsent
+ * reports these are not shown to the developer, but they are uploaded / deleted upon
+ * calling send / delete.
+ */
 - (void)testFilesLeftInPreparedWithDataCollectionDisabled {
   // drop a phony multipart-mime file in here, with non-zero contents
   XCTAssert([_fileManager createDirectoryAtPath:_fileManager.preparedPath]);
   NSString *path = [_fileManager.preparedPath stringByAppendingPathComponent:@"phony-report"];
-  path = [path stringByAppendingPathExtension:@".multipart-mime"];
+  path = [path stringByAppendingPathExtension:@"multipart-mime"];
 
   XCTAssertTrue([[_fileManager underlyingFileManager]
       createFileAtPath:path
@@ -528,10 +541,14 @@
                  @"Prepared should still have the report");
   XCTAssertEqual([self.prepareAndSubmitReportArray count], 0);
 
-  [self processReports:YES];
+  // We don't expect reports here because we don't consider processing or prepared
+  // reports as unsent as they need to be marked for sending before being placed
+  // in those directories.
+  [self processReports:YES andExpectReports:NO];
 
-  // we should not process reports left over in processing
+  // Reports should be moved out of prepared
   XCTAssertEqual([[self contentsOfPreparedPath] count], 0, @"Prepared should be cleared");
+  XCTAssertEqual([[self contentsOfProcessingPath] count], 0, @"Processing should be cleared");
 
   XCTAssertEqual([self.prepareAndSubmitReportArray count], 0);
   XCTAssertEqual([self.uploadReportArray count], 1);
@@ -542,7 +559,7 @@
   // drop a phony multipart-mime file in here, with non-zero contents
   XCTAssert([_fileManager createDirectoryAtPath:_fileManager.preparedPath]);
   NSString *path = [_fileManager.preparedPath stringByAppendingPathComponent:@"phony-report"];
-  path = [path stringByAppendingPathExtension:@".multipart-mime"];
+  path = [path stringByAppendingPathExtension:@"multipart-mime"];
 
   XCTAssertTrue([[_fileManager underlyingFileManager]
       createFileAtPath:path

+ 0 - 130
Crashlytics/UnitTests/FIRCLSReportTests.m

@@ -1,130 +0,0 @@
-// Copyright 2019 Google
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//      http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-#import <Foundation/Foundation.h>
-#import <XCTest/XCTest.h>
-
-#import "Crashlytics/Crashlytics/Components/FIRCLSContext.h"
-#import "Crashlytics/Crashlytics/Components/FIRCLSGlobals.h"
-#import "Crashlytics/Crashlytics/Helpers/FIRCLSFile.h"
-#import "Crashlytics/Crashlytics/Models/FIRCLSInternalReport.h"
-#import "Crashlytics/Crashlytics/Models/FIRCLSReport.h"
-#import "Crashlytics/Crashlytics/Models/FIRCLSReport_Private.h"
-
-@interface FIRCLSReportTests : XCTestCase
-
-@end
-
-@implementation FIRCLSReportTests
-
-- (void)setUp {
-  [super setUp];
-
-  FIRCLSContextBaseInit();
-
-  // these values must be set for the internals of logging to work
-  _firclsContext.readonly->logging.userKVStorage.maxCount = 16;
-  _firclsContext.readonly->logging.userKVStorage.maxIncrementalCount = 16;
-  _firclsContext.readonly->logging.internalKVStorage.maxCount = 32;
-  _firclsContext.readonly->logging.internalKVStorage.maxIncrementalCount = 16;
-
-  _firclsContext.readonly->initialized = true;
-}
-
-- (void)tearDown {
-  FIRCLSContextBaseDeinit();
-
-  [super tearDown];
-}
-
-- (NSString *)resourcePath {
-  return [[NSBundle bundleForClass:[self class]] resourcePath];
-}
-
-- (NSString *)pathForResource:(NSString *)name {
-  return [[self resourcePath] stringByAppendingPathComponent:name];
-}
-
-- (FIRCLSInternalReport *)createTempCopyOfInternalReportWithName:(NSString *)name {
-  NSString *tempPath = [NSTemporaryDirectory() stringByAppendingPathComponent:name];
-
-  // make sure to remove anything that was there previously
-  [[NSFileManager defaultManager] removeItemAtPath:tempPath error:nil];
-
-  NSString *resourcePath = [self pathForResource:name];
-
-  [[NSFileManager defaultManager] copyItemAtPath:resourcePath toPath:tempPath error:nil];
-
-  return [[FIRCLSInternalReport alloc] initWithPath:tempPath];
-}
-
-- (FIRCLSReport *)createTempCopyOfReportWithName:(NSString *)name {
-  FIRCLSInternalReport *internalReport = [self createTempCopyOfInternalReportWithName:name];
-
-  return [[FIRCLSReport alloc] initWithInternalReport:internalReport];
-}
-
-#pragma mark - Public Getter Methods
-- (void)testPropertiesFromMetadatFile {
-  FIRCLSReport *report = [self createTempCopyOfReportWithName:@"metadata_only_report"];
-
-  XCTAssertEqualObjects(@"772929a7f21f4ad293bb644668f257cd", report.identifier);
-  XCTAssertEqualObjects(@"3", report.bundleVersion);
-  XCTAssertEqualObjects(@"1.0", report.bundleShortVersionString);
-  XCTAssertEqualObjects([NSDate dateWithTimeIntervalSince1970:1423944888], report.dateCreated);
-  XCTAssertEqualObjects(@"14C109", report.OSBuildVersion);
-  XCTAssertEqualObjects(@"10.10.2", report.OSVersion);
-}
-
-#pragma mark - Public Setter Methods
-- (void)testSetUserProperties {
-  FIRCLSReport *report = [self createTempCopyOfReportWithName:@"metadata_only_report"];
-
-  [report setUserIdentifier:@"12345-6"];
-
-  NSArray *entries = FIRCLSFileReadSections(
-      [[report.internalReport pathForContentFile:FIRCLSReportInternalIncrementalKVFile]
-          fileSystemRepresentation],
-      false, nil);
-
-  XCTAssertEqual([entries count], 1, @"");
-
-  XCTAssertEqualObjects(entries[0][@"kv"][@"key"],
-                        FIRCLSFileHexEncodeString([FIRCLSUserIdentifierKey UTF8String]), @"");
-  XCTAssertEqualObjects(entries[0][@"kv"][@"value"], FIRCLSFileHexEncodeString("12345-6"), @"");
-}
-
-- (void)testSetKeyValuesWhenNoneWerePresent {
-  FIRCLSReport *report = [self createTempCopyOfReportWithName:@"metadata_only_report"];
-
-  [report setObjectValue:@"hello" forKey:@"mykey"];
-  [report setObjectValue:@"goodbye" forKey:@"anotherkey"];
-
-  NSArray *entries = FIRCLSFileReadSections(
-      [[report.internalReport pathForContentFile:FIRCLSReportUserIncrementalKVFile]
-          fileSystemRepresentation],
-      false, nil);
-
-  XCTAssertEqual([entries count], 2, @"");
-
-  // mykey = "..."
-  XCTAssertEqualObjects(entries[0][@"kv"][@"key"], FIRCLSFileHexEncodeString("mykey"), @"");
-  XCTAssertEqualObjects(entries[0][@"kv"][@"value"], FIRCLSFileHexEncodeString("hello"), @"");
-
-  // anotherkey = "..."
-  XCTAssertEqualObjects(entries[1][@"kv"][@"key"], FIRCLSFileHexEncodeString("anotherkey"), @"");
-  XCTAssertEqualObjects(entries[1][@"kv"][@"value"], FIRCLSFileHexEncodeString("goodbye"), @"");
-}
-
-@end

+ 260 - 0
Crashlytics/UnitTests/FIRCrashlyticsReportTests.m

@@ -0,0 +1,260 @@
+// Copyright 2019 Google
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#import <Foundation/Foundation.h>
+#import <XCTest/XCTest.h>
+
+#import "Crashlytics/Crashlytics/Components/FIRCLSContext.h"
+#import "Crashlytics/Crashlytics/Components/FIRCLSGlobals.h"
+#import "Crashlytics/Crashlytics/Helpers/FIRCLSFile.h"
+#import "Crashlytics/Crashlytics/Models/FIRCLSInternalReport.h"
+#import "Crashlytics/Crashlytics/Private/FIRCrashlyticsReport_Private.h"
+#import "Crashlytics/Crashlytics/Public/FirebaseCrashlytics/FIRCrashlyticsReport.h"
+
+@interface FIRCrashlyticsReportTests : XCTestCase
+
+@end
+
+@implementation FIRCrashlyticsReportTests
+
+- (void)setUp {
+  [super setUp];
+
+  FIRCLSContextBaseInit();
+
+  // these values must be set for the internals of logging to work
+  _firclsContext.readonly->logging.userKVStorage.maxCount = 64;
+  _firclsContext.readonly->logging.userKVStorage.maxIncrementalCount =
+      FIRCLSUserLoggingMaxKVEntries;
+  _firclsContext.readonly->logging.internalKVStorage.maxCount = 32;
+  _firclsContext.readonly->logging.internalKVStorage.maxIncrementalCount = 16;
+
+  _firclsContext.readonly->logging.logStorage.maxSize = 64 * 1000;
+  _firclsContext.readonly->logging.logStorage.maxEntries = 0;
+  _firclsContext.readonly->logging.logStorage.restrictBySize = true;
+  _firclsContext.readonly->logging.logStorage.entryCount = NULL;
+
+  _firclsContext.readonly->initialized = true;
+}
+
+- (void)tearDown {
+  FIRCLSContextBaseDeinit();
+
+  [super tearDown];
+}
+
+- (NSString *)resourcePath {
+  return [[NSBundle bundleForClass:[self class]] resourcePath];
+}
+
+- (NSString *)pathForResource:(NSString *)name {
+  return [[self resourcePath] stringByAppendingPathComponent:name];
+}
+
+- (FIRCLSInternalReport *)createTempCopyOfInternalReportWithName:(NSString *)name {
+  NSString *tempPath = [NSTemporaryDirectory() stringByAppendingPathComponent:name];
+
+  // make sure to remove anything that was there previously
+  [[NSFileManager defaultManager] removeItemAtPath:tempPath error:nil];
+
+  NSString *resourcePath = [self pathForResource:name];
+
+  [[NSFileManager defaultManager] copyItemAtPath:resourcePath toPath:tempPath error:nil];
+
+  return [[FIRCLSInternalReport alloc] initWithPath:tempPath];
+}
+
+- (FIRCrashlyticsReport *)createTempCopyOfReportWithName:(NSString *)name {
+  FIRCLSInternalReport *internalReport = [self createTempCopyOfInternalReportWithName:name];
+  return [[FIRCrashlyticsReport alloc] initWithInternalReport:internalReport];
+}
+
+#pragma mark - Public Getter Methods
+- (void)testPropertiesFromMetadatFile {
+  FIRCrashlyticsReport *report = [self createTempCopyOfReportWithName:@"metadata_only_report"];
+
+  XCTAssertEqualObjects(@"772929a7f21f4ad293bb644668f257cd", report.reportID);
+  XCTAssertEqualObjects([NSDate dateWithTimeIntervalSince1970:1423944888], report.dateCreated);
+}
+
+#pragma mark - Public Setter Methods
+- (void)testSetUserID {
+  FIRCrashlyticsReport *report = [self createTempCopyOfReportWithName:@"metadata_only_report"];
+
+  [report setUserID:@"12345-6"];
+
+  NSArray *entries = FIRCLSFileReadSections(
+      [[report.internalReport pathForContentFile:FIRCLSReportInternalIncrementalKVFile]
+          fileSystemRepresentation],
+      false, nil);
+
+  XCTAssertEqual([entries count], 1, @"");
+
+  XCTAssertEqualObjects(entries[0][@"kv"][@"key"],
+                        FIRCLSFileHexEncodeString([FIRCLSUserIdentifierKey UTF8String]), @"");
+  XCTAssertEqualObjects(entries[0][@"kv"][@"value"], FIRCLSFileHexEncodeString("12345-6"), @"");
+}
+
+- (void)testCustomKeysNoExisting {
+  FIRCrashlyticsReport *report = [self createTempCopyOfReportWithName:@"metadata_only_report"];
+
+  [report setCustomValue:@"hello" forKey:@"mykey"];
+  [report setCustomValue:@"goodbye" forKey:@"anotherkey"];
+
+  [report setCustomKeysAndValues:@{
+    @"is_test" : @(YES),
+    @"test_number" : @(10),
+  }];
+
+  NSArray *entries = FIRCLSFileReadSections(
+      [[report.internalReport pathForContentFile:FIRCLSReportUserIncrementalKVFile]
+          fileSystemRepresentation],
+      false, nil);
+
+  XCTAssertEqual([entries count], 4, @"");
+
+  XCTAssertEqualObjects(entries[0][@"kv"][@"key"], FIRCLSFileHexEncodeString("mykey"), @"");
+  XCTAssertEqualObjects(entries[0][@"kv"][@"value"], FIRCLSFileHexEncodeString("hello"), @"");
+
+  XCTAssertEqualObjects(entries[1][@"kv"][@"key"], FIRCLSFileHexEncodeString("anotherkey"), @"");
+  XCTAssertEqualObjects(entries[1][@"kv"][@"value"], FIRCLSFileHexEncodeString("goodbye"), @"");
+
+  XCTAssertEqualObjects(entries[2][@"kv"][@"key"], FIRCLSFileHexEncodeString("is_test"), @"");
+  XCTAssertEqualObjects(entries[2][@"kv"][@"value"], FIRCLSFileHexEncodeString("1"), @"");
+
+  XCTAssertEqualObjects(entries[3][@"kv"][@"key"], FIRCLSFileHexEncodeString("test_number"), @"");
+  XCTAssertEqualObjects(entries[3][@"kv"][@"value"], FIRCLSFileHexEncodeString("10"), @"");
+}
+
+- (void)testCustomKeysWithExisting {
+  FIRCrashlyticsReport *report = [self createTempCopyOfReportWithName:@"ios_all_files_crash"];
+
+  [report setCustomValue:@"hello" forKey:@"mykey"];
+  [report setCustomValue:@"goodbye" forKey:@"anotherkey"];
+
+  [report setCustomKeysAndValues:@{
+    @"is_test" : @(YES),
+    @"test_number" : @(10),
+  }];
+
+  NSArray *entries = FIRCLSFileReadSections(
+      [[report.internalReport pathForContentFile:FIRCLSReportUserIncrementalKVFile]
+          fileSystemRepresentation],
+      false, nil);
+
+  XCTAssertEqual([entries count], 5, @"");
+
+  XCTAssertEqualObjects(entries[1][@"kv"][@"key"], FIRCLSFileHexEncodeString("mykey"), @"");
+  XCTAssertEqualObjects(entries[1][@"kv"][@"value"], FIRCLSFileHexEncodeString("hello"), @"");
+
+  XCTAssertEqualObjects(entries[2][@"kv"][@"key"], FIRCLSFileHexEncodeString("anotherkey"), @"");
+  XCTAssertEqualObjects(entries[2][@"kv"][@"value"], FIRCLSFileHexEncodeString("goodbye"), @"");
+
+  XCTAssertEqualObjects(entries[3][@"kv"][@"key"], FIRCLSFileHexEncodeString("is_test"), @"");
+  XCTAssertEqualObjects(entries[3][@"kv"][@"value"], FIRCLSFileHexEncodeString("1"), @"");
+
+  XCTAssertEqualObjects(entries[4][@"kv"][@"key"], FIRCLSFileHexEncodeString("test_number"), @"");
+  XCTAssertEqualObjects(entries[4][@"kv"][@"value"], FIRCLSFileHexEncodeString("10"), @"");
+}
+
+- (void)testCustomKeysLimits {
+  FIRCrashlyticsReport *report = [self createTempCopyOfReportWithName:@"ios_all_files_crash"];
+
+  // Write a bunch of keys and values
+  for (int i = 0; i < 120; i++) {
+    NSString *key = [NSString stringWithFormat:@"key_%i", i];
+    [report setCustomValue:@"hello" forKey:key];
+  }
+
+  NSArray *entriesI = FIRCLSFileReadSections(
+      [[report.internalReport pathForContentFile:FIRCLSReportUserIncrementalKVFile]
+          fileSystemRepresentation],
+      false, nil);
+  NSArray *entriesC = FIRCLSFileReadSections(
+      [[report.internalReport pathForContentFile:FIRCLSReportUserCompactedKVFile]
+          fileSystemRepresentation],
+      false, nil);
+
+  // One of these should be the max (64), and one should be the number of written keys modulo 64
+  // (eg. 56 == (120 mod 64))
+  XCTAssertEqual(entriesI.count, 56, @"");
+  XCTAssertEqual(entriesC.count, 64, @"");
+}
+
+- (void)testLogsNoExisting {
+  FIRCrashlyticsReport *report = [self createTempCopyOfReportWithName:@"metadata_only_report"];
+
+  [report log:@"Normal log without formatting"];
+  [report logWithFormat:@"%@, %@", @"First", @"Second"];
+
+  NSArray *entries = FIRCLSFileReadSections(
+      [[report.internalReport pathForContentFile:FIRCLSReportLogAFile] fileSystemRepresentation],
+      false, nil);
+
+  XCTAssertEqual([entries count], 2, @"");
+
+  XCTAssertEqualObjects(entries[0][@"log"][@"msg"],
+                        FIRCLSFileHexEncodeString("Normal log without formatting"), @"");
+  XCTAssertEqualObjects(entries[1][@"log"][@"msg"], FIRCLSFileHexEncodeString("First, Second"),
+                        @"");
+}
+
+- (void)testLogsWithExisting {
+  FIRCrashlyticsReport *report = [self createTempCopyOfReportWithName:@"ios_all_files_crash"];
+
+  [report log:@"Normal log without formatting"];
+  [report logWithFormat:@"%@, %@", @"First", @"Second"];
+
+  NSArray *entries = FIRCLSFileReadSections(
+      [[report.internalReport pathForContentFile:FIRCLSReportLogAFile] fileSystemRepresentation],
+      false, nil);
+
+  XCTAssertEqual([entries count], 8, @"");
+
+  XCTAssertEqualObjects(entries[6][@"log"][@"msg"],
+                        FIRCLSFileHexEncodeString("Normal log without formatting"), @"");
+  XCTAssertEqualObjects(entries[7][@"log"][@"msg"], FIRCLSFileHexEncodeString("First, Second"),
+                        @"");
+}
+
+- (void)testLogLimits {
+  FIRCrashlyticsReport *report = [self createTempCopyOfReportWithName:@"metadata_only_report"];
+
+  for (int i = 0; i < 2000; i++) {
+    [report log:@"0123456789"];
+  }
+
+  unsigned long long sizeA = [[[NSFileManager defaultManager]
+      attributesOfItemAtPath:[report.internalReport pathForContentFile:FIRCLSReportLogAFile]
+                       error:nil] fileSize];
+  unsigned long long sizeB = [[[NSFileManager defaultManager]
+      attributesOfItemAtPath:[report.internalReport pathForContentFile:FIRCLSReportLogBFile]
+                       error:nil] fileSize];
+
+  NSArray *entriesA = FIRCLSFileReadSections(
+      [[report.internalReport pathForContentFile:FIRCLSReportLogAFile] fileSystemRepresentation],
+      false, nil);
+  NSArray *entriesB = FIRCLSFileReadSections(
+      [[report.internalReport pathForContentFile:FIRCLSReportLogBFile] fileSystemRepresentation],
+      false, nil);
+
+  // If these numbers have changed, the goal is to validate that the size of log_a and log_b are
+  // under the limit, logStorage.maxSize (64 * 1000). These numbers don't need to be exact so if
+  // they fluctuate then we might just need to accept a range in these tests.
+  XCTAssertEqual(entriesB.count + entriesA.count, 2000, @"");
+  XCTAssertEqual(sizeA, 64 * 1000 + 20, @"");
+  XCTAssertEqual(sizeB, 55980, @"");
+}
+
+@end

+ 72 - 0
run

@@ -0,0 +1,72 @@
+#!/bin/sh
+
+# Copyright 2019 Google
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# run
+#
+# This script is meant to be run as a Run Script in the "Build Phases" section
+# of your Xcode project. It sends debug symbols to symbolicate stacktraces,
+# sends build events to track versions, and onboards apps for Crashlytics.
+#
+# This script calls upload-symbols twice:
+#
+# 1) First it calls upload-symbols synchronously in "validation" mode. If the
+#    script finds issues with the build environment, it will report errors to Xcode.
+#    In validation mode it exits before doing any time consuming work.
+#
+# 2) Then it calls upload-symbols in the background to actually send the build
+#    event and upload symbols. It does this in the background so that it doesn't
+#    slow down your builds. If an error happens here, you won't see it in Xcode.
+#
+# You can find the output for the background execution in Console.app, by
+# searching for "upload-symbols".
+#
+# If you want verbose output, you can pass the --debug flag to this script
+#
+
+#  Figure out where we're being called from
+DIR=$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )
+
+#  Build up the arguments list, passing through any flags added, and quoting
+#  every argument in case there are spaces in any of the the paths.
+ARGUMENTS=''
+for i in "$@"; do
+  ARGUMENTS="$ARGUMENTS \"$i\""
+done
+
+VALIDATE_ARGUMENTS="$ARGUMENTS --build-phase --validate"
+UPLOAD_ARGUMENTS="$ARGUMENTS --build-phase"
+
+# Quote the path to handle folders with special characters
+COMMAND_PATH="\"$DIR/upload-symbols\" "
+
+#  Ensure params are as expected, run in sync mode to validate,
+#  and cause a build error if validation fails
+eval $COMMAND_PATH$VALIDATE_ARGUMENTS
+return_code=$?
+
+if [[ $return_code != 0 ]]; then
+  exit $return_code
+fi
+
+#  Verification passed, convert and upload dSYMs in the background to prevent
+#  build delays
+#
+#  Note: Validation is performed again at this step before upload
+#
+#  Note: Output can still be found in Console.app, by searching for
+#        "upload-symbols"
+#
+eval $COMMAND_PATH$UPLOAD_ARGUMENTS > /dev/null 2>&1 &

BIN
upload-symbols