diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index a3d4bf1e2..01c637774 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -86,6 +86,9 @@ B96A3B6BA8CC49EE4796D9BF /* OSRemoteLoggingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A72F938F8A3808AC1FF7F3C /* OSRemoteLoggingController.swift */; }; 25898119922BDCDA7AF0B9CC /* OSRemoteLoggingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A72F938F8A3808AC1FF7F3C /* OSRemoteLoggingController.swift */; }; 9EAF92032D0429FA35E04417 /* OSRemoteLoggingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A72F938F8A3808AC1FF7F3C /* OSRemoteLoggingController.swift */; }; + A91B2C3D4E5F60718293A4B5 /* OSRemoteLoggingConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C91A2B0D84F1E9A3C5B6D8E /* OSRemoteLoggingConfiguration.swift */; }; + B82C3D4E5F60718293A4B5C6 /* OSRemoteLoggingConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C91A2B0D84F1E9A3C5B6D8E /* OSRemoteLoggingConfiguration.swift */; }; + C73D4E5F60718293A4B5C6D7 /* OSRemoteLoggingConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C91A2B0D84F1E9A3C5B6D8E /* OSRemoteLoggingConfiguration.swift */; }; ACE2175908241BB46C9F1829 /* OSRemoteLoggingControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 658E6E9E6BC6BBF702BCBD33 /* OSRemoteLoggingControllerTests.swift */; }; 7732574D325D34CC7C498199 /* OSRemoteLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6972EE491A57C79EFE56D4C8 /* OSRemoteLogger.swift */; }; 698F58A488FCE503DFD5247F /* OSLogCrashHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACD3284210A7DF2597594778 /* OSLogCrashHandler.swift */; }; @@ -1814,6 +1817,7 @@ 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 = ""; }; + 7C91A2B0D84F1E9A3C5B6D8E /* OSRemoteLoggingConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSRemoteLoggingConfiguration.swift; sourceTree = ""; }; C0462F96E1AADF655F3B3765 /* OSRemoteLoggingController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OSRemoteLoggingController.h; sourceTree = ""; }; 658E6E9E6BC6BBF702BCBD33 /* OSRemoteLoggingControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSRemoteLoggingControllerTests.swift; sourceTree = ""; }; 6972EE491A57C79EFE56D4C8 /* OSRemoteLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSRemoteLogger.swift; sourceTree = ""; }; @@ -2747,6 +2751,7 @@ DEC08AFF2947D4E900C81DA3 /* OneSignalSwiftInterface.swift */, C0462F96E1AADF655F3B3765 /* OSRemoteLoggingController.h */, 8A72F938F8A3808AC1FF7F3C /* OSRemoteLoggingController.swift */, + 7C91A2B0D84F1E9A3C5B6D8E /* OSRemoteLoggingConfiguration.swift */, 3CC9A6352AFA26E7008F68FD /* PrivacyInfo.xcprivacy */, ); path = Source; @@ -4432,6 +4437,7 @@ 9124121E1E73342200E41FD7 /* OneSignalJailbreakDetection.m in Sources */, 3C14E3A42AFAE54C006ED053 /* OneSignalSwiftInterface.swift in Sources */, B96A3B6BA8CC49EE4796D9BF /* OSRemoteLoggingController.swift in Sources */, + A91B2C3D4E5F60718293A4B5 /* OSRemoteLoggingConfiguration.swift in Sources */, 912412471E73369600E41FD7 /* OneSignalHelper.m in Sources */, CA8E19062193C76D009DA223 /* OSInAppMessagingHelpers.m in Sources */, 7AAA60682485D0420004FADE /* OSMigrationController.m in Sources */, @@ -4591,6 +4597,7 @@ 7AECE59F23675F6300537907 /* OSFocusTimeProcessorFactory.m in Sources */, DEC08B012947D4E900C81DA3 /* OneSignalSwiftInterface.swift in Sources */, 25898119922BDCDA7AF0B9CC /* OSRemoteLoggingController.swift in Sources */, + B82C3D4E5F60718293A4B5C6 /* OSRemoteLoggingConfiguration.swift in Sources */, CA1A6E7120DC2E73001C41B9 /* OneSignalDialogRequest.m in Sources */, 912412331E73342200E41FD7 /* OneSignalTracker.m in Sources */, 7A674F1C2360D82E001F9ACD /* OSBaseFocusTimeProcessor.m in Sources */, @@ -4720,6 +4727,7 @@ 4529DEEA1FA8360C00CEAB1D /* UIApplicationOverrider.m in Sources */, DEC08B022947D4E900C81DA3 /* OneSignalSwiftInterface.swift in Sources */, 9EAF92032D0429FA35E04417 /* OSRemoteLoggingController.swift in Sources */, + C73D4E5F60718293A4B5C6D7 /* OSRemoteLoggingConfiguration.swift in Sources */, 7A93269E25AF4F0300BBEC27 /* OSPendingCallbacks.m in Sources */, 7AECE59823674AB700537907 /* OSUnattributedFocusTimeProcessor.m in Sources */, 7A5A818224897693002E07C8 /* MigrationTests.m in Sources */, diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSRemoteLogger.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSRemoteLogger.swift index 6323e9e88..74fbddcf1 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSRemoteLogger.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSRemoteLogger.swift @@ -54,13 +54,19 @@ public extension OSRemoteLoggerProtocol { @_implementationOnly import OneSignalKMP -private final class OSRemoteLoggerLifecycle { - private let lock = NSLock() +final class OSRemoteLoggerLifecycle { + /// A condition rather than a plain lock so teardown can wait on in-flight flushes. + private let lock = NSCondition() private var isStarted = false private var isShuttingDown = false private var isShutdown = false + private var activeFlushes = 0 - var canStartUploader: Bool { + /// True while the transport is usable and teardown has not begun. Gates record + /// emission, uploader start, and explicit flushes alike, so nothing new is accepted + /// once the SDK has been told to stop. Keyed on shutdown *beginning* rather than + /// finishing, because the final drain is asynchronous. + var isActive: Bool { lock.lock() defer { lock.unlock() } return isStarted && !isShuttingDown && !isShutdown @@ -76,16 +82,51 @@ private final class OSRemoteLoggerLifecycle { return true } + /// Rejects once shutdown has *begun*, not just once it has finished. The final + /// drain is asynchronous, so a logger told to shut down can otherwise still be + /// started afterwards and install a crash handler nothing will ever unregister. func start() -> Bool { lock.lock() defer { lock.unlock() } - guard !isStarted, !isShutdown else { + guard !isStarted, !isShuttingDown, !isShutdown else { return false } isStarted = true return true } + /// Claims a flush slot, so teardown can tell a flush is still crossing into KMP. + /// Returns false once shutdown has begun, meaning the caller must not cross. + func beginFlush() -> Bool { + lock.lock() + defer { lock.unlock() } + guard isStarted, !isShuttingDown, !isShutdown else { + return false + } + activeFlushes += 1 + return true + } + + func endFlush() { + lock.lock() + activeFlushes -= 1 + if activeFlushes == 0 { + lock.broadcast() + } + lock.unlock() + } + + /// Blocks until flushes admitted before shutdown began have finished, so the + /// teardown drain never overlaps one. `beginFlush` already refuses new flushes by + /// this point, so the set can only shrink. Bounded, because a wedged flush must + /// not stop teardown from completing. + func waitForFlushesToDrain(timeout: TimeInterval) { + let deadline = Date().addingTimeInterval(timeout) + lock.lock() + defer { lock.unlock() } + while activeFlushes > 0, lock.wait(until: deadline) {} + } + func beginShutdown() -> Bool { lock.lock() defer { lock.unlock() } @@ -170,6 +211,13 @@ public final class OSRemoteLogger: OSRemoteLoggerProtocol { private let lifecycleOperationLock = NSLock() private let uploaderOwner = UUID() + /// Serial so overlapping teardowns cannot stack several bounded drains at once. + private static let teardownQueue = DispatchQueue(label: "com.onesignal.logger.remote-teardown") + + /// Matches the bound KMP puts on its own shutdown drain, so a wedged flush delays + /// teardown by no more than the drain itself already can. + private static let flushDrainTimeout: TimeInterval = 5 + public convenience init( installIdProvider: @escaping () -> String, onesignalIdProvider: @escaping () -> String?, @@ -237,9 +285,12 @@ public final class OSRemoteLogger: OSRemoteLoggerProtocol { let crashLogger = OSCrashLogger() let lifecycle = OSRemoteLoggerLifecycle() let fileStore = FileLogStore(rootPath: provider.crashStoragePath) + // Console-only logger on purpose. Exporter diagnostics describe the POST that + // ships log records, so routing them through OneSignalLog would feed each POST + // back into the export queue as a new record and never settle. let httpSender = Self.makeHttpSender( requestSender: requestSenderOverride, - logger: logger, + logger: crashLogger, isDiagnosticsEnabled: exporterLoggingEnabledProvider, lifecycle: lifecycle ) @@ -309,7 +360,7 @@ public final class OSRemoteLogger: OSRemoteLoggerProtocol { let logger = self.logger let lifecycle = self.lifecycle OSCrashUploaderCoordinator.shared.enqueue(owner: owner) { - guard lifecycle.canStartUploader else { + guard lifecycle.isActive else { OSCrashUploaderCoordinator.shared.finish(owner: owner) return } @@ -347,6 +398,9 @@ public final class OSRemoteLogger: OSRemoteLoggerProtocol { exceptionMessage: String?, exceptionStacktrace: String? ) { + guard lifecycle.isActive else { + return + } LogLoggingHelper.shared.log( telemetry: telemetry, level: level, @@ -359,20 +413,49 @@ public final class OSRemoteLogger: OSRemoteLoggerProtocol { } public func forceFlush(completion: @escaping () -> Void) { - telemetry.forceFlush(completionHandler: { _ in completion() }) + // Claiming a slot rather than just testing a flag: the flush completes + // asynchronously even when started inline, so shutdown could otherwise begin + // after the check passed and drain the same telemetry concurrently. + // `shutdown()` waits for the slot to be released. The completion still has to + // run on every path — callers end a background task in it, and swallowing it + // would leak that task. + guard lifecycle.beginFlush() else { + completion() + return + } + let lifecycle = self.lifecycle + telemetry.forceFlush(completionHandler: { _ in + lifecycle.endFlush() + completion() + }) } public func shutdown() { lifecycleOperationLock.lock() - defer { lifecycleOperationLock.unlock() } guard lifecycle.beginShutdown() else { + lifecycleOperationLock.unlock() return } OSCrashUploaderCoordinator.shared.cancel(owner: uploaderOwner) crashHandler.unregister() - telemetry.shutdown() - lifecycle.finishShutdown() + lifecycleOperationLock.unlock() + + // `telemetry.shutdown()` blocks for up to five seconds draining buffered + // records, and callers reach here from app launch and app-id changes, where + // that would stall the UI. `beginShutdown()` has already closed the door on + // new records, so the drain can finish on its own thread. Unregistering the + // crash handler stays synchronous above: a later logger cannot install its + // handler while this one is still registered. + Self.teardownQueue.async { [self] in + // A flush admitted just before `beginShutdown()` may still be crossing + // into KMP; the drain below would otherwise run alongside it. Safe to + // block here: this is a background queue, and the KMP completion that + // releases the slot resumes on main. + lifecycle.waitForFlushesToDrain(timeout: Self.flushDrainTimeout) + telemetry.shutdown() + lifecycle.finishShutdown() + } } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSLoggerAdaptersTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSLoggerAdaptersTests.swift index 878bdc639..0b2f2c433 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSLoggerAdaptersTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSLoggerAdaptersTests.swift @@ -44,6 +44,65 @@ final class OSLoggerAdaptersTests: XCTestCase { try? FileManager.default.removeItem(at: temporaryDirectory) } + func testLifecycleRejectsStartOnceShutdownHasBegun() { + let lifecycle = OSRemoteLoggerLifecycle() + + XCTAssertTrue(lifecycle.beginShutdown()) + // The final drain is deferred, so `isShutdown` is still false at this point. + // Starting anyway would register a crash handler that nothing unregisters, + // permanently blocking every later logger from installing its own. + XCTAssertFalse(lifecycle.start()) + XCTAssertFalse(lifecycle.isActive) + } + + func testLifecycleShutdownWaitsForInFlightFlush() { + let lifecycle = OSRemoteLoggerLifecycle() + XCTAssertTrue(lifecycle.start()) + XCTAssertTrue(lifecycle.beginFlush()) + XCTAssertTrue(lifecycle.beginShutdown()) + + // A flush admitted before shutdown began still holds its slot, so teardown + // must not proceed to the drain while it is outstanding. + let drained = DispatchSemaphore(value: 0) + DispatchQueue.global().async { + lifecycle.waitForFlushesToDrain(timeout: 5) + drained.signal() + } + XCTAssertEqual(drained.wait(timeout: .now() + 0.3), .timedOut) + + lifecycle.endFlush() + XCTAssertEqual(drained.wait(timeout: .now() + 2), .success) + } + + func testLifecycleRefusesNewFlushOnceShutdownBegins() { + let lifecycle = OSRemoteLoggerLifecycle() + XCTAssertTrue(lifecycle.start()) + XCTAssertTrue(lifecycle.beginShutdown()) + + // Nothing new may cross into KMP, so the drain has a shrinking set to wait on. + XCTAssertFalse(lifecycle.beginFlush()) + + let drained = DispatchSemaphore(value: 0) + DispatchQueue.global().async { + lifecycle.waitForFlushesToDrain(timeout: 5) + drained.signal() + } + XCTAssertEqual(drained.wait(timeout: .now() + 2), .success) + } + + func testLifecycleStopsAcceptingRecordsWhenShutdownBegins() { + let lifecycle = OSRemoteLoggerLifecycle() + + XCTAssertTrue(lifecycle.start()) + XCTAssertTrue(lifecycle.isActive) + + // Gates emission, uploader start, and explicit flushes together, so nothing + // new is accepted once the SDK has been told to stop. + XCTAssertTrue(lifecycle.beginShutdown()) + XCTAssertFalse(lifecycle.isActive) + XCTAssertFalse(lifecycle.start()) + } + func testFileStoreSynchronouslySavesAndListsPayload() throws { let store = FileLogStore(rootPath: temporaryDirectory.path) let payload = makeKotlinBytes([1, 2, 3, 255]) @@ -246,6 +305,29 @@ final class OSLoggerAdaptersTests: XCTestCase { XCTAssertTrue(lines.isEmpty) } + /// KMP #15 set `objcExportSuspendFunctionLaunchThreadRestriction=none`, lifting + /// Kotlin/Native's rule that exported `suspend` functions may only be called from + /// the main thread. Asserts that actually holds for the framework we link, so the + /// Swift side does not have to marshal every crossing onto main — and fails loudly + /// if that flag is ever dropped. + func testKmpSuspendCallSucceedsOffMainThread() { + let store = FileLogStore(rootPath: temporaryDirectory.path) + let telemetry = LoggerFactory.shared.createCrashLocalTelemetry( + platformProvider: makePlatformProvider(), + fileStore: store + ) + let completed = expectation(description: "KMP suspend call completes off main") + + DispatchQueue.global().async { + XCTAssertFalse(Thread.isMainThread) + telemetry.forceFlush { _ in + completed.fulfill() + } + } + + wait(for: [completed], timeout: 5) + } + func testKmpPipelineInvokesSwiftAdapters() throws { let listener = LoggerAdapterListener() OneSignalLog.debug().__add(listener) diff --git a/iOS_SDK/OneSignalSDK/Source/OSRemoteLoggingConfiguration.swift b/iOS_SDK/OneSignalSDK/Source/OSRemoteLoggingConfiguration.swift new file mode 100644 index 000000000..b105f3127 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/Source/OSRemoteLoggingConfiguration.swift @@ -0,0 +1,177 @@ +/* + 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 + +/// Persisted remote-logging params from the params API, mirroring Android's +/// `RemoteLoggingConfigModel` (`logLevel` + `isEnabled`). +struct OSRemoteLoggingConfiguration: Equatable { + static let loggingConfigKey = "logging_config" + static let logLevelKey = "log_level" + static let cachedAppIdKey = "app_id" + static let cachedLogLevelKey = "log_level" + static let cachedIsEnabledKey = "is_enabled" + + /// Minimum log level to export remotely. `nil` when the backend omitted a valid `log_level`. + let logLevel: ONE_S_LOG_LEVEL? + + /// Set true when the server sends a valid `log_level` (including `NONE`), false otherwise. + let isEnabled: Bool + + var logLevelName: String? { + logLevel.map(Self.levelName) + } + + static var current: OSRemoteLoggingConfiguration { + let params = OSRemoteParamController.shared().remoteParams as? [String: Any] ?? [:] + return OSRemoteLoggingConfiguration(remoteParams: params) + } + + static let disabled = OSRemoteLoggingConfiguration(logLevel: nil, isEnabled: false) + + init(logLevel: ONE_S_LOG_LEVEL?, isEnabled: Bool) { + self.logLevel = logLevel + self.isEnabled = isEnabled + } + + init(remoteParams: [String: Any]) { + let loggingConfig = remoteParams[Self.loggingConfigKey] as? [String: Any] + let parsed = (loggingConfig?[Self.logLevelKey] as? String) + .map { $0.uppercased() } + .flatMap(Self.oneSignalLevel) + self.init(logLevel: parsed, isEnabled: parsed != nil) + } + + init(cached: [AnyHashable: Any]) { + let parsed = (cached[Self.cachedLogLevelKey] as? String) + .map { $0.uppercased() } + .flatMap(Self.oneSignalLevel) + let isEnabled = (cached[Self.cachedIsEnabledKey] as? Bool) ?? (parsed != nil) + // A cache written by a newer SDK can name a level this one cannot parse. + // Android falls back to ERROR when enabling without a level; matching that + // avoids starting a logger that is enabled yet can never export anything. + self.init(logLevel: parsed ?? (isEnabled ? .LL_ERROR : nil), isEnabled: isEnabled) + } + + func cachePayload(appId: String) -> [String: Any] { + var cached: [String: Any] = [ + Self.cachedAppIdKey: appId, + Self.cachedIsEnabledKey: isEnabled + ] + if let logLevelName { + cached[Self.cachedLogLevelKey] = logLevelName + } + return cached + } + + func allows(_ level: ONE_S_LOG_LEVEL) -> Bool { + guard isEnabled, let logLevel, logLevel != .LL_NONE else { + return false + } + return level != .LL_NONE && level.rawValue <= logLevel.rawValue + } + + func matches(_ other: OSRemoteLoggingConfiguration) -> Bool { + logLevel == other.logLevel && isEnabled == other.isEnabled + } + + static func levelName(_ level: ONE_S_LOG_LEVEL) -> String { + switch level { + case .LL_FATAL: + return "FATAL" + case .LL_ERROR: + return "ERROR" + case .LL_WARN: + return "WARN" + case .LL_INFO: + return "INFO" + case .LL_DEBUG: + return "DEBUG" + case .LL_VERBOSE: + return "VERBOSE" + default: + return "NONE" + } + } + + static func oneSignalLevel(_ value: String) -> ONE_S_LOG_LEVEL? { + switch value { + case "NONE": + return .LL_NONE + case "FATAL": + return .LL_FATAL + case "ERROR": + return .LL_ERROR + case "WARN", "WARNING": + return .LL_WARN + case "INFO": + return .LL_INFO + case "DEBUG": + return .LL_DEBUG + case "VERBOSE", "TRACE": + return .LL_VERBOSE + default: + return nil + } + } +} + +enum OSRemoteLoggingConfigAction: Equatable { + case noChange + case enable(ONE_S_LOG_LEVEL) + case updateLogLevel(old: ONE_S_LOG_LEVEL, new: ONE_S_LOG_LEVEL) + case disable +} + +/// Pure diff of old vs new remote-logging config, mirroring Android's `OtelConfigEvaluator`. +/// Android diffs a separate `OtelConfig` snapshot because its config lives in a +/// persistence-backed `Model`; `OSRemoteLoggingConfiguration` is already a value type, +/// so it is diffed directly. +enum OSRemoteLoggingConfigEvaluator { + static func evaluate( + old: OSRemoteLoggingConfiguration?, + new: OSRemoteLoggingConfiguration + ) -> OSRemoteLoggingConfigAction { + let wasEnabled = old?.isEnabled == true + let isNowEnabled = new.isEnabled + + switch (wasEnabled, isNowEnabled) { + case (false, true): + return .enable(new.logLevel ?? .LL_ERROR) + case (true, false): + return .disable + case (true, true) where old?.logLevel != new.logLevel: + return .updateLogLevel( + old: old?.logLevel ?? .LL_ERROR, + new: new.logLevel ?? .LL_ERROR + ) + default: + return .noChange + } + } +} diff --git a/iOS_SDK/OneSignalSDK/Source/OSRemoteLoggingController.swift b/iOS_SDK/OneSignalSDK/Source/OSRemoteLoggingController.swift index b114fbf5d..baf1733b9 100644 --- a/iOS_SDK/OneSignalSDK/Source/OSRemoteLoggingController.swift +++ b/iOS_SDK/OneSignalSDK/Source/OSRemoteLoggingController.swift @@ -31,81 +31,6 @@ import OneSignalCore @_spi(OneSignalInternal) import OneSignalUser import UIKit -struct OSRemoteLoggingConfiguration { - private static let loggingConfigKey = "logging_config" - private static let logLevelKey = "log_level" - - private let threshold: ONE_S_LOG_LEVEL? - - var logLevel: String? { - threshold.map(Self.levelName) - } - - var isRemoteLoggingEnabled: Bool { - threshold != nil && threshold != .LL_NONE - } - - static var current: OSRemoteLoggingConfiguration { - let params = OSRemoteParamController.shared().remoteParams as? [String: Any] ?? [:] - return OSRemoteLoggingConfiguration(remoteParams: params) - } - - init(remoteParams: [String: Any]) { - let loggingConfig = remoteParams[Self.loggingConfigKey] as? [String: Any] - let parsedThreshold = (loggingConfig?[Self.logLevelKey] as? String) - .map { $0.uppercased() } - .flatMap(Self.oneSignalLevel) - - threshold = parsedThreshold - } - - func allows(_ level: ONE_S_LOG_LEVEL) -> Bool { - guard isRemoteLoggingEnabled, let threshold else { - return false - } - return level != .LL_NONE && level.rawValue <= threshold.rawValue - } - - static func levelName(_ level: ONE_S_LOG_LEVEL) -> String { - switch level { - case .LL_FATAL: - return "FATAL" - case .LL_ERROR: - return "ERROR" - case .LL_WARN: - return "WARN" - case .LL_INFO: - return "INFO" - case .LL_DEBUG: - return "DEBUG" - case .LL_VERBOSE: - return "VERBOSE" - default: - return "NONE" - } - } - - private static func oneSignalLevel(_ value: String) -> ONE_S_LOG_LEVEL? { - switch value { - case "FATAL": - return .LL_FATAL - case "ERROR": - return .LL_ERROR - case "WARN", "WARNING": - return .LL_WARN - case "INFO": - return .LL_INFO - case "DEBUG": - return .LL_DEBUG - case "VERBOSE", "TRACE": - return .LL_VERBOSE - default: - return nil - } - } - -} - @objc(OSRemoteLoggingController) final class OSRemoteLoggingController: NSObject, OSInternalLogSink { typealias RemoteLoggerFactory = (OSRemoteLoggerProviders) -> OSStructuredRemoteLoggerProtocol @@ -113,8 +38,6 @@ final class OSRemoteLoggingController: NSObject, OSInternalLogSink { private static let shared = OSRemoteLoggingController() private static let installIdKey = "PREFS_OS_INSTALL_ID" private static let cachedConfigurationKey = "PREFS_OS_REMOTE_LOGGING_CONFIGURATION" - private static let cachedAppIdKey = "app_id" - private static let cachedLogLevelKey = "log_level" private static let backgroundTaskPrefix = "com.onesignal.logger.flush." private static let installId: String = { let defaults = OneSignalUserDefaults.initShared() @@ -133,7 +56,7 @@ final class OSRemoteLoggingController: NSObject, OSInternalLogSink { private let usesScenes: () -> Bool private let beginBackgroundTask: (String) -> Void private let endBackgroundTask: (String) -> Void - private var configuration = OSRemoteLoggingConfiguration(remoteParams: [:]) + private var configuration = OSRemoteLoggingConfiguration.disabled private var configurationGeneration = 0 private var remoteLogger: OSStructuredRemoteLoggerProtocol? private var appState = "unknown" @@ -164,45 +87,37 @@ final class OSRemoteLoggingController: NSObject, OSInternalLogSink { super.init() } - @objc class func configure() { + @objc static func configure() { let configuration = OSRemoteLoggingConfiguration.current cache(configuration: configuration) shared.configure(with: configuration) } @objc(configureFromCacheForAppId:) - class func configureFromCache(appId: String?) { + static func configureFromCache(appId: String?) { guard let appId, let cached = OneSignalUserDefaults.initStandard().getSavedDictionary( forKey: cachedConfigurationKey, defaultValue: nil ), - cached[cachedAppIdKey] as? String == appId else { + cached[OSRemoteLoggingConfiguration.cachedAppIdKey] as? String == appId else { shared.shutdown() return } - let logLevel = cached[cachedLogLevelKey] as? String - let remoteParams = logLevel.map { - ["logging_config": ["log_level": $0]] - } ?? [:] - shared.configure(with: OSRemoteLoggingConfiguration(remoteParams: remoteParams)) + shared.configure(with: OSRemoteLoggingConfiguration(cached: cached)) } - @objc class func reset() { + @objc static func reset() { shared.shutdown() } - private class func cache(configuration: OSRemoteLoggingConfiguration) { + private static func cache(configuration: OSRemoteLoggingConfiguration) { guard let appId = OneSignalIdentifiers.currentAppId else { return } - var cached: [String: Any] = [cachedAppIdKey: appId] - if let logLevel = configuration.logLevel { - cached[cachedLogLevelKey] = logLevel - } OneSignalUserDefaults.initStandard().saveDictionary( forKey: cachedConfigurationKey, - withValue: cached + withValue: configuration.cachePayload(appId: appId) ) } @@ -252,27 +167,7 @@ final class OSRemoteLoggingController: NSObject, OSInternalLogSink { private func configure(with newConfiguration: OSRemoteLoggingConfiguration) { updateAppState(Self.currentApplicationState()) - var startGeneration: Int? - stateQueue.sync { - self.configurationGeneration += 1 - let generation = self.configurationGeneration - let previousLogLevel = self.configuration.logLevel - self.configuration = newConfiguration - guard newConfiguration.isRemoteLoggingEnabled else { - self.stopRemoteLogging() - return - } - - if previousLogLevel != newConfiguration.logLevel { - self.stopRemoteLogging() - } - - if self.remoteLogger == nil { - startGeneration = generation - } - } - - guard let startGeneration else { + guard let startGeneration = applyConfiguration(newConfiguration) else { return } @@ -307,6 +202,46 @@ final class OSRemoteLoggingController: NSObject, OSInternalLogSink { } } + /// Applies `newConfiguration` and tears down the running logger when the evaluated + /// action calls for it. Returns the generation a replacement logger should be built + /// for, or nil when the current state already satisfies the configuration. + private func applyConfiguration(_ newConfiguration: OSRemoteLoggingConfiguration) -> Int? { + var startGeneration: Int? + stateQueue.sync { + self.configurationGeneration += 1 + let generation = self.configurationGeneration + let action = OSRemoteLoggingConfigEvaluator.evaluate( + old: self.configuration, + new: newConfiguration + ) + self.configuration = newConfiguration + + switch action { + case .disable: + self.stopRemoteLogging() + case .updateLogLevel: + // Android rebuilds remote telemetry on a level change rather than + // swapping a filter on the live instance: `startLogging` shuts the + // previous one down before constructing a new one. Rebuilding matters + // for more than parity here — the platform provider handed to KMP is + // built alongside the logger, so keeping the old instance would go on + // reporting the previous level into KMP, and the crash uploader would + // never re-run after a level escalates away from NONE. + self.stopRemoteLogging() + startGeneration = generation + case .enable, .noChange: + guard newConfiguration.isEnabled else { + self.stopRemoteLogging() + break + } + if self.remoteLogger == nil { + startGeneration = generation + } + } + } + return startGeneration + } + private func stopRemoteLogging() { OneSignalLog.__removeInternalLogSink(self) notificationTokens.forEach(notificationCenter.removeObserver) @@ -316,7 +251,12 @@ final class OSRemoteLoggingController: NSObject, OSInternalLogSink { activeRemoteLogger?.shutdown() } - private func registerLifecycleObservers() { +} + +// MARK: - App state, lifecycle observers, and thread helpers + +private extension OSRemoteLoggingController { + func registerLifecycleObservers() { if usesScenes() { observe(Notification.Name("UISceneDidActivateNotification"), appState: "foreground") observe(Notification.Name("UISceneWillDeactivateNotification"), appState: "unknown") @@ -357,7 +297,7 @@ final class OSRemoteLoggingController: NSObject, OSInternalLogSink { // 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 }, + remoteLogLevel: { configuration.logLevelName }, exporterLoggingEnabled: { false } ) } @@ -439,12 +379,6 @@ final class OSRemoteLoggingController: NSObject, OSInternalLogSink { } -private extension OSRemoteLoggingConfiguration { - func matches(_ other: OSRemoteLoggingConfiguration) -> Bool { - logLevel == other.logLevel - } -} - struct OSRemoteLoggerProviders { let installId: () -> String let onesignalId: () -> String? diff --git a/iOS_SDK/OneSignalSDK/UnitTests/OSRemoteLoggingControllerTests.swift b/iOS_SDK/OneSignalSDK/UnitTests/OSRemoteLoggingControllerTests.swift index 523cc5394..7ba395a26 100644 --- a/iOS_SDK/OneSignalSDK/UnitTests/OSRemoteLoggingControllerTests.swift +++ b/iOS_SDK/OneSignalSDK/UnitTests/OSRemoteLoggingControllerTests.swift @@ -40,19 +40,19 @@ final class OSRemoteLoggingControllerTests: XCTestCase { super.tearDown() } - func testConfigurationUsesRemoteLogLevel() { - let enabled = OSRemoteLoggingConfiguration( - remoteParams: ["logging_config": ["log_level": "warn"]] - ) - XCTAssertTrue(enabled.isRemoteLoggingEnabled) - XCTAssertTrue(enabled.allows(.LL_ERROR)) - XCTAssertTrue(enabled.allows(.LL_WARN)) - XCTAssertFalse(enabled.allows(.LL_INFO)) + func testIosParamsPayloadRoutesEverySeverityThroughController() { + let telemetry = RemoteTelemetrySpy() + telemetry.emitExpectation = expectation(description: "routes verbose from ios_params payload") + telemetry.emitExpectation?.expectedFulfillmentCount = 2 + let controller = makeController(remoteLoggerFactory: { _ in telemetry }) - let invalidLevel = OSRemoteLoggingConfiguration( - remoteParams: ["logging_config": ["log_level": "OFF"]] - ) - XCTAssertFalse(invalidLevel.isRemoteLoggingEnabled) + controller.configure(remoteParams: Fixtures.iosParamsPayload) + OneSignalLog.onesignalLog(.LL_VERBOSE, message: "verbose is exported") + OneSignalLog.onesignalLog(.LL_ERROR, message: "error is exported") + + wait(for: [telemetry.emitExpectation!], timeout: 2) + XCTAssertEqual(telemetry.messages, ["verbose is exported", "error is exported"]) + XCTAssertEqual(telemetry.levels, ["VERBOSE", "ERROR"]) } func testControllerRoutesLogsAndFlushesOnBackground() { @@ -94,7 +94,7 @@ final class OSRemoteLoggingControllerTests: XCTestCase { let telemetry = RemoteTelemetrySpy() telemetry.emitExpectation = expectation(description: "routes warning") let controller = makeController(remoteLoggerFactory: { _ in telemetry }) - controller.configure(remoteParams: Self.remoteParams(level: "WARN")) + controller.configure(remoteParams: Fixtures.remoteParams(level: "WARN")) OneSignalLog.onesignalLog(.LL_WARN, message: "warning body") @@ -107,7 +107,7 @@ final class OSRemoteLoggingControllerTests: XCTestCase { let telemetry = RemoteTelemetrySpy() telemetry.emitExpectation = expectation(description: "routes structured exception") let controller = makeController(remoteLoggerFactory: { _ in telemetry }) - controller.configure(remoteParams: Self.remoteParams(level: "ERROR")) + controller.configure(remoteParams: Fixtures.remoteParams(level: "ERROR")) controller.captureLog( with: .LL_ERROR, @@ -126,7 +126,7 @@ final class OSRemoteLoggingControllerTests: XCTestCase { func testDisablingConfigurationStopsRemoteLogging() { let telemetry = RemoteTelemetrySpy() let controller = makeController(remoteLoggerFactory: { _ in telemetry }) - controller.configure(remoteParams: Self.remoteParams(level: "ERROR")) + controller.configure(remoteParams: Fixtures.remoteParams(level: "ERROR")) controller.configure(remoteParams: [:]) telemetry.emitExpectation = expectation(description: "does not route after disable") telemetry.emitExpectation?.isInverted = true @@ -138,6 +138,52 @@ final class OSRemoteLoggingControllerTests: XCTestCase { XCTAssertTrue(telemetry.messages.isEmpty) } + func testNoneLogLevelStartsLoggerButDoesNotSend() { + let telemetry = RemoteTelemetrySpy() + telemetry.emitExpectation = expectation(description: "does not route at NONE") + telemetry.emitExpectation?.isInverted = true + let controller = makeController(remoteLoggerFactory: { _ in telemetry }) + controller.configure(remoteParams: Fixtures.remoteParams(level: "NONE")) + + OneSignalLog.onesignalLog(.LL_ERROR, message: "not uploaded") + + wait(for: [telemetry.emitExpectation!], timeout: 0.2) + XCTAssertEqual(telemetry.startCount, 1) + XCTAssertEqual(telemetry.shutdownCount, 0) + XCTAssertTrue(telemetry.messages.isEmpty) + } + + func testLogLevelUpdateRebuildsLoggerWithTheNewLevel() { + var loggers: [RemoteTelemetrySpy] = [] + var levelsReportedToKmp: [String?] = [] + let controller = makeController { providers in + levelsReportedToKmp.append(providers.remoteLogLevel()) + let logger = RemoteTelemetrySpy() + loggers.append(logger) + return logger + } + + controller.configure(remoteParams: Fixtures.remoteParams(level: "ERROR")) + controller.configure(remoteParams: Fixtures.remoteParams(level: "WARN")) + + // Android's updateLogLevel shuts the previous telemetry down and builds a new + // one. Rebuilding is what keeps the level reported into KMP in step with the + // configured level, which a live-instance filter swap would leave stale. + XCTAssertEqual(loggers.count, 2) + XCTAssertEqual(levelsReportedToKmp, ["ERROR", "WARN"]) + XCTAssertEqual(loggers[0].shutdownCount, 1) + XCTAssertEqual(loggers[1].startCount, 1) + + let current = loggers[1] + current.emitExpectation = expectation(description: "routes warn after level update") + OneSignalLog.onesignalLog(.LL_WARN, message: "uploaded") + OneSignalLog.onesignalLog(.LL_INFO, message: "not uploaded") + + wait(for: [current.emitExpectation!], timeout: 2) + XCTAssertEqual(current.messages, ["uploaded"]) + XCTAssertEqual(current.levels, ["WARN"]) + } + func testTerminationFlushesBeforeShutdown() { let notificationCenter = NotificationCenter() let telemetry = RemoteTelemetrySpy() @@ -147,7 +193,7 @@ final class OSRemoteLoggingControllerTests: XCTestCase { notificationCenter: notificationCenter, remoteLoggerFactory: { _ in telemetry } ) - controller.configure(remoteParams: Self.remoteParams(level: "ERROR")) + controller.configure(remoteParams: Fixtures.remoteParams(level: "ERROR")) notificationCenter.post(name: UIApplication.willTerminateNotification, object: nil) @@ -163,7 +209,7 @@ final class OSRemoteLoggingControllerTests: XCTestCase { usesScenes: { true }, remoteLoggerFactory: { _ in telemetry } ) - controller.configure(remoteParams: Self.remoteParams(level: "ERROR")) + controller.configure(remoteParams: Fixtures.remoteParams(level: "ERROR")) telemetry.flushExpectation = expectation(description: "ignores application background") telemetry.flushExpectation?.isInverted = true @@ -186,7 +232,7 @@ final class OSRemoteLoggingControllerTests: XCTestCase { } DispatchQueue.global().async { - controller.configure(remoteParams: Self.remoteParams(level: "ERROR")) + controller.configure(remoteParams: Fixtures.remoteParams(level: "ERROR")) configured.fulfill() } @@ -202,12 +248,12 @@ final class OSRemoteLoggingControllerTests: XCTestCase { loggers.append(logger) if !didReenter { didReenter = true - controller.configure(remoteParams: Self.remoteParams(level: "ERROR")) + controller.configure(remoteParams: Fixtures.remoteParams(level: "ERROR")) } return logger } - controller.configure(remoteParams: Self.remoteParams(level: "ERROR")) + controller.configure(remoteParams: Fixtures.remoteParams(level: "ERROR")) XCTAssertEqual(loggers.count, 2) XCTAssertEqual(loggers.map(\.startCount).reduce(0, +), 1) @@ -225,7 +271,7 @@ final class OSRemoteLoggingControllerTests: XCTestCase { OneSignalLog.debug().__add(listener) defer { OneSignalLog.debug().__remove(listener) } - controller.configure(remoteParams: Self.remoteParams(level: "ERROR")) + controller.configure(remoteParams: Fixtures.remoteParams(level: "ERROR")) wait(for: [reset], timeout: 2) XCTAssertEqual(telemetry.shutdownCount, 1) @@ -249,9 +295,190 @@ final class OSRemoteLoggingControllerTests: XCTestCase { return controller } - private static func remoteParams(level: String) -> [String: Any] { +} + +/// Shared by the configuration and controller suites. +private enum Fixtures { + static func remoteParams(level: String) -> [String: Any] { ["logging_config": ["log_level": level]] } + + /// Verbatim ios_params response, so parsing stays honest about the real shape + /// rather than only the trimmed dictionaries the other tests use. + static let iosParamsPayload: [String: Any] = [ + "fba": true, + "uses_provisional_auth": true, + "outcomes": [ + "direct": ["enabled": true], + "indirect": [ + "notification_attribution": ["minutes_since_displayed": 1440, "limit": 10], + "enabled": true + ], + "unattributed": ["enabled": true] + ], + "receive_receipts_enable": true, + "logging_config": ["log_level": "VERBOSE"] + ] +} + +final class OSRemoteLoggingConfigurationTests: XCTestCase { + func testConfigurationUsesRemoteLogLevel() { + let enabled = OSRemoteLoggingConfiguration( + remoteParams: ["logging_config": ["log_level": "warn"]] + ) + XCTAssertTrue(enabled.isEnabled) + XCTAssertEqual(enabled.logLevel, .LL_WARN) + XCTAssertTrue(enabled.allows(.LL_ERROR)) + XCTAssertTrue(enabled.allows(.LL_WARN)) + XCTAssertFalse(enabled.allows(.LL_INFO)) + + let invalidLevel = OSRemoteLoggingConfiguration( + remoteParams: ["logging_config": ["log_level": "OFF"]] + ) + XCTAssertFalse(invalidLevel.isEnabled) + XCTAssertNil(invalidLevel.logLevel) + } + + func testIosParamsPayloadEnablesVerboseLevelLogging() { + let configuration = OSRemoteLoggingConfiguration(remoteParams: Fixtures.iosParamsPayload) + + XCTAssertTrue(configuration.isEnabled) + XCTAssertEqual(configuration.logLevel, .LL_VERBOSE) + XCTAssertEqual(configuration.logLevelName, "VERBOSE") + + // VERBOSE is the most permissive level, so every severity is exported. + for level in [ONE_S_LOG_LEVEL.LL_FATAL, .LL_ERROR, .LL_WARN, .LL_INFO, .LL_DEBUG, .LL_VERBOSE] { + XCTAssertTrue(configuration.allows(level)) + } + XCTAssertFalse(configuration.allows(.LL_NONE)) + + XCTAssertEqual( + OSRemoteLoggingConfigEvaluator.evaluate( + old: OSRemoteLoggingConfiguration.disabled, + new: configuration + ), + .enable(.LL_VERBOSE) + ) + + let restored = OSRemoteLoggingConfiguration( + cached: configuration.cachePayload(appId: "app-id") + ) + XCTAssertEqual(restored.logLevel, .LL_VERBOSE) + XCTAssertTrue(restored.isEnabled) + } + + func testNoneLogLevelEnablesRemoteLoggingButDoesNotSend() { + let none = OSRemoteLoggingConfiguration( + remoteParams: ["logging_config": ["log_level": "NONE"]] + ) + XCTAssertTrue(none.isEnabled) + XCTAssertEqual(none.logLevel, .LL_NONE) + XCTAssertFalse(none.allows(.LL_FATAL)) + XCTAssertFalse(none.allows(.LL_ERROR)) + } + + func testCachePersistsLogLevelAndEnabledFlag() { + let enabled = OSRemoteLoggingConfiguration( + remoteParams: ["logging_config": ["log_level": "ERROR"]] + ) + let payload = enabled.cachePayload(appId: "app-id") + XCTAssertEqual(payload["app_id"] as? String, "app-id") + XCTAssertEqual(payload["log_level"] as? String, "ERROR") + XCTAssertEqual(payload["is_enabled"] as? Bool, true) + + let restored = OSRemoteLoggingConfiguration(cached: payload) + XCTAssertTrue(restored.isEnabled) + XCTAssertEqual(restored.logLevel, .LL_ERROR) + } + + func testLegacyCacheWithoutIsEnabledUsesLogLevelPresence() { + let legacyEnabled = OSRemoteLoggingConfiguration(cached: ["log_level": "WARN"]) + XCTAssertTrue(legacyEnabled.isEnabled) + XCTAssertEqual(legacyEnabled.logLevel, .LL_WARN) + + let legacyDisabled = OSRemoteLoggingConfiguration(cached: ["app_id": "app-id"]) + XCTAssertFalse(legacyDisabled.isEnabled) + XCTAssertNil(legacyDisabled.logLevel) + } + + /// A cache written by a newer SDK can name a level this one cannot parse. Without + /// normalizing, the config would be enabled with a nil level, which starts a + /// logger that can never export and reports no level into KMP. + func testCachedEnabledWithUnparseableLevelFallsBackToError() { + let configuration = OSRemoteLoggingConfiguration( + cached: ["app_id": "app-id", "log_level": "TRACE_ALL_THE_THINGS", "is_enabled": true] + ) + + XCTAssertTrue(configuration.isEnabled) + XCTAssertEqual(configuration.logLevel, .LL_ERROR) + XCTAssertTrue(configuration.allows(.LL_ERROR)) + XCTAssertFalse(configuration.allows(.LL_WARN)) + + // Absent entirely, rather than unparseable, behaves the same way. + let missingLevel = OSRemoteLoggingConfiguration( + cached: ["app_id": "app-id", "is_enabled": true] + ) + XCTAssertEqual(missingLevel.logLevel, .LL_ERROR) + + // But an enabled flag of false must not manufacture a level. + let disabled = OSRemoteLoggingConfiguration( + cached: ["app_id": "app-id", "is_enabled": false] + ) + XCTAssertFalse(disabled.isEnabled) + XCTAssertNil(disabled.logLevel) + } + + func testEvaluatorMirrorsAndroidOtelConfigEvaluator() { + XCTAssertEqual( + OSRemoteLoggingConfigEvaluator.evaluate( + old: nil, + new: OSRemoteLoggingConfiguration(logLevel: .LL_WARN, isEnabled: true) + ), + .enable(.LL_WARN) + ) + XCTAssertEqual( + OSRemoteLoggingConfigEvaluator.evaluate( + old: nil, + new: OSRemoteLoggingConfiguration(logLevel: nil, isEnabled: true) + ), + .enable(.LL_ERROR) + ) + XCTAssertEqual( + OSRemoteLoggingConfigEvaluator.evaluate( + old: nil, + new: OSRemoteLoggingConfiguration.disabled + ), + .noChange + ) + XCTAssertEqual( + OSRemoteLoggingConfigEvaluator.evaluate( + old: OSRemoteLoggingConfiguration.disabled, + new: OSRemoteLoggingConfiguration(logLevel: .LL_INFO, isEnabled: true) + ), + .enable(.LL_INFO) + ) + XCTAssertEqual( + OSRemoteLoggingConfigEvaluator.evaluate( + old: OSRemoteLoggingConfiguration(logLevel: .LL_ERROR, isEnabled: true), + new: OSRemoteLoggingConfiguration.disabled + ), + .disable + ) + XCTAssertEqual( + OSRemoteLoggingConfigEvaluator.evaluate( + old: OSRemoteLoggingConfiguration(logLevel: .LL_ERROR, isEnabled: true), + new: OSRemoteLoggingConfiguration(logLevel: .LL_WARN, isEnabled: true) + ), + .updateLogLevel(old: .LL_ERROR, new: .LL_WARN) + ) + XCTAssertEqual( + OSRemoteLoggingConfigEvaluator.evaluate( + old: OSRemoteLoggingConfiguration(logLevel: .LL_ERROR, isEnabled: true), + new: OSRemoteLoggingConfiguration(logLevel: .LL_ERROR, isEnabled: true) + ), + .noChange + ) + } } private final class ReentrantLogListener: NSObject, OSLogListener {