-
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
Open
peterfriese
wants to merge
17
commits into
main
Choose a base branch
from
peterfriese/asyncsequences/remoteconfig
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+319
−0
Open
Changes from 10 commits
Commits
Show all changes
17 commits
Select commit
Hold shift + click to select a range
5cd140b
Add design document
peterfriese 8a6390c
Cleanup
peterfriese 45b8a27
Initial implementation of AsyncStream for RC + basic test
peterfriese 75f2daf
Add more complete unit tests
peterfriese b15e24a
Formatting
peterfriese fdd1aae
Fix copyright and add Sendable conformance
peterfriese 2487abe
Add changelog entry
peterfriese 17548a7
Update FirebaseRemoteConfig/CHANGELOG.md
peterfriese b99276d
Fix typo
peterfriese a963b69
Fix conflicting API in API design proposal for Cloud Storage
peterfriese 8b8bdbc
Simplify listener attachment syncronisation
peterfriese bc822c0
Fix styling
peterfriese e1088fe
Remove design doc in favour of adding it via a separate PR
peterfriese 32c8034
Rename stream from `updates` to `configUpdates` to better align with …
peterfriese 12c08c0
Return stream as an AsyncSequence
peterfriese 39133d6
Update tests
peterfriese b745465
Implement feedback from review
peterfriese File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,71 @@ | ||
// 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.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)") | ||
/// } | ||
/// } | ||
/// } | ||
/// ``` | ||
var updates: AsyncThrowingStream<RemoteConfigUpdate, Error> { | ||
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() | ||
} | ||
} | ||
} | ||
} |
225 changes: 225 additions & 0 deletions
225
FirebaseRemoteConfig/Tests/Swift/SwiftAPI/AsyncStreamTests.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,225 @@ | ||
// 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 `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: 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 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. | ||
} | ||
} | ||
|
||
// Ensure the listener is attached before sending the update. | ||
try await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds | ||
peterfriese marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
||
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<String>] = [] | ||
|
||
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() | ||
} | ||
} |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.