From b1624c8ba73ffc81448dbf6766e7bca2afae1154 Mon Sep 17 00:00:00 2001 From: fhasse95 <49185957+fhasse95@users.noreply.github.com> Date: Wed, 5 Aug 2026 23:12:37 +0200 Subject: [PATCH] Added Real Time Remote Config Update Support --- Package.swift | 2 +- README.md | 25 +++++++- .../SkipFirebaseRemoteConfig.swift | 64 +++++++++++++++++++ 3 files changed, 88 insertions(+), 3 deletions(-) diff --git a/Package.swift b/Package.swift index 22535a0..2ef1749 100644 --- a/Package.swift +++ b/Package.swift @@ -7,7 +7,7 @@ let skipstone = [Target.PluginUsage.plugin(name: "skipstone", package: "skip")] let package = Package( name: "skip-firebase", defaultLocalization: "en", - platforms: [.iOS(.v17), .macOS(.v14), .tvOS(.v17), .watchOS(.v10), .macCatalyst(.v17)], + platforms: [.iOS(.v16), .macOS(.v13), .tvOS(.v16), .watchOS(.v9), .macCatalyst(.v16)], products: [ .library(name: "SkipFirebaseCore", targets: ["SkipFirebaseCore"]), .library(name: "SkipFirebaseFirestore", targets: ["SkipFirebaseFirestore"]), diff --git a/README.md b/README.md index aec4909..8759130 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ See the `Package.swift` files in the | `SkipFirebaseStorage` | ~80% | Bucket and reference resolution, upload (`putFile`/`putData` in both callback and `async` forms), download (`getData`/`write(toFile:)`), `StorageMetadata` read/write, `downloadURL`, `delete`, pause/resume/cancel on uploads, `list`/`listAll` with pagination (`pageToken`), live `Progress` snapshot observers on uploads and file downloads | Full `StorageError` code mapping, `putStream`/`putString` | | `SkipFirebaseMessaging` | ~70% | FCM token retrieval + auto-refresh, topic subscribe/unsubscribe, `MessagingDelegate`, `MessagingService` integrating with iOS-style `UNUserNotificationCenterDelegate`, intent routing, localized `loc_key`/`loc_args` title/body, `RemoteMessage` → `UNNotification` translation | Per-sender FCM token APIs and notification-service-extension helpers (`populateNotificationContent`, `exportDeliveryMetricsToBigQuery`) are stubbed out as `@available(*, unavailable)` | | `SkipFirebaseAnalytics` | ~70% | `logEvent`, user properties/ID, consent (`setConsent` with `ConsentType`/`ConsentStatus`), session ID, `setSessionTimeoutInterval`, `setDefaultEventParameters`, `appInstanceID`, all standard event/parameter/user-property name constants, SwiftUI `.analyticsScreen` modifier | `initiateOnDeviceConversionMeasurement` (email/phone variants), `handleEvents(forSession:)`, deep-link helpers | -| `SkipFirebaseRemoteConfig` | ~70% | `fetch`/`activate`/`fetchAndActivate`, `ensureInitialized`, value retrieval, keys-by-prefix, `RemoteConfigSettings` (intervals + timeout), defaults, `RemoteConfigValue` (string/bool/number/data/json/source) | `addOnConfigUpdateListener` (real-time config updates), `setCustomSignals` | +| `SkipFirebaseRemoteConfig` | ~75% | `fetch`/`activate`/`fetchAndActivate`, `ensureInitialized`, real-time config updates (`addOnConfigUpdateListener`), value retrieval, keys-by-prefix, `RemoteConfigSettings` (intervals + timeout), defaults, `RemoteConfigValue` (string/bool/number/data/json/source) | `setCustomSignals` | | `SkipFirebaseAppCheck` | ~60% | Token retrieval (`token(forcingRefresh:)`, `limitedUseToken`), token-change listener bridged through `NotificationCenter`, debug provider factory, `AppCheckErrorCode` | iOS-only `DeviceCheckProviderFactory`/`AppAttestProviderFactory` (no Android equivalent), custom `AppCheckProvider` implementations | | `SkipFirebaseCrashlytics` | ~75% | `log`, `setCustomValue`/`setCustomKeysAndValues`, `setUserID`, `didCrashDuringPreviousExecution`, `checkForUnsentReports`/`sendUnsentReports`/`deleteUnsentReports`, `record(error:userInfo:)`, `recordExceptionModel`, `FIRExceptionModel`/`FIRStackFrame` | `logWithFormat` and `checkAndUpdateUnsentReports` are marked `@available(*, unavailable)`; no per-`FirebaseApp` instance accessor | | `SkipFirebaseFunctions` | ~75% | Default/regional/emulator instance, `httpsCallable(_:)` and `httpsCallable(_:options:)` (name and URL-based), `HTTPSCallableOptions` with `requireLimitedUseAppCheckTokens`, `async`/`await` `call`, completion-based `call`, `timeoutInterval`, automatic Kotlin→Swift result conversion via `deepSwift` | Streaming RPCs (`stream`), `Callable` Codable wrapper, `customDomain`-based factory | @@ -54,7 +54,7 @@ See the `Package.swift` files in the - **`runTransaction`** in Firestore is not implemented — the `Transaction` class is currently a passthrough wrapper with no operations. Multi-document atomic reads-then-writes need to be expressed as `WriteBatch` commits or as Kotlin-side code today. - **`Codable` decoding API differs by platform** — on Android use `snapshot.decoded()` with explicit type annotation (`let m: MyModel = try snapshot.decoded()`); on iOS use `FirebaseFirestoreSwift`'s `snapshot.data(as: MyModel.self)`. The `setData(from:)` encoding API works identically on both platforms. - **`SkipFirebaseFunctions`** does not support streaming RPCs (`stream`). The Firebase Android SDK has no SSE/streaming equivalent to the iOS `AsyncThrowingStream`-based `stream` API — Android Functions calls are always one-shot request/response. Any code that iterates over `.stream(...)` on iOS will not compile on Android. The `Callable` Codable convenience wrapper is also not bridged; use `call(_ data: Any?)` directly and decode the result manually on Android. -- **`SkipFirebaseRemoteConfig`** does not yet expose `addOnConfigUpdateListener` (real-time config updates) or custom signals. +- **`SkipFirebaseRemoteConfig`** does not yet expose custom signals. - **`SkipFirebaseAppCheck`** ships only the `Debug` provider factory; custom provider implementations are not yet bridgeable. - **`SkipFirebaseInstallations`** cannot bridge the `InstallationIDDidChange` notification or the `InstallationIDDidChangeAppNameKey` userInfo key. The Firebase Android SDK has no equivalent push-notification mechanism for installation ID changes — it exposes no broadcast, callback, or listener that fires when the ID rotates. As a result, any code that observes `NotificationCenter.default.publisher(for: .InstallationIDDidChange)` on iOS will compile on Android but never receive an event. If your app relies on this to, for example, re-upload the installation ID to your backend after it changes, you will need an alternative strategy on Android (such as fetching the ID on every app foreground, or polling after Firebase SDK upgrades). @@ -193,6 +193,27 @@ public actor Model { } ``` +## Remote Config + +`SkipFirebaseRemoteConfig` supports fetching values manually and listening for real-time Remote Config updates. Realtime updates are delivered through `addOnConfigUpdateListener`; activate the fetched values in the callback when your UI can safely apply the change. + +```swift +import SkipFirebaseRemoteConfig + +let remoteConfig = RemoteConfig.remoteConfig() +let registration = remoteConfig.addOnConfigUpdateListener { update, error in + guard error == nil else { return } + + Task { + _ = try? await remoteConfig.activate() + // Read updated values with configValue(forKey:) here. + } +} + +// Later, when the listener is no longer needed: +registration.remove() +``` + ## Messaging After [setting up](#setup) your app to use Firebase, enabling push notifications via Firebase Cloud Messaging (FCM) requires a number of additional steps. diff --git a/Sources/SkipFirebaseRemoteConfig/SkipFirebaseRemoteConfig.swift b/Sources/SkipFirebaseRemoteConfig/SkipFirebaseRemoteConfig.swift index 963e350..2d9cc01 100644 --- a/Sources/SkipFirebaseRemoteConfig/SkipFirebaseRemoteConfig.swift +++ b/Sources/SkipFirebaseRemoteConfig/SkipFirebaseRemoteConfig.swift @@ -62,6 +62,16 @@ public final class RemoteConfig { let _: com.google.firebase.remoteconfig.FirebaseRemoteConfigInfo = remoteconfig.ensureInitialized().await() } + /// Adds a listener that receives real-time Remote Config updates. + /// https://firebase.google.com/docs/reference/swift/firebaseremoteconfig/api/reference/Classes/RemoteConfig#addonconfigupdatelistener(remoteconfigupdatecompletion:) + public func addOnConfigUpdateListener( + remoteConfigUpdateCompletion listener: @escaping (RemoteConfigUpdate?, Error?) -> Void + ) -> ConfigUpdateListenerRegistration { + let androidListener = RemoteConfigUpdateListener(listener: listener) + let registration = remoteconfig.addOnConfigUpdateListener(androidListener) + return ConfigUpdateListenerRegistration(registration: registration) + } + // MARK: - Get values public func configValue(forKey key: String?) -> RemoteConfigValue { @@ -139,6 +149,60 @@ public final class RemoteConfig { } } +// MARK: - Real-time Updates + +public final class RemoteConfigUpdate { + public let platformValue: com.google.firebase.remoteconfig.ConfigUpdate + + public init(platformValue: com.google.firebase.remoteconfig.ConfigUpdate) { + self.platformValue = platformValue + } + + /// The parameter keys that changed in this update. + public var updatedKeys: Set { + var result = Set() + for key in platformValue.getUpdatedKeys() { + result.insert(key) + } + return result + } +} + +public class ConfigUpdateListenerRegistration: KotlinConverting { + public let registration: com.google.firebase.remoteconfig.ConfigUpdateListenerRegistration + + public init(registration: com.google.firebase.remoteconfig.ConfigUpdateListenerRegistration) { + self.registration = registration + } + + // SKIP @nooverride + public override func kotlin(nocopy: Bool = false) -> com.google.firebase.remoteconfig.ConfigUpdateListenerRegistration { + registration + } + + /// Stops receiving real-time Remote Config updates for this listener. + public func remove() { + registration.remove() + } +} + +// SKIP @nobridge +private final class RemoteConfigUpdateListener: com.google.firebase.remoteconfig.ConfigUpdateListener { + private let listener: (RemoteConfigUpdate?, Error?) -> Void + + init(listener: @escaping (RemoteConfigUpdate?, Error?) -> Void) { + self.listener = listener + } + + public override func onUpdate(configUpdate: com.google.firebase.remoteconfig.ConfigUpdate) { + listener(RemoteConfigUpdate(platformValue: configUpdate), nil) + } + + public override func onError(error: com.google.firebase.remoteconfig.FirebaseRemoteConfigException) { + listener(nil, ErrorException(error)) + } +} + // MARK: - RemoteConfigValue public final class RemoteConfigValue {