Bladeren bron

[Sessions] Add SessionStartEvent wrapper for building the nanopb proto (#10311)

Sam Edson 3 jaren geleden
bovenliggende
commit
abf29403d9

+ 8 - 2
FirebaseSessions.podspec

@@ -35,16 +35,22 @@ Pod::Spec.new do |s|
 
   base_dir = "FirebaseSessions/"
   s.source_files = [
-    base_dir + 'Sources/**/*.swift',
+    base_dir + 'Sources/**/*.{swift,h,m}',
+    base_dir + 'Protogen/**/*.{c,h,m,mm}',
   ]
 
   s.dependency 'FirebaseCore', '~> 10.0'
   s.dependency 'FirebaseCoreExtension', '~> 10.0'
   s.dependency 'FirebaseInstallations', '~> 10.0'
+  s.dependency 'GoogleDataTransport', '~> 9.2'
+  s.dependency 'nanopb', '>= 2.30908.0', '< 2.30910.0'
 
   s.pod_target_xcconfig = {
     'GCC_C_LANGUAGE_STANDARD' => 'c99',
-    'HEADER_SEARCH_PATHS' => '"${PODS_TARGET_SRCROOT}"'
+    'HEADER_SEARCH_PATHS' => '"${PODS_TARGET_SRCROOT}"',
+    'GCC_PREPROCESSOR_DEFINITIONS' =>
+      # For nanopb:
+      'PB_FIELD_32BIT=1 PB_NO_PACKED_STRUCTS=1 PB_ENABLE_MALLOC=1',
   }
 
   s.test_spec 'unit' do |unit_tests|

+ 49 - 0
FirebaseSessions/Sources/EventGDTLogger.swift

@@ -0,0 +1,49 @@
+//
+// Copyright 2022 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
+
+@_implementationOnly import GoogleDataTransport
+
+protocol EventGDTLoggerProtocol {
+  func logEvent(event: SessionStartEvent, completion: @escaping (Result<Void, Error>) -> Void)
+}
+
+///
+/// EventGDTLogger is responsible for
+///   1) Creating GDT Events and logging them to the GoogleDataTransport SDK
+///   2) Handling debugging situations (eg. running in Simulator or printing the event to console)
+///
+class EventGDTLogger: EventGDTLoggerProtocol {
+  let googleDataTransport: GoogleDataTransportProtocol
+
+  init(googleDataTransport: GoogleDataTransportProtocol) {
+    self.googleDataTransport = googleDataTransport
+  }
+
+  /// Logs the event to FireLog, taking into account debugging cases such as running
+  /// in simulator.
+  func logEvent(event: SessionStartEvent, completion: @escaping (Result<Void, Error>) -> Void) {
+    let gdtEvent = googleDataTransport.eventForTransport()
+    gdtEvent.dataObject = event
+    gdtEvent.qosTier = GDTCOREventQoS.qosDefault
+    #if targetEnvironment(simulator)
+      Logger.logDebug("Logging events using fast QOS due to running on a simulator")
+      gdtEvent.qosTier = GDTCOREventQoS.qoSFast
+    #endif // targetEnvironment(simulator)
+
+    googleDataTransport.logGDTEvent(event: gdtEvent, completion: completion)
+  }
+}

+ 23 - 5
FirebaseSessions/Sources/FirebaseSessions.swift

@@ -14,11 +14,15 @@
 
 import Foundation
 
-import FirebaseCore
-import FirebaseInstallations
-
 // Avoids exposing internal FirebaseCore APIs to Swift users.
 @_implementationOnly import FirebaseCoreExtension
+@_implementationOnly import FirebaseInstallations
+@_implementationOnly import GoogleDataTransport
+
+private enum GoogleDataTransportConfig {
+  static let sessionsLogSource = "1974"
+  static let sessionsTarget = GDTCORTarget.FLL
+}
 
 @objc(FIRSessionsProvider)
 protocol SessionsProvider {
@@ -38,9 +42,18 @@ protocol SessionsProvider {
 
   // MARK: - Initializers
 
+  // Initializes the SDK and top-level classes
   required convenience init(appID: String, installations: InstallationsProtocol) {
+    let googleDataTransport = GDTCORTransport(
+      mappingID: GoogleDataTransportConfig.sessionsLogSource,
+      transformers: nil,
+      target: GoogleDataTransportConfig.sessionsTarget
+    )
+
+    let fireLogger = EventGDTLogger(googleDataTransport: googleDataTransport!)
+
     let identifiers = Identifiers(installations: installations)
-    let coordinator = SessionCoordinator(identifiers: identifiers)
+    let coordinator = SessionCoordinator(identifiers: identifiers, fireLogger: fireLogger)
     let initiator = SessionInitiator()
 
     self.init(appID: appID,
@@ -49,6 +62,7 @@ protocol SessionsProvider {
               initiator: initiator)
   }
 
+  // Initializes the SDK and begines the process of listening for lifecycle events and logging events
   init(appID: String, identifiers: Identifiers, coordinator: SessionCoordinator,
        initiator: SessionInitiator) {
     self.appID = appID
@@ -61,7 +75,11 @@ protocol SessionsProvider {
 
     self.initiator.beginListening {
       self.identifiers.generateNewSessionID()
-      self.coordinator.runMain()
+      let event = SessionStartEvent(identifiers: self.identifiers)
+      DispatchQueue.global().async {
+        self.coordinator.attemptLoggingSessionStart(event: event) { result in
+        }
+      }
     }
   }
 

+ 41 - 0
FirebaseSessions/Sources/GoogleDataTransport+GoogleDataTransportProtocol.swift

@@ -0,0 +1,41 @@
+//
+// Copyright 2022 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
+
+@_implementationOnly import GoogleDataTransport
+
+enum GoogleDataTransportProtocolErrors: Error {
+  case writeFailure
+}
+
+protocol GoogleDataTransportProtocol {
+  func logGDTEvent(event: GDTCOREvent, completion: @escaping (Result<Void, Error>) -> Void)
+  func eventForTransport() -> GDTCOREvent
+}
+
+extension GDTCORTransport: GoogleDataTransportProtocol {
+  func logGDTEvent(event: GDTCOREvent, completion: @escaping (Result<Void, Error>) -> Void) {
+    sendDataEvent(event) { wasWritten, error in
+      if let error = error {
+        completion(.failure(error))
+      } else if !wasWritten {
+        completion(.failure(GoogleDataTransportProtocolErrors.writeFailure))
+      } else {
+        completion(.success(()))
+      }
+    }
+  }
+}

+ 11 - 8
FirebaseSessions/Sources/Identifiers.swift

@@ -14,7 +14,8 @@
 // limitations under the License.
 
 import Foundation
-import FirebaseInstallations
+
+@_implementationOnly import FirebaseInstallations
 
 let sessionIDUserDefaultsKey = "com.firebase.sessions.sessionID"
 let lastSessionIDUserDefaultsKey = "com.firebase.sessions.lastSessionID"
@@ -28,7 +29,7 @@ protocol IdentifierProvider {
     get
   }
 
-  var lastSessionID: String {
+  var previousSessionID: String {
     get
   }
 }
@@ -43,24 +44,26 @@ protocol IdentifierProvider {
 class Identifiers: IdentifierProvider {
   private let installations: InstallationsProtocol
 
-  private var uuid: UUID
+  private var _sessionID: UUID
 
   init(installations: InstallationsProtocol) {
     self.installations = installations
-    uuid = UUID()
+    _sessionID = UUID()
   }
 
+  // Generates a new Session ID. If there was already a generated Session ID
+  // from the last session during the app's lifecycle, it will also set the last Session ID
   func generateNewSessionID() {
-    uuid = UUID()
+    _sessionID = UUID()
 
     let lastStoredSessionID = UserDefaults.standard.string(forKey: sessionIDUserDefaultsKey) ?? ""
     UserDefaults.standard.set(lastStoredSessionID, forKey: lastSessionIDUserDefaultsKey)
 
-    let newSessionID = uuid.uuidString.replacingOccurrences(of: "-", with: "").lowercased()
+    let newSessionID = _sessionID.uuidString.replacingOccurrences(of: "-", with: "").lowercased()
     UserDefaults.standard.set(newSessionID, forKey: sessionIDUserDefaultsKey)
   }
 
-  // This method must be run on a background thread due to how Firebase Installations
+  // Fetches the Installation ID from Firebase Installation Service. This method must be run on a background thread due to how Firebase Installations
   // handles threading.
   var installationID: String {
     if Thread.isMainThread {
@@ -103,7 +106,7 @@ class Identifiers: IdentifierProvider {
     return UserDefaults.standard.string(forKey: sessionIDUserDefaultsKey) ?? ""
   }
 
-  var lastSessionID: String {
+  var previousSessionID: String {
     return UserDefaults.standard.string(forKey: lastSessionIDUserDefaultsKey) ?? ""
   }
 }

+ 2 - 1
FirebaseSessions/Sources/Installations+InstallationsProtocol.swift

@@ -14,7 +14,8 @@
 // limitations under the License.
 
 import Foundation
-import FirebaseInstallations
+
+@_implementationOnly import FirebaseInstallations
 
 protocol InstallationsProtocol {
   func installationID(completion: @escaping (Result<String, Error>) -> Void)

+ 3 - 0
FirebaseSessions/Sources/Logger.swift

@@ -17,6 +17,9 @@ import Foundation
 
 @_implementationOnly import FirebaseCoreExtension
 
+///
+/// Logger is responsible for printing console logs
+///
 enum Logger {
   private static let logServiceTag = "[FirebaseSessions]"
   private static let logCode = "I-SES000000"

+ 62 - 0
FirebaseSessions/Sources/NanoPB/FIRSESNanoPBHelpers.h

@@ -0,0 +1,62 @@
+//
+// Copyright 2022 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.
+
+#ifndef FIRSESNanoPBHelpers_h
+#define FIRSESNanoPBHelpers_h
+
+#import <nanopb/pb.h>
+#import <nanopb/pb_decode.h>
+#import <nanopb/pb_encode.h>
+
+NS_ASSUME_NONNULL_BEGIN
+
+// It seems impossible to specify the nullability of the `fields` parameter below,
+// yet the compiler complains that it's missing a nullability specifier. Google
+// yields no results at this time.
+#pragma clang diagnostic push
+#pragma clang diagnostic ignored "-Wnullability-completeness"
+NSData* _Nullable FIRSESEncodeProto(const pb_field_t fields[],
+                                    const void* _Nonnull proto,
+                                    NSError** error);
+#pragma clang diagnostic pop
+
+/// Mallocs a pb_bytes_array and copies the given NSData bytes into the bytes array.
+/// @note Memory needs to be freed manually, through pb_free or pb_release.
+/// @param data The data to copy into the new bytes array.
+pb_bytes_array_t* _Nullable FIRSESEncodeData(NSData* _Nullable data);
+
+/// Mallocs a pb_bytes_array and copies the given NSString's bytes into the bytes array.
+/// @note Memory needs to be freed manually, through pb_free or pb_release.
+/// @param string The string to encode as pb_bytes.
+pb_bytes_array_t* _Nullable FIRSESEncodeString(NSString* _Nullable string);
+
+/// Checks if 2 nanopb arrays are equal
+/// @param array array to check
+/// @param expected expected value of the array
+BOOL FIRSESIsPBArrayEqual(pb_bytes_array_t* _Nullable array, pb_bytes_array_t* _Nullable expected);
+
+/// Checks if a nanopb string is equal to an NSString
+/// @param pbString nanopb string to check
+/// @param str NSString that's expected
+BOOL FIRSESIsPBStringEqual(pb_bytes_array_t* _Nullable pbString, NSString* _Nullable str);
+
+/// Checks if a nanopb array is equal to NSData
+/// @param pbArray nanopb array to check
+/// @param data NSData that's expected
+BOOL FIRSESIsPBDataEqual(pb_bytes_array_t* _Nullable pbArray, NSData* _Nullable data);
+
+NS_ASSUME_NONNULL_END
+
+#endif /* FIRSESNanoPBHelpers_h */

+ 129 - 0
FirebaseSessions/Sources/NanoPB/FIRSESNanoPBHelpers.m

@@ -0,0 +1,129 @@
+//
+// Copyright 2022 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>
+
+#import "FirebaseSessions/Sources/NanoPB/FIRSESNanoPBHelpers.m"
+
+#import <nanopb/pb.h>
+#import <nanopb/pb_decode.h>
+#import <nanopb/pb_encode.h>
+
+NS_ASSUME_NONNULL_BEGIN
+
+NSError *FIRSESMakeEncodeError(NSString *description) {
+  return [NSError errorWithDomain:@"FIRSESEncodeError"
+                             code:-1
+                         userInfo:@{@"NSLocalizedDescriptionKey" : description}];
+}
+
+// It seems impossible to specify the nullability of the `fields` parameter below,
+// yet the compiler complains that it's missing a nullability specifier. Google
+// yields no results at this time.
+#pragma clang diagnostic push
+#pragma clang diagnostic ignored "-Wnullability-completeness"
+NSData *_Nullable FIRSESEncodeProto(const pb_field_t fields[],
+                                    const void *_Nonnull proto,
+                                    NSError **error) {
+  pb_ostream_t sizestream = PB_OSTREAM_SIZING;
+
+  // Encode 1 time to determine the size.
+  if (!pb_encode(&sizestream, fields, proto)) {
+    NSString *errorString = [NSString
+        stringWithFormat:@"Error in nanopb encoding to get size: %s", PB_GET_ERROR(&sizestream)];
+    if (error != NULL) {
+      *error = FIRSESMakeEncodeError(errorString);
+    }
+    return nil;
+  }
+
+  // Encode a 2nd time to actually get the bytes from it.
+  size_t bufferSize = sizestream.bytes_written;
+  CFMutableDataRef dataRef = CFDataCreateMutable(CFAllocatorGetDefault(), bufferSize);
+  CFDataSetLength(dataRef, bufferSize);
+  pb_ostream_t ostream = pb_ostream_from_buffer((void *)CFDataGetBytePtr(dataRef), bufferSize);
+  if (!pb_encode(&ostream, fields, proto)) {
+    NSString *errorString =
+        [NSString stringWithFormat:@"Error in nanopb encoding: %s", PB_GET_ERROR(&sizestream)];
+    if (error != NULL) {
+      *error = FIRSESMakeEncodeError(errorString);
+    }
+    CFBridgingRelease(dataRef);
+    return nil;
+  }
+
+  return CFBridgingRelease(dataRef);
+}
+#pragma clang diagnostic pop
+
+/** Mallocs a pb_bytes_array and copies the given NSData bytes into the bytes array.
+ * @note Memory needs to be free manually, through pb_free or pb_release.
+ * @param data The data to copy into the new bytes array.
+ */
+pb_bytes_array_t *_Nullable FIRSESEncodeData(NSData *_Nullable data) {
+  pb_bytes_array_t *pbBytes = malloc(PB_BYTES_ARRAY_T_ALLOCSIZE(data.length));
+  if (pbBytes == NULL) {
+    return NULL;
+  }
+  memcpy(pbBytes->bytes, [data bytes], data.length);
+  pbBytes->size = (pb_size_t)data.length;
+  return pbBytes;
+}
+
+/** Mallocs a pb_bytes_array and copies the given NSString's bytes into the bytes array.
+ * @note Memory needs to be freed manually, through pb_free or pb_release.
+ * @param string The string to encode as pb_bytes.
+ */
+pb_bytes_array_t *_Nullable FIRSESEncodeString(NSString *_Nullable string) {
+  if ([string isMemberOfClass:[NSNull class]]) {
+    string = nil;
+  }
+  NSString *stringToEncode = string ? string : @"";
+  NSData *stringBytes = [stringToEncode dataUsingEncoding:NSUTF8StringEncoding];
+  return FIRSESEncodeData(stringBytes);
+}
+
+BOOL FIRSESIsPBArrayEqual(pb_bytes_array_t *_Nullable array, pb_bytes_array_t *_Nullable expected) {
+  // Treat the empty string as the same as a missing field
+  if (array == nil) {
+    return expected->size == 0;
+  }
+
+  if (array->size != expected->size) {
+    return false;
+  }
+
+  for (int i = 0; i < array->size; i++) {
+    if (expected->bytes[i] != array->bytes[i]) {
+      return false;
+    }
+  }
+
+  return true;
+}
+
+BOOL FIRSESIsPBStringEqual(pb_bytes_array_t *_Nullable pbString, NSString *_Nullable str) {
+  pb_bytes_array_t *expected = FIRSESEncodeString(str);
+  return FIRSESIsPBArrayEqual(pbString, expected);
+}
+
+BOOL FIRSESIsPBDataEqual(pb_bytes_array_t *_Nullable pbArray, NSData *_Nullable data) {
+  pb_bytes_array_t *expected = FIRSESEncodeData(data);
+  BOOL equal = FIRSESIsPBArrayEqual(pbArray, expected);
+  free(expected);
+  return equal;
+}
+
+NS_ASSUME_NONNULL_END

+ 22 - 4
FirebaseSessions/Sources/SessionCoordinator.swift

@@ -19,13 +19,31 @@ import Foundation
 /// involved with sending a Session Start event.
 ///
 class SessionCoordinator {
-  private let identifiers: IdentifierProvider
+  let identifiers: IdentifierProvider
+  let fireLogger: EventGDTLoggerProtocol
 
-  init(identifiers: IdentifierProvider) {
+  init(identifiers: IdentifierProvider, fireLogger: EventGDTLoggerProtocol) {
     self.identifiers = identifiers
+    self.fireLogger = fireLogger
   }
 
-  func runMain() {
-    // TODO:
+  // Begins the process of logging a SessionStartEvent to FireLog, while taking into account Data Collection, Sampling, and fetching Settings
+  func attemptLoggingSessionStart(event: SessionStartEvent,
+                                  callback: @escaping (Result<Void, Error>) -> Void) {
+    event.setInstallationID(identifiers: identifiers)
+
+    fireLogger.logEvent(event: event) { result in
+      switch result {
+      case .success():
+        Logger.logInfo("Successfully logged Session Start event to GoogleDataTransport")
+        callback(.success(()))
+      case let .failure(error):
+        Logger
+          .logError(
+            "Error logging Session Start event to GoogleDataTransport: \(error)."
+          )
+        callback(.failure(error))
+      }
+    }
   }
 }

+ 62 - 0
FirebaseSessions/Sources/SessionStartEvent.swift

@@ -0,0 +1,62 @@
+//
+// Copyright 2022 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
+
+@_implementationOnly import GoogleDataTransport
+
+///
+/// SessionStartEvent is responsible for:
+///   1) Writing fields to the Session proto
+///   2) Synthesizing itself for persisting to disk and logging to GoogleDataTransport
+///
+class SessionStartEvent: NSObject, GDTCOREventDataObject {
+  var proto: firebase_appquality_sessions_SessionEvent
+
+  init(identifiers: IdentifierProvider, time: TimeProvider = Time()) {
+    proto = firebase_appquality_sessions_SessionEvent()
+
+    super.init()
+
+    proto.event_type = firebase_appquality_sessions_EventType_EVENT_SESSION_START
+    proto.session_data.session_id = makeProtoString(identifiers.sessionID)
+    proto.session_data.previous_session_id = makeProtoString(identifiers.previousSessionID)
+    proto.session_data.event_timestamp_us = time.timestampUS
+  }
+
+  func setInstallationID(identifiers: IdentifierProvider) {
+    proto.session_data.firebase_installation_id = makeProtoString(identifiers.installationID)
+  }
+
+  private func makeProtoString(_ string: String) -> UnsafeMutablePointer<pb_bytes_array_t>? {
+    return FIRSESEncodeString(string)
+  }
+
+  // MARK: - GDTCOREventDataObject
+
+  func transportBytes() -> Data {
+    var fields = firebase_appquality_sessions_SessionEvent_fields
+    var error: NSError?
+    let data = FIRSESEncodeProto(&fields.0, &proto, &error)
+    if error != nil {
+      Logger.logError(error.debugDescription)
+    }
+    guard let data = data else {
+      Logger.logError("Session event generated nil transportBytes. Returning empty data.")
+      return Data()
+    }
+    return data
+  }
+}

+ 30 - 0
FirebaseSessions/Sources/Time.swift

@@ -0,0 +1,30 @@
+//
+// Copyright 2022 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
+
+protocol TimeProvider {
+  var timestampUS: Int64 { get }
+}
+
+///
+/// Time is provides timestamp values in different formats to classes in the Sessions SDK. It mainly exists for testing purposes.
+///
+class Time: TimeProvider {
+  // Returns the current time as a timestamp in microseconds
+  var timestampUS: Int64 {
+    return Int64(UInt64(Date().timeIntervalSince1970) * USEC_PER_SEC)
+  }
+}

+ 20 - 19
FirebaseSessions/Tests/Unit/IdentifiersTests.swift

@@ -18,11 +18,11 @@ import XCTest
 @testable import FirebaseSessions
 @testable import FirebaseInstallations
 
-var installations = MockInstallationsProtocol()
-var identifiers = Identifiers(installations: installations)
-
 class IdentifiersTests: XCTestCase {
-  override func setUpWithError() throws {
+  var installations: MockInstallationsProtocol!
+  var identifiers: Identifiers!
+
+  override func setUp() {
     // Clear all UserDefaults
     if let appDomain = Bundle.main.bundleIdentifier {
       UserDefaults.standard.removePersistentDomain(forName: appDomain)
@@ -48,52 +48,53 @@ class IdentifiersTests: XCTestCase {
     return true
   }
 
-  func testInitialSessionIDGeneration() throws {
+  func test_generateNewSessionID_generatesValidID() throws {
     identifiers.generateNewSessionID()
-    assert(isValidSessionID(identifiers.sessionID))
-    assert(identifiers.lastSessionID.count == 0)
+    XCTAssert(isValidSessionID(identifiers.sessionID))
+    XCTAssert(identifiers.previousSessionID.count == 0)
   }
 
-  func testRotateSessionID() throws {
+  /// Ensures that generating a Session ID multiple times results in the last Session ID being set in the previousSessionID field
+  func test_generateNewSessionID_rotatesPreviousID() throws {
     identifiers.generateNewSessionID()
 
     let firstSessionID = identifiers.sessionID
-    assert(isValidSessionID(identifiers.sessionID))
-    assert(identifiers.lastSessionID.count == 0)
+    XCTAssert(isValidSessionID(identifiers.sessionID))
+    XCTAssert(identifiers.previousSessionID.count == 0)
 
     identifiers.generateNewSessionID()
 
-    assert(isValidSessionID(identifiers.sessionID))
-    assert(isValidSessionID(identifiers.lastSessionID))
+    XCTAssert(isValidSessionID(identifiers.sessionID))
+    XCTAssert(isValidSessionID(identifiers.previousSessionID))
 
     // Ensure the new lastSessionID is equal to the sessionID from earlier
-    assert(identifiers.lastSessionID.compare(firstSessionID) == ComparisonResult.orderedSame)
+    XCTAssert(identifiers.previousSessionID.compare(firstSessionID) == ComparisonResult.orderedSame)
   }
 
   // Fetching FIIDs requires that we are on a background thread.
-  func testSuccessfulFIID() throws {
+  func test_installationID_getsValidID() throws {
     // Make our mock return an ID
     let testID = "testID"
     installations.result = .success(testID)
 
     let expectation = XCTestExpectation(description: "Get the Installation ID Asynchronously")
 
-    DispatchQueue.global().async {
-      XCTAssertEqual(identifiers.installationID, testID)
+    DispatchQueue.global().async { [self] in
+      XCTAssertEqual(self.identifiers.installationID, testID)
       expectation.fulfill()
     }
 
     wait(for: [expectation], timeout: 1.0)
   }
 
-  func testFailedFIID() throws {
+  func test_installationID_handlesFailedFetch() throws {
     // Make our mock return an error
     installations.result = .failure(NSError(domain: "FestFailedFIIDErrorDomain", code: 0))
 
     let expectation = XCTestExpectation(description: "Get the Installation ID Asynchronously")
 
-    DispatchQueue.global().async {
-      XCTAssertEqual(identifiers.installationID, "")
+    DispatchQueue.global().async { [self] in
+      XCTAssertEqual(self.identifiers.installationID, "")
       expectation.fulfill()
     }
 

+ 1 - 1
FirebaseSessions/Tests/Unit/InitiatorTests.swift

@@ -16,7 +16,7 @@ import XCTest
 @testable import FirebaseSessions
 
 class InitiatorTests: XCTestCase {
-  func testColdStart() throws {
+  func test_beginListening_initiatesColdStart() throws {
     let initiator = SessionInitiator()
     var initiateCalled = false
     initiator.beginListening {

+ 28 - 0
FirebaseSessions/Tests/Unit/Mocks/MockGDTLogger.swift

@@ -0,0 +1,28 @@
+//
+// Copyright 2022 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
+
+@testable import FirebaseSessions
+
+class MockGDTLogger: EventGDTLoggerProtocol {
+  var loggedEvent: SessionStartEvent?
+  var result: Result<Void, Error> = .success(())
+
+  func logEvent(event: SessionStartEvent, completion: @escaping (Result<Void, Error>) -> Void) {
+    loggedEvent = event
+    completion(result)
+  }
+}

+ 36 - 0
FirebaseSessions/Tests/Unit/Mocks/MockIdentifierProvider.swift

@@ -0,0 +1,36 @@
+//
+// Copyright 2022 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
+
+@testable import FirebaseSessions
+
+class MockIdentifierProvider: IdentifierProvider {
+  var sessionID: String = ""
+
+  var previousSessionID: String = ""
+
+  var installationID: String = ""
+
+  static let testSessionID = "testSessionID"
+  static let testPreviousSessionID = "testPreviousSessionID"
+  static let testInstallationID = "testInstallationID"
+
+  func mockAllValidIDs() {
+    sessionID = MockIdentifierProvider.testSessionID
+    previousSessionID = MockIdentifierProvider.testPreviousSessionID
+    installationID = MockIdentifierProvider.testInstallationID
+  }
+}

+ 22 - 0
FirebaseSessions/Tests/Unit/Mocks/MockTimeProvider.swift

@@ -0,0 +1,22 @@
+//
+// Copyright 2022 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
+
+@testable import FirebaseSessions
+
+class MockTimeProvider: TimeProvider {
+  var timestampUS: Int64 = 123
+}

+ 86 - 0
FirebaseSessions/Tests/Unit/SessionCoordinatorTests.swift

@@ -0,0 +1,86 @@
+//
+// Copyright 2022 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 XCTest
+
+@testable import FirebaseSessions
+
+class SessionCoordinatorTests: XCTestCase {
+  var identifiers = MockIdentifierProvider()
+  var time = MockTimeProvider()
+  var fireLogger = MockGDTLogger()
+
+  var coordinator: SessionCoordinator!
+
+  override func setUp() {
+    super.setUp()
+
+    coordinator = SessionCoordinator(identifiers: identifiers, fireLogger: fireLogger)
+  }
+
+  func test_attemptLoggingSessionStart_logsToGDT() throws {
+    identifiers.mockAllValidIDs()
+
+    let event = SessionStartEvent(identifiers: identifiers, time: time)
+    var resultSuccess = false
+    coordinator.attemptLoggingSessionStart(event: event) { result in
+      switch result {
+      case .success(()):
+        resultSuccess = true
+      case .failure:
+        resultSuccess = false
+      }
+    }
+    // Make sure we've set the Installation ID
+    assertEqualProtoString(
+      event.proto.session_data.firebase_installation_id,
+      expected: MockIdentifierProvider.testInstallationID,
+      fieldName: "installation_id"
+    )
+
+    // We should have logged successfully
+    XCTAssertEqual(fireLogger.loggedEvent, event)
+    XCTAssert(resultSuccess)
+  }
+
+  func test_attemptLoggingSessionStart_handlesGDTError() throws {
+    identifiers.mockAllValidIDs()
+    fireLogger.result = .failure(NSError(domain: "TestError", code: -1))
+
+    let event = SessionStartEvent(identifiers: identifiers, time: time)
+
+    // Start success so it must be set to false
+    var resultSuccess = true
+    coordinator.attemptLoggingSessionStart(event: event) { result in
+      switch result {
+      case .success(()):
+        resultSuccess = true
+      case .failure:
+        resultSuccess = false
+      }
+    }
+
+    // Make sure we've set the Installation ID
+    assertEqualProtoString(
+      event.proto.session_data.firebase_installation_id,
+      expected: MockIdentifierProvider.testInstallationID,
+      fieldName: "installation_id"
+    )
+
+    // We should have logged the event, but with a failed result
+    XCTAssertEqual(fireLogger.loggedEvent, event)
+    XCTAssertFalse(resultSuccess)
+  }
+}

+ 60 - 0
FirebaseSessions/Tests/Unit/SessionStartEventTests.swift

@@ -0,0 +1,60 @@
+//
+// Copyright 2022 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 XCTest
+
+@testable import FirebaseSessions
+
+class SessionStartEventTests: XCTestCase {
+  var identifiers: MockIdentifierProvider!
+  var time: MockTimeProvider!
+
+  override func setUp() {
+    super.setUp()
+
+    identifiers = MockIdentifierProvider()
+    time = MockTimeProvider()
+  }
+
+  func test_init_setsSessionIDs() {
+    identifiers.mockAllValidIDs()
+
+    let event = SessionStartEvent(identifiers: identifiers, time: time)
+    assertEqualProtoString(
+      event.proto.session_data.session_id,
+      expected: MockIdentifierProvider.testSessionID,
+      fieldName: "session_id"
+    )
+    assertEqualProtoString(
+      event.proto.session_data.previous_session_id,
+      expected: MockIdentifierProvider.testPreviousSessionID,
+      fieldName: "previous_session_id"
+    )
+
+    XCTAssertEqual(event.proto.session_data.event_timestamp_us, 123)
+  }
+
+  func test_setInstallationID_setsInstallationID() {
+    identifiers.mockAllValidIDs()
+
+    let event = SessionStartEvent(identifiers: identifiers, time: time)
+    event.setInstallationID(identifiers: identifiers)
+    assertEqualProtoString(
+      event.proto.session_data.firebase_installation_id,
+      expected: MockIdentifierProvider.testInstallationID,
+      fieldName: "firebase_installation_id"
+    )
+  }
+}

+ 41 - 0
FirebaseSessions/Tests/Unit/TestNanoPBHelpers.swift

@@ -0,0 +1,41 @@
+//
+// Copyright 2022 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 XCTest
+
+@testable import FirebaseSessions
+
+// The file and line fields in this function capture the file and line of the
+// function call site in the test, and pass them to XCTAssert. Without this, the
+// test will attribute the test failure to this function instead of the call in the test.
+func assertEqualProtoString(_ value: UnsafeMutablePointer<pb_bytes_array_t>!, expected: String,
+                            fieldName: String, file: StaticString = #filePath,
+                            line: UInt = #line) {
+  if value == nil {
+    XCTAssert(
+      false,
+      "Field \(fieldName) is nil, but we expected \"\(expected)\"",
+      file: file,
+      line: line
+    )
+  } else {
+    XCTAssert(
+      FIRSESIsPBStringEqual(value, expected),
+      "Field \(fieldName) does not match expected value \"\(expected)\"",
+      file: file,
+      line: line
+    )
+  }
+}