Skip to content
Open
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
6 changes: 6 additions & 0 deletions FirebaseRemoteConfig/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
# Unreleased
- [added] Introduced a new `configUpdates` property to `RemoteConfig` that
provides an `AsyncSequence` for consuming real-time config updates.
This offers a modern, Swift Concurrency-native alternative to the existing
closure-based listener.

# 12.6.0
- [fixed] Fixed a bug where Remote Config does not work after a restore
of a previous backup of the device. (#14459)
Expand Down
142 changes: 142 additions & 0 deletions FirebaseRemoteConfig/Swift/RemoteConfig+Async.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
// Copyright 2025 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

@available(iOS 13.0.0, macOS 10.15.0, macCatalyst 13.0.0, tvOS 13.0.0, watchOS 7.0.0, *)
public extension RemoteConfig {
/// Returns an `AsyncSequence` that provides real-time updates to the configuration.
///
/// You can listen for updates by iterating over the stream using a `for try await` loop.
/// The stream will yield a `RemoteConfigUpdate` whenever a change is pushed from the
/// Remote Config backend. After receiving an update, you must call `activate()` to make the
/// new configuration available to your app.
///
/// The underlying listener is automatically added when you begin iterating and is removed when
/// the iteration is cancelled or finishes.
///
/// - Throws: An `Error` if the listener encounters a server-side error or another
/// issue, causing the stream to terminate.
///
/// ### Example Usage
///
/// ```swift
/// func listenForRealtimeUpdates() {
/// Task {
/// do {
/// for try await configUpdate in remoteConfig.configUpdates {
/// print("Updated keys: \(configUpdate.updatedKeys)")
/// // Activate the new config to make it available
/// let status = try await remoteConfig.activate()
/// print("Config activated with status: \(status)")
/// }
/// } catch {
/// print("Error listening for remote config updates: \(error)")
/// }
/// }
/// }
/// ```
@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *)
var configUpdates: RemoteConfigUpdateSequence {
RemoteConfigUpdateSequence(self)
}

/// An `AsyncSequence` that emits `RemoteConfigUpdate` values whenever the config is updated.
///
/// This struct is the concrete type returned by the `RemoteConfig.configUpdates` property.
///
/// - Important: This type is marked `Sendable` because `RemoteConfig` is assumed to be
/// `Sendable`.
@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *)
@frozen
struct RemoteConfigUpdateSequence: AsyncSequence, Sendable {
public typealias Element = RemoteConfigUpdate
public typealias Failure = Error
public typealias AsyncIterator = Iterator

@usableFromInline
let remoteConfig: RemoteConfig

/// Creates a new sequence for monitoring real-time config updates.
/// - Parameter remoteConfig: The `RemoteConfig` instance to monitor.
@inlinable
public init(_ remoteConfig: RemoteConfig) {
self.remoteConfig = remoteConfig
}

/// Creates and returns an iterator for this asynchronous sequence.
/// - Returns: An `Iterator` for `RemoteConfigUpdateSequence`.
@inlinable
public func makeAsyncIterator() -> Iterator {
Iterator(remoteConfig: remoteConfig)
}

/// The asynchronous iterator for `RemoteConfigUpdateSequence`.
@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *)
@frozen
public struct Iterator: AsyncIteratorProtocol {
public typealias Element = RemoteConfigUpdate

@usableFromInline
let stream: AsyncThrowingStream<RemoteConfigUpdate, Error>
@usableFromInline
var streamIterator: AsyncThrowingStream<RemoteConfigUpdate, Error>.Iterator

/// Initializes the iterator with the provided `RemoteConfig` instance.
/// This sets up the `AsyncThrowingStream` and registers the necessary listener.
/// - Parameter remoteConfig: The `RemoteConfig` instance to monitor.
@inlinable
init(remoteConfig: RemoteConfig) {
stream = AsyncThrowingStream { continuation in
let listener = remoteConfig.addOnConfigUpdateListener { update, error in
switch (update, error) {
case let (update?, _):
// If there's an update, yield it. We prioritize the update over a potential error.
continuation.yield(update)
case let (_, error?):
// If there's no update but there is an error, terminate the stream with the error.
continuation.finish(throwing: error)
case (nil, nil):
// If both are nil (the "should not happen" case), gracefully finish the stream.
continuation.finish()
}
}

continuation.onTermination = { @Sendable _ in
listener.remove()
}
}
streamIterator = stream.makeAsyncIterator()
}

/// Produces the next element in the asynchronous sequence.
///
/// Returns a `RemoteConfigUpdate` value or `nil` if the sequence has terminated.
/// Throws an error if the underlying listener encounters an issue.
/// - Returns: An optional `RemoteConfigUpdate` object.
@inlinable
public mutating func next() async throws -> Element? {
try await streamIterator.next()
}
}
}
}

