From 3d321094da0afe183cb88e94578a081305b8cc8d Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Fri, 21 Aug 2026 14:08:32 -0500 Subject: [PATCH 1/6] feat: [SDK-5048] call KMP features API and wire flags into OSFeatureManager iOS now consumes the shared Turbine feature-flags client the same way Android does, so flag semantics cannot drift between platforms: KMP owns path building, app-id/SDK-version validation, response parsing, and activation-mode latching, while the host supplies HTTP, persistence, and foreground lifecycle. Bumps the KMP submodule to v0.3.0 for the com.onesignal.features package, and replaces the placeholder empty feature-flag provider in remote logging with the real enabled-key list. Co-authored-by: Cursor --- OneSignal-KMP-SDK | 2 +- .../OneSignal.xcodeproj/project.pbxproj | 32 ++ .../xcschemes/OneSignalOSCore.xcscheme | 10 + .../Source/OneSignalCommonDefines.h | 3 + .../OSFeatureFlagsBackendService.swift | 182 ++++++++++ .../OSFeatureFlagsRefreshService.swift | 249 +++++++++++++ .../Source/Feature/OSFeatureFlagsStore.swift | 107 ++++++ .../Source/Feature/OSFeatureManager.swift | 62 ++++ .../Source/Feature/OSFeatureManagerImpl.swift | 102 ++++++ .../OSFeatureFlagsTests.swift | 335 ++++++++++++++++++ .../Source/OSRemoteLoggingController.swift | 2 +- iOS_SDK/OneSignalSDK/Source/OneSignal.m | 3 + 12 files changed, 1087 insertions(+), 2 deletions(-) create mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Feature/OSFeatureFlagsBackendService.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Feature/OSFeatureFlagsRefreshService.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Feature/OSFeatureFlagsStore.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Feature/OSFeatureManager.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Feature/OSFeatureManagerImpl.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSFeatureFlagsTests.swift diff --git a/OneSignal-KMP-SDK b/OneSignal-KMP-SDK index 17dcabac3..87e87fd26 160000 --- a/OneSignal-KMP-SDK +++ b/OneSignal-KMP-SDK @@ -1 +1 @@ -Subproject commit 17dcabac39240f95ce0e35301842429101248508 +Subproject commit 87e87fd264284448541bfc34b7f1b0673bbe2dbb diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index ab6fc07bd..33c9d7a27 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -75,6 +75,12 @@ 3C14E3B32FAE54C006ED053 /* OSLoggerPlatformProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C14E3AC2FAE54C006ED053 /* OSLoggerPlatformProvider.swift */; }; 3C14E3B42FAE54C006ED053 /* KotlinByteArray+Data.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C14E3AD2FAE54C006ED053 /* KotlinByteArray+Data.swift */; }; 3C14E3B52FAE54C006ED053 /* OSLoggerAdaptersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C14E3AE2FAE54C006ED053 /* OSLoggerAdaptersTests.swift */; }; + A5048F01A1B2C3D4E5F60002 /* OSFeatureFlagsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5048F01A1B2C3D4E5F60001 /* OSFeatureFlagsStore.swift */; }; + A5048F01A1B2C3D4E5F60004 /* OSFeatureFlagsBackendService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5048F01A1B2C3D4E5F60003 /* OSFeatureFlagsBackendService.swift */; }; + A5048F01A1B2C3D4E5F60006 /* OSFeatureManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5048F01A1B2C3D4E5F60005 /* OSFeatureManager.swift */; }; + A5048F01A1B2C3D4E5F6000D /* OSFeatureManagerImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5048F01A1B2C3D4E5F6000C /* OSFeatureManagerImpl.swift */; }; + A5048F01A1B2C3D4E5F60008 /* OSFeatureFlagsRefreshService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5048F01A1B2C3D4E5F60007 /* OSFeatureFlagsRefreshService.swift */; }; + A5048F01A1B2C3D4E5F6000A /* OSFeatureFlagsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5048F01A1B2C3D4E5F60009 /* OSFeatureFlagsTests.swift */; }; C781A33FED62B4B54221A09A /* OSLogCrashHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B6A59620B83538CEFF77269 /* OSLogCrashHandlerTests.swift */; }; B96A3B6BA8CC49EE4796D9BF /* OSRemoteLoggingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A72F938F8A3808AC1FF7F3C /* OSRemoteLoggingController.swift */; }; 25898119922BDCDA7AF0B9CC /* OSRemoteLoggingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A72F938F8A3808AC1FF7F3C /* OSRemoteLoggingController.swift */; }; @@ -1798,6 +1804,12 @@ 3C14E3AC2FAE54C006ED053 /* OSLoggerPlatformProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLoggerPlatformProvider.swift; sourceTree = ""; }; 3C14E3AD2FAE54C006ED053 /* KotlinByteArray+Data.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KotlinByteArray+Data.swift"; sourceTree = ""; }; 3C14E3AE2FAE54C006ED053 /* OSLoggerAdaptersTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLoggerAdaptersTests.swift; sourceTree = ""; }; + A5048F01A1B2C3D4E5F60001 /* OSFeatureFlagsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSFeatureFlagsStore.swift; sourceTree = ""; }; + A5048F01A1B2C3D4E5F60003 /* OSFeatureFlagsBackendService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSFeatureFlagsBackendService.swift; sourceTree = ""; }; + A5048F01A1B2C3D4E5F60005 /* OSFeatureManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSFeatureManager.swift; sourceTree = ""; }; + A5048F01A1B2C3D4E5F6000C /* OSFeatureManagerImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSFeatureManagerImpl.swift; sourceTree = ""; }; + A5048F01A1B2C3D4E5F60007 /* OSFeatureFlagsRefreshService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSFeatureFlagsRefreshService.swift; sourceTree = ""; }; + A5048F01A1B2C3D4E5F60009 /* OSFeatureFlagsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSFeatureFlagsTests.swift; sourceTree = ""; }; 3B6A59620B83538CEFF77269 /* OSLogCrashHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLogCrashHandlerTests.swift; sourceTree = ""; }; 8A72F938F8A3808AC1FF7F3C /* OSRemoteLoggingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSRemoteLoggingController.swift; sourceTree = ""; }; C0462F96E1AADF655F3B3765 /* OSRemoteLoggingController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OSRemoteLoggingController.h; sourceTree = ""; }; @@ -2269,6 +2281,7 @@ isa = PBXGroup; children = ( 5BC1DE652C90BC9F00CA8807 /* Consistency */, + A5048F01A1B2C3D4E5F6000B /* Feature */, 3C14E3AF2FAE54C006ED053 /* Logging */, 3C115163289A259500565C41 /* OneSignalOSCore.h */, 3C115188289ADEA300565C41 /* OSModelStore.swift */, @@ -2306,6 +2319,18 @@ path = Logging; sourceTree = ""; }; + A5048F01A1B2C3D4E5F6000B /* Feature */ = { + isa = PBXGroup; + children = ( + A5048F01A1B2C3D4E5F60003 /* OSFeatureFlagsBackendService.swift */, + A5048F01A1B2C3D4E5F60007 /* OSFeatureFlagsRefreshService.swift */, + A5048F01A1B2C3D4E5F60001 /* OSFeatureFlagsStore.swift */, + A5048F01A1B2C3D4E5F60005 /* OSFeatureManager.swift */, + A5048F01A1B2C3D4E5F6000C /* OSFeatureManagerImpl.swift */, + ); + path = Feature; + sourceTree = ""; + }; 3C70222A2ECF126B001768C6 /* OneSignalInAppMessagesMocks */ = { isa = PBXGroup; children = ( @@ -2574,6 +2599,7 @@ 5BC1DE672C90C23E00CA8807 /* OSConsistencyManagerTests.swift */, 3C427AC8301BB28A0059B8B7 /* OSOperationRepoFlushTests.swift */, 3C14E3AE2FAE54C006ED053 /* OSLoggerAdaptersTests.swift */, + A5048F01A1B2C3D4E5F60009 /* OSFeatureFlagsTests.swift */, 3B6A59620B83538CEFF77269 /* OSLogCrashHandlerTests.swift */, 3C23A21A2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift */, 3C23A21E2FCE0AA1001D32E3 /* OSResilientStorageTests.swift */, @@ -4446,6 +4472,11 @@ 3C14E3B22FAE54C006ED053 /* FileLogStore.swift in Sources */, 5BC1DE602C90B83900CA8807 /* OSConsistencyKeyEnum.swift in Sources */, 3C14E3B12FAE54C006ED053 /* OneSignalLogHttpSender.swift in Sources */, + A5048F01A1B2C3D4E5F60002 /* OSFeatureFlagsStore.swift in Sources */, + A5048F01A1B2C3D4E5F60004 /* OSFeatureFlagsBackendService.swift in Sources */, + A5048F01A1B2C3D4E5F60006 /* OSFeatureManager.swift in Sources */, + A5048F01A1B2C3D4E5F6000D /* OSFeatureManagerImpl.swift in Sources */, + A5048F01A1B2C3D4E5F60008 /* OSFeatureFlagsRefreshService.swift in Sources */, 3C4F9E4428A4466C009F453A /* OSOperationRepo.swift in Sources */, 3C14E3B02FAE54C006ED053 /* IOSLogger.swift in Sources */, 698F58A488FCE503DFD5247F /* OSLogCrashHandler.swift in Sources */, @@ -4611,6 +4642,7 @@ 3C23A21D2FCE0A83001D32E3 /* OSModelStoreRefreshTests.swift in Sources */, 3C427AC9301BB28A0059B8B7 /* OSOperationRepoFlushTests.swift in Sources */, 3C14E3B52FAE54C006ED053 /* OSLoggerAdaptersTests.swift in Sources */, + A5048F01A1B2C3D4E5F6000A /* OSFeatureFlagsTests.swift in Sources */, C781A33FED62B4B54221A09A /* OSLogCrashHandlerTests.swift in Sources */, 3C23A21B2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift in Sources */, ); diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/xcshareddata/xcschemes/OneSignalOSCore.xcscheme b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/xcshareddata/xcschemes/OneSignalOSCore.xcscheme index 2aa670e4a..635df8269 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/xcshareddata/xcschemes/OneSignalOSCore.xcscheme +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/xcshareddata/xcschemes/OneSignalOSCore.xcscheme @@ -28,6 +28,16 @@ selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES"> + + + + Void + + private let client: FeatureFlagsClient + private let sdkVersionProvider: () -> String + + init( + http: IFeatureFlagsHttp, + sdkVersionProvider: @escaping () -> String = { ONESIGNAL_VERSION } + ) { + self.client = FeatureFlagsClient(http: http) + self.sdkVersionProvider = sdkVersionProvider + } + + convenience init( + requestSender: @escaping OSFeatureFlagsHttpAdapter.RequestSender = OSFeatureFlagsHttpAdapter.defaultSender + ) { + self.init(http: OSFeatureFlagsHttpAdapter(requestSender: requestSender)) + } + + func fetchRemoteFeatureFlags(appId: String, completion: @escaping FetchCompletion) { + OneSignalLog.onesignalLog( + .LL_DEBUG, + message: "FeatureFlagsBackendService.fetchRemoteFeatureFlags(appId=\(appId))" + ) + let sdkVersion = sdkVersionProvider() + client.fetchRemoteFeatureFlags(appId: appId, platform: Self.turbineFeaturesPlatformIOS, sdkVersion: sdkVersion) { + outcome, + error in + let resolved: RemoteFeatureFlagsFetchOutcome + if let outcome { + resolved = outcome + } else { + resolved = RemoteFeatureFlagsFetchOutcome.companion.unavailable( + reason: .nonSuccessHttp, + statusCode: KotlinInt(int: 0), + bodySnippet: error?.localizedDescription ?? "" + ) + } + if resolved.isUnavailable { + Self.logUnavailable(resolved, appId: appId, sdkVersion: sdkVersion) + } + completion(resolved) + } + } + + private static func logUnavailable( + _ outcome: RemoteFeatureFlagsFetchOutcome, + appId: String, + sdkVersion: String + ) { + let reason = outcome.reason + if reason == RemoteFeatureFlagsUnavailableReason.invalidAppId { + OneSignalLog.onesignalLog( + .LL_WARN, + message: "FeatureFlagsBackendService: app id not usable for Turbine path: '\(appId)'" + ) + } else if reason == RemoteFeatureFlagsUnavailableReason.invalidSdkVersion { + OneSignalLog.onesignalLog( + .LL_WARN, + message: "FeatureFlagsBackendService: sdk version not usable for Turbine path (expected " + + "6-digit label optional -suffix, e.g. 050801 or 050801-beta): '\(sdkVersion)'" + ) + } else if reason == RemoteFeatureFlagsUnavailableReason.nonSuccessHttp { + let message = + "FeatureFlagsBackendService: non-success status=\(statusDescription(outcome.statusCode)) " + + "body=\(outcome.bodySnippet ?? "")" + if outcome.isClientError { + OneSignalLog.onesignalLog(.LL_WARN, message: message) + } else { + OneSignalLog.onesignalLog(.LL_DEBUG, message: message) + } + } else if reason == RemoteFeatureFlagsUnavailableReason.emptyBody { + OneSignalLog.onesignalLog( + .LL_WARN, + message: "FeatureFlagsBackendService: empty body for success status=\(statusDescription(outcome.statusCode))" + ) + } else if reason == RemoteFeatureFlagsUnavailableReason.invalidJson { + OneSignalLog.onesignalLog( + .LL_WARN, + message: "FeatureFlagsBackendService: response body is not valid Turbine feature-flags JSON: " + + (outcome.bodySnippet ?? "") + ) + } else { + OneSignalLog.onesignalLog( + .LL_WARN, + message: "FeatureFlagsBackendService: unavailable without reason" + ) + } + } + + private static func statusDescription(_ statusCode: KotlinInt?) -> String { + statusCode.map { "\($0.intValue)" } ?? "nil" + } +} + +/// URLSession (or injected sender) as `IFeatureFlagsHttp`. Resolves KMP's relative path +/// against `OS_API_SERVER_URL` and attaches the same SDK-Version / Accept headers as +/// `OneSignalRequest`. +final class OSFeatureFlagsHttpAdapter: IFeatureFlagsHttp { + typealias RequestSender = ( + URLRequest, + @escaping (Data?, URLResponse?, Error?) -> Void + ) -> Void + + private static let requestTimeout: TimeInterval = 10 + + static let defaultSender: RequestSender = { request, completion in + defaultSession.dataTask(with: request, completionHandler: completion).resume() + } + + private static let defaultSession: URLSession = { + let configuration = URLSessionConfiguration.default + configuration.timeoutIntervalForRequest = requestTimeout + configuration.timeoutIntervalForResource = requestTimeout + configuration.requestCachePolicy = .reloadIgnoringLocalCacheData + return URLSession(configuration: configuration) + }() + + private let requestSender: RequestSender + + init(requestSender: @escaping RequestSender = OSFeatureFlagsHttpAdapter.defaultSender) { + self.requestSender = requestSender + } + + func get(relativePath: String, completionHandler: @escaping (FeatureFlagsHttpResponse?, Error?) -> Void) { + let request = OneSignalRequest() + request.method = GET + request.path = relativePath + request.disableLocalCaching = true + requestSender(request.urlRequest() as URLRequest) { data, response, error in + let statusCode: Int32 + if error != nil { + statusCode = 0 + } else if let http = response as? HTTPURLResponse { + statusCode = Int32(http.statusCode) + } else { + statusCode = 0 + } + let body = data.flatMap { String(data: $0, encoding: .utf8) } + completionHandler(FeatureFlagsHttpResponse(statusCode: statusCode, body: body), nil) + } + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Feature/OSFeatureFlagsRefreshService.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Feature/OSFeatureFlagsRefreshService.swift new file mode 100644 index 000000000..fe3deaed2 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Feature/OSFeatureFlagsRefreshService.swift @@ -0,0 +1,249 @@ +/* + Modified MIT License + + Copyright 2026 OneSignal + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + 1. The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + 2. All copies of substantial portions of the Software may only be used in connection + with services provided by OneSignal. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ + +import Foundation +import OneSignalCore +import UIKit +@_implementationOnly import OneSignalKMP + +/// Fetches remote SDK feature flags when the app is in the foreground, immediately on +/// focus and then every `refreshInterval` while the session stays in the foreground. +/// Updates `OSFeatureFlagsStore` so `OSFeatureManager` stays in sync. +/// +/// Polling is keyed on the active appId: once a poll loop is running for a given +/// appId, redundant triggers are a no-op so we don't double-fire the Turbine GET +/// at startup. Genuine appId changes still cancel and restart. +@objc(OSFeatureFlagsRefreshService) +public final class OSFeatureFlagsRefreshService: NSObject { + private static let defaultRefreshInterval: TimeInterval = 480 + + private static let lock = NSLock() + private static var _shared: OSFeatureFlagsRefreshService? + + @objc public static var shared: OSFeatureFlagsRefreshService { + lock.withLock { + if let existing = _shared { + return existing + } + let created = OSFeatureFlagsRefreshService() + _shared = created + return created + } + } + + private let backend: OSFeatureFlagsBackendService + private let store: OSFeatureFlagsStore + private let ioQueue: OSDispatchQueue + private let notificationCenter: NotificationCenter + private let usesScenes: () -> Bool + private let appIdProvider: () -> String? + private let isInForegroundProvider: () -> Bool + + var refreshInterval: TimeInterval + + private let stateLock = NSLock() + private var pollGeneration = 0 + private var pollingAppId: String? + private var started = false + private var notificationTokens: [NSObjectProtocol] = [] + + init( + backend: OSFeatureFlagsBackendService = OSFeatureFlagsBackendService(), + store: OSFeatureFlagsStore = .shared, + ioQueue: OSDispatchQueue = DispatchQueue(label: "com.onesignal.feature-flags.refresh"), + notificationCenter: NotificationCenter = .default, + usesScenes: @escaping () -> Bool = { OSBundleUtils.isAppUsingUIScene() }, + appIdProvider: @escaping () -> String? = { + OneSignalIdentifiers.currentAppId ?? OneSignalIdentifiers.storedAppId + }, + isInForegroundProvider: @escaping () -> Bool = { true }, + refreshInterval: TimeInterval = OSFeatureFlagsRefreshService.defaultRefreshInterval + ) { + self.backend = backend + self.store = store + self.ioQueue = ioQueue + self.notificationCenter = notificationCenter + self.usesScenes = usesScenes + self.appIdProvider = appIdProvider + self.isInForegroundProvider = isInForegroundProvider + self.refreshInterval = refreshInterval + super.init() + } + + @objc public class func start() { + _ = OSFeatureManager.shared + shared.startPolling() + } + + @objc public class func reset() { + lock.withLock { + _shared?.stopPolling() + _shared = nil + } + } + + func startPolling() { + ioQueue.async { [weak self] in + guard let self else { + return + } + self.registerLifecycleObserversIfNeeded() + if self.isInForegroundProvider() { + self.restartForegroundPolling() + } + } + } + + func onFocus() { + ioQueue.async { [weak self] in + self?.restartForegroundPolling() + } + } + + func onUnfocused() { + ioQueue.async { [weak self] in + self?.stateLock.withLock { + self?.pollGeneration += 1 + self?.pollingAppId = nil + } + } + } + + func notifyAppIdMayHaveChanged() { + ioQueue.async { [weak self] in + guard let self, self.isInForegroundProvider() else { + return + } + self.restartForegroundPolling() + } + } + + private func stopPolling() { + notificationTokens.forEach(notificationCenter.removeObserver) + notificationTokens.removeAll() + stateLock.withLock { + pollGeneration += 1 + pollingAppId = nil + started = false + } + } + + private func registerLifecycleObserversIfNeeded() { + let alreadyStarted = stateLock.withLock { () -> Bool in + if started { + return true + } + started = true + return false + } + guard !alreadyStarted else { + return + } + + if usesScenes() { + observe(Notification.Name("UISceneDidActivateNotification"), onFocus: true) + observe(Notification.Name("UISceneDidEnterBackgroundNotification"), onFocus: false) + } else { + observe(UIApplication.didBecomeActiveNotification, onFocus: true) + observe(UIApplication.didEnterBackgroundNotification, onFocus: false) + } + } + + private func observe(_ name: Notification.Name, onFocus: Bool) { + notificationTokens.append( + notificationCenter.addObserver(forName: name, object: nil, queue: nil) { [weak self] _ in + if onFocus { + self?.onFocus() + } else { + self?.onUnfocused() + } + } + ) + } + + private func restartForegroundPolling() { + let appId = appIdProvider() ?? "" + let generation: Int = stateLock.withLock { + if appId.isEmpty { + pollGeneration += 1 + pollingAppId = nil + return -1 + } + if pollingAppId == appId { + return -1 + } + pollGeneration += 1 + pollingAppId = appId + return pollGeneration + } + guard generation >= 0 else { + return + } + poll(appId: appId, generation: generation) + } + + private func poll(appId: String, generation: Int) { + guard isCurrentGeneration(generation), isInForegroundProvider() else { + return + } + let current = appIdProvider() ?? "" + guard !current.isEmpty else { + return + } + backend.fetchRemoteFeatureFlags(appId: current) { [weak self] outcome in + guard let self, self.isCurrentGeneration(generation) else { + return + } + self.apply(outcome) + self.ioQueue.asyncAfterTime(deadline: .now() + self.refreshInterval) { [weak self] in + self?.poll(appId: current, generation: generation) + } + } + } + + private func apply(_ outcome: RemoteFeatureFlagsFetchOutcome) { + guard outcome.isSuccess, let result = outcome.result else { + return + } + let keys = Self.stringArray(result.enabledKeys) + store.applyRemoteFlags(keys, metadata: result.metadataJson) + } + + private func isCurrentGeneration(_ generation: Int) -> Bool { + stateLock.withLock { pollGeneration == generation } + } + + private static func stringArray(_ value: Any) -> [String] { + if let strings = value as? [String] { + return strings + } + if let array = value as? NSArray { + return array.compactMap { $0 as? String } + } + return [] + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Feature/OSFeatureFlagsStore.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Feature/OSFeatureFlagsStore.swift new file mode 100644 index 000000000..ab2b4f36d --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Feature/OSFeatureFlagsStore.swift @@ -0,0 +1,107 @@ +/* + Modified MIT License + + Copyright 2026 OneSignal + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + 1. The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + 2. All copies of substantial portions of the Software may only be used in connection + with services provided by OneSignal. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ + +import Foundation +import OneSignalCore + +/// Notified when persisted remote feature flags change, matching Android's +/// `ConfigModelStore` subscribers for `sdkRemoteFeatureFlags`. +protocol OSFeatureFlagsStoreChangeHandler: AnyObject { + func featureFlagsDidUpdate() +} + +/// Persistence for Turbine feature-flag keys and metadata, the iOS analog of +/// Android `ConfigModel.sdkRemoteFeatureFlags` / `sdkRemoteFeatureFlagMetadata`. +final class OSFeatureFlagsStore { + static let shared = OSFeatureFlagsStore() + + private let lock = NSLock() + private let defaults: OneSignalUserDefaults + private var handlers: [WeakHandler] = [] + private var _sdkRemoteFeatureFlags: [String] + private var _sdkRemoteFeatureFlagMetadata: String? + + var sdkRemoteFeatureFlags: [String] { + lock.withLock { _sdkRemoteFeatureFlags } + } + + var sdkRemoteFeatureFlagMetadata: String? { + lock.withLock { _sdkRemoteFeatureFlagMetadata } + } + + init(defaults: OneSignalUserDefaults = .initShared()) { + self.defaults = defaults + _sdkRemoteFeatureFlags = + defaults.getSavedObject(forKey: OSUD_SDK_REMOTE_FEATURE_FLAGS, defaultValue: []) as? [String] + ?? [] + _sdkRemoteFeatureFlagMetadata = defaults.getSavedString( + forKey: OSUD_SDK_REMOTE_FEATURE_FLAG_METADATA, + defaultValue: nil + ) + } + + func subscribe(_ handler: OSFeatureFlagsStoreChangeHandler) { + lock.withLock { + handlers.append(WeakHandler(handler)) + } + } + + /// Writes keys + metadata in place. No-op when both values are unchanged so a + /// successful empty poll does not wake `OSFeatureManager`. + func applyRemoteFlags(_ keys: [String], metadata: String?) { + let changed: Bool = lock.withLock { + if keys == _sdkRemoteFeatureFlags && metadata == _sdkRemoteFeatureFlagMetadata { + return false + } + _sdkRemoteFeatureFlags = keys + _sdkRemoteFeatureFlagMetadata = metadata + defaults.saveObject(forKey: OSUD_SDK_REMOTE_FEATURE_FLAGS, withValue: keys) + defaults.saveString(forKey: OSUD_SDK_REMOTE_FEATURE_FLAG_METADATA, withValue: metadata) + handlers.removeAll { $0.value == nil } + return true + } + guard changed else { + return + } + let snapshot: [OSFeatureFlagsStoreChangeHandler] = lock.withLock { + handlers.compactMap(\.value) + } + snapshot.forEach { $0.featureFlagsDidUpdate() } + } + + func clear() { + applyRemoteFlags([], metadata: nil) + } + + private final class WeakHandler { + weak var value: OSFeatureFlagsStoreChangeHandler? + + init(_ value: OSFeatureFlagsStoreChangeHandler) { + self.value = value + } + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Feature/OSFeatureManager.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Feature/OSFeatureManager.swift new file mode 100644 index 000000000..da396e65d --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Feature/OSFeatureManager.swift @@ -0,0 +1,62 @@ +/* + Modified MIT License + + Copyright 2026 OneSignal + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + 1. The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + 2. All copies of substantial portions of the Software may only be used in connection + with services provided by OneSignal. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ + +import Foundation +import OneSignalCore + +/// Resolves backend-driven feature flag state for the current device run. +/// Catalog and latching live in shared KMP `FeatureManager`; this host hydrates +/// from `OSFeatureFlagsStore` and applies activation-mode rules via `refresh`. +@objc(OSFeatureManager) +public final class OSFeatureManager: NSObject { + @objc public static let shared = OSFeatureManager() + + private let impl: OSFeatureManagerImpl + + init(store: OSFeatureFlagsStore = .shared) { + impl = OSFeatureManagerImpl(store: store) + super.init() + } + + /// Whether the catalog flag with this Turbine key is enabled after latching. + @objc(isEnabledForKey:) + public func isEnabled(featureKey: String) -> Bool { + impl.isEnabled(featureKey: featureKey) + } + + /// Canonical keys enabled for this process after latching, in catalog order. + @objc public func enabledFeatureKeys() -> [String] { + impl.enabledFeatureKeys() + } + + func remoteFeatureFlagMetadata() -> [String: String]? { + impl.remoteFeatureFlagMetadata() + } + + /// Local-only test hook for forcing features ON without backend config. + static var localFeatureOverrides: [String] = [] +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Feature/OSFeatureManagerImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Feature/OSFeatureManagerImpl.swift new file mode 100644 index 000000000..16634ae2a --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Feature/OSFeatureManagerImpl.swift @@ -0,0 +1,102 @@ +/* + Modified MIT License + + Copyright 2026 OneSignal + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + 1. The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + 2. All copies of substantial portions of the Software may only be used in connection + with services provided by OneSignal. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ + +import Foundation +import OneSignalCore +@_implementationOnly import OneSignalKMP + +/// Wraps shared KMP `FeatureManager`. Persistence and store subscriptions stay here. +final class OSFeatureManagerImpl: OSFeatureFlagsStoreChangeHandler { + private let store: OSFeatureFlagsStore + private let latch = FeatureManager() + + init(store: OSFeatureFlagsStore) { + self.store = store + OneSignalLog.onesignalLog( + .LL_DEBUG, + message: "OneSignal: FeatureManager initializing from cached config features" + ) + refreshFrom(applyAppStartupFlags: true) + store.subscribe(self) + } + + func isEnabled(featureKey: String) -> Bool { + guard let feature = Self.feature(forKey: featureKey) else { + return false + } + return latch.isEnabled(feature: feature) + } + + func enabledFeatureKeys() -> [String] { + Array(latch.enabledFeatureKeys()) + } + + func remoteFeatureFlagMetadata() -> [String: String]? { + guard let parsed = FeatureFlagMetadata.companion.parse(raw: store.sdkRemoteFeatureFlagMetadata) else { + return nil + } + var result: [String: String] = [:] + for id in Array(parsed.ids()) { + result[id] = parsed.jsonObjectForId(id: id) ?? "" + } + return result + } + + func featureFlagsDidUpdate() { + OneSignalLog.onesignalLog( + .LL_DEBUG, + message: "OneSignal: FeatureManager.featureFlagsDidUpdate" + ) + refreshFrom(applyAppStartupFlags: false) + } + + private func refreshFrom(applyAppStartupFlags: Bool) { + if !OSFeatureManager.localFeatureOverrides.isEmpty { + OneSignalLog.onesignalLog( + .LL_WARN, + message: "OneSignal: Local feature override enabled for testing only: \(OSFeatureManager.localFeatureOverrides)" + ) + } + let deferred = latch.refresh( + remoteKeys: store.sdkRemoteFeatureFlags, + applyAppStartupFlags: applyAppStartupFlags, + localOverrides: OSFeatureManager.localFeatureOverrides + ) + for change in deferred { + OneSignalLog.onesignalLog( + .LL_INFO, + message: "OneSignal: Feature \(change.key) changed remotely to \(change.desiredEnabled) " + + "but is NEXT_RUN, keeping current run value=\(change.latchedEnabled)" + ) + } + } + + private static func feature(forKey key: String) -> FeatureFlag? { + let canonical = key.lowercased() + return FeatureFlag.entries.first { $0.key == canonical } + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSFeatureFlagsTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSFeatureFlagsTests.swift new file mode 100644 index 000000000..af0edefb4 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSFeatureFlagsTests.swift @@ -0,0 +1,335 @@ +/* + Modified MIT License + + Copyright 2026 OneSignal + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + 1. The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + 2. All copies of substantial portions of the Software may only be used in connection + with services provided by OneSignal. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ + +import Foundation +import OneSignalCore +import OneSignalKMP +@testable import OneSignalOSCore +import XCTest + +final class OSFeatureManagerTests: XCTestCase { + private var store: OSFeatureFlagsStore! + + override func setUp() { + super.setUp() + OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_SDK_REMOTE_FEATURE_FLAGS) + OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_SDK_REMOTE_FEATURE_FLAG_METADATA) + OSFeatureManager.localFeatureOverrides = [] + store = OSFeatureFlagsStore() + } + + override func tearDown() { + OSFeatureManager.localFeatureOverrides = [] + OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_SDK_REMOTE_FEATURE_FLAGS) + OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_SDK_REMOTE_FEATURE_FLAG_METADATA) + super.tearDown() + } + + func testIsEnabledIsFalseWhenTheKeyIsNotPresent() { + let manager = OSFeatureManager(store: store) + XCTAssertFalse(manager.isEnabled(featureKey: FeatureFlag.sdkIdentityVerification.key)) + } + + func testInitialStateEnablesAFeatureWhenItsKeyIsPresent() { + store.applyRemoteFlags([FeatureFlag.sdkIdentityVerification.key], metadata: nil) + let manager = OSFeatureManager(store: store) + XCTAssertTrue(manager.isEnabled(featureKey: FeatureFlag.sdkIdentityVerification.key)) + } + + func testInitialStateEnablesAFeatureWhenTheRemoteKeyDiffersOnlyByLetterCase() { + store.applyRemoteFlags(["SDK_Identity_Verification"], metadata: nil) + let manager = OSFeatureManager(store: store) + XCTAssertTrue(manager.isEnabled(featureKey: FeatureFlag.sdkIdentityVerification.key)) + } + + func testRemoteFeatureFlagMetadataReturnsParsedJSONFromStore() { + store.applyRemoteFlags([], metadata: #"{"X":{"note":"y"}}"#) + let manager = OSFeatureManager(store: store) + let meta = manager.remoteFeatureFlagMetadata() + XCTAssertNotNil(meta) + XCTAssertTrue(meta?["X"]?.contains("\"note\"") == true) + XCTAssertTrue(meta?["X"]?.contains("y") == true) + } + + func testRemoteFeatureFlagMetadataIsNilWhenStoreHasNoMetadata() { + let manager = OSFeatureManager(store: store) + XCTAssertNil(manager.remoteFeatureFlagMetadata()) + } + + func testEnabledFeatureKeysIsEmptyWhenNoFlagsAreEnabled() { + let manager = OSFeatureManager(store: store) + XCTAssertEqual(manager.enabledFeatureKeys(), []) + } + + func testEnabledFeatureKeysReturnsCanonicalKeyWhenEnabledAtStartup() { + store.applyRemoteFlags([FeatureFlag.sdkIdentityVerification.key], metadata: nil) + let manager = OSFeatureManager(store: store) + XCTAssertEqual(manager.enabledFeatureKeys(), [FeatureFlag.sdkIdentityVerification.key]) + } + + func testIdentityVerificationIsImmediateMidSessionFlagFlipFlowsThroughIsEnabled() { + let manager = OSFeatureManager(store: store) + XCTAssertFalse(manager.isEnabled(featureKey: FeatureFlag.sdkIdentityVerification.key)) + + store.applyRemoteFlags([FeatureFlag.sdkIdentityVerification.key], metadata: nil) + + XCTAssertTrue(manager.isEnabled(featureKey: FeatureFlag.sdkIdentityVerification.key)) + } + + func testAppStartupCustomLoggingStaysLatchedMidSession() { + store.applyRemoteFlags([FeatureFlag.sdkCustomLogging.key], metadata: nil) + let manager = OSFeatureManager(store: store) + XCTAssertTrue(manager.isEnabled(featureKey: FeatureFlag.sdkCustomLogging.key)) + + store.applyRemoteFlags([], metadata: nil) + + XCTAssertTrue(manager.isEnabled(featureKey: FeatureFlag.sdkCustomLogging.key)) + } +} + +final class OSFeatureFlagsBackendServiceTests: XCTestCase { + func test403ForbiddenReturnsUnavailableAndIsClientError() { + let outcome = fetch(statusCode: 403, body: #"{"errors":["Forbidden"]}"#) + XCTAssertTrue(outcome.isUnavailable) + XCTAssertTrue(outcome.reason == RemoteFeatureFlagsUnavailableReason.nonSuccessHttp) + XCTAssertTrue(outcome.isClientError) + } + + func test500ServerErrorIsNotClientError() { + let outcome = fetch(statusCode: 500, body: "boom") + XCTAssertTrue(outcome.isUnavailable) + XCTAssertTrue(outcome.reason == RemoteFeatureFlagsUnavailableReason.nonSuccessHttp) + XCTAssertFalse(outcome.isClientError) + } + + func test200WithValidEmptyFeaturesArrayIsSuccess() { + let outcome = fetch(statusCode: 200, body: #"{"features":[]}"#) + XCTAssertTrue(outcome.isSuccess) + XCTAssertEqual(stringArray(outcome.result?.enabledKeys), []) + } + + func test200WithNonContractJSONIsInvalidJson() { + let outcome = fetch(statusCode: 200, body: #"{"errors":["Forbidden"]}"#) + XCTAssertTrue(outcome.isUnavailable) + XCTAssertTrue(outcome.reason == RemoteFeatureFlagsUnavailableReason.invalidJson) + } + + func test200WithHTMLBodyIsInvalidJsonAndDoesNotThrow() { + let html = "Burpintercepted" + let outcome = fetch(statusCode: 200, body: html) + XCTAssertTrue(outcome.isUnavailable) + XCTAssertTrue(outcome.reason == RemoteFeatureFlagsUnavailableReason.invalidJson) + } + + func test200WithEmptyBodyIsUnavailable() { + let outcome = fetch(statusCode: 200, body: nil) + XCTAssertTrue(outcome.isUnavailable) + XCTAssertTrue(outcome.reason == RemoteFeatureFlagsUnavailableReason.emptyBody) + } + + func testPathShapingAppIdReturnsInvalidAppIdWithoutHTTP() { + var didGet = false + let http = StubFeatureFlagsHttp { _, completion in + didGet = true + completion(FeatureFlagsHttpResponse(statusCode: 200, body: #"{"features":[]}"#), nil) + } + let outcome = fetch(appId: "app/../other", http: http) + XCTAssertTrue(outcome.isUnavailable) + XCTAssertTrue(outcome.reason == RemoteFeatureFlagsUnavailableReason.invalidAppId) + XCTAssertFalse(didGet) + } + + func testSdkVersionFromONESIGNAL_VERSIONMatchesTurbineLabelRules() { + XCTAssertTrue(TurbineSdkFeatureFlagsPath.shared.isValidFeaturesSdkVersionLabel(label: ONESIGNAL_VERSION)) + } + + private func fetch( + statusCode: Int32 = 200, + body: String? = nil, + appId: String = "appId", + http: IFeatureFlagsHttp? = nil + ) -> RemoteFeatureFlagsFetchOutcome { + let transport = http ?? StubFeatureFlagsHttp { _, completion in + completion(FeatureFlagsHttpResponse(statusCode: statusCode, body: body), nil) + } + let service = OSFeatureFlagsBackendService(http: transport, sdkVersionProvider: { "050506" }) + let done = expectation(description: "fetch") + var outcome: RemoteFeatureFlagsFetchOutcome? + service.fetchRemoteFeatureFlags(appId: appId) { + outcome = $0 + done.fulfill() + } + wait(for: [done], timeout: 2) + return try! XCTUnwrap(outcome) + } + + private func stringArray(_ value: Any?) -> [String] { + if let strings = value as? [String] { + return strings + } + if let array = value as? NSArray { + return array.compactMap { $0 as? String } + } + return [] + } +} + +final class OSFeatureFlagsRefreshServiceTests: XCTestCase { + private var store: OSFeatureFlagsStore! + + override func setUp() { + super.setUp() + OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_SDK_REMOTE_FEATURE_FLAGS) + OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_SDK_REMOTE_FEATURE_FLAG_METADATA) + store = OSFeatureFlagsStore() + } + + override func tearDown() { + OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_SDK_REMOTE_FEATURE_FLAGS) + OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_SDK_REMOTE_FEATURE_FLAG_METADATA) + super.tearDown() + } + + func testSuccessfulFetchPersistsKeysOnTheStore() { + let http = StubFeatureFlagsHttp { _, completion in + completion( + FeatureFlagsHttpResponse( + statusCode: 200, + body: #"{"features":["sdk_identity_verification"]}"# + ), + nil + ) + } + let backend = OSFeatureFlagsBackendService(http: http, sdkVersionProvider: { "050506" }) + let service = OSFeatureFlagsRefreshService( + backend: backend, + store: store, + ioQueue: ImmediateDispatchQueue(), + appIdProvider: { "app-id-1" }, + isInForegroundProvider: { true }, + refreshInterval: 10_000 + ) + + service.startPolling() + + XCTAssertEqual(store.sdkRemoteFeatureFlags, [FeatureFlag.sdkIdentityVerification.key]) + } + + func testUnavailableFetchLeavesCachedFlagsInPlace() { + store.applyRemoteFlags(["sdk_identity_verification"], metadata: nil) + let http = StubFeatureFlagsHttp { _, completion in + completion(FeatureFlagsHttpResponse(statusCode: 500, body: "boom"), nil) + } + let backend = OSFeatureFlagsBackendService(http: http, sdkVersionProvider: { "050506" }) + let service = OSFeatureFlagsRefreshService( + backend: backend, + store: store, + ioQueue: ImmediateDispatchQueue(), + appIdProvider: { "app-id-1" }, + isInForegroundProvider: { true }, + refreshInterval: 10_000 + ) + + service.startPolling() + + XCTAssertEqual(store.sdkRemoteFeatureFlags, ["sdk_identity_verification"]) + } + + func testSameAppIdRestartDoesNotDoubleFetch() { + var fetches = 0 + let http = StubFeatureFlagsHttp { _, completion in + fetches += 1 + completion(FeatureFlagsHttpResponse(statusCode: 200, body: #"{"features":[]}"#), nil) + } + let backend = OSFeatureFlagsBackendService(http: http, sdkVersionProvider: { "050506" }) + let service = OSFeatureFlagsRefreshService( + backend: backend, + store: store, + ioQueue: ImmediateDispatchQueue(), + appIdProvider: { "app-id-1" }, + isInForegroundProvider: { true }, + refreshInterval: 10_000 + ) + + service.startPolling() + service.notifyAppIdMayHaveChanged() + + XCTAssertEqual(fetches, 1) + } + + func testAppIdChangeRefetches() { + var appId = "app-id-1" + var fetched: [String] = [] + let http = StubFeatureFlagsHttp { path, completion in + fetched.append(path) + completion(FeatureFlagsHttpResponse(statusCode: 200, body: #"{"features":[]}"#), nil) + } + let backend = OSFeatureFlagsBackendService(http: http, sdkVersionProvider: { "050506" }) + let service = OSFeatureFlagsRefreshService( + backend: backend, + store: store, + ioQueue: ImmediateDispatchQueue(), + appIdProvider: { appId }, + isInForegroundProvider: { true }, + refreshInterval: 10_000 + ) + + service.startPolling() + service.onUnfocused() + appId = "app-id-2" + service.onFocus() + + XCTAssertEqual(fetched.count, 2) + XCTAssertTrue(fetched[0].contains("app-id-1")) + XCTAssertTrue(fetched[1].contains("app-id-2")) + } +} + +private final class StubFeatureFlagsHttp: IFeatureFlagsHttp { + let onGet: ( + String, + @escaping (FeatureFlagsHttpResponse?, Error?) -> Void + ) -> Void + + init(onGet: @escaping (String, @escaping (FeatureFlagsHttpResponse?, Error?) -> Void) -> Void) { + self.onGet = onGet + } + + func get(relativePath: String, completionHandler: @escaping (FeatureFlagsHttpResponse?, Error?) -> Void) { + onGet(relativePath, completionHandler) + } +} + +private final class ImmediateDispatchQueue: OSDispatchQueue { + func async(execute work: @escaping @convention(block) () -> Void) { + work() + } + + func asyncAfterTime(deadline: DispatchTime, execute work: @escaping @Sendable @convention(block) () -> Void) { + // Tests use a large refreshInterval and assert the first fetch only. + } +} diff --git a/iOS_SDK/OneSignalSDK/Source/OSRemoteLoggingController.swift b/iOS_SDK/OneSignalSDK/Source/OSRemoteLoggingController.swift index 55e452656..78968cb69 100644 --- a/iOS_SDK/OneSignalSDK/Source/OSRemoteLoggingController.swift +++ b/iOS_SDK/OneSignalSDK/Source/OSRemoteLoggingController.swift @@ -352,7 +352,7 @@ final class OSRemoteLoggingController: NSObject, OSInternalLogSink { onesignalId: { OneSignalUserManagerImpl.sharedInstance.internalOnesignalId }, pushSubscriptionId: { OneSignalUserManagerImpl.sharedInstance.pushSubscriptionId }, appState: { [weak self] in self?.currentAppState ?? "unknown" }, - featureFlags: { [] }, + featureFlags: { OSFeatureManager.shared.enabledFeatureKeys() }, remoteLogLevel: { configuration.logLevel }, exporterLoggingEnabled: { false } ) diff --git a/iOS_SDK/OneSignalSDK/Source/OneSignal.m b/iOS_SDK/OneSignalSDK/Source/OneSignal.m index 0a1bd38ec..1cf0551e2 100755 --- a/iOS_SDK/OneSignalSDK/Source/OneSignal.m +++ b/iOS_SDK/OneSignalSDK/Source/OneSignal.m @@ -140,6 +140,7 @@ + (OneSignalReceiveReceiptsController*)receiveReceiptsController { //TODO: This is related to unit tests and will change with um tests + (void)clearStatics { [OSRemoteLoggingController reset]; + [OSFeatureFlagsRefreshService reset]; [OneSignalIdentifiers setCurrentAppId:nil]; launchOptions = false; appSettings = nil; @@ -632,6 +633,7 @@ + (void)init { [self startTrackIAP]; [self startTrackFirebaseAnalytics]; [self startLifecycleObserver]; + [OSFeatureFlagsRefreshService start]; //TODO: Should these be started in Dependency order? e.g. IAM depends on User Manager shared instance [self startUserManager]; // By here, app_id exists, and consent is granted. // Defer LA and IAM init during prewarm: both eagerly read UserDefaults at first access and would @@ -666,6 +668,7 @@ + (void)handleAppIdChange:(NSString*)appId { _downloadedParameters = false; _didCallDownloadParameters = false; [OSRemoteLoggingController reset]; + [OSFeatureFlagsRefreshService reset]; let sharedUserDefaults = OneSignalUserDefaults.initShared; From 23955111aa2fdddfa227992cf97c59b3f339c235 Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Mon, 24 Aug 2026 12:55:52 -0500 Subject: [PATCH 2/6] fix: [SDK-5048] harden feature-flag startup, reset, and lifecycle Addresses review feedback on the initial wiring. Defers feature-flag startup behind the same prewarm guard as Live Activities and In-App Messages, and re-drives it from the protected-data recovery path, so an unreadable UserDefaults read can no longer latch APP_STARTUP flags off for the whole process. Remote logging now reads flags through a non-forcing accessor, closing both the earlier hydration path through configureFromCache and the crash-handler exposure, since first-touch construction takes locks and reads storage. Makes the manager resettable so an app-id change drops the latch and the cached keys instead of running the new app on the previous app's flags. Guards the notification token list with the same lock as the rest of the polling state, releases the dedupe key on every early return so the loop cannot wedge permanently, and counts scenes so one backgrounded window no longer stops refreshing app-wide. Replaces try! XCTUnwrap with a throwing helper to satisfy SwiftLint force_try. --- .../OSFeatureFlagsRefreshService.swift | 88 +++++++++++++++---- .../Source/Feature/OSFeatureManager.swift | 56 +++++++++++- .../OSFeatureFlagsTests.swift | 33 +++---- .../Source/OSRemoteLoggingController.swift | 6 +- iOS_SDK/OneSignalSDK/Source/OneSignal.m | 14 ++- 5 files changed, 158 insertions(+), 39 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Feature/OSFeatureFlagsRefreshService.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Feature/OSFeatureFlagsRefreshService.swift index fe3deaed2..7a788ca51 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Feature/OSFeatureFlagsRefreshService.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Feature/OSFeatureFlagsRefreshService.swift @@ -70,6 +70,9 @@ public final class OSFeatureFlagsRefreshService: NSObject { private var pollingAppId: String? private var started = false private var notificationTokens: [NSObjectProtocol] = [] + /// Scenes currently foregrounded. Polling only stops once the last one backgrounds, + /// so an iPad user backgrounding one of two windows keeps flags refreshing. + private var activeSceneCount = 0 init( backend: OSFeatureFlagsBackendService = OSFeatureFlagsBackendService(), @@ -143,13 +146,18 @@ public final class OSFeatureFlagsRefreshService: NSObject { } private func stopPolling() { - notificationTokens.forEach(notificationCenter.removeObserver) - notificationTokens.removeAll() - stateLock.withLock { + // `reset()` reaches here on the caller's thread while `observe` may be appending + // on ioQueue, so the token list has to move under the same lock as the rest of + // the mutable state. Deregistration itself happens outside the lock. + let tokens: [NSObjectProtocol] = stateLock.withLock { + let current = notificationTokens + notificationTokens.removeAll() pollGeneration += 1 pollingAppId = nil started = false + return current } + tokens.forEach(notificationCenter.removeObserver) } private func registerLifecycleObserversIfNeeded() { @@ -165,24 +173,50 @@ public final class OSFeatureFlagsRefreshService: NSObject { } if usesScenes() { - observe(Notification.Name("UISceneDidActivateNotification"), onFocus: true) - observe(Notification.Name("UISceneDidEnterBackgroundNotification"), onFocus: false) + observe(Notification.Name("UISceneDidActivateNotification")) { [weak self] in + self?.sceneDidActivate() + } + observe(Notification.Name("UISceneDidEnterBackgroundNotification")) { [weak self] in + self?.sceneDidBackground() + } } else { - observe(UIApplication.didBecomeActiveNotification, onFocus: true) - observe(UIApplication.didEnterBackgroundNotification, onFocus: false) + observe(UIApplication.didBecomeActiveNotification) { [weak self] in + self?.onFocus() + } + observe(UIApplication.didEnterBackgroundNotification) { [weak self] in + self?.onUnfocused() + } } } - private func observe(_ name: Notification.Name, onFocus: Bool) { - notificationTokens.append( - notificationCenter.addObserver(forName: name, object: nil, queue: nil) { [weak self] _ in - if onFocus { - self?.onFocus() - } else { - self?.onUnfocused() - } - } - ) + private func observe(_ name: Notification.Name, handler: @escaping () -> Void) { + let token = notificationCenter.addObserver(forName: name, object: nil, queue: nil) { _ in + handler() + } + stateLock.withLock { + notificationTokens.append(token) + } + } + + private func sceneDidActivate() { + stateLock.withLock { + activeSceneCount += 1 + } + onFocus() + } + + /// Only the last scene leaving the foreground stops polling. The count can start + /// below the true number of live scenes (we only see activations after registering), + /// so it is floored at zero rather than trusted absolutely. + private func sceneDidBackground() { + let allScenesBackgrounded: Bool = stateLock.withLock { + activeSceneCount = max(0, activeSceneCount - 1) + return activeSceneCount == 0 + } + guard allScenesBackgrounded else { + return + } + onUnfocused() } private func restartForegroundPolling() { @@ -207,11 +241,19 @@ public final class OSFeatureFlagsRefreshService: NSObject { } private func poll(appId: String, generation: Int) { - guard isCurrentGeneration(generation), isInForegroundProvider() else { + guard isCurrentGeneration(generation) else { + return + } + // Bailing out has to release the dedupe key as well, otherwise + // `restartForegroundPolling` keeps matching `pollingAppId` and no later focus + // event can ever restart the loop for this app id. + guard isInForegroundProvider() else { + releasePollingKey(for: generation) return } let current = appIdProvider() ?? "" guard !current.isEmpty else { + releasePollingKey(for: generation) return } backend.fetchRemoteFeatureFlags(appId: current) { [weak self] outcome in @@ -237,6 +279,16 @@ public final class OSFeatureFlagsRefreshService: NSObject { stateLock.withLock { pollGeneration == generation } } + /// Clears the dedupe key only if this generation still owns it, so a newer loop + /// started in the meantime keeps its claim. + private func releasePollingKey(for generation: Int) { + stateLock.withLock { + if pollGeneration == generation { + pollingAppId = nil + } + } + } + private static func stringArray(_ value: Any) -> [String] { if let strings = value as? [String] { return strings diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Feature/OSFeatureManager.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Feature/OSFeatureManager.swift index da396e65d..ba940af11 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Feature/OSFeatureManager.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Feature/OSFeatureManager.swift @@ -33,7 +33,61 @@ import OneSignalCore /// from `OSFeatureFlagsStore` and applies activation-mode rules via `refresh`. @objc(OSFeatureManager) public final class OSFeatureManager: NSObject { - @objc public static let shared = OSFeatureManager() + private static let lock = NSLock() + private static var _shared: OSFeatureManager? + + /// Constructing this reads persisted flags and builds the KMP latch, so the first + /// access decides which `APP_STARTUP` flags are latched for the process. Only touch + /// it once storage is readable; see `enabledFeatureKeysIfInitialized()`. + @objc public static var shared: OSFeatureManager { + if let existing = lock.withLock({ _shared }) { + return existing + } + // Built outside the lock on purpose: construction reads storage and logs, and + // logging reaches back through the feature-flag provider. Holding the lock + // across that would risk re-entering it on the same thread. A lost race just + // discards the extra instance, which has only read the store. + let created = OSFeatureManager() + return lock.withLock { + if let existing = _shared { + return existing + } + _shared = created + return created + } + } + + /// Enabled keys *without* forcing construction, for callers that must not trigger + /// first-touch initialization: a crash handler (initialization takes locks and reads + /// UserDefaults, neither async-signal-safe) and any pre-unlock caller that would + /// otherwise latch `APP_STARTUP` flags from unreadable storage. + /// + /// Returns empty when the manager has not been built yet. Reading keys from an + /// already-built manager still takes the KMP latch's lock. + @objc public static func enabledFeatureKeysIfInitialized() -> [String] { + guard let existing = lock.withLock({ _shared }) else { + return [] + } + return existing.enabledFeatureKeys() + } + + /// Drops the latch and cached state so the next access re-reads storage. Required on + /// an app-id change: `APP_STARTUP` flags never unlatch within a process, so without + /// this the previous app's flags would govern the new one. + @objc public static func reset() { + lock.withLock { + _shared = nil + } + } + + /// Drops the latch *and* discards the persisted keys. Flags are scoped to an app id + /// but stored unscoped, so on an app-id change the cache has to go too — otherwise + /// the new app runs on the old app's flags until its first successful fetch, and + /// never for `APP_STARTUP` flags. + @objc public static func resetAndClearCachedFlags() { + OSFeatureFlagsStore.shared.clear() + reset() + } private let impl: OSFeatureManagerImpl diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSFeatureFlagsTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSFeatureFlagsTests.swift index af0edefb4..6a7bd9dd2 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSFeatureFlagsTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSFeatureFlagsTests.swift @@ -112,52 +112,53 @@ final class OSFeatureManagerTests: XCTestCase { } final class OSFeatureFlagsBackendServiceTests: XCTestCase { - func test403ForbiddenReturnsUnavailableAndIsClientError() { - let outcome = fetch(statusCode: 403, body: #"{"errors":["Forbidden"]}"#) + func test403ForbiddenReturnsUnavailableAndIsClientError() throws { + let outcome = try fetch(statusCode: 403, body: #"{"errors":["Forbidden"]}"#) XCTAssertTrue(outcome.isUnavailable) XCTAssertTrue(outcome.reason == RemoteFeatureFlagsUnavailableReason.nonSuccessHttp) XCTAssertTrue(outcome.isClientError) } - func test500ServerErrorIsNotClientError() { - let outcome = fetch(statusCode: 500, body: "boom") + func test500ServerErrorIsNotClientError() throws { + let outcome = try fetch(statusCode: 500, body: "boom") XCTAssertTrue(outcome.isUnavailable) XCTAssertTrue(outcome.reason == RemoteFeatureFlagsUnavailableReason.nonSuccessHttp) XCTAssertFalse(outcome.isClientError) } - func test200WithValidEmptyFeaturesArrayIsSuccess() { - let outcome = fetch(statusCode: 200, body: #"{"features":[]}"#) + func test200WithValidEmptyFeaturesArrayIsSuccess() throws { + let outcome = try fetch(statusCode: 200, body: #"{"features":[]}"#) XCTAssertTrue(outcome.isSuccess) + XCTAssertNotNil(outcome.result) XCTAssertEqual(stringArray(outcome.result?.enabledKeys), []) } - func test200WithNonContractJSONIsInvalidJson() { - let outcome = fetch(statusCode: 200, body: #"{"errors":["Forbidden"]}"#) + func test200WithNonContractJSONIsInvalidJson() throws { + let outcome = try fetch(statusCode: 200, body: #"{"errors":["Forbidden"]}"#) XCTAssertTrue(outcome.isUnavailable) XCTAssertTrue(outcome.reason == RemoteFeatureFlagsUnavailableReason.invalidJson) } - func test200WithHTMLBodyIsInvalidJsonAndDoesNotThrow() { + func test200WithHTMLBodyIsInvalidJsonAndDoesNotThrow() throws { let html = "Burpintercepted" - let outcome = fetch(statusCode: 200, body: html) + let outcome = try fetch(statusCode: 200, body: html) XCTAssertTrue(outcome.isUnavailable) XCTAssertTrue(outcome.reason == RemoteFeatureFlagsUnavailableReason.invalidJson) } - func test200WithEmptyBodyIsUnavailable() { - let outcome = fetch(statusCode: 200, body: nil) + func test200WithEmptyBodyIsUnavailable() throws { + let outcome = try fetch(statusCode: 200, body: nil) XCTAssertTrue(outcome.isUnavailable) XCTAssertTrue(outcome.reason == RemoteFeatureFlagsUnavailableReason.emptyBody) } - func testPathShapingAppIdReturnsInvalidAppIdWithoutHTTP() { + func testPathShapingAppIdReturnsInvalidAppIdWithoutHTTP() throws { var didGet = false let http = StubFeatureFlagsHttp { _, completion in didGet = true completion(FeatureFlagsHttpResponse(statusCode: 200, body: #"{"features":[]}"#), nil) } - let outcome = fetch(appId: "app/../other", http: http) + let outcome = try fetch(appId: "app/../other", http: http) XCTAssertTrue(outcome.isUnavailable) XCTAssertTrue(outcome.reason == RemoteFeatureFlagsUnavailableReason.invalidAppId) XCTAssertFalse(didGet) @@ -172,7 +173,7 @@ final class OSFeatureFlagsBackendServiceTests: XCTestCase { body: String? = nil, appId: String = "appId", http: IFeatureFlagsHttp? = nil - ) -> RemoteFeatureFlagsFetchOutcome { + ) throws -> RemoteFeatureFlagsFetchOutcome { let transport = http ?? StubFeatureFlagsHttp { _, completion in completion(FeatureFlagsHttpResponse(statusCode: statusCode, body: body), nil) } @@ -184,7 +185,7 @@ final class OSFeatureFlagsBackendServiceTests: XCTestCase { done.fulfill() } wait(for: [done], timeout: 2) - return try! XCTUnwrap(outcome) + return try XCTUnwrap(outcome) } private func stringArray(_ value: Any?) -> [String] { diff --git a/iOS_SDK/OneSignalSDK/Source/OSRemoteLoggingController.swift b/iOS_SDK/OneSignalSDK/Source/OSRemoteLoggingController.swift index 78968cb69..b114fbf5d 100644 --- a/iOS_SDK/OneSignalSDK/Source/OSRemoteLoggingController.swift +++ b/iOS_SDK/OneSignalSDK/Source/OSRemoteLoggingController.swift @@ -352,7 +352,11 @@ final class OSRemoteLoggingController: NSObject, OSInternalLogSink { onesignalId: { OneSignalUserManagerImpl.sharedInstance.internalOnesignalId }, pushSubscriptionId: { OneSignalUserManagerImpl.sharedInstance.pushSubscriptionId }, appState: { [weak self] in self?.currentAppState ?? "unknown" }, - featureFlags: { OSFeatureManager.shared.enabledFeatureKeys() }, + // Deliberately non-forcing: this closure runs on arbitrary threads and + // synchronously from the crash handler, and it can fire before storage is + // readable. Touching `.shared` here would both latch APP_STARTUP flags from + // an empty prewarm read and do lock/UserDefaults work on a crashing thread. + featureFlags: { OSFeatureManager.enabledFeatureKeysIfInitialized() }, remoteLogLevel: { configuration.logLevel }, exporterLoggingEnabled: { false } ) diff --git a/iOS_SDK/OneSignalSDK/Source/OneSignal.m b/iOS_SDK/OneSignalSDK/Source/OneSignal.m index 1cf0551e2..68599e0d2 100755 --- a/iOS_SDK/OneSignalSDK/Source/OneSignal.m +++ b/iOS_SDK/OneSignalSDK/Source/OneSignal.m @@ -141,6 +141,7 @@ + (OneSignalReceiveReceiptsController*)receiveReceiptsController { + (void)clearStatics { [OSRemoteLoggingController reset]; [OSFeatureFlagsRefreshService reset]; + [OSFeatureManager reset]; [OneSignalIdentifiers setCurrentAppId:nil]; launchOptions = false; appSettings = nil; @@ -543,6 +544,7 @@ + (void)setupProtectedDataObserverOnce { [OSNotificationsManager sendPushTokenToDelegate]; [OneSignal startLiveActivitiesManager]; [OneSignal startInAppMessages]; + [OSFeatureFlagsRefreshService start]; [OneSignal startNewSession:YES]; }; @@ -633,14 +635,17 @@ + (void)init { [self startTrackIAP]; [self startTrackFirebaseAnalytics]; [self startLifecycleObserver]; - [OSFeatureFlagsRefreshService start]; //TODO: Should these be started in Dependency order? e.g. IAM depends on User Manager shared instance [self startUserManager]; // By here, app_id exists, and consent is granted. - // Defer LA and IAM init during prewarm: both eagerly read UserDefaults at first access and would - // overwrite the on-disk state with empty caches on the next save. The observer re-drives them post-unlock. + // Defer LA, IAM, and feature flags during prewarm: all three eagerly read UserDefaults at first access + // and would overwrite the on-disk state with empty caches on the next save. The observer re-drives them + // post-unlock. Feature flags are the least forgiving of the three: the first read also latches every + // APP_STARTUP flag for the process, and that latch never reopens, so an empty prewarm read would pin + // them off for the whole run. if (![OneSignalConfig shouldAwaitAppIdAndLogMissingPrivacyConsentForMethod:nil]) { [self startLiveActivitiesManager]; [self startInAppMessages]; + [OSFeatureFlagsRefreshService start]; } [self startNewSession:YES]; @@ -669,6 +674,9 @@ + (void)handleAppIdChange:(NSString*)appId { _didCallDownloadParameters = false; [OSRemoteLoggingController reset]; [OSFeatureFlagsRefreshService reset]; + // Flags are app-scoped but stored unscoped, and APP_STARTUP flags never unlatch + // within a process, so both the cache and the latch have to go. + [OSFeatureManager resetAndClearCachedFlags]; let sharedUserDefaults = OneSignalUserDefaults.initShared; From 67f95c91bd2a667e53718ac9c737242ba0bb13f9 Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Mon, 24 Aug 2026 14:09:35 -0500 Subject: [PATCH 3/6] fix: [SDK-5048] honor the foreground-only contract and name transport failures Closes the remaining review findings on the feature-flags wiring. `isInForegroundProvider` defaulted to `{ true }`, so the documented foreground-only contract was inert and a background launch (silent push, background fetch, prewarm) would start the 8-minute poll loop. The service now tracks foreground state itself, defaulting to false and driven by the same lifecycle notifications it already observed. OneSignalOSCore is extension-safe and cannot read `UIApplication`, so the host seeds the initial value through `start(isInForeground:)`. `+init` can run off the main queue where `applicationState` is unreadable, so that call starts the service anyway to register observers and then re-seeds from the main thread; without the second pass an off-main init while the app was already active would wait for a foreground transition that may never come. Folds `notifyAppIdMayHaveChanged` into `onFocus`, which now re-drives polling on every focus event. It had no production caller, and refocusing is exactly when an app-id change needs picking up, so the two collapse into one path that is actually reachable. `poll` reads the app id per iteration instead of capturing it, so a mid-loop change can no longer keep fetching the old app's flags. Transport failures carried no status and an empty body, collapsing offline, DNS, TLS, and timeout into an indistinguishable `status=0 body=` and leaving the `localizedDescription` branch unreachable. The error text now travels as the body. Drops `disableLocalCaching`, which only `OneSignalClient` reads and this path bypasses; the private session already sets `reloadIgnoringLocalCacheData`. Removes the no-arg `start()`: it has no callers, and a public entry point that silently means "assume backgrounded" is a trap. Tests: the fake queue now holds deferred work until a test releases it, so the self-rescheduling loop, generation cancellation, unfocus-stops-the-loop, background launches, multi-scene backgrounding, and the wedged-dedupe-key regression are all exercised. 93/93 pass. Co-authored-by: Cursor --- .../OSFeatureFlagsBackendService.swift | 14 +- .../OSFeatureFlagsRefreshService.swift | 67 ++++-- .../OSFeatureFlagsTests.swift | 224 ++++++++++++++---- iOS_SDK/OneSignalSDK/Source/OneSignal.m | 39 ++- 4 files changed, 272 insertions(+), 72 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Feature/OSFeatureFlagsBackendService.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Feature/OSFeatureFlagsBackendService.swift index 6f2087a08..d9151e75d 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Feature/OSFeatureFlagsBackendService.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Feature/OSFeatureFlagsBackendService.swift @@ -162,10 +162,12 @@ final class OSFeatureFlagsHttpAdapter: IFeatureFlagsHttp { } func get(relativePath: String, completionHandler: @escaping (FeatureFlagsHttpResponse?, Error?) -> Void) { + // `OneSignalRequest` is used for the base URL and the standard SDK headers only. + // Its `disableLocalCaching` flag is read by `OneSignalClient`, which this path + // deliberately bypasses, so cache policy is set on the session instead. let request = OneSignalRequest() request.method = GET request.path = relativePath - request.disableLocalCaching = true requestSender(request.urlRequest() as URLRequest) { data, response, error in let statusCode: Int32 if error != nil { @@ -175,7 +177,15 @@ final class OSFeatureFlagsHttpAdapter: IFeatureFlagsHttp { } else { statusCode = 0 } - let body = data.flatMap { String(data: $0, encoding: .utf8) } + // A transport failure carries no HTTP status, so forward the error text as the + // body. Without it every such failure logs as `status=0 body=` and + // offline, DNS, TLS, and timeout are indistinguishable in the field. + let body: String? + if let error { + body = error.localizedDescription + } else { + body = data.flatMap { String(data: $0, encoding: .utf8) } + } completionHandler(FeatureFlagsHttpResponse(statusCode: statusCode, body: body), nil) } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Feature/OSFeatureFlagsRefreshService.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Feature/OSFeatureFlagsRefreshService.swift index 7a788ca51..b9395c3da 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Feature/OSFeatureFlagsRefreshService.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Feature/OSFeatureFlagsRefreshService.swift @@ -61,7 +61,10 @@ public final class OSFeatureFlagsRefreshService: NSObject { private let notificationCenter: NotificationCenter private let usesScenes: () -> Bool private let appIdProvider: () -> String? - private let isInForegroundProvider: () -> Bool + /// Test-only override. Production tracks foreground state from lifecycle + /// notifications, seeded by `start(isInForeground:)`, because this framework is + /// extension-safe and so cannot read `UIApplication.shared` to ask directly. + private let isInForegroundOverride: (() -> Bool)? var refreshInterval: TimeInterval @@ -73,6 +76,9 @@ public final class OSFeatureFlagsRefreshService: NSObject { /// Scenes currently foregrounded. Polling only stops once the last one backgrounds, /// so an iPad user backgrounding one of two windows keeps flags refreshing. private var activeSceneCount = 0 + /// Defaults to false so a background launch (silent push, background fetch, prewarm) + /// does not start polling before a focus event says otherwise. + private var isForeground = false init( backend: OSFeatureFlagsBackendService = OSFeatureFlagsBackendService(), @@ -83,7 +89,7 @@ public final class OSFeatureFlagsRefreshService: NSObject { appIdProvider: @escaping () -> String? = { OneSignalIdentifiers.currentAppId ?? OneSignalIdentifiers.storedAppId }, - isInForegroundProvider: @escaping () -> Bool = { true }, + isInForegroundProvider: (() -> Bool)? = nil, refreshInterval: TimeInterval = OSFeatureFlagsRefreshService.defaultRefreshInterval ) { self.backend = backend @@ -92,14 +98,20 @@ public final class OSFeatureFlagsRefreshService: NSObject { self.notificationCenter = notificationCenter self.usesScenes = usesScenes self.appIdProvider = appIdProvider - self.isInForegroundProvider = isInForegroundProvider + self.isInForegroundOverride = isInForegroundProvider self.refreshInterval = refreshInterval super.init() } - @objc public class func start() { + /// Idempotent: safe to call again once the host learns its real foreground state. + /// + /// - Parameter isInForeground: the host's current foreground state. Passing `false` + /// registers the lifecycle observers but leaves polling idle until a focus event. + @objc public class func start(isInForeground: Bool) { + let service = shared + service.setForeground(isInForeground) _ = OSFeatureManager.shared - shared.startPolling() + service.startPolling() } @objc public class func reset() { @@ -115,33 +127,48 @@ public final class OSFeatureFlagsRefreshService: NSObject { return } self.registerLifecycleObserversIfNeeded() - if self.isInForegroundProvider() { + if self.inForeground() { self.restartForegroundPolling() } } } - func onFocus() { - ioQueue.async { [weak self] in - self?.restartForegroundPolling() + /// Seeds the tracked state. Lifecycle notifications keep it current from here on. + func setForeground(_ value: Bool) { + stateLock.withLock { + isForeground = value } } - func onUnfocused() { + private func inForeground() -> Bool { + if let isInForegroundOverride { + return isInForegroundOverride() + } + return stateLock.withLock { isForeground } + } + + func onFocus() { ioQueue.async { [weak self] in - self?.stateLock.withLock { - self?.pollGeneration += 1 - self?.pollingAppId = nil + guard let self else { + return } + self.stateLock.withLock { + self.isForeground = true + } + self.restartForegroundPolling() } } - func notifyAppIdMayHaveChanged() { + func onUnfocused() { ioQueue.async { [weak self] in - guard let self, self.isInForegroundProvider() else { + guard let self else { return } - self.restartForegroundPolling() + self.stateLock.withLock { + self.isForeground = false + self.pollGeneration += 1 + self.pollingAppId = nil + } } } @@ -237,17 +264,17 @@ public final class OSFeatureFlagsRefreshService: NSObject { guard generation >= 0 else { return } - poll(appId: appId, generation: generation) + poll(generation: generation) } - private func poll(appId: String, generation: Int) { + private func poll(generation: Int) { guard isCurrentGeneration(generation) else { return } // Bailing out has to release the dedupe key as well, otherwise // `restartForegroundPolling` keeps matching `pollingAppId` and no later focus // event can ever restart the loop for this app id. - guard isInForegroundProvider() else { + guard inForeground() else { releasePollingKey(for: generation) return } @@ -262,7 +289,7 @@ public final class OSFeatureFlagsRefreshService: NSObject { } self.apply(outcome) self.ioQueue.asyncAfterTime(deadline: .now() + self.refreshInterval) { [weak self] in - self?.poll(appId: current, generation: generation) + self?.poll(generation: generation) } } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSFeatureFlagsTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSFeatureFlagsTests.swift index 6a7bd9dd2..8a2eec534 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSFeatureFlagsTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSFeatureFlagsTests.swift @@ -215,6 +215,33 @@ final class OSFeatureFlagsRefreshServiceTests: XCTestCase { super.tearDown() } + private func makeService( + http: IFeatureFlagsHttp, + queue: ControllableDispatchQueue, + notificationCenter: NotificationCenter = NotificationCenter(), + usesScenes: @escaping () -> Bool = { false }, + appIdProvider: @escaping () -> String? = { "app-id-1" }, + isInForegroundProvider: (() -> Bool)? = { true } + ) -> OSFeatureFlagsRefreshService { + OSFeatureFlagsRefreshService( + backend: OSFeatureFlagsBackendService(http: http, sdkVersionProvider: { "050506" }), + store: store, + ioQueue: queue, + notificationCenter: notificationCenter, + usesScenes: usesScenes, + appIdProvider: appIdProvider, + isInForegroundProvider: isInForegroundProvider, + refreshInterval: 10_000 + ) + } + + private func countingHttp(_ counter: @escaping () -> Void) -> StubFeatureFlagsHttp { + StubFeatureFlagsHttp { _, completion in + counter() + completion(FeatureFlagsHttpResponse(statusCode: 200, body: #"{"features":[]}"#), nil) + } + } + func testSuccessfulFetchPersistsKeysOnTheStore() { let http = StubFeatureFlagsHttp { _, completion in completion( @@ -225,15 +252,7 @@ final class OSFeatureFlagsRefreshServiceTests: XCTestCase { nil ) } - let backend = OSFeatureFlagsBackendService(http: http, sdkVersionProvider: { "050506" }) - let service = OSFeatureFlagsRefreshService( - backend: backend, - store: store, - ioQueue: ImmediateDispatchQueue(), - appIdProvider: { "app-id-1" }, - isInForegroundProvider: { true }, - refreshInterval: 10_000 - ) + let service = makeService(http: http, queue: ControllableDispatchQueue()) service.startPolling() @@ -245,68 +264,165 @@ final class OSFeatureFlagsRefreshServiceTests: XCTestCase { let http = StubFeatureFlagsHttp { _, completion in completion(FeatureFlagsHttpResponse(statusCode: 500, body: "boom"), nil) } - let backend = OSFeatureFlagsBackendService(http: http, sdkVersionProvider: { "050506" }) - let service = OSFeatureFlagsRefreshService( - backend: backend, - store: store, - ioQueue: ImmediateDispatchQueue(), - appIdProvider: { "app-id-1" }, - isInForegroundProvider: { true }, - refreshInterval: 10_000 - ) + let service = makeService(http: http, queue: ControllableDispatchQueue()) service.startPolling() XCTAssertEqual(store.sdkRemoteFeatureFlags, ["sdk_identity_verification"]) } - func testSameAppIdRestartDoesNotDoubleFetch() { + func testSameAppIdRefocusDoesNotDoubleFetch() { var fetches = 0 - let http = StubFeatureFlagsHttp { _, completion in - fetches += 1 - completion(FeatureFlagsHttpResponse(statusCode: 200, body: #"{"features":[]}"#), nil) - } - let backend = OSFeatureFlagsBackendService(http: http, sdkVersionProvider: { "050506" }) - let service = OSFeatureFlagsRefreshService( - backend: backend, - store: store, - ioQueue: ImmediateDispatchQueue(), - appIdProvider: { "app-id-1" }, - isInForegroundProvider: { true }, - refreshInterval: 10_000 - ) + let service = makeService(http: countingHttp { fetches += 1 }, queue: ControllableDispatchQueue()) service.startPolling() - service.notifyAppIdMayHaveChanged() + service.onFocus() XCTAssertEqual(fetches, 1) } - func testAppIdChangeRefetches() { + func testAppIdChangeRefetchesWithTheFullTurbinePath() { var appId = "app-id-1" var fetched: [String] = [] let http = StubFeatureFlagsHttp { path, completion in fetched.append(path) completion(FeatureFlagsHttpResponse(statusCode: 200, body: #"{"features":[]}"#), nil) } - let backend = OSFeatureFlagsBackendService(http: http, sdkVersionProvider: { "050506" }) - let service = OSFeatureFlagsRefreshService( - backend: backend, - store: store, - ioQueue: ImmediateDispatchQueue(), - appIdProvider: { appId }, - isInForegroundProvider: { true }, - refreshInterval: 10_000 - ) + let service = makeService(http: http, queue: ControllableDispatchQueue(), appIdProvider: { appId }) service.startPolling() service.onUnfocused() appId = "app-id-2" service.onFocus() - XCTAssertEqual(fetched.count, 2) - XCTAssertTrue(fetched[0].contains("app-id-1")) - XCTAssertTrue(fetched[1].contains("app-id-2")) + // Asserting the whole path, not just the app id: the platform segment is the + // cross-platform contract this wiring exists to keep stable. + XCTAssertEqual(fetched, [ + "apps/app-id-1/sdk/features/ios/050506", + "apps/app-id-2/sdk/features/ios/050506" + ]) + } + + func testPollReschedulesItselfAfterTheRefreshInterval() { + var fetches = 0 + let queue = ControllableDispatchQueue() + let service = makeService(http: countingHttp { fetches += 1 }, queue: queue) + + service.startPolling() + XCTAssertEqual(fetches, 1) + + queue.runPendingDeferredWork() + + XCTAssertEqual(fetches, 2) + } + + func testUnfocusCancelsTheScheduledPoll() { + var fetches = 0 + let queue = ControllableDispatchQueue() + let service = makeService(http: countingHttp { fetches += 1 }, queue: queue) + + service.startPolling() + XCTAssertEqual(fetches, 1) + + service.onUnfocused() + queue.runPendingDeferredWork() + + XCTAssertEqual(fetches, 1, "the queued poll belongs to a cancelled generation") + } + + func testDoesNotPollWhileBackgrounded() { + var fetches = 0 + let service = makeService( + http: countingHttp { fetches += 1 }, + queue: ControllableDispatchQueue(), + isInForegroundProvider: { false } + ) + + service.startPolling() + + XCTAssertEqual(fetches, 0) + } + + func testDoesNotPollUntilTheHostReportsForeground() { + var fetches = 0 + // No override, so the service uses its own tracked state, which starts false. + let service = makeService( + http: countingHttp { fetches += 1 }, + queue: ControllableDispatchQueue(), + isInForegroundProvider: nil + ) + + service.startPolling() + XCTAssertEqual(fetches, 0, "a background launch must not fetch") + + service.setForeground(true) + service.startPolling() + + XCTAssertEqual(fetches, 1) + } + + func testMomentarilyEmptyAppIdDoesNotWedgePolling() { + // The app id is readable when polling is armed but empty by the time the poll + // runs. Without releasing the dedupe key, no later focus could ever restart. + var appIdReads = ["app-id-1", ""] + var fetches = 0 + let service = makeService( + http: countingHttp { fetches += 1 }, + queue: ControllableDispatchQueue(), + appIdProvider: { appIdReads.isEmpty ? "app-id-1" : appIdReads.removeFirst() } + ) + + service.startPolling() + XCTAssertEqual(fetches, 0) + + service.onFocus() + + XCTAssertEqual(fetches, 1, "a later focus must be able to restart polling") + } + + func testBackgroundingOneOfTwoScenesKeepsPolling() { + var fetches = 0 + let center = NotificationCenter() + let queue = ControllableDispatchQueue() + let service = makeService( + http: countingHttp { fetches += 1 }, + queue: queue, + notificationCenter: center, + usesScenes: { true }, + isInForegroundProvider: nil + ) + service.startPolling() + + center.post(name: Notification.Name("UISceneDidActivateNotification"), object: nil) + center.post(name: Notification.Name("UISceneDidActivateNotification"), object: nil) + XCTAssertEqual(fetches, 1, "the second scene is deduped against the same app id") + + center.post(name: Notification.Name("UISceneDidEnterBackgroundNotification"), object: nil) + queue.runPendingDeferredWork() + + XCTAssertEqual(fetches, 2, "one scene is still foregrounded") + } + + func testBackgroundingTheLastSceneStopsPolling() { + var fetches = 0 + let center = NotificationCenter() + let queue = ControllableDispatchQueue() + let service = makeService( + http: countingHttp { fetches += 1 }, + queue: queue, + notificationCenter: center, + usesScenes: { true }, + isInForegroundProvider: nil + ) + service.startPolling() + + center.post(name: Notification.Name("UISceneDidActivateNotification"), object: nil) + XCTAssertEqual(fetches, 1) + + center.post(name: Notification.Name("UISceneDidEnterBackgroundNotification"), object: nil) + queue.runPendingDeferredWork() + + XCTAssertEqual(fetches, 1) } } @@ -325,12 +441,24 @@ private final class StubFeatureFlagsHttp: IFeatureFlagsHttp { } } -private final class ImmediateDispatchQueue: OSDispatchQueue { +/// Runs immediate work inline and holds deferred work until a test releases it, so the +/// self-rescheduling poll loop can be stepped without waiting out the refresh interval. +private final class ControllableDispatchQueue: OSDispatchQueue { + private var deferredWork: [() -> Void] = [] + func async(execute work: @escaping @convention(block) () -> Void) { work() } func asyncAfterTime(deadline: DispatchTime, execute work: @escaping @Sendable @convention(block) () -> Void) { - // Tests use a large refreshInterval and assert the first fetch only. + deferredWork.append(work) + } + + /// Runs work queued so far. Work scheduled *by* that work is left for the next call, + /// so a single step cannot recurse forever. + func runPendingDeferredWork() { + let scheduled = deferredWork + deferredWork.removeAll() + scheduled.forEach { $0() } } } diff --git a/iOS_SDK/OneSignalSDK/Source/OneSignal.m b/iOS_SDK/OneSignalSDK/Source/OneSignal.m index 68599e0d2..d52161428 100755 --- a/iOS_SDK/OneSignalSDK/Source/OneSignal.m +++ b/iOS_SDK/OneSignalSDK/Source/OneSignal.m @@ -467,6 +467,41 @@ + (void)delayInitializationForPrivacyConsent { [OneSignalIdentifiers setCurrentAppId:nil]; } +/// Current foreground state for components that cannot read `UIApplication` themselves +/// (OneSignalOSCore is extension-safe). Answers NO when it cannot know: `applicationState` +/// is main-thread only, and `sharedApplication` is nil before `UIApplicationMain` +/// (e.g. a SwiftUI `App.init()`), where a prewarmed background launch is the safe guess. +static BOOL IsAppInForeground(void) { + if (![NSThread isMainThread]) { + return NO; + } + UIApplication *sharedApp = UIApplication.sharedApplication; + if (!sharedApp) { + return NO; + } + return sharedApp.applicationState != UIApplicationStateBackground; +} + +/// Starts feature-flag polling seeded with the host's foreground state. +/// +/// `+init` can run off the main queue, where the state is unreadable. Starting anyway +/// registers the lifecycle observers; the hop back to main then supplies the real value. +/// Without that second pass, an off-main init while the app is already active would see +/// NO and wait for the next foreground transition — which may never come this session. +/// The re-seed only reports YES so it cannot clobber a focus event that landed first. +static void StartFeatureFlagsRefresh(void) { + if ([NSThread isMainThread]) { + [OSFeatureFlagsRefreshService startWithIsInForeground:IsAppInForeground()]; + return; + } + [OSFeatureFlagsRefreshService startWithIsInForeground:NO]; + dispatch_async(dispatch_get_main_queue(), ^{ + if (IsAppInForeground()) { + [OSFeatureFlagsRefreshService startWithIsInForeground:YES]; + } + }); +} + /// Computes the initial value for `gProtectedDataAvailable` (see the case table in /// `+setupProtectedDataObserverOnce`). static BOOL ComputeInitialStorageReadable(void) { @@ -544,7 +579,7 @@ + (void)setupProtectedDataObserverOnce { [OSNotificationsManager sendPushTokenToDelegate]; [OneSignal startLiveActivitiesManager]; [OneSignal startInAppMessages]; - [OSFeatureFlagsRefreshService start]; + StartFeatureFlagsRefresh(); [OneSignal startNewSession:YES]; }; @@ -645,7 +680,7 @@ + (void)init { if (![OneSignalConfig shouldAwaitAppIdAndLogMissingPrivacyConsentForMethod:nil]) { [self startLiveActivitiesManager]; [self startInAppMessages]; - [OSFeatureFlagsRefreshService start]; + StartFeatureFlagsRefresh(); } [self startNewSession:YES]; From 941bca3d90383f07290584b021ece215937f31a9 Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Mon, 24 Aug 2026 14:48:20 -0500 Subject: [PATCH 4/6] fix: [SDK-5048] close three feature-flag lifecycle races MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review feedback on the reset and scene-tracking paths. `OSFeatureManager` built the shared instance outside its lock so construction could log without re-entering it, on the reasoning that a lost race merely discards a redundant instance. That misses the reset interleaving: a manager that read storage before an app-id change would find `_shared` still nil afterwards and publish itself, restoring the `APP_STARTUP` latch that the reset existed to drop. Construction stays outside the lock, but publication is now guarded by a generation stamped before the read, so a manager whose storage snapshot has been invalidated is discarded and the read retried. `stopPolling` now marks the instance invalidated, and the work that can restart a loop consults that flag. Previously a `startPolling` already queued on `ioQueue` when a reset landed would re-register lifecycle observers and start polling on an instance `shared` had already dropped, leaving a second poller and observers nothing could reach to remove. `restartForegroundPolling` checks the flag inside the same lock that claims a generation, so a reset cannot be overtaken by work that passed an earlier check. `observe` also tears down its own token when a reset lands between registering and recording it, the one window where nothing else holds the token. Replaces scene counting with the app-level lifecycle notifications in both scene and non-scene apps. The counter could not be made correct: it only saw activations from registration onward, so scenes already active were invisible and the first background event stopped polling app-wide, and repeated activations without an intervening background event inflated it so polling outlived the foreground. UIKit already answers the exact question being asked — `didEnterBackgroundNotification` posts only once the last scene backgrounds, and `didBecomeActiveNotification` when the app becomes active again — so the aggregation belongs to the OS rather than to a counter here. This also drops the `usesScenes` dependency from the service. Tests: 97/97. Adds coverage for a reset racing a queued start, a reset racing observer registration, observer teardown asserted rather than inferred, repeated activation still stopping on one background event, and a reset landing mid-construction in the manager. Co-authored-by: Cursor --- .../OSFeatureFlagsRefreshService.swift | 90 +++++----- .../Source/Feature/OSFeatureManager.swift | 44 +++-- .../OSFeatureFlagsTests.swift | 156 ++++++++++++++++-- 3 files changed, 216 insertions(+), 74 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Feature/OSFeatureFlagsRefreshService.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Feature/OSFeatureFlagsRefreshService.swift index b9395c3da..70e157c8d 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Feature/OSFeatureFlagsRefreshService.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Feature/OSFeatureFlagsRefreshService.swift @@ -59,7 +59,6 @@ public final class OSFeatureFlagsRefreshService: NSObject { private let store: OSFeatureFlagsStore private let ioQueue: OSDispatchQueue private let notificationCenter: NotificationCenter - private let usesScenes: () -> Bool private let appIdProvider: () -> String? /// Test-only override. Production tracks foreground state from lifecycle /// notifications, seeded by `start(isInForeground:)`, because this framework is @@ -73,19 +72,20 @@ public final class OSFeatureFlagsRefreshService: NSObject { private var pollingAppId: String? private var started = false private var notificationTokens: [NSObjectProtocol] = [] - /// Scenes currently foregrounded. Polling only stops once the last one backgrounds, - /// so an iPad user backgrounding one of two windows keeps flags refreshing. - private var activeSceneCount = 0 /// Defaults to false so a background launch (silent push, background fetch, prewarm) /// does not start polling before a focus event says otherwise. private var isForeground = false + /// Set by `stopPolling` and never cleared. Work already queued on `ioQueue` when a + /// reset lands would otherwise re-register observers and restart the poll loop on an + /// instance that `shared` has already dropped, leaving a second poller and a set of + /// observers that nothing can reach to remove. + private var invalidated = false init( backend: OSFeatureFlagsBackendService = OSFeatureFlagsBackendService(), store: OSFeatureFlagsStore = .shared, ioQueue: OSDispatchQueue = DispatchQueue(label: "com.onesignal.feature-flags.refresh"), notificationCenter: NotificationCenter = .default, - usesScenes: @escaping () -> Bool = { OSBundleUtils.isAppUsingUIScene() }, appIdProvider: @escaping () -> String? = { OneSignalIdentifiers.currentAppId ?? OneSignalIdentifiers.storedAppId }, @@ -96,7 +96,6 @@ public final class OSFeatureFlagsRefreshService: NSObject { self.store = store self.ioQueue = ioQueue self.notificationCenter = notificationCenter - self.usesScenes = usesScenes self.appIdProvider = appIdProvider self.isInForegroundOverride = isInForegroundProvider self.refreshInterval = refreshInterval @@ -123,7 +122,7 @@ public final class OSFeatureFlagsRefreshService: NSObject { func startPolling() { ioQueue.async { [weak self] in - guard let self else { + guard let self, !self.isInvalidated() else { return } self.registerLifecycleObserversIfNeeded() @@ -133,6 +132,10 @@ public final class OSFeatureFlagsRefreshService: NSObject { } } + private func isInvalidated() -> Bool { + stateLock.withLock { invalidated } + } + /// Seeds the tracked state. Lifecycle notifications keep it current from here on. func setForeground(_ value: Bool) { stateLock.withLock { @@ -149,7 +152,7 @@ public final class OSFeatureFlagsRefreshService: NSObject { func onFocus() { ioQueue.async { [weak self] in - guard let self else { + guard let self, !self.isInvalidated() else { return } self.stateLock.withLock { @@ -172,11 +175,12 @@ public final class OSFeatureFlagsRefreshService: NSObject { } } - private func stopPolling() { + func stopPolling() { // `reset()` reaches here on the caller's thread while `observe` may be appending // on ioQueue, so the token list has to move under the same lock as the rest of // the mutable state. Deregistration itself happens outside the lock. let tokens: [NSObjectProtocol] = stateLock.withLock { + invalidated = true let current = notificationTokens notificationTokens.removeAll() pollGeneration += 1 @@ -187,32 +191,30 @@ public final class OSFeatureFlagsRefreshService: NSObject { tokens.forEach(notificationCenter.removeObserver) } + /// Deliberately app-level rather than per-scene, in both scene and non-scene apps. + /// UIKit posts `didEnterBackgroundNotification` only once the *last* scene backgrounds + /// and `didBecomeActiveNotification` when the app becomes active again, so the OS + /// already aggregates exactly the "is any part of this app foreground" question that + /// polling turns on. Tracking scenes ourselves cannot match it: we only see + /// activations from registration onward, so scenes already active are invisible, and + /// activations can repeat without an intervening background event. private func registerLifecycleObserversIfNeeded() { - let alreadyStarted = stateLock.withLock { () -> Bool in - if started { + let shouldSkip = stateLock.withLock { () -> Bool in + if started || invalidated { return true } started = true return false } - guard !alreadyStarted else { + guard !shouldSkip else { return } - if usesScenes() { - observe(Notification.Name("UISceneDidActivateNotification")) { [weak self] in - self?.sceneDidActivate() - } - observe(Notification.Name("UISceneDidEnterBackgroundNotification")) { [weak self] in - self?.sceneDidBackground() - } - } else { - observe(UIApplication.didBecomeActiveNotification) { [weak self] in - self?.onFocus() - } - observe(UIApplication.didEnterBackgroundNotification) { [weak self] in - self?.onUnfocused() - } + observe(UIApplication.didBecomeActiveNotification) { [weak self] in + self?.onFocus() + } + observe(UIApplication.didEnterBackgroundNotification) { [weak self] in + self?.onUnfocused() } } @@ -220,35 +222,29 @@ public final class OSFeatureFlagsRefreshService: NSObject { let token = notificationCenter.addObserver(forName: name, object: nil, queue: nil) { _ in handler() } - stateLock.withLock { + // A reset can land between registering above and recording below. Nothing else + // holds this token by then, so it has to be torn down here or it outlives the + // service with no way to reach it. + let recorded = stateLock.withLock { () -> Bool in + guard !invalidated else { + return false + } notificationTokens.append(token) + return true } - } - - private func sceneDidActivate() { - stateLock.withLock { - activeSceneCount += 1 - } - onFocus() - } - - /// Only the last scene leaving the foreground stops polling. The count can start - /// below the true number of live scenes (we only see activations after registering), - /// so it is floored at zero rather than trusted absolutely. - private func sceneDidBackground() { - let allScenesBackgrounded: Bool = stateLock.withLock { - activeSceneCount = max(0, activeSceneCount - 1) - return activeSceneCount == 0 - } - guard allScenesBackgrounded else { - return + if !recorded { + notificationCenter.removeObserver(token) } - onUnfocused() } private func restartForegroundPolling() { let appId = appIdProvider() ?? "" let generation: Int = stateLock.withLock { + // Single atomic gate for starting a loop, so a reset that lands mid-startup + // cannot be overtaken by work that already passed an earlier check. + if invalidated { + return -1 + } if appId.isEmpty { pollGeneration += 1 pollingAppId = nil diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Feature/OSFeatureManager.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Feature/OSFeatureManager.swift index ba940af11..e27dfec1d 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Feature/OSFeatureManager.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Feature/OSFeatureManager.swift @@ -35,25 +35,40 @@ import OneSignalCore public final class OSFeatureManager: NSObject { private static let lock = NSLock() private static var _shared: OSFeatureManager? + /// Bumped by `reset()`. Construction happens outside the lock, so this is what tells + /// a builder that the state it read has since been invalidated. + private static var generation = 0 /// Constructing this reads persisted flags and builds the KMP latch, so the first /// access decides which `APP_STARTUP` flags are latched for the process. Only touch /// it once storage is readable; see `enabledFeatureKeysIfInitialized()`. @objc public static var shared: OSFeatureManager { - if let existing = lock.withLock({ _shared }) { - return existing - } - // Built outside the lock on purpose: construction reads storage and logs, and - // logging reaches back through the feature-flag provider. Holding the lock - // across that would risk re-entering it on the same thread. A lost race just - // discards the extra instance, which has only read the store. - let created = OSFeatureManager() - return lock.withLock { - if let existing = _shared { + while true { + let (existing, startGeneration) = lock.withLock { (_shared, generation) } + if let existing { return existing } - _shared = created - return created + // Built outside the lock on purpose: construction reads storage and logs, and + // logging reaches back through the feature-flag provider. Holding the lock + // across that would risk re-entering it on the same thread. + let created = OSFeatureManager() + didConstructForTesting?() + let published: OSFeatureManager? = lock.withLock { + if let existing = _shared { + return existing + } + // A reset landed while we were reading storage, so this instance latched + // the previous app id's flags. Publishing it would restore exactly the + // stale latch the reset existed to drop. + guard generation == startGeneration else { + return nil + } + _shared = created + return created + } + if let published { + return published + } } } @@ -77,6 +92,7 @@ public final class OSFeatureManager: NSObject { @objc public static func reset() { lock.withLock { _shared = nil + generation += 1 } } @@ -113,4 +129,8 @@ public final class OSFeatureManager: NSObject { /// Local-only test hook for forcing features ON without backend config. static var localFeatureOverrides: [String] = [] + + /// Test-only hook, fired after construction but before publication, so a reset can be + /// landed inside that window deterministically. + static var didConstructForTesting: (() -> Void)? } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSFeatureFlagsTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSFeatureFlagsTests.swift index 8a2eec534..7c231cdce 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSFeatureFlagsTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSFeatureFlagsTests.swift @@ -29,6 +29,7 @@ import Foundation import OneSignalCore import OneSignalKMP @testable import OneSignalOSCore +import UIKit import XCTest final class OSFeatureManagerTests: XCTestCase { @@ -39,11 +40,16 @@ final class OSFeatureManagerTests: XCTestCase { OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_SDK_REMOTE_FEATURE_FLAGS) OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_SDK_REMOTE_FEATURE_FLAG_METADATA) OSFeatureManager.localFeatureOverrides = [] + // One test drives the real singleton, so drop it either side to keep the + // process-wide latch from leaking between cases. + OSFeatureManager.reset() store = OSFeatureFlagsStore() } override func tearDown() { OSFeatureManager.localFeatureOverrides = [] + OSFeatureManager.didConstructForTesting = nil + OSFeatureManager.reset() OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_SDK_REMOTE_FEATURE_FLAGS) OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_SDK_REMOTE_FEATURE_FLAG_METADATA) super.tearDown() @@ -109,6 +115,32 @@ final class OSFeatureManagerTests: XCTestCase { XCTAssertTrue(manager.isEnabled(featureKey: FeatureFlag.sdkCustomLogging.key)) } + + /// Construction happens outside the lock, so an app-id change can land after a + /// manager has read storage but before it publishes itself. Publishing it anyway + /// would restore exactly the `APP_STARTUP` latch the reset existed to drop. + func testResetDuringConstructionDiscardsTheStaleManager() { + OSFeatureFlagsStore.shared.applyRemoteFlags([FeatureFlag.sdkCustomLogging.key], metadata: nil) + OSFeatureManager.reset() + + var alreadyReset = false + OSFeatureManager.didConstructForTesting = { + guard !alreadyReset else { + return + } + alreadyReset = true + OSFeatureManager.resetAndClearCachedFlags() + } + defer { OSFeatureManager.didConstructForTesting = nil } + + let manager = OSFeatureManager.shared + + XCTAssertTrue(alreadyReset, "the race hook must have fired") + XCTAssertFalse( + manager.isEnabled(featureKey: FeatureFlag.sdkCustomLogging.key), + "the published manager must not carry a latch read before the reset" + ) + } } final class OSFeatureFlagsBackendServiceTests: XCTestCase { @@ -219,7 +251,6 @@ final class OSFeatureFlagsRefreshServiceTests: XCTestCase { http: IFeatureFlagsHttp, queue: ControllableDispatchQueue, notificationCenter: NotificationCenter = NotificationCenter(), - usesScenes: @escaping () -> Bool = { false }, appIdProvider: @escaping () -> String? = { "app-id-1" }, isInForegroundProvider: (() -> Bool)? = { true } ) -> OSFeatureFlagsRefreshService { @@ -228,7 +259,6 @@ final class OSFeatureFlagsRefreshServiceTests: XCTestCase { store: store, ioQueue: queue, notificationCenter: notificationCenter, - usesScenes: usesScenes, appIdProvider: appIdProvider, isInForegroundProvider: isInForegroundProvider, refreshInterval: 10_000 @@ -380,7 +410,7 @@ final class OSFeatureFlagsRefreshServiceTests: XCTestCase { XCTAssertEqual(fetches, 1, "a later focus must be able to restart polling") } - func testBackgroundingOneOfTwoScenesKeepsPolling() { + func testBecomingActiveStartsPollingAndBackgroundingStopsIt() { var fetches = 0 let center = NotificationCenter() let queue = ControllableDispatchQueue() @@ -388,22 +418,24 @@ final class OSFeatureFlagsRefreshServiceTests: XCTestCase { http: countingHttp { fetches += 1 }, queue: queue, notificationCenter: center, - usesScenes: { true }, isInForegroundProvider: nil ) service.startPolling() + XCTAssertEqual(fetches, 0, "a background launch must not fetch") - center.post(name: Notification.Name("UISceneDidActivateNotification"), object: nil) - center.post(name: Notification.Name("UISceneDidActivateNotification"), object: nil) - XCTAssertEqual(fetches, 1, "the second scene is deduped against the same app id") + center.post(name: UIApplication.didBecomeActiveNotification, object: nil) + XCTAssertEqual(fetches, 1) - center.post(name: Notification.Name("UISceneDidEnterBackgroundNotification"), object: nil) + center.post(name: UIApplication.didEnterBackgroundNotification, object: nil) queue.runPendingDeferredWork() - XCTAssertEqual(fetches, 2, "one scene is still foregrounded") + XCTAssertEqual(fetches, 1, "backgrounding cancels the scheduled poll") } - func testBackgroundingTheLastSceneStopsPolling() { + /// UIKit can post `didBecomeActive` repeatedly without an intervening background + /// event. A counter-based approach treated each as another live scene and then needed + /// as many background events to stop, so polling outlived the foreground. + func testRepeatedActivationStillStopsOnASingleBackgroundEvent() { var fetches = 0 let center = NotificationCenter() let queue = ControllableDispatchQueue() @@ -411,18 +443,76 @@ final class OSFeatureFlagsRefreshServiceTests: XCTestCase { http: countingHttp { fetches += 1 }, queue: queue, notificationCenter: center, - usesScenes: { true }, isInForegroundProvider: nil ) service.startPolling() - center.post(name: Notification.Name("UISceneDidActivateNotification"), object: nil) - XCTAssertEqual(fetches, 1) + center.post(name: UIApplication.didBecomeActiveNotification, object: nil) + center.post(name: UIApplication.didBecomeActiveNotification, object: nil) + center.post(name: UIApplication.didBecomeActiveNotification, object: nil) + XCTAssertEqual(fetches, 1, "redundant activations dedupe against the same app id") - center.post(name: Notification.Name("UISceneDidEnterBackgroundNotification"), object: nil) + center.post(name: UIApplication.didEnterBackgroundNotification, object: nil) queue.runPendingDeferredWork() - XCTAssertEqual(fetches, 1) + XCTAssertEqual(fetches, 1, "one background event must undo any number of activations") + } + + func testResetStopsAnAlreadyQueuedStart() { + var fetches = 0 + let queue = ControllableDispatchQueue(deferImmediateWork: true) + let service = makeService( + http: countingHttp { fetches += 1 }, + queue: queue, + isInForegroundProvider: { true } + ) + + // `startPolling` is queued but has not run; the reset lands first, exactly as it + // would when `handleAppIdChange` fires while startup work is still in flight. + service.startPolling() + service.stopPolling() + queue.runPendingDeferredWork() + + XCTAssertEqual(fetches, 0, "a dropped instance must not resurrect its poll loop") + } + + func testResetLeavesNoLifecycleObserversBehind() { + var fetches = 0 + let center = ObserverTrackingCenter() + let service = makeService( + http: countingHttp { fetches += 1 }, + queue: ControllableDispatchQueue(), + notificationCenter: center, + isInForegroundProvider: { true } + ) + service.startPolling() + XCTAssertEqual(center.liveObserverCount, 2) + + service.stopPolling() + + XCTAssertEqual(center.liveObserverCount, 0) + } + + /// The reset lands between `addObserver` returning and the service recording the + /// token. Nothing else holds the token at that instant, so if `observe` does not tear + /// it down itself it outlives the service with no way left to reach it. + func testObserverRegisteredDuringAResetIsTornDown() { + var fetches = 0 + let center = ObserverTrackingCenter() + let service = makeService( + http: countingHttp { fetches += 1 }, + queue: ControllableDispatchQueue(), + notificationCenter: center, + isInForegroundProvider: { true } + ) + center.onAddObserver = { [weak service] in + service?.stopPolling() + } + + service.startPolling() + + XCTAssertEqual(center.liveObserverCount, 0, "no observer may survive the reset that raced it") + XCTAssertEqual(fetches, 0, "and the dropped instance must not start a loop") } } @@ -441,12 +531,48 @@ private final class StubFeatureFlagsHttp: IFeatureFlagsHttp { } } +/// Counts observers that are currently registered, so a test can assert teardown rather +/// than infer it, and can inject a reset into the window inside `observe`. +private final class ObserverTrackingCenter: NotificationCenter { + var onAddObserver: (() -> Void)? + private(set) var liveObserverCount = 0 + + override func addObserver( + forName name: NSNotification.Name?, + object obj: Any?, + queue: OperationQueue?, + using block: @escaping (Notification) -> Void + ) -> NSObjectProtocol { + let token = super.addObserver(forName: name, object: obj, queue: queue, using: block) + liveObserverCount += 1 + onAddObserver?() + return token + } + + override func removeObserver(_ observer: Any) { + super.removeObserver(observer) + liveObserverCount -= 1 + } +} + /// Runs immediate work inline and holds deferred work until a test releases it, so the /// self-rescheduling poll loop can be stepped without waiting out the refresh interval. +/// +/// `deferImmediateWork` also holds `async` work, which lets a test interleave a reset +/// ahead of already-queued startup work. private final class ControllableDispatchQueue: OSDispatchQueue { + private let deferImmediateWork: Bool private var deferredWork: [() -> Void] = [] + init(deferImmediateWork: Bool = false) { + self.deferImmediateWork = deferImmediateWork + } + func async(execute work: @escaping @convention(block) () -> Void) { + guard !deferImmediateWork else { + deferredWork.append(work) + return + } work() } From b1a528dcdf2dd0fe3c9b0f4b0b7a2edec2eb1a68 Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Mon, 24 Aug 2026 14:52:07 -0500 Subject: [PATCH 5/6] test: [SDK-5048] split the feature-flag tests by unit under test The single file had grown past the 400-line SwiftLint limit as the refresh service picked up lifecycle coverage. Moves the refresh-service cases and their fakes into their own file, leaving the manager and backend cases behind, so both sit well under the limit and each file covers one unit. `StubFeatureFlagsHttp` is shared by both and becomes internal. Co-authored-by: Cursor --- .../OneSignal.xcodeproj/project.pbxproj | 4 + .../OSFeatureFlagsRefreshServiceTests.swift | 376 ++++++++++++++++++ .../OSFeatureFlagsTests.swift | 346 +--------------- 3 files changed, 381 insertions(+), 345 deletions(-) create mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSFeatureFlagsRefreshServiceTests.swift diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index 33c9d7a27..a3d4bf1e2 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -81,6 +81,7 @@ A5048F01A1B2C3D4E5F6000D /* OSFeatureManagerImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5048F01A1B2C3D4E5F6000C /* OSFeatureManagerImpl.swift */; }; A5048F01A1B2C3D4E5F60008 /* OSFeatureFlagsRefreshService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5048F01A1B2C3D4E5F60007 /* OSFeatureFlagsRefreshService.swift */; }; A5048F01A1B2C3D4E5F6000A /* OSFeatureFlagsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5048F01A1B2C3D4E5F60009 /* OSFeatureFlagsTests.swift */; }; + A5048F01A1B2C3D4E5F6100B /* OSFeatureFlagsRefreshServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5048F01A1B2C3D4E5F6100A /* OSFeatureFlagsRefreshServiceTests.swift */; }; C781A33FED62B4B54221A09A /* OSLogCrashHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B6A59620B83538CEFF77269 /* OSLogCrashHandlerTests.swift */; }; B96A3B6BA8CC49EE4796D9BF /* OSRemoteLoggingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A72F938F8A3808AC1FF7F3C /* OSRemoteLoggingController.swift */; }; 25898119922BDCDA7AF0B9CC /* OSRemoteLoggingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A72F938F8A3808AC1FF7F3C /* OSRemoteLoggingController.swift */; }; @@ -1810,6 +1811,7 @@ A5048F01A1B2C3D4E5F6000C /* OSFeatureManagerImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSFeatureManagerImpl.swift; sourceTree = ""; }; A5048F01A1B2C3D4E5F60007 /* OSFeatureFlagsRefreshService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSFeatureFlagsRefreshService.swift; sourceTree = ""; }; A5048F01A1B2C3D4E5F60009 /* OSFeatureFlagsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSFeatureFlagsTests.swift; sourceTree = ""; }; + A5048F01A1B2C3D4E5F6100A /* OSFeatureFlagsRefreshServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSFeatureFlagsRefreshServiceTests.swift; sourceTree = ""; }; 3B6A59620B83538CEFF77269 /* OSLogCrashHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLogCrashHandlerTests.swift; sourceTree = ""; }; 8A72F938F8A3808AC1FF7F3C /* OSRemoteLoggingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSRemoteLoggingController.swift; sourceTree = ""; }; C0462F96E1AADF655F3B3765 /* OSRemoteLoggingController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OSRemoteLoggingController.h; sourceTree = ""; }; @@ -2600,6 +2602,7 @@ 3C427AC8301BB28A0059B8B7 /* OSOperationRepoFlushTests.swift */, 3C14E3AE2FAE54C006ED053 /* OSLoggerAdaptersTests.swift */, A5048F01A1B2C3D4E5F60009 /* OSFeatureFlagsTests.swift */, + A5048F01A1B2C3D4E5F6100A /* OSFeatureFlagsRefreshServiceTests.swift */, 3B6A59620B83538CEFF77269 /* OSLogCrashHandlerTests.swift */, 3C23A21A2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift */, 3C23A21E2FCE0AA1001D32E3 /* OSResilientStorageTests.swift */, @@ -4643,6 +4646,7 @@ 3C427AC9301BB28A0059B8B7 /* OSOperationRepoFlushTests.swift in Sources */, 3C14E3B52FAE54C006ED053 /* OSLoggerAdaptersTests.swift in Sources */, A5048F01A1B2C3D4E5F6000A /* OSFeatureFlagsTests.swift in Sources */, + A5048F01A1B2C3D4E5F6100B /* OSFeatureFlagsRefreshServiceTests.swift in Sources */, C781A33FED62B4B54221A09A /* OSLogCrashHandlerTests.swift in Sources */, 3C23A21B2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift in Sources */, ); diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSFeatureFlagsRefreshServiceTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSFeatureFlagsRefreshServiceTests.swift new file mode 100644 index 000000000..d392ed2cc --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSFeatureFlagsRefreshServiceTests.swift @@ -0,0 +1,376 @@ +/* + Modified MIT License + + Copyright 2026 OneSignal + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + 1. The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + 2. All copies of substantial portions of the Software may only be used in connection + with services provided by OneSignal. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ + +import Foundation +import OneSignalCore +import OneSignalKMP +@testable import OneSignalOSCore +import UIKit +import XCTest + +final class OSFeatureFlagsRefreshServiceTests: XCTestCase { + private var store: OSFeatureFlagsStore! + + override func setUp() { + super.setUp() + OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_SDK_REMOTE_FEATURE_FLAGS) + OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_SDK_REMOTE_FEATURE_FLAG_METADATA) + store = OSFeatureFlagsStore() + } + + override func tearDown() { + OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_SDK_REMOTE_FEATURE_FLAGS) + OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_SDK_REMOTE_FEATURE_FLAG_METADATA) + super.tearDown() + } + + private func makeService( + http: IFeatureFlagsHttp, + queue: ControllableDispatchQueue, + notificationCenter: NotificationCenter = NotificationCenter(), + appIdProvider: @escaping () -> String? = { "app-id-1" }, + isInForegroundProvider: (() -> Bool)? = { true } + ) -> OSFeatureFlagsRefreshService { + OSFeatureFlagsRefreshService( + backend: OSFeatureFlagsBackendService(http: http, sdkVersionProvider: { "050506" }), + store: store, + ioQueue: queue, + notificationCenter: notificationCenter, + appIdProvider: appIdProvider, + isInForegroundProvider: isInForegroundProvider, + refreshInterval: 10_000 + ) + } + + private func countingHttp(_ counter: @escaping () -> Void) -> StubFeatureFlagsHttp { + StubFeatureFlagsHttp { _, completion in + counter() + completion(FeatureFlagsHttpResponse(statusCode: 200, body: #"{"features":[]}"#), nil) + } + } + + func testSuccessfulFetchPersistsKeysOnTheStore() { + let http = StubFeatureFlagsHttp { _, completion in + completion( + FeatureFlagsHttpResponse( + statusCode: 200, + body: #"{"features":["sdk_identity_verification"]}"# + ), + nil + ) + } + let service = makeService(http: http, queue: ControllableDispatchQueue()) + + service.startPolling() + + XCTAssertEqual(store.sdkRemoteFeatureFlags, [FeatureFlag.sdkIdentityVerification.key]) + } + + func testUnavailableFetchLeavesCachedFlagsInPlace() { + store.applyRemoteFlags(["sdk_identity_verification"], metadata: nil) + let http = StubFeatureFlagsHttp { _, completion in + completion(FeatureFlagsHttpResponse(statusCode: 500, body: "boom"), nil) + } + let service = makeService(http: http, queue: ControllableDispatchQueue()) + + service.startPolling() + + XCTAssertEqual(store.sdkRemoteFeatureFlags, ["sdk_identity_verification"]) + } + + func testSameAppIdRefocusDoesNotDoubleFetch() { + var fetches = 0 + let service = makeService(http: countingHttp { fetches += 1 }, queue: ControllableDispatchQueue()) + + service.startPolling() + service.onFocus() + + XCTAssertEqual(fetches, 1) + } + + func testAppIdChangeRefetchesWithTheFullTurbinePath() { + var appId = "app-id-1" + var fetched: [String] = [] + let http = StubFeatureFlagsHttp { path, completion in + fetched.append(path) + completion(FeatureFlagsHttpResponse(statusCode: 200, body: #"{"features":[]}"#), nil) + } + let service = makeService(http: http, queue: ControllableDispatchQueue(), appIdProvider: { appId }) + + service.startPolling() + service.onUnfocused() + appId = "app-id-2" + service.onFocus() + + // Asserting the whole path, not just the app id: the platform segment is the + // cross-platform contract this wiring exists to keep stable. + XCTAssertEqual(fetched, [ + "apps/app-id-1/sdk/features/ios/050506", + "apps/app-id-2/sdk/features/ios/050506" + ]) + } + + func testPollReschedulesItselfAfterTheRefreshInterval() { + var fetches = 0 + let queue = ControllableDispatchQueue() + let service = makeService(http: countingHttp { fetches += 1 }, queue: queue) + + service.startPolling() + XCTAssertEqual(fetches, 1) + + queue.runPendingDeferredWork() + + XCTAssertEqual(fetches, 2) + } + + func testUnfocusCancelsTheScheduledPoll() { + var fetches = 0 + let queue = ControllableDispatchQueue() + let service = makeService(http: countingHttp { fetches += 1 }, queue: queue) + + service.startPolling() + XCTAssertEqual(fetches, 1) + + service.onUnfocused() + queue.runPendingDeferredWork() + + XCTAssertEqual(fetches, 1, "the queued poll belongs to a cancelled generation") + } + + func testDoesNotPollWhileBackgrounded() { + var fetches = 0 + let service = makeService( + http: countingHttp { fetches += 1 }, + queue: ControllableDispatchQueue(), + isInForegroundProvider: { false } + ) + + service.startPolling() + + XCTAssertEqual(fetches, 0) + } + + func testDoesNotPollUntilTheHostReportsForeground() { + var fetches = 0 + // No override, so the service uses its own tracked state, which starts false. + let service = makeService( + http: countingHttp { fetches += 1 }, + queue: ControllableDispatchQueue(), + isInForegroundProvider: nil + ) + + service.startPolling() + XCTAssertEqual(fetches, 0, "a background launch must not fetch") + + service.setForeground(true) + service.startPolling() + + XCTAssertEqual(fetches, 1) + } + + func testMomentarilyEmptyAppIdDoesNotWedgePolling() { + // The app id is readable when polling is armed but empty by the time the poll + // runs. Without releasing the dedupe key, no later focus could ever restart. + var appIdReads = ["app-id-1", ""] + var fetches = 0 + let service = makeService( + http: countingHttp { fetches += 1 }, + queue: ControllableDispatchQueue(), + appIdProvider: { appIdReads.isEmpty ? "app-id-1" : appIdReads.removeFirst() } + ) + + service.startPolling() + XCTAssertEqual(fetches, 0) + + service.onFocus() + + XCTAssertEqual(fetches, 1, "a later focus must be able to restart polling") + } + + func testBecomingActiveStartsPollingAndBackgroundingStopsIt() { + var fetches = 0 + let center = NotificationCenter() + let queue = ControllableDispatchQueue() + let service = makeService( + http: countingHttp { fetches += 1 }, + queue: queue, + notificationCenter: center, + isInForegroundProvider: nil + ) + service.startPolling() + XCTAssertEqual(fetches, 0, "a background launch must not fetch") + + center.post(name: UIApplication.didBecomeActiveNotification, object: nil) + XCTAssertEqual(fetches, 1) + + center.post(name: UIApplication.didEnterBackgroundNotification, object: nil) + queue.runPendingDeferredWork() + + XCTAssertEqual(fetches, 1, "backgrounding cancels the scheduled poll") + } + + /// UIKit can post `didBecomeActive` repeatedly without an intervening background + /// event. A counter-based approach treated each as another live scene and then needed + /// as many background events to stop, so polling outlived the foreground. + func testRepeatedActivationStillStopsOnASingleBackgroundEvent() { + var fetches = 0 + let center = NotificationCenter() + let queue = ControllableDispatchQueue() + let service = makeService( + http: countingHttp { fetches += 1 }, + queue: queue, + notificationCenter: center, + isInForegroundProvider: nil + ) + service.startPolling() + + center.post(name: UIApplication.didBecomeActiveNotification, object: nil) + center.post(name: UIApplication.didBecomeActiveNotification, object: nil) + center.post(name: UIApplication.didBecomeActiveNotification, object: nil) + XCTAssertEqual(fetches, 1, "redundant activations dedupe against the same app id") + + center.post(name: UIApplication.didEnterBackgroundNotification, object: nil) + queue.runPendingDeferredWork() + + XCTAssertEqual(fetches, 1, "one background event must undo any number of activations") + } + + func testResetStopsAnAlreadyQueuedStart() { + var fetches = 0 + let queue = ControllableDispatchQueue(deferImmediateWork: true) + let service = makeService( + http: countingHttp { fetches += 1 }, + queue: queue, + isInForegroundProvider: { true } + ) + + // `startPolling` is queued but has not run; the reset lands first, exactly as it + // would when `handleAppIdChange` fires while startup work is still in flight. + service.startPolling() + service.stopPolling() + queue.runPendingDeferredWork() + + XCTAssertEqual(fetches, 0, "a dropped instance must not resurrect its poll loop") + } + + func testResetLeavesNoLifecycleObserversBehind() { + var fetches = 0 + let center = ObserverTrackingCenter() + let service = makeService( + http: countingHttp { fetches += 1 }, + queue: ControllableDispatchQueue(), + notificationCenter: center, + isInForegroundProvider: { true } + ) + service.startPolling() + XCTAssertEqual(center.liveObserverCount, 2) + + service.stopPolling() + + XCTAssertEqual(center.liveObserverCount, 0) + } + + /// The reset lands between `addObserver` returning and the service recording the + /// token. Nothing else holds the token at that instant, so if `observe` does not tear + /// it down itself it outlives the service with no way left to reach it. + func testObserverRegisteredDuringAResetIsTornDown() { + var fetches = 0 + let center = ObserverTrackingCenter() + let service = makeService( + http: countingHttp { fetches += 1 }, + queue: ControllableDispatchQueue(), + notificationCenter: center, + isInForegroundProvider: { true } + ) + center.onAddObserver = { [weak service] in + service?.stopPolling() + } + + service.startPolling() + + XCTAssertEqual(center.liveObserverCount, 0, "no observer may survive the reset that raced it") + XCTAssertEqual(fetches, 0, "and the dropped instance must not start a loop") + } +} + +/// Counts observers that are currently registered, so a test can assert teardown rather +/// than infer it, and can inject a reset into the window inside `observe`. +private final class ObserverTrackingCenter: NotificationCenter { + var onAddObserver: (() -> Void)? + private(set) var liveObserverCount = 0 + + override func addObserver( + forName name: NSNotification.Name?, + object obj: Any?, + queue: OperationQueue?, + using block: @escaping (Notification) -> Void + ) -> NSObjectProtocol { + let token = super.addObserver(forName: name, object: obj, queue: queue, using: block) + liveObserverCount += 1 + onAddObserver?() + return token + } + + override func removeObserver(_ observer: Any) { + super.removeObserver(observer) + liveObserverCount -= 1 + } +} + +/// Runs immediate work inline and holds deferred work until a test releases it, so the +/// self-rescheduling poll loop can be stepped without waiting out the refresh interval. +/// +/// `deferImmediateWork` also holds `async` work, which lets a test interleave a reset +/// ahead of already-queued startup work. +private final class ControllableDispatchQueue: OSDispatchQueue { + private let deferImmediateWork: Bool + private var deferredWork: [() -> Void] = [] + + init(deferImmediateWork: Bool = false) { + self.deferImmediateWork = deferImmediateWork + } + + func async(execute work: @escaping @convention(block) () -> Void) { + guard !deferImmediateWork else { + deferredWork.append(work) + return + } + work() + } + + func asyncAfterTime(deadline: DispatchTime, execute work: @escaping @Sendable @convention(block) () -> Void) { + deferredWork.append(work) + } + + /// Runs work queued so far. Work scheduled *by* that work is left for the next call, + /// so a single step cannot recurse forever. + func runPendingDeferredWork() { + let scheduled = deferredWork + deferredWork.removeAll() + scheduled.forEach { $0() } + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSFeatureFlagsTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSFeatureFlagsTests.swift index 7c231cdce..a48f36353 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSFeatureFlagsTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSFeatureFlagsTests.swift @@ -29,7 +29,6 @@ import Foundation import OneSignalCore import OneSignalKMP @testable import OneSignalOSCore -import UIKit import XCTest final class OSFeatureManagerTests: XCTestCase { @@ -231,292 +230,7 @@ final class OSFeatureFlagsBackendServiceTests: XCTestCase { } } -final class OSFeatureFlagsRefreshServiceTests: XCTestCase { - private var store: OSFeatureFlagsStore! - - override func setUp() { - super.setUp() - OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_SDK_REMOTE_FEATURE_FLAGS) - OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_SDK_REMOTE_FEATURE_FLAG_METADATA) - store = OSFeatureFlagsStore() - } - - override func tearDown() { - OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_SDK_REMOTE_FEATURE_FLAGS) - OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_SDK_REMOTE_FEATURE_FLAG_METADATA) - super.tearDown() - } - - private func makeService( - http: IFeatureFlagsHttp, - queue: ControllableDispatchQueue, - notificationCenter: NotificationCenter = NotificationCenter(), - appIdProvider: @escaping () -> String? = { "app-id-1" }, - isInForegroundProvider: (() -> Bool)? = { true } - ) -> OSFeatureFlagsRefreshService { - OSFeatureFlagsRefreshService( - backend: OSFeatureFlagsBackendService(http: http, sdkVersionProvider: { "050506" }), - store: store, - ioQueue: queue, - notificationCenter: notificationCenter, - appIdProvider: appIdProvider, - isInForegroundProvider: isInForegroundProvider, - refreshInterval: 10_000 - ) - } - - private func countingHttp(_ counter: @escaping () -> Void) -> StubFeatureFlagsHttp { - StubFeatureFlagsHttp { _, completion in - counter() - completion(FeatureFlagsHttpResponse(statusCode: 200, body: #"{"features":[]}"#), nil) - } - } - - func testSuccessfulFetchPersistsKeysOnTheStore() { - let http = StubFeatureFlagsHttp { _, completion in - completion( - FeatureFlagsHttpResponse( - statusCode: 200, - body: #"{"features":["sdk_identity_verification"]}"# - ), - nil - ) - } - let service = makeService(http: http, queue: ControllableDispatchQueue()) - - service.startPolling() - - XCTAssertEqual(store.sdkRemoteFeatureFlags, [FeatureFlag.sdkIdentityVerification.key]) - } - - func testUnavailableFetchLeavesCachedFlagsInPlace() { - store.applyRemoteFlags(["sdk_identity_verification"], metadata: nil) - let http = StubFeatureFlagsHttp { _, completion in - completion(FeatureFlagsHttpResponse(statusCode: 500, body: "boom"), nil) - } - let service = makeService(http: http, queue: ControllableDispatchQueue()) - - service.startPolling() - - XCTAssertEqual(store.sdkRemoteFeatureFlags, ["sdk_identity_verification"]) - } - - func testSameAppIdRefocusDoesNotDoubleFetch() { - var fetches = 0 - let service = makeService(http: countingHttp { fetches += 1 }, queue: ControllableDispatchQueue()) - - service.startPolling() - service.onFocus() - - XCTAssertEqual(fetches, 1) - } - - func testAppIdChangeRefetchesWithTheFullTurbinePath() { - var appId = "app-id-1" - var fetched: [String] = [] - let http = StubFeatureFlagsHttp { path, completion in - fetched.append(path) - completion(FeatureFlagsHttpResponse(statusCode: 200, body: #"{"features":[]}"#), nil) - } - let service = makeService(http: http, queue: ControllableDispatchQueue(), appIdProvider: { appId }) - - service.startPolling() - service.onUnfocused() - appId = "app-id-2" - service.onFocus() - - // Asserting the whole path, not just the app id: the platform segment is the - // cross-platform contract this wiring exists to keep stable. - XCTAssertEqual(fetched, [ - "apps/app-id-1/sdk/features/ios/050506", - "apps/app-id-2/sdk/features/ios/050506" - ]) - } - - func testPollReschedulesItselfAfterTheRefreshInterval() { - var fetches = 0 - let queue = ControllableDispatchQueue() - let service = makeService(http: countingHttp { fetches += 1 }, queue: queue) - - service.startPolling() - XCTAssertEqual(fetches, 1) - - queue.runPendingDeferredWork() - - XCTAssertEqual(fetches, 2) - } - - func testUnfocusCancelsTheScheduledPoll() { - var fetches = 0 - let queue = ControllableDispatchQueue() - let service = makeService(http: countingHttp { fetches += 1 }, queue: queue) - - service.startPolling() - XCTAssertEqual(fetches, 1) - - service.onUnfocused() - queue.runPendingDeferredWork() - - XCTAssertEqual(fetches, 1, "the queued poll belongs to a cancelled generation") - } - - func testDoesNotPollWhileBackgrounded() { - var fetches = 0 - let service = makeService( - http: countingHttp { fetches += 1 }, - queue: ControllableDispatchQueue(), - isInForegroundProvider: { false } - ) - - service.startPolling() - - XCTAssertEqual(fetches, 0) - } - - func testDoesNotPollUntilTheHostReportsForeground() { - var fetches = 0 - // No override, so the service uses its own tracked state, which starts false. - let service = makeService( - http: countingHttp { fetches += 1 }, - queue: ControllableDispatchQueue(), - isInForegroundProvider: nil - ) - - service.startPolling() - XCTAssertEqual(fetches, 0, "a background launch must not fetch") - - service.setForeground(true) - service.startPolling() - - XCTAssertEqual(fetches, 1) - } - - func testMomentarilyEmptyAppIdDoesNotWedgePolling() { - // The app id is readable when polling is armed but empty by the time the poll - // runs. Without releasing the dedupe key, no later focus could ever restart. - var appIdReads = ["app-id-1", ""] - var fetches = 0 - let service = makeService( - http: countingHttp { fetches += 1 }, - queue: ControllableDispatchQueue(), - appIdProvider: { appIdReads.isEmpty ? "app-id-1" : appIdReads.removeFirst() } - ) - - service.startPolling() - XCTAssertEqual(fetches, 0) - - service.onFocus() - - XCTAssertEqual(fetches, 1, "a later focus must be able to restart polling") - } - - func testBecomingActiveStartsPollingAndBackgroundingStopsIt() { - var fetches = 0 - let center = NotificationCenter() - let queue = ControllableDispatchQueue() - let service = makeService( - http: countingHttp { fetches += 1 }, - queue: queue, - notificationCenter: center, - isInForegroundProvider: nil - ) - service.startPolling() - XCTAssertEqual(fetches, 0, "a background launch must not fetch") - - center.post(name: UIApplication.didBecomeActiveNotification, object: nil) - XCTAssertEqual(fetches, 1) - - center.post(name: UIApplication.didEnterBackgroundNotification, object: nil) - queue.runPendingDeferredWork() - - XCTAssertEqual(fetches, 1, "backgrounding cancels the scheduled poll") - } - - /// UIKit can post `didBecomeActive` repeatedly without an intervening background - /// event. A counter-based approach treated each as another live scene and then needed - /// as many background events to stop, so polling outlived the foreground. - func testRepeatedActivationStillStopsOnASingleBackgroundEvent() { - var fetches = 0 - let center = NotificationCenter() - let queue = ControllableDispatchQueue() - let service = makeService( - http: countingHttp { fetches += 1 }, - queue: queue, - notificationCenter: center, - isInForegroundProvider: nil - ) - service.startPolling() - - center.post(name: UIApplication.didBecomeActiveNotification, object: nil) - center.post(name: UIApplication.didBecomeActiveNotification, object: nil) - center.post(name: UIApplication.didBecomeActiveNotification, object: nil) - XCTAssertEqual(fetches, 1, "redundant activations dedupe against the same app id") - - center.post(name: UIApplication.didEnterBackgroundNotification, object: nil) - queue.runPendingDeferredWork() - - XCTAssertEqual(fetches, 1, "one background event must undo any number of activations") - } - - func testResetStopsAnAlreadyQueuedStart() { - var fetches = 0 - let queue = ControllableDispatchQueue(deferImmediateWork: true) - let service = makeService( - http: countingHttp { fetches += 1 }, - queue: queue, - isInForegroundProvider: { true } - ) - - // `startPolling` is queued but has not run; the reset lands first, exactly as it - // would when `handleAppIdChange` fires while startup work is still in flight. - service.startPolling() - service.stopPolling() - queue.runPendingDeferredWork() - - XCTAssertEqual(fetches, 0, "a dropped instance must not resurrect its poll loop") - } - - func testResetLeavesNoLifecycleObserversBehind() { - var fetches = 0 - let center = ObserverTrackingCenter() - let service = makeService( - http: countingHttp { fetches += 1 }, - queue: ControllableDispatchQueue(), - notificationCenter: center, - isInForegroundProvider: { true } - ) - service.startPolling() - XCTAssertEqual(center.liveObserverCount, 2) - - service.stopPolling() - - XCTAssertEqual(center.liveObserverCount, 0) - } - - /// The reset lands between `addObserver` returning and the service recording the - /// token. Nothing else holds the token at that instant, so if `observe` does not tear - /// it down itself it outlives the service with no way left to reach it. - func testObserverRegisteredDuringAResetIsTornDown() { - var fetches = 0 - let center = ObserverTrackingCenter() - let service = makeService( - http: countingHttp { fetches += 1 }, - queue: ControllableDispatchQueue(), - notificationCenter: center, - isInForegroundProvider: { true } - ) - center.onAddObserver = { [weak service] in - service?.stopPolling() - } - - service.startPolling() - - XCTAssertEqual(center.liveObserverCount, 0, "no observer may survive the reset that raced it") - XCTAssertEqual(fetches, 0, "and the dropped instance must not start a loop") - } -} - -private final class StubFeatureFlagsHttp: IFeatureFlagsHttp { +final class StubFeatureFlagsHttp: IFeatureFlagsHttp { let onGet: ( String, @escaping (FeatureFlagsHttpResponse?, Error?) -> Void @@ -530,61 +244,3 @@ private final class StubFeatureFlagsHttp: IFeatureFlagsHttp { onGet(relativePath, completionHandler) } } - -/// Counts observers that are currently registered, so a test can assert teardown rather -/// than infer it, and can inject a reset into the window inside `observe`. -private final class ObserverTrackingCenter: NotificationCenter { - var onAddObserver: (() -> Void)? - private(set) var liveObserverCount = 0 - - override func addObserver( - forName name: NSNotification.Name?, - object obj: Any?, - queue: OperationQueue?, - using block: @escaping (Notification) -> Void - ) -> NSObjectProtocol { - let token = super.addObserver(forName: name, object: obj, queue: queue, using: block) - liveObserverCount += 1 - onAddObserver?() - return token - } - - override func removeObserver(_ observer: Any) { - super.removeObserver(observer) - liveObserverCount -= 1 - } -} - -/// Runs immediate work inline and holds deferred work until a test releases it, so the -/// self-rescheduling poll loop can be stepped without waiting out the refresh interval. -/// -/// `deferImmediateWork` also holds `async` work, which lets a test interleave a reset -/// ahead of already-queued startup work. -private final class ControllableDispatchQueue: OSDispatchQueue { - private let deferImmediateWork: Bool - private var deferredWork: [() -> Void] = [] - - init(deferImmediateWork: Bool = false) { - self.deferImmediateWork = deferImmediateWork - } - - func async(execute work: @escaping @convention(block) () -> Void) { - guard !deferImmediateWork else { - deferredWork.append(work) - return - } - work() - } - - func asyncAfterTime(deadline: DispatchTime, execute work: @escaping @Sendable @convention(block) () -> Void) { - deferredWork.append(work) - } - - /// Runs work queued so far. Work scheduled *by* that work is left for the next call, - /// so a single step cannot recurse forever. - func runPendingDeferredWork() { - let scheduled = deferredWork - deferredWork.removeAll() - scheduled.forEach { $0() } - } -} From 80b722e49d365b7f15cca1d2138fc4f858c92d71 Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Mon, 24 Aug 2026 15:30:32 -0500 Subject: [PATCH 6/6] test: [SDK-5048] assert the manager discard without touching shared storage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reset-during-construction test proved the discard by writing a flag to the shared store and asserting the published manager did not carry the latch. That worked, but it wrote to the real app-group UserDefaults from a unit test, which is avoidable cross-test residue. Counting constructions asserts the same invariant — build, discard, build again — and touches no storage. Still fails against the unguarded publication path. Co-authored-by: Cursor --- .../OSFeatureFlagsTests.swift | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSFeatureFlagsTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSFeatureFlagsTests.swift index a48f36353..58c0f0c8b 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSFeatureFlagsTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSFeatureFlagsTests.swift @@ -119,25 +119,24 @@ final class OSFeatureManagerTests: XCTestCase { /// manager has read storage but before it publishes itself. Publishing it anyway /// would restore exactly the `APP_STARTUP` latch the reset existed to drop. func testResetDuringConstructionDiscardsTheStaleManager() { - OSFeatureFlagsStore.shared.applyRemoteFlags([FeatureFlag.sdkCustomLogging.key], metadata: nil) - OSFeatureManager.reset() - + var constructions = 0 var alreadyReset = false OSFeatureManager.didConstructForTesting = { + constructions += 1 guard !alreadyReset else { return } alreadyReset = true - OSFeatureManager.resetAndClearCachedFlags() + OSFeatureManager.reset() } defer { OSFeatureManager.didConstructForTesting = nil } - let manager = OSFeatureManager.shared + _ = OSFeatureManager.shared - XCTAssertTrue(alreadyReset, "the race hook must have fired") - XCTAssertFalse( - manager.isEnabled(featureKey: FeatureFlag.sdkCustomLogging.key), - "the published manager must not carry a latch read before the reset" + XCTAssertEqual( + constructions, + 2, + "the manager built before the reset must be discarded and storage read again" ) } }