From 5cd140b3f45ee6ddfb3b84c4ba8fceb1cded7800 Mon Sep 17 00:00:00 2001 From: Peter Friese Date: Mon, 22 Sep 2025 19:46:49 +0200 Subject: [PATCH 01/17] Add design document --- docs/AsyncStreams/api-design-proposal.md | 303 +++++++++++++++++++++++ 1 file changed, 303 insertions(+) create mode 100644 docs/AsyncStreams/api-design-proposal.md diff --git a/docs/AsyncStreams/api-design-proposal.md b/docs/AsyncStreams/api-design-proposal.md new file mode 100644 index 00000000000..ca1748cab97 --- /dev/null +++ b/docs/AsyncStreams/api-design-proposal.md @@ -0,0 +1,303 @@ +# Swift `AsyncSequence` API Design Proposal + +* **Authors** + * Peter Friese (peterfriese@google.com) + +* **Status**: `In Review` +* **Last Updated**: 2025-09-22 + +## 1. Abstract + +This proposal outlines the integration of Swift's `AsyncStream` and `AsyncSequence` APIs into the Firebase Apple SDK. The goal is to provide a modern, developer-friendly way to consume real-time data streams from Firebase APIs, aligning the SDK with Swift's structured concurrency model and improving the overall developer experience. + +## 2. Background + +Many Firebase APIs produce a sequence of asynchronous events, such as authentication state changes, document and collection updates, and remote configuration updates. Currently, the SDK exposes these through completion-handler-based APIs (listeners). + +```swift +// Current listener-based approach +db.collection("cities").document("SF") + .addSnapshotListener { documentSnapshot, error in + guard let document = documentSnapshot else { /* ... */ } + guard let data = document.data() else { /* ... */ } + print("Current data: \(data)") + } +``` + +This approach breaks the otherwise linear control flow, requires manual management of listener lifecycles, and complicates error handling. Swift's `AsyncSequence` provides a modern, type-safe alternative that integrates seamlessly with structured concurrency, offering automatic resource management, simplified error handling, and a more intuitive, linear control flow. + +## 3. Motivation + +Adopting `AsyncSequence` will: + +* **Modernize the SDK:** Align with Swift's modern concurrency approach, making Firebase feel more native to Swift developers. +* **Simplify Development:** Eliminate the need for manual listener management and reduce boilerplate code, especially when integrating with SwiftUI. +* **Improve Code Quality:** Provide official, high-quality implementations for streaming APIs, reducing ecosystem fragmentation caused by unofficial solutions. +* **Enhance Readability:** Leverage structured error handling (`throws`) and a linear `for try await` syntax to make asynchronous code easier to read and maintain. +* **Enable Composition:** Allow developers to use a rich set of sequence operators (like `map`, `filter`, `prefix`) to transform and combine streams declaratively. + +## 4. Goals + +* To design and implement an idiomatic, `AsyncSequence`-based API surface for all relevant event-streaming Firebase APIs. +* To provide a clear and consistent naming convention that aligns with Apple's own Swift APIs. +* To ensure the new APIs automatically manage the lifecycle of underlying listeners, removing this burden from the developer. +* To improve the testability of asynchronous Firebase interactions. + +## 5. Non-Goals + +* To deprecate or remove the existing listener-based APIs in the immediate future. The new APIs will be additive. +* To introduce `AsyncSequence` wrappers for one-shot asynchronous calls (which are better served by `async/await` functions). This proposal is focused exclusively on event streams. +* To provide a custom `AsyncSequence` implementation. We will use Swift's standard `Async(Throwing)Stream` types. + +## 6. API Naming Convention + +The guiding principle is to establish a clear, concise, and idiomatic naming convention that aligns with modern Swift practices and mirrors Apple's own frameworks. + +### Recommended Approach: Name the sequence based on its conceptual model. + +1. **For sequences of discrete items, use a plural noun.** + * This applies when the stream represents a series of distinct objects, like data snapshots. + * **Guidance:** Use a computed property for parameter-less access and a method for cases that require parameters. + * **Examples:** `url.lines`, `db.collection("users").snapshots`. + +2. **For sequences observing a single entity, describe the event with a suffix.** + * This applies when the stream represents the changing value of a single property or entity over time. + * **Guidance:** Use the entity's name combined with a suffix like `Changes`, `Updates`, or `Events`. + * **Example:** `auth.authStateChanges`. + +This approach was chosen over verb-based (`.streamSnapshots()`) or suffix-based (`.snapshotStream`) alternatives because it aligns most closely with Apple's API design guidelines, leading to a more idiomatic and less verbose call site. + +## 7. Proposed API Design + +### 7.1. Cloud Firestore + +Provides an async alternative to `addSnapshotListener`. + +#### API Design + +```swift +// Collection snapshots +extension CollectionReference { + var snapshots: AsyncThrowingStream { get } + func snapshots(includeMetadataChanges: Bool) -> AsyncThrowingStream +} + +// Query snapshots +extension Query { + var snapshots: AsyncThrowingStream { get } + func snapshots(includeMetadataChanges: Bool) -> AsyncThrowingStream +} + +// Document snapshots +extension DocumentReference { + var snapshots: AsyncThrowingStream { get } + func snapshots(includeMetadataChanges: Bool) -> AsyncThrowingStream +} +``` + +#### Usage + +```swift +// Streaming updates on a collection +func observeUsers() async throws { + for try await snapshot in db.collection("users").snapshots { + // ... + } +} +``` + +### 7.2. Realtime Database + +Provides an async alternative to the `observe(_:with:)` method. + +#### API Design + +```swift +/// An enumeration of granular child-level events. +public enum DatabaseEvent { + case childAdded(DataSnapshot, previousSiblingKey: String?) + case childChanged(DataSnapshot, previousSiblingKey: String?) + case childRemoved(DataSnapshot) + case childMoved(DataSnapshot, previousSiblingKey: String?) +} + +extension DatabaseQuery { + /// An asynchronous stream of the entire contents at a location. + /// This stream emits a new `DataSnapshot` every time the data changes. + var value: AsyncThrowingStream { get } + + /// An asynchronous stream of child-level events at a location. + func events() -> AsyncThrowingStream +} +``` + +#### Usage + +```swift +// Streaming a single value +let scoreRef = Database.database().reference(withPath: "game/score") +for try await snapshot in scoreRef.value { + // ... +} + +// Streaming child events +let messagesRef = Database.database().reference(withPath: "chats/123/messages") +for try await event in messagesRef.events() { + switch event { + case .childAdded(let snapshot, _): + // ... + // ... + } +} +``` + +### 7.3. Authentication + +Provides an async alternative to `addStateDidChangeListener`. + +#### API Design + +```swift +extension Auth { + /// An asynchronous stream of authentication state changes. + var authStateChanges: AsyncStream { get } +} +``` + +#### Usage + +```swift +// Monitoring authentication state +for await user in Auth.auth().authStateChanges { + if let user = user { + // User is signed in + } else { + // User is signed out + } +} +``` + +### 7.4. Cloud Storage + +Provides an async alternative to `observe(.progress, ...)`. + +#### API Design + +```swift +extension StorageTask { + /// An asynchronous stream of progress updates for an ongoing task. + var progress: AsyncThrowingStream { get } +} +``` + +#### Usage + +```swift +// Monitoring an upload task +let uploadTask = ref.putData(data, metadata: nil) +do { + for try await progress in uploadTask.progress { + // Update progress bar + } + print("Upload complete") +} catch { + // Handle error +} +``` + +### 7.5. Remote Config + +Provides an async alternative to `addOnConfigUpdateListener`. + +#### API Design + +```swift +extension RemoteConfig { + /// An asynchronous stream of configuration updates. + var updates: AsyncThrowingStream { get } +} +``` + +#### Usage + +```swift +// Listening for real-time config updates +for try await update in RemoteConfig.remoteConfig().updates { + // Activate new config +} +``` + +### 7.6. Cloud Messaging (FCM) + +Provides an async alternative to the delegate-based approach for token updates and foreground messages. + +#### API Design + +```swift +extension Messaging { + /// An asynchronous stream of FCM registration token updates. + var tokenUpdates: AsyncStream { get } + + /// An asynchronous stream of remote messages received while the app is in the foreground. + var foregroundMessages: AsyncStream { get } +} +``` + +#### Usage + +```swift +// Monitoring FCM token updates +for await token in Messaging.messaging().tokenUpdates { + // Send token to server +} +``` + +## 8. Testing Plan + +The quality and reliability of this new API surface will be ensured through a multi-layered testing strategy, covering unit, integration, and cancellation scenarios. + +### 8.1. Unit Tests + +The primary goal of unit tests is to verify the correctness of the `AsyncStream` wrapping logic in isolation from the network and backend services. + +* **Mocking:** Each product's stream implementation will be tested against a mocked version of its underlying service (e.g., a mock `Firestore` client). +* **Behavior Verification:** + * Tests will confirm that initiating a stream correctly registers a listener with the underlying service. + * We will use the mock listeners to simulate events (e.g., new snapshots, auth state changes) and assert that the `AsyncStream` yields the corresponding values correctly. + * Error conditions will be simulated to ensure that the stream correctly throws errors. +* **Teardown Logic:** We will verify that the underlying listener is removed when the stream is either cancelled or finishes naturally. + +### 8.2. Integration Tests + +Integration tests will validate the end-to-end functionality of the async sequences against a live backend environment using the **Firebase Emulator Suite**. + +* **Environment:** A new integration test suite will be created that configures the Firebase SDK to connect to the local emulators (Firestore, Database, Auth, etc.). +* **Validation:** These tests will perform real operations (e.g., writing a document and then listening to its `snapshots` stream) to verify that real-time updates are correctly received and propagated through the `AsyncSequence` API. +* **Cross-Product Scenarios:** We will test scenarios that involve multiple Firebase products where applicable. + +### 8.3. Cancellation Behavior Tests + +A specific set of tests will be dedicated to ensuring that resource cleanup (i.e., listener removal) happens correctly and promptly when the consuming task is cancelled. + +* **Test Scenario:** + 1. A stream will be consumed within a Swift `Task`. + 2. The `Task` will be cancelled immediately after the stream is initiated. + 3. Using a mock or a spy object, we will assert that the `remove()` method on the underlying listener registration is called. +* **Importance:** This is critical for preventing resource leaks and ensuring the new API behaves predictably within the Swift structured concurrency model, especially in SwiftUI contexts where tasks are automatically managed. + +## 9. Implementation Plan + +The implementation will be phased, with each product's API being added in a separate Pull Request to facilitate focused reviews. + +* **Firestore:** [PR #14924: Support AsyncStream in realtime query](https://github.com/firebase/firebase-ios-sdk/pull/14924) +* **Authentication:** [Link to PR when available] +* **Realtime Database:** [Link to PR when available] +* ...and so on. + +## 10. Open Questions & Future Work + +* Should we provide convenience wrappers for common `AsyncSequence` operators? (e.g., a method to directly stream decoded objects instead of snapshots). For now, this is considered a **Non-Goal** but could be revisited. + +--- + +*This document is based on the original proposal and discussions in PRs #11508 and #14924.* From 8a6390c8fc922ced26ce8cfe6858b9ddfd1721ff Mon Sep 17 00:00:00 2001 From: Peter Friese Date: Mon, 22 Sep 2025 19:52:29 +0200 Subject: [PATCH 02/17] Cleanup --- docs/AsyncStreams/api-design-proposal.md | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/docs/AsyncStreams/api-design-proposal.md b/docs/AsyncStreams/api-design-proposal.md index ca1748cab97..6d76463f91c 100644 --- a/docs/AsyncStreams/api-design-proposal.md +++ b/docs/AsyncStreams/api-design-proposal.md @@ -2,7 +2,6 @@ * **Authors** * Peter Friese (peterfriese@google.com) - * **Status**: `In Review` * **Last Updated**: 2025-09-22 @@ -296,8 +295,4 @@ The implementation will be phased, with each product's API being added in a sepa ## 10. Open Questions & Future Work -* Should we provide convenience wrappers for common `AsyncSequence` operators? (e.g., a method to directly stream decoded objects instead of snapshots). For now, this is considered a **Non-Goal** but could be revisited. - ---- - -*This document is based on the original proposal and discussions in PRs #11508 and #14924.* +* Should we provide convenience wrappers for common `AsyncSequence` operators? (e.g., a method to directly stream decoded objects instead of snapshots). For now, this is considered a **Non-Goal** but could be revisited. \ No newline at end of file From 45b8a275d4712049c226580a7bf67d7ab30fe029 Mon Sep 17 00:00:00 2001 From: Peter Friese Date: Mon, 22 Sep 2025 22:56:57 +0200 Subject: [PATCH 03/17] Initial implementation of AsyncStream for RC + basic test --- .../Swift/RemoteConfig+Async.swift | 71 +++++++++++++++++++ .../Swift/SwiftAPI/AsyncStreamTests.swift | 36 ++++++++++ 2 files changed, 107 insertions(+) create mode 100644 FirebaseRemoteConfig/Swift/RemoteConfig+Async.swift create mode 100644 FirebaseRemoteConfig/Tests/Swift/SwiftAPI/AsyncStreamTests.swift diff --git a/FirebaseRemoteConfig/Swift/RemoteConfig+Async.swift b/FirebaseRemoteConfig/Swift/RemoteConfig+Async.swift new file mode 100644 index 00000000000..e778ad497f0 --- /dev/null +++ b/FirebaseRemoteConfig/Swift/RemoteConfig+Async.swift @@ -0,0 +1,71 @@ +// Copyright 2024 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, *) +extension RemoteConfig { + /// Returns an `AsyncThrowingStream` 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: `RemoteConfigUpdateError` 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.updates { + /// 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)") + /// } + /// } + /// } + /// ``` + public var updates: AsyncThrowingStream { + return AsyncThrowingStream { continuation in + let listener = 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() + } + } + } +} diff --git a/FirebaseRemoteConfig/Tests/Swift/SwiftAPI/AsyncStreamTests.swift b/FirebaseRemoteConfig/Tests/Swift/SwiftAPI/AsyncStreamTests.swift new file mode 100644 index 00000000000..ff68dd49390 --- /dev/null +++ b/FirebaseRemoteConfig/Tests/Swift/SwiftAPI/AsyncStreamTests.swift @@ -0,0 +1,36 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache-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 + +@available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) +class AsyncStreamTests: APITestBase { + func testConfigUpdateStreamReceivesUpdates() async throws { + guard APITests.useFakeConfig else { return } + + let expectation = self.expectation(description: #function) + + Task { + for try await update in config.updates { + expectation.fulfill() + } + } + + fakeConsole.config[Constants.key1] = Constants.value1 + await fulfillment(of: [expectation], timeout: 5) + } +} From 75f2daf71cacdb061b9b57693d34a32646b09e10 Mon Sep 17 00:00:00 2001 From: Peter Friese Date: Mon, 22 Sep 2025 23:15:10 +0200 Subject: [PATCH 04/17] Add more complete unit tests --- .../Swift/SwiftAPI/AsyncStreamTests.swift | 216 ++++++++++++++++-- 1 file changed, 203 insertions(+), 13 deletions(-) diff --git a/FirebaseRemoteConfig/Tests/Swift/SwiftAPI/AsyncStreamTests.swift b/FirebaseRemoteConfig/Tests/Swift/SwiftAPI/AsyncStreamTests.swift index ff68dd49390..3c3e1a7b3aa 100644 --- a/FirebaseRemoteConfig/Tests/Swift/SwiftAPI/AsyncStreamTests.swift +++ b/FirebaseRemoteConfig/Tests/Swift/SwiftAPI/AsyncStreamTests.swift @@ -1,36 +1,226 @@ // Copyright 2024 Google LLC // -// Licensed under the Apache-Version 2.0 (the "License"); +// 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. +// 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 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 { + /// The listener closure captured from the `updates` async stream. + var listener: ((RemoteConfigUpdate?, Error?) -> Void)? + let mockRegistration = MockListenerRegistration() + + + override func addConfigUpdateListener( + _ listener: @escaping (RemoteConfigUpdate?, Error?) -> Void + ) -> ConfigUpdateListenerRegistration { + self.listener = listener + 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: - AsyncStreamTests2 @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) -class AsyncStreamTests: APITestBase { - func testConfigUpdateStreamReceivesUpdates() async throws { - guard APITests.useFakeConfig else { return } - - let expectation = self.expectation(description: #function) +class AsyncStreamTests: XCTestCase { + var app: FirebaseApp! + var config: RemoteConfig! + var mockRealtime: MockRealtime! - Task { + 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 testStreamYieldsUpdate_whenUpdateIsSent() async throws { + let expectation = self.expectation(description: "Stream should yield an update.") + let keysToUpdate = ["foo", "bar"] + + let listeningTask = Task { for try await update in config.updates { + XCTAssertEqual(update.updatedKeys, Set(keysToUpdate)) expectation.fulfill() + break // End the loop after receiving the expected update. } } - fakeConsole.config[Constants.key1] = Constants.value1 - await fulfillment(of: [expectation], timeout: 5) + // Ensure the listener is attached before sending the update. + try await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds + + mockRealtime.sendUpdate(keys: keysToUpdate) + + await fulfillment(of: [expectation], timeout: 1.0) + listeningTask.cancel() + } + + func testStreamFinishes_whenErrorIsSent() async throws { + let expectation = self.expectation(description: "Stream should throw an error.") + let testError = TestError() + + let listeningTask = Task { + do { + for try await _ in config.updates { + XCTFail("Stream should not have yielded any updates.") + } + } catch { + XCTAssertEqual(error as? TestError, testError) + expectation.fulfill() + } + } + + // Ensure the listener is attached before sending the error. + try await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds + + mockRealtime.sendError(testError) + + await fulfillment(of: [expectation], timeout: 1.0) + listeningTask.cancel() + } + + func testStreamCancellation_callsRemoveOnListener() async throws { + let listeningTask = Task { + for try await _ in config.updates { + // We will cancel the task, so it should not reach here. + } + } + + // Ensure the listener has time to be established. + try await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds + + // 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 testStreamFinishesGracefully_whenListenerSendsNil() async throws { + let expectation = self.expectation(description: "Stream should finish without error.") + + let listeningTask = Task { + var updateCount = 0 + do { + for try await _ in config.updates { + 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).") + } + } + + try await Task.sleep(nanoseconds: 100_000_000) + mockRealtime.sendCompletion() + + await fulfillment(of: [expectation], timeout: 1.0) + listeningTask.cancel() + } + + func testStreamYieldsMultipleUpdates_whenMultipleUpdatesAreSent() async throws { + let expectation = self.expectation(description: "Stream should receive two updates.") + expectation.expectedFulfillmentCount = 2 + + let updatesToSend = [ + Set(["key1", "key2"]), + Set(["key3"]), + ] + var receivedUpdates: [Set] = [] + + let listeningTask = Task { + for try await update in config.updates { + receivedUpdates.append(update.updatedKeys) + expectation.fulfill() + if receivedUpdates.count == updatesToSend.count { + break + } + } + return receivedUpdates + } + + try await Task.sleep(nanoseconds: 100_000_000) + + mockRealtime.sendUpdate(keys: Array(updatesToSend[0])) + try await Task.sleep(nanoseconds: 100_000_000) // Brief pause between sends + mockRealtime.sendUpdate(keys: Array(updatesToSend[1])) + + await fulfillment(of: [expectation], timeout: 2.0) + + let finalUpdates = try await listeningTask.value + XCTAssertEqual(finalUpdates, updatesToSend) + listeningTask.cancel() } } From b15e24a5bdc60ba6d3b428fca9d41de9898b3b30 Mon Sep 17 00:00:00 2001 From: Peter Friese Date: Mon, 22 Sep 2025 23:22:45 +0200 Subject: [PATCH 05/17] Formatting --- FirebaseRemoteConfig/Swift/RemoteConfig+Async.swift | 8 ++++---- .../Tests/Swift/SwiftAPI/AsyncStreamTests.swift | 11 +++++------ 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/FirebaseRemoteConfig/Swift/RemoteConfig+Async.swift b/FirebaseRemoteConfig/Swift/RemoteConfig+Async.swift index e778ad497f0..118db970d9b 100644 --- a/FirebaseRemoteConfig/Swift/RemoteConfig+Async.swift +++ b/FirebaseRemoteConfig/Swift/RemoteConfig+Async.swift @@ -15,7 +15,7 @@ import Foundation @available(iOS 13.0.0, macOS 10.15.0, macCatalyst 13.0.0, tvOS 13.0.0, watchOS 7.0.0, *) -extension RemoteConfig { +public extension RemoteConfig { /// Returns an `AsyncThrowingStream` that provides real-time updates to the configuration. /// /// You can listen for updates by iterating over the stream using a `for try await` loop. @@ -47,14 +47,14 @@ extension RemoteConfig { /// } /// } /// ``` - public var updates: AsyncThrowingStream { + var updates: AsyncThrowingStream { return AsyncThrowingStream { continuation in let listener = addOnConfigUpdateListener { update, error in switch (update, error) { - case (let update?, _): + case let (update?, _): // If there's an update, yield it. We prioritize the update over a potential error. continuation.yield(update) - case (_, let error?): + 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): diff --git a/FirebaseRemoteConfig/Tests/Swift/SwiftAPI/AsyncStreamTests.swift b/FirebaseRemoteConfig/Tests/Swift/SwiftAPI/AsyncStreamTests.swift index 3c3e1a7b3aa..ca05ed547de 100644 --- a/FirebaseRemoteConfig/Tests/Swift/SwiftAPI/AsyncStreamTests.swift +++ b/FirebaseRemoteConfig/Tests/Swift/SwiftAPI/AsyncStreamTests.swift @@ -12,9 +12,9 @@ // See the License for the specific language governing permissions and // limitations under the License. -import XCTest import FirebaseCore @testable import FirebaseRemoteConfig +import XCTest #if SWIFT_PACKAGE import RemoteConfigFakeConsoleObjC @@ -30,16 +30,15 @@ class MockListenerRegistration: ConfigUpdateListenerRegistration, @unchecked Sen } } -/// A mock for the RCNConfigRealtime component that allows tests to control the config update listener. +/// A mock for the RCNConfigRealtime component that allows tests to control the config update +/// listener. class MockRealtime: RCNConfigRealtime { /// The listener closure captured from the `updates` async stream. var listener: ((RemoteConfigUpdate?, Error?) -> Void)? let mockRegistration = MockListenerRegistration() - - override func addConfigUpdateListener( - _ listener: @escaping (RemoteConfigUpdate?, Error?) -> Void - ) -> ConfigUpdateListenerRegistration { + override func addConfigUpdateListener(_ listener: @escaping (RemoteConfigUpdate?, Error?) + -> Void) -> ConfigUpdateListenerRegistration { self.listener = listener return mockRegistration } From fdd1aae903628d8084b38f1105f7d30c93c8356a Mon Sep 17 00:00:00 2001 From: Peter Friese Date: Mon, 22 Sep 2025 23:25:50 +0200 Subject: [PATCH 06/17] Fix copyright and add Sendable conformance --- FirebaseRemoteConfig/Swift/RemoteConfig+Async.swift | 2 +- .../Tests/Swift/SwiftAPI/AsyncStreamTests.swift | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/FirebaseRemoteConfig/Swift/RemoteConfig+Async.swift b/FirebaseRemoteConfig/Swift/RemoteConfig+Async.swift index 118db970d9b..e7dcd978e17 100644 --- a/FirebaseRemoteConfig/Swift/RemoteConfig+Async.swift +++ b/FirebaseRemoteConfig/Swift/RemoteConfig+Async.swift @@ -1,4 +1,4 @@ -// Copyright 2024 Google LLC +// 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. diff --git a/FirebaseRemoteConfig/Tests/Swift/SwiftAPI/AsyncStreamTests.swift b/FirebaseRemoteConfig/Tests/Swift/SwiftAPI/AsyncStreamTests.swift index ca05ed547de..2075265f85a 100644 --- a/FirebaseRemoteConfig/Tests/Swift/SwiftAPI/AsyncStreamTests.swift +++ b/FirebaseRemoteConfig/Tests/Swift/SwiftAPI/AsyncStreamTests.swift @@ -1,4 +1,4 @@ -// Copyright 2024 Google LLC +// 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. @@ -32,7 +32,7 @@ class MockListenerRegistration: ConfigUpdateListenerRegistration, @unchecked Sen /// A mock for the RCNConfigRealtime component that allows tests to control the config update /// listener. -class MockRealtime: RCNConfigRealtime { +class MockRealtime: RCNConfigRealtime, @unchecked Sendable { /// The listener closure captured from the `updates` async stream. var listener: ((RemoteConfigUpdate?, Error?) -> Void)? let mockRegistration = MockListenerRegistration() From 2487abebd64ff5ed8382ef735bc29fe0b75e74f7 Mon Sep 17 00:00:00 2001 From: Peter Friese Date: Mon, 22 Sep 2025 23:55:10 +0200 Subject: [PATCH 07/17] Add changelog entry --- FirebaseRemoteConfig/CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/FirebaseRemoteConfig/CHANGELOG.md b/FirebaseRemoteConfig/CHANGELOG.md index 91ba0e80f24..1f4466eb2b8 100644 --- a/FirebaseRemoteConfig/CHANGELOG.md +++ b/FirebaseRemoteConfig/CHANGELOG.md @@ -1,3 +1,9 @@ +# unreleased +- [added] Introduced a new `updates` property to `RemoteConfig` that + providesan `AsyncThrowingStream` for consuming real-time config updates. + This offers a modern, Swift Concurrency-native alternative to the existing + closure-based listener. + # 12.3.0 - [fixed] Add missing GoogleUtilities dependency to fix SwiftPM builds when building dynamically linked libraries. (#15276) From 17548a777763669c9645e45b35290dfb028b7f52 Mon Sep 17 00:00:00 2001 From: Peter Friese Date: Wed, 24 Sep 2025 15:55:45 +0200 Subject: [PATCH 08/17] Update FirebaseRemoteConfig/CHANGELOG.md Co-authored-by: Nick Cooke <36927374+ncooke3@users.noreply.github.com> --- FirebaseRemoteConfig/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FirebaseRemoteConfig/CHANGELOG.md b/FirebaseRemoteConfig/CHANGELOG.md index 1f4466eb2b8..7ed401da95e 100644 --- a/FirebaseRemoteConfig/CHANGELOG.md +++ b/FirebaseRemoteConfig/CHANGELOG.md @@ -1,4 +1,4 @@ -# unreleased +# Unreleased - [added] Introduced a new `updates` property to `RemoteConfig` that providesan `AsyncThrowingStream` for consuming real-time config updates. This offers a modern, Swift Concurrency-native alternative to the existing From b99276d7427b21911acf184c6e12d52ef1c4e5e7 Mon Sep 17 00:00:00 2001 From: Peter Friese Date: Wed, 24 Sep 2025 15:57:56 +0200 Subject: [PATCH 09/17] Fix typo --- FirebaseRemoteConfig/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FirebaseRemoteConfig/CHANGELOG.md b/FirebaseRemoteConfig/CHANGELOG.md index 7ed401da95e..5837b9daeaf 100644 --- a/FirebaseRemoteConfig/CHANGELOG.md +++ b/FirebaseRemoteConfig/CHANGELOG.md @@ -1,6 +1,6 @@ # Unreleased - [added] Introduced a new `updates` property to `RemoteConfig` that - providesan `AsyncThrowingStream` for consuming real-time config updates. + provides an `AsyncThrowingStream` for consuming real-time config updates. This offers a modern, Swift Concurrency-native alternative to the existing closure-based listener. From a963b697c88e1122d037bf2c231ce5ce540c6d95 Mon Sep 17 00:00:00 2001 From: Peter Friese Date: Wed, 24 Sep 2025 16:27:43 +0200 Subject: [PATCH 10/17] Fix conflicting API in API design proposal for Cloud Storage --- docs/AsyncStreams/api-design-proposal.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/AsyncStreams/api-design-proposal.md b/docs/AsyncStreams/api-design-proposal.md index 6d76463f91c..ae44a5a6160 100644 --- a/docs/AsyncStreams/api-design-proposal.md +++ b/docs/AsyncStreams/api-design-proposal.md @@ -185,7 +185,7 @@ Provides an async alternative to `observe(.progress, ...)`. ```swift extension StorageTask { /// An asynchronous stream of progress updates for an ongoing task. - var progress: AsyncThrowingStream { get } + var progressUpdates: AsyncThrowingStream { get } } ``` From 8b8bdbc29dfa1303c4d86ac1c5c94f0eaebee89c Mon Sep 17 00:00:00 2001 From: Peter Friese Date: Thu, 25 Sep 2025 08:28:29 +0200 Subject: [PATCH 11/17] Simplify listener attachment syncronisation --- .../Swift/SwiftAPI/AsyncStreamTests.swift | 34 ++++++++++++++----- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/FirebaseRemoteConfig/Tests/Swift/SwiftAPI/AsyncStreamTests.swift b/FirebaseRemoteConfig/Tests/Swift/SwiftAPI/AsyncStreamTests.swift index 2075265f85a..720f8bed970 100644 --- a/FirebaseRemoteConfig/Tests/Swift/SwiftAPI/AsyncStreamTests.swift +++ b/FirebaseRemoteConfig/Tests/Swift/SwiftAPI/AsyncStreamTests.swift @@ -36,10 +36,12 @@ class MockRealtime: RCNConfigRealtime, @unchecked Sendable { /// The listener closure captured from the `updates` 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 } @@ -101,6 +103,9 @@ class AsyncStreamTests: XCTestCase { let expectation = self.expectation(description: "Stream 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.updates { XCTAssertEqual(update.updatedKeys, Set(keysToUpdate)) @@ -109,8 +114,8 @@ class AsyncStreamTests: XCTestCase { } } - // Ensure the listener is attached before sending the update. - try await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds + // Wait for the listener to be attached before sending the update. + await fulfillment(of: [listenerAttachedExpectation], timeout: 1.0) mockRealtime.sendUpdate(keys: keysToUpdate) @@ -122,6 +127,9 @@ class AsyncStreamTests: XCTestCase { let expectation = self.expectation(description: "Stream 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.updates { @@ -133,8 +141,8 @@ class AsyncStreamTests: XCTestCase { } } - // Ensure the listener is attached before sending the error. - try await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds + // Wait for the listener to be attached before sending the error. + await fulfillment(of: [listenerAttachedExpectation], timeout: 1.0) mockRealtime.sendError(testError) @@ -143,14 +151,17 @@ class AsyncStreamTests: XCTestCase { } func testStreamCancellation_callsRemoveOnListener() async throws { + let listenerAttachedExpectation = self.expectation(description: "Listener should be attached.") + mockRealtime.listenerAttachedExpectation = listenerAttachedExpectation + let listeningTask = Task { for try await _ in config.updates { // We will cancel the task, so it should not reach here. } } - // Ensure the listener has time to be established. - try await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds + // 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) @@ -168,6 +179,9 @@ class AsyncStreamTests: XCTestCase { func testStreamFinishesGracefully_whenListenerSendsNil() async throws { let expectation = self.expectation(description: "Stream should finish without error.") + let listenerAttachedExpectation = self.expectation(description: "Listener should be attached.") + mockRealtime.listenerAttachedExpectation = listenerAttachedExpectation + let listeningTask = Task { var updateCount = 0 do { @@ -182,7 +196,7 @@ class AsyncStreamTests: XCTestCase { } } - try await Task.sleep(nanoseconds: 100_000_000) + await fulfillment(of: [listenerAttachedExpectation], timeout: 1.0) mockRealtime.sendCompletion() await fulfillment(of: [expectation], timeout: 1.0) @@ -199,6 +213,9 @@ class AsyncStreamTests: XCTestCase { ] var receivedUpdates: [Set] = [] + let listenerAttachedExpectation = self.expectation(description: "Listener should be attached.") + mockRealtime.listenerAttachedExpectation = listenerAttachedExpectation + let listeningTask = Task { for try await update in config.updates { receivedUpdates.append(update.updatedKeys) @@ -210,10 +227,9 @@ class AsyncStreamTests: XCTestCase { return receivedUpdates } - try await Task.sleep(nanoseconds: 100_000_000) + await fulfillment(of: [listenerAttachedExpectation], timeout: 1.0) mockRealtime.sendUpdate(keys: Array(updatesToSend[0])) - try await Task.sleep(nanoseconds: 100_000_000) // Brief pause between sends mockRealtime.sendUpdate(keys: Array(updatesToSend[1])) await fulfillment(of: [expectation], timeout: 2.0) From bc822c0db8e6b52126707dbd963b2be25d78c925 Mon Sep 17 00:00:00 2001 From: Peter Friese Date: Thu, 25 Sep 2025 08:35:35 +0200 Subject: [PATCH 12/17] Fix styling --- .../Tests/Swift/SwiftAPI/AsyncStreamTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FirebaseRemoteConfig/Tests/Swift/SwiftAPI/AsyncStreamTests.swift b/FirebaseRemoteConfig/Tests/Swift/SwiftAPI/AsyncStreamTests.swift index 720f8bed970..7d344acc7d3 100644 --- a/FirebaseRemoteConfig/Tests/Swift/SwiftAPI/AsyncStreamTests.swift +++ b/FirebaseRemoteConfig/Tests/Swift/SwiftAPI/AsyncStreamTests.swift @@ -151,7 +151,7 @@ class AsyncStreamTests: XCTestCase { } func testStreamCancellation_callsRemoveOnListener() async throws { - let listenerAttachedExpectation = self.expectation(description: "Listener should be attached.") + let listenerAttachedExpectation = expectation(description: "Listener should be attached.") mockRealtime.listenerAttachedExpectation = listenerAttachedExpectation let listeningTask = Task { From e1088fe86a4588a5683477b7aac0de9ecea3461a Mon Sep 17 00:00:00 2001 From: Peter Friese Date: Thu, 25 Sep 2025 13:52:29 +0200 Subject: [PATCH 13/17] Remove design doc in favour of adding it via a separate PR --- docs/AsyncStreams/api-design-proposal.md | 298 ----------------------- 1 file changed, 298 deletions(-) delete mode 100644 docs/AsyncStreams/api-design-proposal.md diff --git a/docs/AsyncStreams/api-design-proposal.md b/docs/AsyncStreams/api-design-proposal.md deleted file mode 100644 index ae44a5a6160..00000000000 --- a/docs/AsyncStreams/api-design-proposal.md +++ /dev/null @@ -1,298 +0,0 @@ -# Swift `AsyncSequence` API Design Proposal - -* **Authors** - * Peter Friese (peterfriese@google.com) -* **Status**: `In Review` -* **Last Updated**: 2025-09-22 - -## 1. Abstract - -This proposal outlines the integration of Swift's `AsyncStream` and `AsyncSequence` APIs into the Firebase Apple SDK. The goal is to provide a modern, developer-friendly way to consume real-time data streams from Firebase APIs, aligning the SDK with Swift's structured concurrency model and improving the overall developer experience. - -## 2. Background - -Many Firebase APIs produce a sequence of asynchronous events, such as authentication state changes, document and collection updates, and remote configuration updates. Currently, the SDK exposes these through completion-handler-based APIs (listeners). - -```swift -// Current listener-based approach -db.collection("cities").document("SF") - .addSnapshotListener { documentSnapshot, error in - guard let document = documentSnapshot else { /* ... */ } - guard let data = document.data() else { /* ... */ } - print("Current data: \(data)") - } -``` - -This approach breaks the otherwise linear control flow, requires manual management of listener lifecycles, and complicates error handling. Swift's `AsyncSequence` provides a modern, type-safe alternative that integrates seamlessly with structured concurrency, offering automatic resource management, simplified error handling, and a more intuitive, linear control flow. - -## 3. Motivation - -Adopting `AsyncSequence` will: - -* **Modernize the SDK:** Align with Swift's modern concurrency approach, making Firebase feel more native to Swift developers. -* **Simplify Development:** Eliminate the need for manual listener management and reduce boilerplate code, especially when integrating with SwiftUI. -* **Improve Code Quality:** Provide official, high-quality implementations for streaming APIs, reducing ecosystem fragmentation caused by unofficial solutions. -* **Enhance Readability:** Leverage structured error handling (`throws`) and a linear `for try await` syntax to make asynchronous code easier to read and maintain. -* **Enable Composition:** Allow developers to use a rich set of sequence operators (like `map`, `filter`, `prefix`) to transform and combine streams declaratively. - -## 4. Goals - -* To design and implement an idiomatic, `AsyncSequence`-based API surface for all relevant event-streaming Firebase APIs. -* To provide a clear and consistent naming convention that aligns with Apple's own Swift APIs. -* To ensure the new APIs automatically manage the lifecycle of underlying listeners, removing this burden from the developer. -* To improve the testability of asynchronous Firebase interactions. - -## 5. Non-Goals - -* To deprecate or remove the existing listener-based APIs in the immediate future. The new APIs will be additive. -* To introduce `AsyncSequence` wrappers for one-shot asynchronous calls (which are better served by `async/await` functions). This proposal is focused exclusively on event streams. -* To provide a custom `AsyncSequence` implementation. We will use Swift's standard `Async(Throwing)Stream` types. - -## 6. API Naming Convention - -The guiding principle is to establish a clear, concise, and idiomatic naming convention that aligns with modern Swift practices and mirrors Apple's own frameworks. - -### Recommended Approach: Name the sequence based on its conceptual model. - -1. **For sequences of discrete items, use a plural noun.** - * This applies when the stream represents a series of distinct objects, like data snapshots. - * **Guidance:** Use a computed property for parameter-less access and a method for cases that require parameters. - * **Examples:** `url.lines`, `db.collection("users").snapshots`. - -2. **For sequences observing a single entity, describe the event with a suffix.** - * This applies when the stream represents the changing value of a single property or entity over time. - * **Guidance:** Use the entity's name combined with a suffix like `Changes`, `Updates`, or `Events`. - * **Example:** `auth.authStateChanges`. - -This approach was chosen over verb-based (`.streamSnapshots()`) or suffix-based (`.snapshotStream`) alternatives because it aligns most closely with Apple's API design guidelines, leading to a more idiomatic and less verbose call site. - -## 7. Proposed API Design - -### 7.1. Cloud Firestore - -Provides an async alternative to `addSnapshotListener`. - -#### API Design - -```swift -// Collection snapshots -extension CollectionReference { - var snapshots: AsyncThrowingStream { get } - func snapshots(includeMetadataChanges: Bool) -> AsyncThrowingStream -} - -// Query snapshots -extension Query { - var snapshots: AsyncThrowingStream { get } - func snapshots(includeMetadataChanges: Bool) -> AsyncThrowingStream -} - -// Document snapshots -extension DocumentReference { - var snapshots: AsyncThrowingStream { get } - func snapshots(includeMetadataChanges: Bool) -> AsyncThrowingStream -} -``` - -#### Usage - -```swift -// Streaming updates on a collection -func observeUsers() async throws { - for try await snapshot in db.collection("users").snapshots { - // ... - } -} -``` - -### 7.2. Realtime Database - -Provides an async alternative to the `observe(_:with:)` method. - -#### API Design - -```swift -/// An enumeration of granular child-level events. -public enum DatabaseEvent { - case childAdded(DataSnapshot, previousSiblingKey: String?) - case childChanged(DataSnapshot, previousSiblingKey: String?) - case childRemoved(DataSnapshot) - case childMoved(DataSnapshot, previousSiblingKey: String?) -} - -extension DatabaseQuery { - /// An asynchronous stream of the entire contents at a location. - /// This stream emits a new `DataSnapshot` every time the data changes. - var value: AsyncThrowingStream { get } - - /// An asynchronous stream of child-level events at a location. - func events() -> AsyncThrowingStream -} -``` - -#### Usage - -```swift -// Streaming a single value -let scoreRef = Database.database().reference(withPath: "game/score") -for try await snapshot in scoreRef.value { - // ... -} - -// Streaming child events -let messagesRef = Database.database().reference(withPath: "chats/123/messages") -for try await event in messagesRef.events() { - switch event { - case .childAdded(let snapshot, _): - // ... - // ... - } -} -``` - -### 7.3. Authentication - -Provides an async alternative to `addStateDidChangeListener`. - -#### API Design - -```swift -extension Auth { - /// An asynchronous stream of authentication state changes. - var authStateChanges: AsyncStream { get } -} -``` - -#### Usage - -```swift -// Monitoring authentication state -for await user in Auth.auth().authStateChanges { - if let user = user { - // User is signed in - } else { - // User is signed out - } -} -``` - -### 7.4. Cloud Storage - -Provides an async alternative to `observe(.progress, ...)`. - -#### API Design - -```swift -extension StorageTask { - /// An asynchronous stream of progress updates for an ongoing task. - var progressUpdates: AsyncThrowingStream { get } -} -``` - -#### Usage - -```swift -// Monitoring an upload task -let uploadTask = ref.putData(data, metadata: nil) -do { - for try await progress in uploadTask.progress { - // Update progress bar - } - print("Upload complete") -} catch { - // Handle error -} -``` - -### 7.5. Remote Config - -Provides an async alternative to `addOnConfigUpdateListener`. - -#### API Design - -```swift -extension RemoteConfig { - /// An asynchronous stream of configuration updates. - var updates: AsyncThrowingStream { get } -} -``` - -#### Usage - -```swift -// Listening for real-time config updates -for try await update in RemoteConfig.remoteConfig().updates { - // Activate new config -} -``` - -### 7.6. Cloud Messaging (FCM) - -Provides an async alternative to the delegate-based approach for token updates and foreground messages. - -#### API Design - -```swift -extension Messaging { - /// An asynchronous stream of FCM registration token updates. - var tokenUpdates: AsyncStream { get } - - /// An asynchronous stream of remote messages received while the app is in the foreground. - var foregroundMessages: AsyncStream { get } -} -``` - -#### Usage - -```swift -// Monitoring FCM token updates -for await token in Messaging.messaging().tokenUpdates { - // Send token to server -} -``` - -## 8. Testing Plan - -The quality and reliability of this new API surface will be ensured through a multi-layered testing strategy, covering unit, integration, and cancellation scenarios. - -### 8.1. Unit Tests - -The primary goal of unit tests is to verify the correctness of the `AsyncStream` wrapping logic in isolation from the network and backend services. - -* **Mocking:** Each product's stream implementation will be tested against a mocked version of its underlying service (e.g., a mock `Firestore` client). -* **Behavior Verification:** - * Tests will confirm that initiating a stream correctly registers a listener with the underlying service. - * We will use the mock listeners to simulate events (e.g., new snapshots, auth state changes) and assert that the `AsyncStream` yields the corresponding values correctly. - * Error conditions will be simulated to ensure that the stream correctly throws errors. -* **Teardown Logic:** We will verify that the underlying listener is removed when the stream is either cancelled or finishes naturally. - -### 8.2. Integration Tests - -Integration tests will validate the end-to-end functionality of the async sequences against a live backend environment using the **Firebase Emulator Suite**. - -* **Environment:** A new integration test suite will be created that configures the Firebase SDK to connect to the local emulators (Firestore, Database, Auth, etc.). -* **Validation:** These tests will perform real operations (e.g., writing a document and then listening to its `snapshots` stream) to verify that real-time updates are correctly received and propagated through the `AsyncSequence` API. -* **Cross-Product Scenarios:** We will test scenarios that involve multiple Firebase products where applicable. - -### 8.3. Cancellation Behavior Tests - -A specific set of tests will be dedicated to ensuring that resource cleanup (i.e., listener removal) happens correctly and promptly when the consuming task is cancelled. - -* **Test Scenario:** - 1. A stream will be consumed within a Swift `Task`. - 2. The `Task` will be cancelled immediately after the stream is initiated. - 3. Using a mock or a spy object, we will assert that the `remove()` method on the underlying listener registration is called. -* **Importance:** This is critical for preventing resource leaks and ensuring the new API behaves predictably within the Swift structured concurrency model, especially in SwiftUI contexts where tasks are automatically managed. - -## 9. Implementation Plan - -The implementation will be phased, with each product's API being added in a separate Pull Request to facilitate focused reviews. - -* **Firestore:** [PR #14924: Support AsyncStream in realtime query](https://github.com/firebase/firebase-ios-sdk/pull/14924) -* **Authentication:** [Link to PR when available] -* **Realtime Database:** [Link to PR when available] -* ...and so on. - -## 10. Open Questions & Future Work - -* Should we provide convenience wrappers for common `AsyncSequence` operators? (e.g., a method to directly stream decoded objects instead of snapshots). For now, this is considered a **Non-Goal** but could be revisited. \ No newline at end of file From 32c80343812f38d309a764543f4375e3bd6588fb Mon Sep 17 00:00:00 2001 From: Peter Friese Date: Tue, 30 Sep 2025 11:49:18 +0200 Subject: [PATCH 14/17] Rename stream from `updates` to `configUpdates` to better align with the naming scheme --- .../Swift/RemoteConfig+Async.swift | 6 +++--- .../Tests/Swift/SwiftAPI/AsyncStreamTests.swift | 14 +++++++------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/FirebaseRemoteConfig/Swift/RemoteConfig+Async.swift b/FirebaseRemoteConfig/Swift/RemoteConfig+Async.swift index e7dcd978e17..663df2f7f96 100644 --- a/FirebaseRemoteConfig/Swift/RemoteConfig+Async.swift +++ b/FirebaseRemoteConfig/Swift/RemoteConfig+Async.swift @@ -35,7 +35,7 @@ public extension RemoteConfig { /// func listenForRealtimeUpdates() { /// Task { /// do { - /// for try await configUpdate in remoteConfig.updates { + /// 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() @@ -47,8 +47,8 @@ public extension RemoteConfig { /// } /// } /// ``` - var updates: AsyncThrowingStream { - return AsyncThrowingStream { continuation in + var configUpdates: AsyncThrowingStream { + AsyncThrowingStream { continuation in let listener = addOnConfigUpdateListener { update, error in switch (update, error) { case let (update?, _): diff --git a/FirebaseRemoteConfig/Tests/Swift/SwiftAPI/AsyncStreamTests.swift b/FirebaseRemoteConfig/Tests/Swift/SwiftAPI/AsyncStreamTests.swift index 7d344acc7d3..08d39326392 100644 --- a/FirebaseRemoteConfig/Tests/Swift/SwiftAPI/AsyncStreamTests.swift +++ b/FirebaseRemoteConfig/Tests/Swift/SwiftAPI/AsyncStreamTests.swift @@ -33,7 +33,7 @@ class MockListenerRegistration: ConfigUpdateListenerRegistration, @unchecked Sen /// 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 `updates` async stream. + /// The listener closure captured from the `configUpdates` async stream. var listener: ((RemoteConfigUpdate?, Error?) -> Void)? let mockRegistration = MockListenerRegistration() var listenerAttachedExpectation: XCTestExpectation? @@ -62,7 +62,7 @@ class MockRealtime: RCNConfigRealtime, @unchecked Sendable { } } -// MARK: - AsyncStreamTests2 +// MARK: - AsyncStreamTests @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) class AsyncStreamTests: XCTestCase { @@ -107,7 +107,7 @@ class AsyncStreamTests: XCTestCase { mockRealtime.listenerAttachedExpectation = listenerAttachedExpectation let listeningTask = Task { - for try await update in config.updates { + for try await update in config.configUpdates { XCTAssertEqual(update.updatedKeys, Set(keysToUpdate)) expectation.fulfill() break // End the loop after receiving the expected update. @@ -132,7 +132,7 @@ class AsyncStreamTests: XCTestCase { let listeningTask = Task { do { - for try await _ in config.updates { + for try await _ in config.configUpdates { XCTFail("Stream should not have yielded any updates.") } } catch { @@ -155,7 +155,7 @@ class AsyncStreamTests: XCTestCase { mockRealtime.listenerAttachedExpectation = listenerAttachedExpectation let listeningTask = Task { - for try await _ in config.updates { + for try await _ in config.configUpdates { // We will cancel the task, so it should not reach here. } } @@ -185,7 +185,7 @@ class AsyncStreamTests: XCTestCase { let listeningTask = Task { var updateCount = 0 do { - for try await _ in config.updates { + for try await _ in config.configUpdates { updateCount += 1 } // The loop finished without throwing, which is the success condition. @@ -217,7 +217,7 @@ class AsyncStreamTests: XCTestCase { mockRealtime.listenerAttachedExpectation = listenerAttachedExpectation let listeningTask = Task { - for try await update in config.updates { + for try await update in config.configUpdates { receivedUpdates.append(update.updatedKeys) expectation.fulfill() if receivedUpdates.count == updatesToSend.count { From 12c08c0498d84696e4c6a995b56c4bab90dff7cf Mon Sep 17 00:00:00 2001 From: Peter Friese Date: Wed, 1 Oct 2025 06:33:49 +0200 Subject: [PATCH 15/17] Return stream as an AsyncSequence --- FirebaseRemoteConfig/Swift/RemoteConfig+Async.swift | 3 ++- .../{AsyncStreamTests.swift => AsyncSequenceTests.swift} | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) rename FirebaseRemoteConfig/Tests/Swift/SwiftAPI/{AsyncStreamTests.swift => AsyncSequenceTests.swift} (99%) diff --git a/FirebaseRemoteConfig/Swift/RemoteConfig+Async.swift b/FirebaseRemoteConfig/Swift/RemoteConfig+Async.swift index 663df2f7f96..57982ddfbbf 100644 --- a/FirebaseRemoteConfig/Swift/RemoteConfig+Async.swift +++ b/FirebaseRemoteConfig/Swift/RemoteConfig+Async.swift @@ -47,7 +47,8 @@ public extension RemoteConfig { /// } /// } /// ``` - var configUpdates: AsyncThrowingStream { + @available(iOS 18.0, *) + var configUpdates: some AsyncSequence { AsyncThrowingStream { continuation in let listener = addOnConfigUpdateListener { update, error in switch (update, error) { diff --git a/FirebaseRemoteConfig/Tests/Swift/SwiftAPI/AsyncStreamTests.swift b/FirebaseRemoteConfig/Tests/Swift/SwiftAPI/AsyncSequenceTests.swift similarity index 99% rename from FirebaseRemoteConfig/Tests/Swift/SwiftAPI/AsyncStreamTests.swift rename to FirebaseRemoteConfig/Tests/Swift/SwiftAPI/AsyncSequenceTests.swift index 08d39326392..e8ad32d1c1a 100644 --- a/FirebaseRemoteConfig/Tests/Swift/SwiftAPI/AsyncStreamTests.swift +++ b/FirebaseRemoteConfig/Tests/Swift/SwiftAPI/AsyncSequenceTests.swift @@ -65,7 +65,7 @@ class MockRealtime: RCNConfigRealtime, @unchecked Sendable { // MARK: - AsyncStreamTests @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) -class AsyncStreamTests: XCTestCase { +class AsyncSequenceTests: XCTestCase { var app: FirebaseApp! var config: RemoteConfig! var mockRealtime: MockRealtime! From 39133d61c3b55b7b34725149ff17fe6e4e9a2153 Mon Sep 17 00:00:00 2001 From: Peter Friese Date: Wed, 1 Oct 2025 08:04:33 +0100 Subject: [PATCH 16/17] Update tests --- .../Swift/SwiftAPI/AsyncSequenceTests.swift | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/FirebaseRemoteConfig/Tests/Swift/SwiftAPI/AsyncSequenceTests.swift b/FirebaseRemoteConfig/Tests/Swift/SwiftAPI/AsyncSequenceTests.swift index e8ad32d1c1a..3cc8448ac21 100644 --- a/FirebaseRemoteConfig/Tests/Swift/SwiftAPI/AsyncSequenceTests.swift +++ b/FirebaseRemoteConfig/Tests/Swift/SwiftAPI/AsyncSequenceTests.swift @@ -62,7 +62,7 @@ class MockRealtime: RCNConfigRealtime, @unchecked Sendable { } } -// MARK: - AsyncStreamTests +// MARK: - AsyncSequenceTests @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) class AsyncSequenceTests: XCTestCase { @@ -99,8 +99,8 @@ class AsyncSequenceTests: XCTestCase { try super.tearDownWithError() } - func testStreamYieldsUpdate_whenUpdateIsSent() async throws { - let expectation = self.expectation(description: "Stream should yield an update.") + 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.") @@ -123,8 +123,8 @@ class AsyncSequenceTests: XCTestCase { listeningTask.cancel() } - func testStreamFinishes_whenErrorIsSent() async throws { - let expectation = self.expectation(description: "Stream should throw an error.") + 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.") @@ -150,7 +150,7 @@ class AsyncSequenceTests: XCTestCase { listeningTask.cancel() } - func testStreamCancellation_callsRemoveOnListener() async throws { + func testSequenceCancellation_callsRemoveOnListener() async throws { let listenerAttachedExpectation = expectation(description: "Listener should be attached.") mockRealtime.listenerAttachedExpectation = listenerAttachedExpectation @@ -176,8 +176,8 @@ class AsyncSequenceTests: XCTestCase { XCTAssertTrue(mockRealtime.mockRegistration.wasRemoveCalled) } - func testStreamFinishesGracefully_whenListenerSendsNil() async throws { - let expectation = self.expectation(description: "Stream should finish without error.") + 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 @@ -203,8 +203,8 @@ class AsyncSequenceTests: XCTestCase { listeningTask.cancel() } - func testStreamYieldsMultipleUpdates_whenMultipleUpdatesAreSent() async throws { - let expectation = self.expectation(description: "Stream should receive two updates.") + func testSequenceYieldsMultipleUpdates_whenMultipleUpdatesAreSent() async throws { + let expectation = self.expectation(description: "Sequence should receive two updates.") expectation.expectedFulfillmentCount = 2 let updatesToSend = [ From b745465dffbec1d1860f03bfd512184a3c25bbd7 Mon Sep 17 00:00:00 2001 From: Peter Friese Date: Thu, 2 Oct 2025 22:53:57 +0100 Subject: [PATCH 17/17] Implement feedback from review --- FirebaseRemoteConfig/CHANGELOG.md | 4 ++-- FirebaseRemoteConfig/Swift/RemoteConfig+Async.swift | 6 +++--- .../Tests/Swift/SwiftAPI/AsyncSequenceTests.swift | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/FirebaseRemoteConfig/CHANGELOG.md b/FirebaseRemoteConfig/CHANGELOG.md index 5837b9daeaf..e77f729c5d0 100644 --- a/FirebaseRemoteConfig/CHANGELOG.md +++ b/FirebaseRemoteConfig/CHANGELOG.md @@ -1,6 +1,6 @@ # Unreleased -- [added] Introduced a new `updates` property to `RemoteConfig` that - provides an `AsyncThrowingStream` for consuming real-time config updates. +- [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. diff --git a/FirebaseRemoteConfig/Swift/RemoteConfig+Async.swift b/FirebaseRemoteConfig/Swift/RemoteConfig+Async.swift index 57982ddfbbf..783a186099d 100644 --- a/FirebaseRemoteConfig/Swift/RemoteConfig+Async.swift +++ b/FirebaseRemoteConfig/Swift/RemoteConfig+Async.swift @@ -16,7 +16,7 @@ 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 `AsyncThrowingStream` that provides real-time updates to the configuration. + /// 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 @@ -26,7 +26,7 @@ public extension RemoteConfig { /// The underlying listener is automatically added when you begin iterating and is removed when /// the iteration is cancelled or finishes. /// - /// - Throws: `RemoteConfigUpdateError` if the listener encounters a server-side error or another + /// - Throws: An `Error` if the listener encounters a server-side error or another /// issue, causing the stream to terminate. /// /// ### Example Usage @@ -47,7 +47,7 @@ public extension RemoteConfig { /// } /// } /// ``` - @available(iOS 18.0, *) + @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) var configUpdates: some AsyncSequence { AsyncThrowingStream { continuation in let listener = addOnConfigUpdateListener { update, error in diff --git a/FirebaseRemoteConfig/Tests/Swift/SwiftAPI/AsyncSequenceTests.swift b/FirebaseRemoteConfig/Tests/Swift/SwiftAPI/AsyncSequenceTests.swift index 3cc8448ac21..a168c814638 100644 --- a/FirebaseRemoteConfig/Tests/Swift/SwiftAPI/AsyncSequenceTests.swift +++ b/FirebaseRemoteConfig/Tests/Swift/SwiftAPI/AsyncSequenceTests.swift @@ -64,7 +64,7 @@ class MockRealtime: RCNConfigRealtime, @unchecked Sendable { // MARK: - AsyncSequenceTests -@available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) +@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!