// Explicitly mark the Iterator as unavailable for Sendable conformance
@available(*, unavailable)
extension RemoteConfig.RemoteConfigUpdateSequence.Iterator: Sendable {}

// Since RemoteConfig is a thread-safe Objective-C class (it uses a serial queue for its
// operations), we can safely declare its conformance to Sendable.
extension RemoteConfig: @unchecked @retroactive Sendable {}

Check warning on line 142 in FirebaseRemoteConfig/Swift/RemoteConfig+Async.swift

View workflow job for this annotation

GitHub Actions / remoteconfig (iOS)

'retroactive' attribute does not apply; 'RemoteConfig' is declared in this module; this is an error in the Swift 6 language mode

Check warning on line 142 in FirebaseRemoteConfig/Swift/RemoteConfig+Async.swift

View workflow job for this annotation

GitHub Actions / remoteconfig (iOS)

'retroactive' attribute does not apply; 'RemoteConfig' is declared in this module; this is an error in the Swift 6 language mode
241 changes: 241 additions & 0 deletions FirebaseRemoteConfig/Tests/Swift/SwiftAPI/AsyncSequenceTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
// Copyright 2025 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 FirebaseCore
@testable import FirebaseRemoteConfig
import XCTest

#if SWIFT_PACKAGE
import RemoteConfigFakeConsoleObjC
#endif

// MARK: - Mock Objects for Testing

/// A mock listener registration that allows tests to verify that its `remove()` method was called.
class MockListenerRegistration: ConfigUpdateListenerRegistration, @unchecked Sendable {
var wasRemoveCalled = false
override func remove() {
wasRemoveCalled = true
}
}

/// A mock for the RCNConfigRealtime component that allows tests to control the config update
/// listener.
class MockRealtime: RCNConfigRealtime, @unchecked Sendable {
/// The listener closure captured from the `configUpdates` async stream.
var listener: ((RemoteConfigUpdate?, Error?) -> Void)?
let mockRegistration = MockListenerRegistration()
var listenerAttachedExpectation: XCTestExpectation?

override func addConfigUpdateListener(_ listener: @escaping (RemoteConfigUpdate?, Error?)
-> Void) -> ConfigUpdateListenerRegistration {
self.listener = listener
listenerAttachedExpectation?.fulfill()
return mockRegistration
}

/// Simulates the backend sending a successful configuration update.
func sendUpdate(keys: [String]) {
let update = RemoteConfigUpdate(updatedKeys: Set(keys))
listener?(update, nil)
}

/// Simulates the backend sending an error.
func sendError(_ error: Error) {
listener?(nil, error)
}

/// Simulates the listener completing without an update or error.
func sendCompletion() {
listener?(nil, nil)
}
}

// MARK: - AsyncSequenceTests

