ソースを参照

[Swift 6] Add Swift 6 testing for Sessions (#14599)

Co-authored-by: Morgan Chen <morganchen12@gmail.com>
Nick Cooke 10 ヶ月 前
コミット
491d0374c2
30 ファイル変更252 行追加219 行削除
  1. 9 0
      .github/workflows/sessions.yml
  2. 1 2
      FirebaseInstallations/Source/Library/Public/FirebaseInstallations/FIRInstallations.h
  3. 6 3
      FirebaseSessions/Sources/ApplicationInfo.swift
  4. 1 1
      FirebaseSessions/Sources/Development/DevEventConsoleLogger.swift
  5. 2 2
      FirebaseSessions/Sources/EventGDTLogger.swift
  6. 28 6
      FirebaseSessions/Sources/FirebaseSessions.swift
  7. 1 1
      FirebaseSessions/Sources/FirebaseSessionsError.swift
  8. 20 5
      FirebaseSessions/Sources/GoogleDataTransport+GoogleDataTransportProtocol.swift
  9. 1 1
      FirebaseSessions/Sources/Installations+InstallationsProtocol.swift
  10. 2 2
      FirebaseSessions/Sources/NetworkInfo.swift
  11. 2 2
      FirebaseSessions/Sources/Public/SessionsSubscriber.swift
  12. 3 2
      FirebaseSessions/Sources/SessionCoordinator.swift
  13. 3 3
      FirebaseSessions/Sources/SessionInitiator.swift
  14. 34 57
      FirebaseSessions/Sources/Settings/RemoteSettings.swift
  15. 44 4
      FirebaseSessions/Sources/Settings/SettingsCacheClient.swift
  16. 6 4
      FirebaseSessions/Sources/Settings/SettingsDownloadClient.swift
  17. 5 5
      FirebaseSessions/Tests/Unit/FirebaseSessionsTests+BaseBehaviors.swift
  18. 4 4
      FirebaseSessions/Tests/Unit/FirebaseSessionsTests+DataCollection.swift
  19. 5 5
      FirebaseSessions/Tests/Unit/FirebaseSessionsTests+Subscribers.swift
  20. 5 5
      FirebaseSessions/Tests/Unit/InitiatorTests.swift
  21. 13 10
      FirebaseSessions/Tests/Unit/Library/FirebaseSessionsTestsBase.swift
  22. 30 82
      FirebaseSessions/Tests/Unit/Library/LifecycleNotifications.swift
  23. 1 1
      FirebaseSessions/Tests/Unit/Mocks/MockApplicationInfo.swift
  24. 2 1
      FirebaseSessions/Tests/Unit/Mocks/MockGDTLogger.swift
  25. 1 1
      FirebaseSessions/Tests/Unit/Mocks/MockInstallationsProtocol.swift
  26. 1 1
      FirebaseSessions/Tests/Unit/Mocks/MockNetworkInfo.swift
  27. 1 1
      FirebaseSessions/Tests/Unit/Mocks/MockSessionCoordinator.swift
  28. 1 1
      FirebaseSessions/Tests/Unit/Mocks/MockSettingsDownloader.swift
  29. 19 7
      FirebaseSessions/Tests/Unit/Mocks/MockSubscriber.swift
  30. 1 0
      FirebaseSessions/Tests/Unit/SessionGeneratorTests.swift

+ 9 - 0
.github/workflows/sessions.yml

@@ -39,10 +39,17 @@ jobs:
           - os: macos-14
             xcode: Xcode_16.2
             tests:
+            swift_version: 5.9
           # Flaky tests on CI
           - os: macos-15
             xcode: Xcode_16.3
             tests: --skip-tests
+            swift_version: 5.9
+          # Flaky tests on CI
+          - os: macos-15
+            xcode: Xcode_16.2
+            tests: --skip-tests
+            swift_version: 6.0
     runs-on: ${{ matrix.build-env.os }}
     steps:
     - uses: actions/checkout@v4
@@ -51,6 +58,8 @@ jobs:
       run: scripts/setup_bundler.sh
     - name: Xcode
       run: sudo xcode-select -s /Applications/${{ matrix.build-env.xcode }}.app/Contents/Developer
+    - name: Set Swift swift_version
+      run: sed -i "" "s/s.swift_version[[:space:]]*=[[:space:]]*'5.9'/s.swift_version = '${{ matrix.build-env.swift_version }}'/" FirebaseSessions.podspec
     - uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 # v3
       with:
         timeout_minutes: 120

+ 1 - 2
FirebaseInstallations/Source/Library/Public/FirebaseInstallations/FIRInstallations.h

@@ -57,8 +57,7 @@ typedef void (^FIRInstallationsTokenHandler)(
  * as the ability to delete it. A Firebase Installation is unique by `FirebaseApp.name` and
  * `FirebaseApp.options.googleAppID` .
  */
-NS_SWIFT_NAME(Installations)
-@interface FIRInstallations : NSObject
+NS_SWIFT_NAME(Installations) NS_SWIFT_SENDABLE @interface FIRInstallations : NSObject
 
 - (instancetype)init NS_UNAVAILABLE;
 

+ 6 - 3
FirebaseSessions/Sources/ApplicationInfo.swift

@@ -34,7 +34,7 @@ enum DevEnvironment: String {
   case autopush // Autopush environment
 }
 
-protocol ApplicationInfoProtocol {
+protocol ApplicationInfoProtocol: Sendable {
   /// Google App ID / GMP App ID
   var appID: String { get }
 
@@ -62,12 +62,15 @@ protocol ApplicationInfoProtocol {
   var osDisplayVersion: String { get }
 }
 
-class ApplicationInfo: ApplicationInfoProtocol {
+final class ApplicationInfo: ApplicationInfoProtocol {
   let appID: String
 
   private let networkInformation: NetworkInfoProtocol
   private let envParams: [String: String]
-  private let infoDict: [String: Any]?
+
+  // Used to hold bundle info, so the `Any` params should also
+  // be Sendable.
+  private nonisolated(unsafe) let infoDict: [String: Any]?
 
   init(appID: String, networkInfo: NetworkInfoProtocol = NetworkInfo(),
        envParams: [String: String] = ProcessInfo.processInfo.environment,

+ 1 - 1
FirebaseSessions/Sources/Development/DevEventConsoleLogger.swift

@@ -19,7 +19,7 @@ import Foundation
   import FirebaseSessionsObjC
 #endif // SWIFT_PACKAGE
 
-class DevEventConsoleLogger: EventGDTLoggerProtocol {
+final class DevEventConsoleLogger: EventGDTLoggerProtocol {
   private let commandLineArgument = "-FIRSessionsDebugEvents"
 
   func logEvent(event: SessionStartEvent, completion: @escaping (Result<Void, Error>) -> Void) {

+ 2 - 2
FirebaseSessions/Sources/EventGDTLogger.swift

@@ -17,7 +17,7 @@ import Foundation
 
 internal import GoogleDataTransport
 
-protocol EventGDTLoggerProtocol {
+protocol EventGDTLoggerProtocol: Sendable {
   func logEvent(event: SessionStartEvent, completion: @escaping (Result<Void, Error>) -> Void)
 }
 
@@ -26,7 +26,7 @@ protocol EventGDTLoggerProtocol {
 ///   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 {
+final class EventGDTLogger: EventGDTLoggerProtocol {
   let googleDataTransport: GoogleDataTransportProtocol
   let devEventConsoleLogger: EventGDTLoggerProtocol
 

+ 28 - 6
FirebaseSessions/Sources/FirebaseSessions.swift

@@ -62,13 +62,13 @@ private enum GoogleDataTransportConfig {
 
   // Initializes the SDK and top-level classes
   required convenience init(appID: String, installations: InstallationsProtocol) {
-    let googleDataTransport = GDTCORTransport(
+    let googleDataTransport = GoogleDataTransporter(
       mappingID: GoogleDataTransportConfig.sessionsLogSource,
       transformers: nil,
       target: GoogleDataTransportConfig.sessionsTarget
     )
 
-    let fireLogger = EventGDTLogger(googleDataTransport: googleDataTransport!)
+    let fireLogger = EventGDTLogger(googleDataTransport: googleDataTransport)
 
     let appInfo = ApplicationInfo(appID: appID)
     let settings = SessionsSettings(
@@ -135,10 +135,10 @@ private enum GoogleDataTransportConfig {
   }
 
   // Initializes the SDK and begins the process of listening for lifecycle events and logging
-  // events
+  // events. `logEventCallback` is invoked on a global background queue.
   init(appID: String, sessionGenerator: SessionGenerator, coordinator: SessionCoordinatorProtocol,
        initiator: SessionInitiator, appInfo: ApplicationInfoProtocol, settings: SettingsProtocol,
-       loggedEventCallback: @escaping (Result<Void, FirebaseSessionsError>) -> Void) {
+       loggedEventCallback: @escaping @Sendable (Result<Void, FirebaseSessionsError>) -> Void) {
     self.appID = appID
 
     self.sessionGenerator = sessionGenerator
@@ -247,18 +247,40 @@ private enum GoogleDataTransportConfig {
     return SessionDetails(sessionId: sessionGenerator.currentSession?.sessionId)
   }
 
+  // This type is not actually sendable, but works around an issue below.
+  // It's safe only if executed on the main actor.
+  private struct MainActorNotificationCallback: @unchecked Sendable {
+    private let callback: (Notification) -> Void
+
+    init(_ callback: @escaping (Notification) -> Void) {
+      self.callback = callback
+    }
+
+    func invoke(notification: Notification) {
+      dispatchPrecondition(condition: .onQueue(.main))
+      callback(notification)
+    }
+  }
+
   func register(subscriber: SessionsSubscriber) {
     Logger
       .logDebug(
         "Registering Sessions SDK subscriber with name: \(subscriber.sessionsSubscriberName), data collection enabled: \(subscriber.isDataCollectionEnabled)"
       )
 
+    // TODO(Firebase 12): After bumping to iOS 13, this hack should be replaced
+    // with `Task { @MainActor in }`.
+    let callback = MainActorNotificationCallback { notification in
+      subscriber.onSessionChanged(self.currentSessionDetails)
+    }
+
+    // Guaranteed to execute its callback on the main queue because of the queue parameter.
     notificationCenter.addObserver(
       forName: Sessions.SessionIDChangedNotificationName,
       object: nil,
-      queue: nil
+      queue: OperationQueue.main
     ) { notification in
-      subscriber.onSessionChanged(self.currentSessionDetails)
+      callback.invoke(notification: notification)
     }
     // Immediately call the callback because the Sessions SDK starts
     // before subscribers, so subscribers will miss the first Notification

+ 1 - 1
FirebaseSessions/Sources/FirebaseSessionsError.swift

@@ -15,7 +15,7 @@
 import Foundation
 
 /// Contains the list of errors that are localized for Firebase Sessions Library
-enum FirebaseSessionsError: Error {
+enum FirebaseSessionsError: Error, Sendable {
   /// Event sampling related error
   case SessionSamplingError
   /// Firebase Installation ID related error

+ 20 - 5
FirebaseSessions/Sources/GoogleDataTransport+GoogleDataTransportProtocol.swift

@@ -15,20 +15,31 @@
 
 import Foundation
 
-internal import GoogleDataTransport
+@preconcurrency internal import GoogleDataTransport
 
 enum GoogleDataTransportProtocolErrors: Error {
   case writeFailure
 }
 
-protocol GoogleDataTransportProtocol {
+protocol GoogleDataTransportProtocol: Sendable {
   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
+/// Workaround in combo with preconcurrency import of GDT. When GDT's
+/// `GDTCORTransport`type conforms to Sendable within the GDT module,
+/// this can be removed.
+final class GoogleDataTransporter: GoogleDataTransportProtocol {
+  private let transporter: GDTCORTransport
+
+  init(mappingID: String,
+       transformers: [any GDTCOREventTransformer]?,
+       target: GDTCORTarget) {
+    transporter = GDTCORTransport(mappingID: mappingID, transformers: transformers, target: target)!
+  }
+
+  func logGDTEvent(event: GDTCOREvent, completion: @escaping (Result<Void, any Error>) -> Void) {
+    transporter.sendDataEvent(event) { wasWritten, error in
       if let error {
         completion(.failure(error))
       } else if !wasWritten {
@@ -38,4 +49,8 @@ extension GDTCORTransport: GoogleDataTransportProtocol {
       }
     }
   }
+
+  func eventForTransport() -> GDTCOREvent {
+    transporter.eventForTransport()
+  }
 }

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

@@ -17,7 +17,7 @@ import Foundation
 
 internal import FirebaseInstallations
 
-protocol InstallationsProtocol {
+protocol InstallationsProtocol: Sendable {
   var installationsWaitTimeInSecond: Int { get }
 
   /// Override Installation function for testing

+ 2 - 2
FirebaseSessions/Sources/NetworkInfo.swift

@@ -25,13 +25,13 @@ import Foundation
   internal import GoogleUtilities
 #endif // SWIFT_PACKAGE
 
-protocol NetworkInfoProtocol {
+protocol NetworkInfoProtocol: Sendable {
   var networkType: GULNetworkType { get }
 
   var mobileSubtype: String { get }
 }
 
-class NetworkInfo: NetworkInfoProtocol {
+final class NetworkInfo: NetworkInfoProtocol {
   var networkType: GULNetworkType {
     return GULNetworkInfo.getNetworkType()
   }

+ 2 - 2
FirebaseSessions/Sources/Public/SessionsSubscriber.swift

@@ -18,7 +18,7 @@ import Foundation
 /// Sessions Subscriber is an interface that dependent SDKs
 /// must implement.
 @objc(FIRSessionsSubscriber)
-public protocol SessionsSubscriber {
+public protocol SessionsSubscriber: Sendable {
   func onSessionChanged(_ session: SessionDetails)
   var isDataCollectionEnabled: Bool { get }
   var sessionsSubscriberName: SessionsSubscriberName { get }
@@ -38,7 +38,7 @@ public class SessionDetails: NSObject {
 
 /// Session Subscriber Names are used for identifying subscribers
 @objc(FIRSessionsSubscriberName)
-public enum SessionsSubscriberName: Int, CustomStringConvertible {
+public enum SessionsSubscriberName: Int, CustomStringConvertible, Sendable {
   case Unknown
   case Crashlytics
   case Performance

+ 3 - 2
FirebaseSessions/Sources/SessionCoordinator.swift

@@ -14,7 +14,7 @@
 
 import Foundation
 
-protocol SessionCoordinatorProtocol {
+protocol SessionCoordinatorProtocol: Sendable {
   func attemptLoggingSessionStart(event: SessionStartEvent,
                                   callback: @escaping (Result<Void, FirebaseSessionsError>) -> Void)
 }
@@ -23,8 +23,9 @@ protocol SessionCoordinatorProtocol {
 /// SessionCoordinator is responsible for coordinating the systems in this SDK
 /// involved with sending a Session Start event.
 ///
-class SessionCoordinator: SessionCoordinatorProtocol {
+final class SessionCoordinator: SessionCoordinatorProtocol {
   let installations: InstallationsProtocol
+
   let fireLogger: EventGDTLoggerProtocol
 
   init(installations: InstallationsProtocol,

+ 3 - 3
FirebaseSessions/Sources/SessionInitiator.swift

@@ -41,9 +41,9 @@ import Foundation
 ///
 class SessionInitiator {
   let currentTime: () -> Date
-  var settings: SettingsProtocol
-  var backgroundTime = Date.distantFuture
-  var initiateSessionStart: () -> Void = {}
+  let settings: SettingsProtocol
+  private var backgroundTime = Date.distantFuture
+  private var initiateSessionStart: () -> Void = {}
 
   init(settings: SettingsProtocol, currentTimeProvider: @escaping () -> Date = Date.init) {
     currentTime = currentTimeProvider

+ 34 - 57
FirebaseSessions/Sources/Settings/RemoteSettings.swift

@@ -14,6 +14,7 @@
 // limitations under the License.
 
 import Foundation
+internal import FirebaseCoreInternal
 
 /// Extends ApplicationInfoProtocol to string-format a combined appDisplayVersion and
 /// appBuildVersion
@@ -21,56 +22,57 @@ extension ApplicationInfoProtocol {
   var synthesizedVersion: String { return "\(appDisplayVersion) (\(appBuildVersion))" }
 }
 
-class RemoteSettings: SettingsProvider {
-  private static let cacheDurationSecondsDefault: TimeInterval = 60 * 60
+final class RemoteSettings: SettingsProvider, Sendable {
   private static let flagSessionsEnabled = "sessions_enabled"
   private static let flagSamplingRate = "sampling_rate"
   private static let flagSessionTimeout = "session_timeout_seconds"
-  private static let flagCacheDuration = "cache_duration"
   private static let flagSessionsCache = "app_quality"
   private let appInfo: ApplicationInfoProtocol
   private let downloader: SettingsDownloadClient
-  private var cache: SettingsCacheClient
-
-  private var cacheDurationSeconds: TimeInterval {
-    guard let duration = cache.cacheContent[RemoteSettings.flagCacheDuration] as? Double else {
-      return RemoteSettings.cacheDurationSecondsDefault
-    }
-    return duration
-  }
+  private let cache: FIRAllocatedUnfairLock<SettingsCacheClient>
 
   private var sessionsCache: [String: Any] {
-    return cache.cacheContent[RemoteSettings.flagSessionsCache] as? [String: Any] ?? [:]
+    cache.withLock { cache in
+      cache.cacheContent[RemoteSettings.flagSessionsCache] as? [String: Any] ?? [:]
+    }
   }
 
   init(appInfo: ApplicationInfoProtocol,
        downloader: SettingsDownloadClient,
        cache: SettingsCacheClient = SettingsCache()) {
     self.appInfo = appInfo
-    self.cache = cache
+    self.cache = FIRAllocatedUnfairLock(initialState: cache)
     self.downloader = downloader
   }
 
   private func fetchAndCacheSettings(currentTime: Date) {
-    // Only fetch if cache is expired, otherwise do nothing
-    guard isCacheExpired(time: currentTime) else {
-      Logger.logDebug("[Settings] Cache is not expired, no fetch will be made.")
-      return
+    let shouldFetch = cache.withLock { cache in
+      // Only fetch if cache is expired, otherwise do nothing
+      guard cache.isExpired(for: appInfo, time: currentTime) else {
+        Logger.logDebug("[Settings] Cache is not expired, no fetch will be made.")
+        return false
+      }
+      return true
     }
 
-    downloader.fetch { result in
-      switch result {
-      case let .success(dictionary):
-        // Saves all newly fetched Settings to cache
-        self.cache.cacheContent = dictionary
-        // Saves a "cache-key" which carries TTL metadata about current cache
-        self.cache.cacheKey = CacheKey(
-          createdAt: currentTime,
-          googleAppID: self.appInfo.appID,
-          appVersion: self.appInfo.synthesizedVersion
-        )
-      case let .failure(error):
-        Logger.logError("[Settings] Fetching newest settings failed with error: \(error)")
+    if shouldFetch {
+      downloader.fetch { result in
+
+        switch result {
+        case let .success(dictionary):
+          self.cache.withLock { cache in
+            // Saves all newly fetched Settings to cache
+            cache.cacheContent = dictionary
+            // Saves a "cache-key" which carries TTL metadata about current cache
+            cache.cacheKey = CacheKey(
+              createdAt: currentTime,
+              googleAppID: self.appInfo.appID,
+              appVersion: self.appInfo.synthesizedVersion
+            )
+          }
+        case let .failure(error):
+          Logger.logError("[Settings] Fetching newest settings failed with error: \(error)")
+        }
       }
     }
   }
@@ -102,33 +104,8 @@ extension RemoteSettingsConfigurations {
   }
 
   func isSettingsStale() -> Bool {
-    return isCacheExpired(time: Date())
-  }
-
-  private func isCacheExpired(time: Date) -> Bool {
-    guard !cache.cacheContent.isEmpty else {
-      cache.removeCache()
-      return true
-    }
-    guard let cacheKey = cache.cacheKey else {
-      Logger.logError("[Settings] Could not load settings cache key")
-      cache.removeCache()
-      return true
-    }
-    guard cacheKey.googleAppID == appInfo.appID else {
-      Logger
-        .logDebug("[Settings] Cache expired because Google App ID changed")
-      cache.removeCache()
-      return true
-    }
-    if time.timeIntervalSince(cacheKey.createdAt) > cacheDurationSeconds {
-      Logger.logDebug("[Settings] Cache TTL expired")
-      return true
-    }
-    if appInfo.synthesizedVersion != cacheKey.appVersion {
-      Logger.logDebug("[Settings] Cache expired because app version changed")
-      return true
+    cache.withLock { cache in
+      cache.isExpired(for: appInfo, time: Date())
     }
-    return false
   }
 }

+ 44 - 4
FirebaseSessions/Sources/Settings/SettingsCacheClient.swift

@@ -15,10 +15,11 @@
 
 import Foundation
 
+// TODO: sendable (remove preconcurrency)
 #if SWIFT_PACKAGE
-  internal import GoogleUtilities_UserDefaults
+  @preconcurrency internal import GoogleUtilities_UserDefaults
 #else
-  internal import GoogleUtilities
+  @preconcurrency internal import GoogleUtilities
 #endif // SWIFT_PACKAGE
 
 /// CacheKey is like a "key" to a "safe". It provides necessary metadata about the current cache to
@@ -30,7 +31,7 @@ struct CacheKey: Codable {
 }
 
 /// SettingsCacheClient is responsible for accessing the cache that Settings are stored in.
-protocol SettingsCacheClient {
+protocol SettingsCacheClient: Sendable {
   /// Returns in-memory cache content in O(1) time. Returns empty dictionary if it does not exist in
   /// cache.
   var cacheContent: [String: Any] { get set }
@@ -39,13 +40,17 @@ protocol SettingsCacheClient {
   var cacheKey: CacheKey? { get set }
   /// Removes all cache content and cache-key
   func removeCache()
+  /// Returns whether the cache is expired for the given app info structure and time.
+  func isExpired(for appInfo: ApplicationInfoProtocol, time: Date) -> Bool
 }
 
 /// SettingsCache uses UserDefaults to store Settings on-disk, but also directly query UserDefaults
 /// when accessing Settings values during run-time. This is because UserDefaults encapsulates both
 /// in-memory and persisted-on-disk storage, allowing fast synchronous access in-app while hiding
 /// away the complexity of managing persistence asynchronously.
-class SettingsCache: SettingsCacheClient {
+final class SettingsCache: SettingsCacheClient {
+  private static let cacheDurationSecondsDefault: TimeInterval = 60 * 60
+  private static let flagCacheDuration = "cache_duration"
   private static let settingsVersion: Int = 1
   private enum UserDefaultsKeys {
     static let forContent = "firebase-sessions-settings"
@@ -92,4 +97,39 @@ class SettingsCache: SettingsCacheClient {
     cache.setObject(nil, forKey: UserDefaultsKeys.forContent)
     cache.setObject(nil, forKey: UserDefaultsKeys.forCacheKey)
   }
+
+  func isExpired(for appInfo: ApplicationInfoProtocol, time: Date) -> Bool {
+    guard !cacheContent.isEmpty else {
+      removeCache()
+      return true
+    }
+    guard let cacheKey = cacheKey else {
+      Logger.logError("[Settings] Could not load settings cache key")
+      removeCache()
+      return true
+    }
+    guard cacheKey.googleAppID == appInfo.appID else {
+      Logger
+        .logDebug("[Settings] Cache expired because Google App ID changed")
+      removeCache()
+      return true
+    }
+    if time.timeIntervalSince(cacheKey.createdAt) > cacheDuration() {
+      Logger.logDebug("[Settings] Cache TTL expired")
+      return true
+    }
+    if appInfo.synthesizedVersion != cacheKey.appVersion {
+      Logger.logDebug("[Settings] Cache expired because app version changed")
+      return true
+    }
+    return false
+  }
+
+  private func cacheDuration() -> TimeInterval {
+    guard let duration = cacheContent[Self.flagCacheDuration] as? Double else {
+      return Self.cacheDurationSecondsDefault
+    }
+    print("Duration: \(duration)")
+    return duration
+  }
 }

+ 6 - 4
FirebaseSessions/Sources/Settings/SettingsDownloadClient.swift

@@ -21,8 +21,9 @@ import Foundation
   internal import GoogleUtilities
 #endif // SWIFT_PACKAGE
 
-protocol SettingsDownloadClient {
-  func fetch(completion: @escaping (Result<[String: Any], SettingsDownloaderError>) -> Void)
+protocol SettingsDownloadClient: Sendable {
+  func fetch(completion: @Sendable @escaping (Result<[String: Any], SettingsDownloaderError>)
+    -> Void)
 }
 
 enum SettingsDownloaderError: Error {
@@ -36,7 +37,7 @@ enum SettingsDownloaderError: Error {
   case InstallationIDError(String)
 }
 
-class SettingsDownloader: SettingsDownloadClient {
+final class SettingsDownloader: SettingsDownloadClient {
   private let appInfo: ApplicationInfoProtocol
   private let installations: InstallationsProtocol
 
@@ -45,7 +46,8 @@ class SettingsDownloader: SettingsDownloadClient {
     self.installations = installations
   }
 
-  func fetch(completion: @escaping (Result<[String: Any], SettingsDownloaderError>) -> Void) {
+  func fetch(completion: @Sendable @escaping (Result<[String: Any], SettingsDownloaderError>)
+    -> Void) {
     guard let validURL = url else {
       completion(.failure(.URLError("Invalid URL")))
       return

+ 5 - 5
FirebaseSessions/Tests/Unit/FirebaseSessionsTests+BaseBehaviors.swift

@@ -24,7 +24,7 @@ import XCTest
 final class FirebaseSessionsTestsBase_BaseBehaviors: FirebaseSessionsTestsBase {
   // MARK: - Test Settings & Sampling
 
-  func test_settingsDisabled_doesNotLogSessionEventButDoesFetchSettings() {
+  @MainActor func test_settingsDisabled_doesNotLogSessionEventButDoesFetchSettings() {
     runSessionsSDK(
       subscriberSDKs: [
         mockPerformanceSubscriber,
@@ -49,7 +49,7 @@ final class FirebaseSessionsTestsBase_BaseBehaviors: FirebaseSessionsTestsBase {
     )
   }
 
-  func test_sessionSampled_doesNotLogSessionEventButDoesFetchSettings() {
+  @MainActor func test_sessionSampled_doesNotLogSessionEventButDoesFetchSettings() {
     runSessionsSDK(
       subscriberSDKs: [
         mockPerformanceSubscriber,
@@ -87,7 +87,7 @@ final class FirebaseSessionsTestsBase_BaseBehaviors: FirebaseSessionsTestsBase {
     // We wanted to make sure that since we've introduced promises,
     // once the promise has been fulfilled, that .then'ing on the promise
     // in future initiations still results in a log
-    func test_multipleInitiations_logsSessionEventEachInitiation() {
+    @MainActor func test_multipleInitiations_logsSessionEventEachInitiation() {
       var loggedCount = 0
       var lastLoggedSessionID = ""
       let loggedTwiceExpectation = expectation(description: "Sessions SDK logged events twice")
@@ -128,9 +128,9 @@ final class FirebaseSessionsTestsBase_BaseBehaviors: FirebaseSessionsTestsBase {
             // then bring the app to the foreground to generate another session.
             //
             // This postLogEvent callback will be called again after this
-            self.postBackgroundedNotification()
+            postBackgroundedNotification()
             self.pausedClock.addTimeInterval(30 * 60 + 1)
-            self.postForegroundedNotification()
+            postForegroundedNotification()
 
           } else {
             loggedTwiceExpectation.fulfill()

+ 4 - 4
FirebaseSessions/Tests/Unit/FirebaseSessionsTests+DataCollection.swift

@@ -81,7 +81,7 @@ final class FirebaseSessionsTestsBase_DataCollection: FirebaseSessionsTestsBase
 
   // MARK: - Test Data Collection
 
-  func test_subscriberWithDataCollectionEnabled_logsSessionEvent() {
+  @MainActor func test_subscriberWithDataCollectionEnabled_logsSessionEvent() {
     runSessionsSDK(
       subscriberSDKs: [
         mockCrashlyticsSubscriber,
@@ -105,7 +105,7 @@ final class FirebaseSessionsTestsBase_DataCollection: FirebaseSessionsTestsBase
     )
   }
 
-  func test_subscribersSomeDataCollectionDisabled_logsSessionEvent() {
+  @MainActor func test_subscribersSomeDataCollectionDisabled_logsSessionEvent() {
     runSessionsSDK(
       subscriberSDKs: [
         mockCrashlyticsSubscriber,
@@ -132,7 +132,7 @@ final class FirebaseSessionsTestsBase_DataCollection: FirebaseSessionsTestsBase
     )
   }
 
-  func test_subscribersAllDataCollectionDisabled_doesNotLogSessionEvent() {
+  @MainActor func test_subscribersAllDataCollectionDisabled_doesNotLogSessionEvent() {
     runSessionsSDK(
       subscriberSDKs: [
         mockCrashlyticsSubscriber,
@@ -159,7 +159,7 @@ final class FirebaseSessionsTestsBase_DataCollection: FirebaseSessionsTestsBase
     )
   }
 
-  func test_defaultSamplingRate_isSetInProto() {
+  @MainActor func test_defaultSamplingRate_isSetInProto() {
     runSessionsSDK(
       subscriberSDKs: [
         mockCrashlyticsSubscriber,

+ 5 - 5
FirebaseSessions/Tests/Unit/FirebaseSessionsTests+Subscribers.swift

@@ -25,7 +25,7 @@ final class FirebaseSessionsTestsBase_Subscribers: FirebaseSessionsTestsBase {
   // Check that the Session ID that was passed to the Subscriber SDK
   // matches the Session ID that the Sessions SDK logged, and ensure
   // both are not empty.
-  func assertValidChangedSessionID() {
+  @MainActor func assertValidChangedSessionID() {
     let expectedSessionID = sessions.currentSessionDetails.sessionId
     XCTAssert(expectedSessionID!.count > 0)
     for mock in [mockCrashlyticsSubscriber, mockPerformanceSubscriber] {
@@ -37,7 +37,7 @@ final class FirebaseSessionsTestsBase_Subscribers: FirebaseSessionsTestsBase {
 
   // MARK: - Test Subscriber Callbacks
 
-  func test_registerSubscriber_callsOnSessionChanged() {
+  @MainActor func test_registerSubscriber_callsOnSessionChanged() {
     runSessionsSDK(
       subscriberSDKs: [
         mockCrashlyticsSubscriber,
@@ -61,7 +61,7 @@ final class FirebaseSessionsTestsBase_Subscribers: FirebaseSessionsTestsBase {
   // Make sure that even if the Sessions SDK is disabled, and data collection
   // is disabled, the Sessions SDK still generates Session IDs and provides
   // them to Subscribers
-  func test_subscribersDataCollectionDisabled_callsOnSessionChanged() {
+  @MainActor func test_subscribersDataCollectionDisabled_callsOnSessionChanged() {
     runSessionsSDK(
       subscriberSDKs: [
         mockCrashlyticsSubscriber,
@@ -86,7 +86,7 @@ final class FirebaseSessionsTestsBase_Subscribers: FirebaseSessionsTestsBase {
     )
   }
 
-  func test_noDependencies_doesNotLogSessionEvent() {
+  @MainActor func test_noDependencies_doesNotLogSessionEvent() {
     runSessionsSDK(
       subscriberSDKs: [],
       preSessionsInit: { _ in
@@ -102,7 +102,7 @@ final class FirebaseSessionsTestsBase_Subscribers: FirebaseSessionsTestsBase {
     )
   }
 
-  func test_noSubscribersWithRegistrations_doesNotCrash() {
+  @MainActor func test_noSubscribersWithRegistrations_doesNotCrash() {
     runSessionsSDK(
       subscriberSDKs: [],
       preSessionsInit: { _ in

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

@@ -58,7 +58,7 @@ class InitiatorTests: XCTestCase {
     XCTAssert(initiateCalled)
   }
 
-  func test_appForegrounded_initiatesNewSession() throws {
+  func test_appForegrounded_initiatesNewSession() async throws {
     // Given
     var pausedClock = date
     let initiator = SessionInitiator(
@@ -73,18 +73,18 @@ class InitiatorTests: XCTestCase {
 
     // When
     // Background, advance time by 30 minutes + 1 second, then foreground
-    postBackgroundedNotification()
+    await postBackgroundedNotification()
     pausedClock.addTimeInterval(30 * 60 + 1)
-    postForegroundedNotification()
+    await postForegroundedNotification()
     // Then
     // Session count increases because time spent in background > 30 minutes
     XCTAssert(sessionCount == 2)
 
     // When
     // Background, advance time by exactly 30 minutes, then foreground
-    postBackgroundedNotification()
+    await postBackgroundedNotification()
     pausedClock.addTimeInterval(30 * 60)
-    postForegroundedNotification()
+    await postForegroundedNotification()
     // Then
     // Session count doesn't increase because time spent in background <= 30 minutes
     XCTAssert(sessionCount == 2)

+ 13 - 10
FirebaseSessions/Tests/Unit/Library/FirebaseSessionsTestsBase.swift

@@ -71,11 +71,13 @@ class FirebaseSessionsTestsBase: XCTestCase {
   /// is a good place for Subscribers to call register on the Sessions SDK
   ///  - `postLogEvent` is called whenever an event is logged via the Sessions SDK. This is where
   /// most assertions will happen.
-  func runSessionsSDK(subscriberSDKs: [SessionsSubscriber],
-                      preSessionsInit: (MockSettingsProtocol) -> Void,
-                      postSessionsInit: () -> Void,
-                      postLogEvent: @escaping (Result<Void, FirebaseSessionsError>,
-                                               [SessionsSubscriber]) -> Void) {
+  @MainActor func runSessionsSDK(subscriberSDKs: [SessionsSubscriber],
+                                 preSessionsInit: (MockSettingsProtocol) -> Void,
+                                 postSessionsInit: () -> Void,
+                                 postLogEvent: @escaping @MainActor (Result<Void,
+                                   FirebaseSessionsError>,
+                                 [SessionsSubscriber])
+                                   -> Void) {
     // This class is static, so we need to clear global state
     SessionsDependencies.removeAll()
 
@@ -109,12 +111,13 @@ class FirebaseSessionsTestsBase: XCTestCase {
                         initiator: initiator,
                         appInfo: mockAppInfo,
                         settings: mockSettings) { result in
+      DispatchQueue.main.async {
+        // Provide the result for tests to test against
+        postLogEvent(result, subscriberSDKs)
 
-      // Provide the result for tests to test against
-      postLogEvent(result, subscriberSDKs)
-
-      // Fulfil the expectation so the test can continue
-      loggedEventExpectation.fulfill()
+        // Fulfil the expectation so the test can continue
+        loggedEventExpectation.fulfill()
+      }
     }
 
     // Execute test cases after Sessions is initialized. This is a good

+ 30 - 82
FirebaseSessions/Tests/Unit/Library/LifecycleNotifications.swift

@@ -17,7 +17,7 @@ import XCTest
 
 import Dispatch
 
-#if os(iOS) || os(tvOS)
+#if os(iOS) || os(tvOS) || os(visionOS)
   import UIKit
 #elseif os(macOS)
   import AppKit
@@ -26,88 +26,36 @@ import Dispatch
   import WatchKit
 #endif // os(iOS) || os(tvOS)
 
-// swift(>=5.9) implies Xcode 15+
-// Need to have this Swift version check to use os(visionOS) macro, VisionOS support.
-// TODO: Remove this check and add `os(visionOS)` to the `os(iOS) || os(tvOS)` conditional above
-// when Xcode 15 is the minimum supported by Firebase.
-#if swift(>=5.9)
-  #if os(visionOS)
-    import UIKit
-  #endif // os(visionOS)
-#endif // swift(>=5.9)
-
-extension XCTestCase {
-  func postBackgroundedNotification() {
-    // On Catalyst, the notifications can only be called on a the main thread
-    if Thread.isMainThread {
-      postBackgroundedNotificationInternal()
-    } else {
-      DispatchQueue.main.sync {
-        self.postBackgroundedNotificationInternal()
-      }
+@MainActor func postBackgroundedNotification() {
+  // On Catalyst, the notifications can only be called on the main thread
+  let notificationCenter = NotificationCenter.default
+  #if os(iOS) || os(tvOS) || os(visionOS)
+    notificationCenter.post(name: UIApplication.didEnterBackgroundNotification, object: nil)
+  #elseif os(macOS)
+    notificationCenter.post(name: NSApplication.didResignActiveNotification, object: nil)
+  #elseif os(watchOS)
+    if #available(watchOSApplicationExtension 7.0, *) {
+      notificationCenter.post(
+        name: WKExtension.applicationDidEnterBackgroundNotification,
+        object: nil
+      )
     }
-  }
-
-  private func postBackgroundedNotificationInternal() {
-    let notificationCenter = NotificationCenter.default
-    #if os(iOS) || os(tvOS)
-      notificationCenter.post(name: UIApplication.didEnterBackgroundNotification, object: nil)
-    #elseif os(macOS)
-      notificationCenter.post(name: NSApplication.didResignActiveNotification, object: nil)
-    #elseif os(watchOS)
-      if #available(watchOSApplicationExtension 7.0, *) {
-        notificationCenter.post(
-          name: WKExtension.applicationDidEnterBackgroundNotification,
-          object: nil
-        )
-      }
-    #endif // os(iOS) || os(tvOS)
-
-    // swift(>=5.9) implies Xcode 15+
-    // Need to have this Swift version check to use os(visionOS) macro, VisionOS support.
-    // TODO: Remove this check and add `os(visionOS)` to the `os(iOS) || os(tvOS)` conditional above
-    // when Xcode 15 is the minimum supported by Firebase.
-    #if swift(>=5.9)
-      #if os(visionOS)
-        notificationCenter.post(name: UIApplication.didEnterBackgroundNotification, object: nil)
-      #endif // os(visionOS)
-    #endif // swift(>=5.9)
-  }
+  #endif // os(iOS) || os(tvOS)
+}
 
-  func postForegroundedNotification() {
-    // On Catalyst, the notifications can only be called on a the main thread
-    if Thread.isMainThread {
-      postForegroundedNotificationInternal()
-    } else {
-      DispatchQueue.main.sync {
-        self.postForegroundedNotificationInternal()
-      }
+@MainActor func postForegroundedNotification() {
+  // On Catalyst, the notifications can only be called on a the main thread
+  let notificationCenter = NotificationCenter.default
+  #if os(iOS) || os(tvOS) || os(visionOS)
+    notificationCenter.post(name: UIApplication.didBecomeActiveNotification, object: nil)
+  #elseif os(macOS)
+    notificationCenter.post(name: NSApplication.didBecomeActiveNotification, object: nil)
+  #elseif os(watchOS)
+    if #available(watchOSApplicationExtension 7.0, *) {
+      notificationCenter.post(
+        name: WKExtension.applicationDidBecomeActiveNotification,
+        object: nil
+      )
     }
-  }
-
-  private func postForegroundedNotificationInternal() {
-    let notificationCenter = NotificationCenter.default
-    #if os(iOS) || os(tvOS)
-      notificationCenter.post(name: UIApplication.didBecomeActiveNotification, object: nil)
-    #elseif os(macOS)
-      notificationCenter.post(name: NSApplication.didBecomeActiveNotification, object: nil)
-    #elseif os(watchOS)
-      if #available(watchOSApplicationExtension 7.0, *) {
-        notificationCenter.post(
-          name: WKExtension.applicationDidBecomeActiveNotification,
-          object: nil
-        )
-      }
-    #endif // os(iOS) || os(tvOS)
-
-    // swift(>=5.9) implies Xcode 15+
-    // Need to have this Swift version check to use os(visionOS) macro, VisionOS support.
-    // TODO: Remove this check and add `os(visionOS)` to the `os(iOS) || os(tvOS)` conditional above
-    // when Xcode 15 is the minimum supported by Firebase.
-    #if swift(>=5.9)
-      #if os(visionOS)
-        notificationCenter.post(name: UIApplication.didBecomeActiveNotification, object: nil)
-      #endif // os(visionOS)
-    #endif // swift(>=5.9)
-  }
+  #endif // os(iOS) || os(tvOS)
 }

+ 1 - 1
FirebaseSessions/Tests/Unit/Mocks/MockApplicationInfo.swift

@@ -23,7 +23,7 @@ import Foundation
 
 @testable import FirebaseSessions
 
-class MockApplicationInfo: ApplicationInfoProtocol {
+class MockApplicationInfo: ApplicationInfoProtocol, @unchecked Sendable {
   var appID: String = ""
 
   var bundleID: String = ""

+ 2 - 1
FirebaseSessions/Tests/Unit/Mocks/MockGDTLogger.swift

@@ -17,7 +17,8 @@ import Foundation
 
 @testable import FirebaseSessions
 
-class MockGDTLogger: EventGDTLoggerProtocol {
+// TODO(Swift 6): Add checked Sendable support.
+final class MockGDTLogger: EventGDTLoggerProtocol, @unchecked Sendable {
   var loggedEvent: SessionStartEvent?
   var result: Result<Void, Error> = .success(())
 

+ 1 - 1
FirebaseSessions/Tests/Unit/Mocks/MockInstallationsProtocol.swift

@@ -17,7 +17,7 @@ internal import FirebaseInstallations
 
 @testable import FirebaseSessions
 
-class MockInstallationsProtocol: InstallationsProtocol {
+class MockInstallationsProtocol: InstallationsProtocol, @unchecked Sendable {
   static let testInstallationId = "testInstallationId"
   static let testAuthToken = "testAuthToken"
   var result: Result<(String, String), Error> = .success((testInstallationId, testAuthToken))

+ 1 - 1
FirebaseSessions/Tests/Unit/Mocks/MockNetworkInfo.swift

@@ -23,7 +23,7 @@ import Foundation
 
 @testable import FirebaseSessions
 
-class MockNetworkInfo: NetworkInfoProtocol {
+class MockNetworkInfo: NetworkInfoProtocol, @unchecked Sendable {
   var mobileCountryCode: String?
   var mobileNetworkCode: String?
   var networkType: GULNetworkType = .WIFI

+ 1 - 1
FirebaseSessions/Tests/Unit/Mocks/MockSessionCoordinator.swift

@@ -16,7 +16,7 @@
 @testable import FirebaseSessions
 import XCTest
 
-class MockSessionCoordinator: SessionCoordinatorProtocol {
+class MockSessionCoordinator: SessionCoordinatorProtocol, @unchecked Sendable {
   var loggedEvent: FirebaseSessions.SessionStartEvent?
 
   func attemptLoggingSessionStart(event: FirebaseSessions.SessionStartEvent,

+ 1 - 1
FirebaseSessions/Tests/Unit/Mocks/MockSettingsDownloader.swift

@@ -17,7 +17,7 @@ import Foundation
 
 @testable import FirebaseSessions
 
-class MockSettingsDownloader: SettingsDownloadClient {
+class MockSettingsDownloader: SettingsDownloadClient, @unchecked Sendable {
   public var shouldSucceed: Bool = true
   public var successResponse: [String: Any]
 

+ 19 - 7
FirebaseSessions/Tests/Unit/Mocks/MockSubscriber.swift

@@ -13,21 +13,33 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
+import FirebaseCoreInternal
 @testable import FirebaseSessions
 import Foundation
 
-final class MockSubscriber: SessionsSubscriber {
-  var sessionThatChanged: FirebaseSessions.SessionDetails?
+final class MockSubscriber: SessionsSubscriber, Sendable {
+  let sessionsSubscriberName: FirebaseSessions.SessionsSubscriberName
+
+  var sessionThatChanged: FirebaseSessions.SessionDetails? {
+    get { _sessionThatChanged.value() }
+    set { _sessionThatChanged.withLock { $0 = newValue } }
+  }
+
+  var isDataCollectionEnabled: Bool {
+    get { _isDataCollectionEnabled.value() }
+    set { _isDataCollectionEnabled.withLock { $0 = newValue } }
+  }
+
+  private let _sessionThatChanged = FIRAllocatedUnfairLock<FirebaseSessions.SessionDetails?>(
+    initialState: nil
+  )
+  private let _isDataCollectionEnabled = FIRAllocatedUnfairLock<Bool>(initialState: true)
 
   init(name: SessionsSubscriberName) {
     sessionsSubscriberName = name
   }
 
   func onSessionChanged(_ session: FirebaseSessions.SessionDetails) {
-    sessionThatChanged = session
+    _sessionThatChanged.withLock { $0 = session }
   }
-
-  var isDataCollectionEnabled: Bool = true
-
-  var sessionsSubscriberName: FirebaseSessions.SessionsSubscriberName
 }

+ 1 - 0
FirebaseSessions/Tests/Unit/SessionGeneratorTests.swift

@@ -53,6 +53,7 @@ class SessionGeneratorTests: XCTestCase {
       localOverrides: localOverrideSettings,
       remoteSettings: remoteSettings
     )
+
     generator = SessionGenerator(collectEvents: Sessions
       .shouldCollectEvents(settings: sessionSettings))
   }