diff --git a/FirebaseRemoteConfig/CHANGELOG.md b/FirebaseRemoteConfig/CHANGELOG.md index 91ba0e80f24..e77f729c5d0 100644 --- a/FirebaseRemoteConfig/CHANGELOG.md +++ b/FirebaseRemoteConfig/CHANGELOG.md @@ -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.3.0 - [fixed] Add missing GoogleUtilities dependency to fix SwiftPM builds when building dynamically linked libraries. (#15276) diff --git a/FirebaseRemoteConfig/Swift/RemoteConfig+Async.swift b/FirebaseRemoteConfig/Swift/RemoteConfig+Async.swift new file mode 100644 index 00000000000..783a186099d --- /dev/null +++ b/FirebaseRemoteConfig/Swift/RemoteConfig+Async.swift @@ -0,0 +1,72 @@ +// 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: some AsyncSequence { + 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/AsyncSequenceTests.swift b/FirebaseRemoteConfig/Tests/Swift/SwiftAPI/AsyncSequenceTests.swift new file mode 100644 index 00000000000..a168c814638 --- /dev/null +++ b/FirebaseRemoteConfig/Tests/Swift/SwiftAPI/AsyncSequenceTests.swift @@ -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] = [] + + 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() + } +}