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..a3d4bf1e2 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -75,6 +75,13 @@ 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 */; }; + 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 */; }; @@ -1798,6 +1805,13 @@ 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 = ""; }; + 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 = ""; }; @@ -2269,6 +2283,7 @@ isa = PBXGroup; children = ( 5BC1DE652C90BC9F00CA8807 /* Consistency */, + A5048F01A1B2C3D4E5F6000B /* Feature */, 3C14E3AF2FAE54C006ED053 /* Logging */, 3C115163289A259500565C41 /* OneSignalOSCore.h */, 3C115188289ADEA300565C41 /* OSModelStore.swift */, @@ -2306,6 +2321,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 +2601,8 @@ 5BC1DE672C90C23E00CA8807 /* OSConsistencyManagerTests.swift */, 3C427AC8301BB28A0059B8B7 /* OSOperationRepoFlushTests.swift */, 3C14E3AE2FAE54C006ED053 /* OSLoggerAdaptersTests.swift */, + A5048F01A1B2C3D4E5F60009 /* OSFeatureFlagsTests.swift */, + A5048F01A1B2C3D4E5F6100A /* OSFeatureFlagsRefreshServiceTests.swift */, 3B6A59620B83538CEFF77269 /* OSLogCrashHandlerTests.swift */, 3C23A21A2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift */, 3C23A21E2FCE0AA1001D32E3 /* OSResilientStorageTests.swift */, @@ -4446,6 +4475,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 +4645,8 @@ 3C23A21D2FCE0A83001D32E3 /* OSModelStoreRefreshTests.swift in Sources */, 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/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) { + // `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 + 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 + } + // 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 new file mode 100644 index 000000000..70e157c8d --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Feature/OSFeatureFlagsRefreshService.swift @@ -0,0 +1,324 @@ +/* + 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 appIdProvider: () -> String? + /// 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 + + private let stateLock = NSLock() + private var pollGeneration = 0 + private var pollingAppId: String? + private var started = false + private var notificationTokens: [NSObjectProtocol] = [] + /// 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, + appIdProvider: @escaping () -> String? = { + OneSignalIdentifiers.currentAppId ?? OneSignalIdentifiers.storedAppId + }, + isInForegroundProvider: (() -> Bool)? = nil, + refreshInterval: TimeInterval = OSFeatureFlagsRefreshService.defaultRefreshInterval + ) { + self.backend = backend + self.store = store + self.ioQueue = ioQueue + self.notificationCenter = notificationCenter + self.appIdProvider = appIdProvider + self.isInForegroundOverride = isInForegroundProvider + self.refreshInterval = refreshInterval + super.init() + } + + /// 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 + service.startPolling() + } + + @objc public class func reset() { + lock.withLock { + _shared?.stopPolling() + _shared = nil + } + } + + func startPolling() { + ioQueue.async { [weak self] in + guard let self, !self.isInvalidated() else { + return + } + self.registerLifecycleObserversIfNeeded() + if self.inForeground() { + self.restartForegroundPolling() + } + } + } + + 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 { + isForeground = value + } + } + + private func inForeground() -> Bool { + if let isInForegroundOverride { + return isInForegroundOverride() + } + return stateLock.withLock { isForeground } + } + + func onFocus() { + ioQueue.async { [weak self] in + guard let self, !self.isInvalidated() else { + return + } + self.stateLock.withLock { + self.isForeground = true + } + self.restartForegroundPolling() + } + } + + func onUnfocused() { + ioQueue.async { [weak self] in + guard let self else { + return + } + self.stateLock.withLock { + self.isForeground = false + self.pollGeneration += 1 + self.pollingAppId = nil + } + } + } + + 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 + pollingAppId = nil + started = false + return current + } + 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 shouldSkip = stateLock.withLock { () -> Bool in + if started || invalidated { + return true + } + started = true + return false + } + guard !shouldSkip else { + return + } + + observe(UIApplication.didBecomeActiveNotification) { [weak self] in + self?.onFocus() + } + observe(UIApplication.didEnterBackgroundNotification) { [weak self] in + self?.onUnfocused() + } + } + + private func observe(_ name: Notification.Name, handler: @escaping () -> Void) { + let token = notificationCenter.addObserver(forName: name, object: nil, queue: nil) { _ in + handler() + } + // 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 + } + if !recorded { + notificationCenter.removeObserver(token) + } + } + + 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 + return -1 + } + if pollingAppId == appId { + return -1 + } + pollGeneration += 1 + pollingAppId = appId + return pollGeneration + } + guard generation >= 0 else { + return + } + poll(generation: generation) + } + + 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 inForeground() else { + releasePollingKey(for: generation) + return + } + let current = appIdProvider() ?? "" + guard !current.isEmpty else { + releasePollingKey(for: generation) + 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(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 } + } + + /// 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 + } + 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..e27dfec1d --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Feature/OSFeatureManager.swift @@ -0,0 +1,136 @@ +/* + 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 { + 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 { + while true { + let (existing, startGeneration) = lock.withLock { (_shared, generation) } + if let existing { + 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. + 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 + } + } + } + + /// 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 + generation += 1 + } + } + + /// 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 + + 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] = [] + + /// 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/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/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 new file mode 100644 index 000000000..58c0f0c8b --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSFeatureFlagsTests.swift @@ -0,0 +1,245 @@ +/* + 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 = [] + // 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() + } + + 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)) + } + + /// 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() { + var constructions = 0 + var alreadyReset = false + OSFeatureManager.didConstructForTesting = { + constructions += 1 + guard !alreadyReset else { + return + } + alreadyReset = true + OSFeatureManager.reset() + } + defer { OSFeatureManager.didConstructForTesting = nil } + + _ = OSFeatureManager.shared + + XCTAssertEqual( + constructions, + 2, + "the manager built before the reset must be discarded and storage read again" + ) + } +} + +final class OSFeatureFlagsBackendServiceTests: XCTestCase { + 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() throws { + let outcome = try fetch(statusCode: 500, body: "boom") + XCTAssertTrue(outcome.isUnavailable) + XCTAssertTrue(outcome.reason == RemoteFeatureFlagsUnavailableReason.nonSuccessHttp) + XCTAssertFalse(outcome.isClientError) + } + + func test200WithValidEmptyFeaturesArrayIsSuccess() throws { + let outcome = try fetch(statusCode: 200, body: #"{"features":[]}"#) + XCTAssertTrue(outcome.isSuccess) + XCTAssertNotNil(outcome.result) + XCTAssertEqual(stringArray(outcome.result?.enabledKeys), []) + } + + func test200WithNonContractJSONIsInvalidJson() throws { + let outcome = try fetch(statusCode: 200, body: #"{"errors":["Forbidden"]}"#) + XCTAssertTrue(outcome.isUnavailable) + XCTAssertTrue(outcome.reason == RemoteFeatureFlagsUnavailableReason.invalidJson) + } + + func test200WithHTMLBodyIsInvalidJsonAndDoesNotThrow() throws { + let html = "Burpintercepted" + let outcome = try fetch(statusCode: 200, body: html) + XCTAssertTrue(outcome.isUnavailable) + XCTAssertTrue(outcome.reason == RemoteFeatureFlagsUnavailableReason.invalidJson) + } + + func test200WithEmptyBodyIsUnavailable() throws { + let outcome = try fetch(statusCode: 200, body: nil) + XCTAssertTrue(outcome.isUnavailable) + XCTAssertTrue(outcome.reason == RemoteFeatureFlagsUnavailableReason.emptyBody) + } + + func testPathShapingAppIdReturnsInvalidAppIdWithoutHTTP() throws { + var didGet = false + let http = StubFeatureFlagsHttp { _, completion in + didGet = true + completion(FeatureFlagsHttpResponse(statusCode: 200, body: #"{"features":[]}"#), nil) + } + let outcome = try 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 + ) throws -> 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 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) + } +} diff --git a/iOS_SDK/OneSignalSDK/Source/OSRemoteLoggingController.swift b/iOS_SDK/OneSignalSDK/Source/OSRemoteLoggingController.swift index 55e452656..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: { [] }, + // 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 0a1bd38ec..d52161428 100755 --- a/iOS_SDK/OneSignalSDK/Source/OneSignal.m +++ b/iOS_SDK/OneSignalSDK/Source/OneSignal.m @@ -140,6 +140,8 @@ + (OneSignalReceiveReceiptsController*)receiveReceiptsController { //TODO: This is related to unit tests and will change with um tests + (void)clearStatics { [OSRemoteLoggingController reset]; + [OSFeatureFlagsRefreshService reset]; + [OSFeatureManager reset]; [OneSignalIdentifiers setCurrentAppId:nil]; launchOptions = false; appSettings = nil; @@ -465,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) { @@ -542,6 +579,7 @@ + (void)setupProtectedDataObserverOnce { [OSNotificationsManager sendPushTokenToDelegate]; [OneSignal startLiveActivitiesManager]; [OneSignal startInAppMessages]; + StartFeatureFlagsRefresh(); [OneSignal startNewSession:YES]; }; @@ -634,11 +672,15 @@ + (void)init { [self startLifecycleObserver]; //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]; + StartFeatureFlagsRefresh(); } [self startNewSession:YES]; @@ -666,6 +708,10 @@ + (void)handleAppIdChange:(NSString*)appId { _downloadedParameters = false; _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;