@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *)
class AsyncSequenceTests: XCTestCase {
var app: FirebaseApp!
var config: RemoteConfig!
var mockRealtime: MockRealtime!

struct TestError: Error, Equatable {}

override func setUpWithError() throws {
try super.setUpWithError()

// Perform one-time setup of the FirebaseApp for testing.
if FirebaseApp.app() == nil {
let options = FirebaseOptions(googleAppID: "1:123:ios:123abc",
gcmSenderID: "correct_gcm_sender_id")
options.apiKey = "A23456789012345678901234567890123456789"
options.projectID = "Fake_Project"
FirebaseApp.configure(options: options)
}

app = FirebaseApp.app()!
config = RemoteConfig.remoteConfig(app: app)

// Install the mock realtime service.
mockRealtime = MockRealtime()
config.configRealtime = mockRealtime
}

override func tearDownWithError() throws {
app = nil
config = nil
mockRealtime = nil
try super.tearDownWithError()
}

func testSequenceYieldsUpdate_whenUpdateIsSent() async throws {
let expectation = self.expectation(description: "Sequence should yield an update.")
let keysToUpdate = ["foo", "bar"]

let listenerAttachedExpectation = self.expectation(description: "Listener should be attached.")
mockRealtime.listenerAttachedExpectation = listenerAttachedExpectation

let listeningTask = Task {
for try await update in config.configUpdates {
XCTAssertEqual(update.updatedKeys, Set(keysToUpdate))
expectation.fulfill()
break // End the loop after receiving the expected update.
}
}

// Wait for the listener to be attached before sending the update.
await fulfillment(of: [listenerAttachedExpectation], timeout: 1.0)

mockRealtime.sendUpdate(keys: keysToUpdate)

await fulfillment(of: [expectation], timeout: 1.0)
listeningTask.cancel()
}

func testSequenceFinishes_whenErrorIsSent() async throws {
let expectation = self.expectation(description: "Sequence should throw an error.")
let testError = TestError()

let listenerAttachedExpectation = self.expectation(description: "Listener should be attached.")
mockRealtime.listenerAttachedExpectation = listenerAttachedExpectation

let listeningTask = Task {
do {
for try await _ in config.configUpdates {
XCTFail("Stream should not have yielded any updates.")
}
} catch {
XCTAssertEqual(error as? TestError, testError)
expectation.fulfill()
}
}

// Wait for the listener to be attached before sending the error.
await fulfillment(of: [listenerAttachedExpectation], timeout: 1.0)

mockRealtime.sendError(testError)

await fulfillment(of: [expectation], timeout: 1.0)
listeningTask.cancel()
}

func testSequenceCancellation_callsRemoveOnListener() async throws {
let listenerAttachedExpectation = expectation(description: "Listener should be attached.")
mockRealtime.listenerAttachedExpectation = listenerAttachedExpectation

let listeningTask = Task {
for try await _ in config.configUpdates {
// We will cancel the task, so it should not reach here.
}
}

// Wait for the listener to be attached.
await fulfillment(of: [listenerAttachedExpectation], timeout: 1.0)

// Verify the listener has not been removed yet.
XCTAssertFalse(mockRealtime.mockRegistration.wasRemoveCalled)

// Cancel the task, which should trigger the stream's onTermination handler.
listeningTask.cancel()

// Give the cancellation a moment to propagate.
try await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds

// Verify the listener was removed.
XCTAssertTrue(mockRealtime.mockRegistration.wasRemoveCalled)
}

func testSequenceFinishesGracefully_whenListenerSendsNil() async throws {
let expectation = self.expectation(description: "Sequence should finish without error.")

let listenerAttachedExpectation = self.expectation(description: "Listener should be attached.")
mockRealtime.listenerAttachedExpectation = listenerAttachedExpectation

let listeningTask = Task {
var updateCount = 0
do {
for try await _ in config.configUpdates {
updateCount += 1
}
// The loop finished without throwing, which is the success condition.
XCTAssertEqual(updateCount, 0, "No updates should have been received.")
expectation.fulfill()
} catch {
XCTFail("Stream should not have thrown an error, but threw \(error).")
}
}

await fulfillment(of: [listenerAttachedExpectation], timeout: 1.0)
mockRealtime.sendCompletion()

await fulfillment(of: [expectation], timeout: 1.0)
listeningTask.cancel()
}

func testSequenceYieldsMultipleUpdates_whenMultipleUpdatesAreSent() async throws {
let expectation = self.expectation(description: "Sequence should receive two updates.")
expectation.expectedFulfillmentCount = 2

let updatesToSend = [
Set(["key1", "key2"]),
Set(["key3"]),
]
var receivedUpdates: [Set<String>] = []

let listenerAttachedExpectation = self.expectation(description: "Listener should be attached.")
mockRealtime.listenerAttachedExpectation = listenerAttachedExpectation

let listeningTask = Task {
for try await update in config.configUpdates {
receivedUpdates.append(update.updatedKeys)
expectation.fulfill()
if receivedUpdates.count == updatesToSend.count {
break
}
}
return receivedUpdates
}

await fulfillment(of: [listenerAttachedExpectation], timeout: 1.0)

mockRealtime.sendUpdate(keys: Array(updatesToSend[0]))
mockRealtime.sendUpdate(keys: Array(updatesToSend[1]))

await fulfillment(of: [expectation], timeout: 2.0)

let finalUpdates = try await listeningTask.value
XCTAssertEqual(finalUpdates, updatesToSend)
listeningTask.cancel()
}
}
Loading