-
Notifications
You must be signed in to change notification settings - Fork 1.7k
Add AsyncStream support for Remote Config #15352
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 16 commits
5cd140b
8a6390c
45b8a27
75f2daf
b15e24a
fdd1aae
2487abe
17548a7
b99276d
a963b69
8b8bdbc
bc822c0
e1088fe
32c8034
12c08c0
39133d6
b745465
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 `AsyncThrowingStream` that provides real-time updates to the configuration. | ||
peterfriese marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
/// | ||
/// 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.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(iOS 18.0, *) | ||
peterfriese marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
var configUpdates: some AsyncSequence<RemoteConfigUpdate, Error> { | ||
Check failure on line 51 in FirebaseRemoteConfig/Swift/RemoteConfig+Async.swift
|
||
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() | ||
} | ||
} | ||
} | ||
} |
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(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) | ||
peterfriese marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
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 { | ||
Check failure on line 110 in FirebaseRemoteConfig/Tests/Swift/SwiftAPI/AsyncSequenceTests.swift
|
||
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 { | ||
Check failure on line 135 in FirebaseRemoteConfig/Tests/Swift/SwiftAPI/AsyncSequenceTests.swift
|
||
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 { | ||
Check failure on line 158 in FirebaseRemoteConfig/Tests/Swift/SwiftAPI/AsyncSequenceTests.swift
|
||
// 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 { | ||
Check failure on line 188 in FirebaseRemoteConfig/Tests/Swift/SwiftAPI/AsyncSequenceTests.swift
|
||
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 { | ||
Check failure on line 220 in FirebaseRemoteConfig/Tests/Swift/SwiftAPI/AsyncSequenceTests.swift
|
||
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() | ||
} | ||
} |
Uh oh!
There was an error while loading. Please reload this page.