Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion FirebaseSessions.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,6 @@ Pod::Spec.new do |s|
s.dependency 'GoogleUtilities/Environment', '>= 8.1.3', '< 9.0'
s.dependency 'GoogleUtilities/UserDefaults', '>= 8.1.3', '< 9.0'
s.dependency 'nanopb', '~> 3.30910.0'
s.dependency 'PromisesSwift', '>= 2.4.1', '< 3.0'

s.pod_target_xcconfig = {
'HEADER_SEARCH_PATHS' => '"${PODS_TARGET_SRCROOT}"',
Expand Down
145 changes: 70 additions & 75 deletions FirebaseSessions/Sources/FirebaseSessions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,20 +20,12 @@ internal import FirebaseCoreExtension
internal import FirebaseInstallations
internal import GoogleDataTransport

#if swift(>=6.0)
internal import Promises
#elseif swift(>=5.10)
import Promises
#else
internal import Promises
#endif

private enum GoogleDataTransportConfig {
static let sessionsLogSource = "1974"
static let sessionsTarget = GDTCORTarget.FLL
}

@objc(FIRSessions) final class Sessions: NSObject, Library, SessionsProvider {
@objc(FIRSessions) final class Sessions: NSObject, Library, SessionsProvider, @unchecked Sendable {
// MARK: - Private Variables

/// The Firebase App ID associated with Sessions.
Expand All @@ -46,13 +38,12 @@ private enum GoogleDataTransportConfig {
private let appInfo: ApplicationInfoProtocol
private let settings: SettingsProtocol

/// Subscribers
/// `subscribers` are used to determine the Data Collection state of the Sessions SDK.
/// If any Subscribers has Data Collection enabled, the Sessions SDK will send events
private var subscribers: [SessionsSubscriber] = []
/// `subscriberPromises` are used to wait until all Subscribers have registered
/// themselves. Subscribers must have Data Collection state available upon registering.
private var subscriberPromises: [SessionsSubscriberName: Promise<Void>] = [:]
/// `state` holds the mutable state (subscribers array and registration)
/// ensuring mathematical safety in Swift Concurrency.
private let state: SessionsState

/// Queue for callbacks
private let loggedEventCallbackQueue: DispatchQueue

/// Notifications
static let SessionIDChangedNotificationName = Notification
Expand Down Expand Up @@ -91,7 +82,8 @@ private enum GoogleDataTransportConfig {
coordinator: coordinator,
initiator: initiator,
appInfo: appInfo,
settings: settings) { result in
settings: settings,
loggedEventCallbackQueue: .global(qos: .background)) { result in
switch result {
case .success(()):
Logger.logInfo("Successfully logged Session Start event")
Expand Down Expand Up @@ -150,13 +142,12 @@ private enum GoogleDataTransportConfig {
self.initiator = initiator
self.appInfo = appInfo
self.settings = settings

super.init()
self.loggedEventCallbackQueue = loggedEventCallbackQueue

let dependencies = SessionsDependencies.dependencies
for subscriberName in dependencies {
subscriberPromises[subscriberName] = Promise<Void>.pending()
}
state = SessionsState(expectedSubscribers: dependencies)

super.init()

Logger
.logDebug(
Expand All @@ -176,42 +167,55 @@ private enum GoogleDataTransportConfig {

// If there are no Dependencies, then the Sessions SDK can't acknowledge
// any products data collection state, so the Sessions SDK won't send events.
guard !self.subscriberPromises.isEmpty else {
loggedEventCallback(.failure(.NoDependenciesError))
guard !self.state.expectedSubscribers.isEmpty else {
self.loggedEventCallbackQueue.async {
loggedEventCallback(.failure(.NoDependenciesError))
}
return
}

// Wait until all subscriber promises have been fulfilled before
// Wait until all expected subscribers have registered before
// doing any data collection.
all(self.subscriberPromises.values).then(on: loggedEventCallbackQueue) { _ in
guard self.isAnyDataCollectionEnabled else {
loggedEventCallback(.failure(.DataCollectionError))
return
}

Logger.logDebug("Data Collection is enabled for at least one Subscriber")

// Fetch settings if they have expired. This must happen after the check for
// data collection because it uses the network, but it must happen before the
// check for sessionsEnabled from Settings because otherwise we would permanently
// turn off the Sessions SDK when we disabled it.
self.settings.updateSettings()

self.addSubscriberFields(event: event)
event.setSamplingRate(samplingRate: self.settings.samplingRate)

guard sessionInfo.shouldDispatchEvents else {
loggedEventCallback(.failure(.SessionSamplingError))
return
}

guard self.settings.sessionsEnabled else {
loggedEventCallback(.failure(.DisabledViaSettingsError))
return
}

self.coordinator.attemptLoggingSessionStart(event: event) { result in
loggedEventCallback(result)
Task {
let subscribers = await self.state.waitUntilAllRegistered()

self.loggedEventCallbackQueue.async {
let isAnyDataCollectionEnabled = subscribers.contains { $0.isDataCollectionEnabled }
guard isAnyDataCollectionEnabled else {
loggedEventCallback(.failure(.DataCollectionError))
return
}

Logger.logDebug("Data Collection is enabled for at least one Subscriber")

// Fetch settings if they have expired. This must happen after the check for
// data collection because it uses the network, but it must happen before the
// check for sessionsEnabled from Settings because otherwise we would permanently
// turn off the Sessions SDK when we disabled it.
self.settings.updateSettings()
let samplingRate = self.settings.samplingRate
let sessionsEnabled = self.settings.sessionsEnabled

for subscriber in subscribers {
event.set(subscriber: subscriber.sessionsSubscriberName,
isDataCollectionEnabled: subscriber.isDataCollectionEnabled,
appInfo: self.appInfo)
}
event.setSamplingRate(samplingRate: samplingRate)

guard sessionInfo.shouldDispatchEvents else {
loggedEventCallback(.failure(.SessionSamplingError))
return
}

guard sessionsEnabled else {
loggedEventCallback(.failure(.DisabledViaSettingsError))
return
}

self.coordinator.attemptLoggingSessionStart(event: event) { result in
loggedEventCallback(result)
}
}
}
}
Expand All @@ -228,23 +232,6 @@ private enum GoogleDataTransportConfig {

// MARK: - Data Collection

var isAnyDataCollectionEnabled: Bool {
for subscriber in subscribers {
if subscriber.isDataCollectionEnabled {
return true
}
}
return false
}

func addSubscriberFields(event: SessionStartEvent) {
for subscriber in subscribers {
event.set(subscriber: subscriber.sessionsSubscriberName,
isDataCollectionEnabled: subscriber.isDataCollectionEnabled,
appInfo: appInfo)
}
}

// MARK: - SessionsProvider

var currentSessionDetails: SessionDetails {
Expand All @@ -266,7 +253,7 @@ private enum GoogleDataTransportConfig {
}
}

func register(subscriber: SessionsSubscriber) {
@objc(registerWithSubscriber:) func register(subscriber: SessionsSubscriber) {
Logger
.logDebug(
"Registering Sessions SDK subscriber with name: \(subscriber.sessionsSubscriberName), data collection enabled: \(subscriber.isDataCollectionEnabled)"
Expand All @@ -290,9 +277,17 @@ private enum GoogleDataTransportConfig {
// before subscribers, so subscribers will miss the first Notification
subscriber.onSessionChanged(currentSessionDetails)

// Fulfil this subscriber's promise
subscribers.append(subscriber)
subscriberPromises[subscriber.sessionsSubscriberName]?.fulfill(())
// Register this subscriber to resume any waiting tasks.
//
// Unlike the previous promise-based implementation, this hop is
// asynchronous: returning from `register(subscriber:)` does not guarantee
// the subscriber is visible to an in-flight session start. That is safe
// because a session start awaits `waitUntilAllRegistered()`, which only
// proceeds once this `Task` has run for every expected subscriber.
let subscriberName = subscriber.sessionsSubscriberName
Task {
await state.register(subscriber: subscriber, name: subscriberName)
}
}

// MARK: - Library conformance
Expand Down
2 changes: 1 addition & 1 deletion FirebaseSessions/Sources/Public/SessionsProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,5 @@ import Foundation
// interface for other 1P SDKs to talk to.
@objc(FIRSessionsProvider)
public protocol SessionsProvider {
@objc func register(subscriber: SessionsSubscriber)
@objc(registerWithSubscriber:) func register(subscriber: SessionsSubscriber)
}
12 changes: 11 additions & 1 deletion FirebaseSessions/Sources/SessionStartEvent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,17 @@ internal import GoogleDataTransport
/// 1) Writing fields to the Session proto
/// 2) Synthesizing itself for persisting to disk and logging to GoogleDataTransport
///
class SessionStartEvent: NSObject, GDTCOREventDataObject {
/// - Note: This type is `@unchecked Sendable` rather than genuinely thread safe.
/// It wraps a mutable nanopb struct holding manually managed pointers that are
/// freed in `deinit`, so concurrent mutation would corrupt memory. The safety
/// invariant is that an event instance is only ever *handed off* between
/// executors, never shared: it is created on the initiator's thread, mutated
/// on a single callback queue, and then handed to the coordinator, which
/// serializes its own writes. Do not retain an event across those stages or
/// mutate it from more than one context.
/// TODO: Make this checked `Sendable` by making the proto writes internally
/// synchronized, or by modeling the event as a value type.
class SessionStartEvent: NSObject, GDTCOREventDataObject, @unchecked Sendable {
var proto: firebase_appquality_sessions_SessionEvent

init(sessionInfo: SessionInfo, appInfo: ApplicationInfoProtocol,
Expand Down
89 changes: 89 additions & 0 deletions FirebaseSessions/Sources/SessionsState.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// Copyright 2026 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

/// An internal actor that protects mutable state for the Sessions SDK.
///
/// This replaces the previous `FBLPromise`-based registration barrier. It acts
/// as a *latching gate*: once every expected subscriber has registered, the
/// gate stays open for the remaining lifetime of the process, so session
/// starts after the first one resolve without suspending. That mirrors the old
/// behavior, where the per-subscriber promises stayed fulfilled once resolved.
///
/// If an expected subscriber never registers, waiters are never resumed and no
/// event is sent. This is also the pre-existing behavior of the unfulfilled
/// promises.
actor SessionsState {
private var subscribers: [SessionsSubscriber] = []
private var registeredSubscribers: Set<SessionsSubscriberName> = []
nonisolated let expectedSubscribers: Set<SessionsSubscriberName>
private var continuations: [CheckedContinuation<Void, Never>] = []

init(expectedSubscribers: Set<SessionsSubscriberName>) {
self.expectedSubscribers = expectedSubscribers
}

/// Records a subscriber and opens the gate once all expected subscribers
/// have registered.
///
/// Registration is idempotent per subscriber *name*. Subscribers that were
/// never declared as dependencies are still tracked (so they contribute to
/// the data collection check) but cannot open the gate on their own.
///
/// This method is deliberately non-`async`: it contains no suspension
/// points, so the actor runs it to completion. The de-duplication check, the
/// append, and the continuation resume therefore cannot interleave with
/// another `register` or `waitUntilAllRegistered` call.
func register(subscriber: SessionsSubscriber, name: SessionsSubscriberName) {
guard !registeredSubscribers.contains(name) else { return }
subscribers.append(subscriber)
registeredSubscribers.insert(name)
if registeredSubscribers.isSuperset(of: expectedSubscribers) {
for continuation in continuations {
continuation.resume()
}
continuations.removeAll()
}
}

/// Suspends until every expected subscriber has registered, then returns a
/// snapshot of the registered subscribers.
///
/// The snapshot is returned from here rather than read through a separate
/// accessor so that the session-start path only needs a single actor hop.
/// As before, the snapshot reflects the state *after* the gate opens, so a
/// subscriber that registers late is still included.
func waitUntilAllRegistered() async -> [SessionsSubscriber] {
if expectedSubscribers.isEmpty || registeredSubscribers.isSuperset(of: expectedSubscribers) {
return subscribers
}
// No lost-wakeup race here: the closure passed to `withCheckedContinuation`
// runs synchronously in this actor's isolation domain before the caller
// suspends. A `register` call therefore cannot slip in between the check
// above and the append below, so the continuation is always either
// enqueued before the gate opens, or the fast path above already returned.
//
// Note: If cancellation is required, use withTaskCancellationHandler and a throwing
// continuation.
await withCheckedContinuation { continuation in
continuations.append(continuation)
}
return subscribers
}

var currentSubscribers: [SessionsSubscriber] {
subscribers
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ import XCTest
final class FirebaseSessionsTestsBase_BaseBehaviors: FirebaseSessionsTestsBase {
// MARK: - Test Settings & Sampling

@MainActor func test_settingsDisabled_doesNotLogSessionEventButDoesFetchSettings() {
runSessionsSDK(
@MainActor func test_settingsDisabled_doesNotLogSessionEventButDoesFetchSettings() async {
await runSessionsSDK(
subscriberSDKs: [
mockPerformanceSubscriber,

Expand All @@ -49,8 +49,8 @@ final class FirebaseSessionsTestsBase_BaseBehaviors: FirebaseSessionsTestsBase {
)
}

@MainActor func test_sessionSampled_doesNotLogSessionEventButDoesFetchSettings() {
runSessionsSDK(
@MainActor func test_sessionSampled_doesNotLogSessionEventButDoesFetchSettings() async {
await runSessionsSDK(
subscriberSDKs: [
mockPerformanceSubscriber,

Expand Down Expand Up @@ -84,15 +84,15 @@ final class FirebaseSessionsTestsBase_BaseBehaviors: FirebaseSessionsTestsBase {
// This test ensures that if we go into the background for longer than
// the Session Timeout, we log another event when we come to the foreground.
//
// We wanted to make sure that since we've introduced promises,
// once the promise has been fulfilled, that .then'ing on the promise
// We wanted to make sure that since we've introduced Swift Concurrency,
// once all expected subscribers have been registered, awaiting on the registration
// in future initiations still results in a log
@MainActor func test_multipleInitiations_logsSessionEventEachInitiation() {
@MainActor func test_multipleInitiations_logsSessionEventEachInitiation() async {
var loggedCount = 0
var lastLoggedSessionID = ""
let loggedTwiceExpectation = expectation(description: "Sessions SDK logged events twice")

runSessionsSDK(
await runSessionsSDK(
subscriberSDKs: [
mockPerformanceSubscriber,

Expand Down Expand Up @@ -138,7 +138,7 @@ final class FirebaseSessionsTestsBase_BaseBehaviors: FirebaseSessionsTestsBase {
}
)

wait(for: [loggedTwiceExpectation], timeout: 3)
await fulfillment(of: [loggedTwiceExpectation], timeout: 3)

// Make sure we logged 2 events
XCTAssertEqual(loggedCount, 2)
Expand Down
Loading
Loading