diff --git a/OneSignal-KMP-SDK b/OneSignal-KMP-SDK index d417111af..72581f399 160000 --- a/OneSignal-KMP-SDK +++ b/OneSignal-KMP-SDK @@ -1 +1 @@ -Subproject commit d417111afa4e81ab80e49931944230e6a0ad47df +Subproject commit 72581f39968104755f322b7dc48f5b3e4a401403 diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index 10f512fa6..078da8380 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -75,6 +75,11 @@ 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 */; }; + 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 */; }; + ACE2175908241BB46C9F1829 /* OSRemoteLoggingControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 658E6E9E6BC6BBF702BCBD33 /* OSRemoteLoggingControllerTests.swift */; }; + 7732574D325D34CC7C498199 /* OSRemoteLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6972EE491A57C79EFE56D4C8 /* OSRemoteLogger.swift */; }; 3C19C6322E919F0C00D6731E /* OSRequestLiveActivityClicked.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C19C6312E919F0C00D6731E /* OSRequestLiveActivityClicked.swift */; }; 3C23A21B2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C23A21A2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift */; }; 3C23A21D2FCE0A83001D32E3 /* OSModelStoreRefreshTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C23A21C2FCE0A83001D32E3 /* OSModelStoreRefreshTests.swift */; }; @@ -438,6 +443,7 @@ DE7D1832270279D9002D3A5D /* OSNotificationClasses.h in Headers */ = {isa = PBXBuildFile; fileRef = DE7D1831270279D9002D3A5D /* OSNotificationClasses.h */; settings = {ATTRIBUTES = (Public, ); }; }; DE7D183427027A73002D3A5D /* OneSignalLog.m in Sources */ = {isa = PBXBuildFile; fileRef = DE7D183327027A73002D3A5D /* OneSignalLog.m */; }; DE7D183627027AA0002D3A5D /* OneSignalLog.h in Headers */ = {isa = PBXBuildFile; fileRef = DE7D183527027AA0002D3A5D /* OneSignalLog.h */; settings = {ATTRIBUTES = (Public, ); }; }; + A7B30D5F3E124B58A41C9D20 /* OneSignalLogInternal.h in Headers */ = {isa = PBXBuildFile; fileRef = C2F81A6D904B47E3B5A02C11 /* OneSignalLogInternal.h */; settings = {ATTRIBUTES = (Public, ); }; }; DE7D183827027CC4002D3A5D /* OneSignalAttachmentHandler.h in Headers */ = {isa = PBXBuildFile; fileRef = DE7D183727027CC4002D3A5D /* OneSignalAttachmentHandler.h */; settings = {ATTRIBUTES = (Public, ); }; }; DE7D183A27027CD7002D3A5D /* OneSignalAttachmentHandler.m in Sources */ = {isa = PBXBuildFile; fileRef = DE7D183927027CD7002D3A5D /* OneSignalAttachmentHandler.m */; }; DE7D183B27027EFC002D3A5D /* NSURL+OneSignal.m in Sources */ = {isa = PBXBuildFile; fileRef = CA36A42B208FDEFB003EFA9A /* NSURL+OneSignal.m */; }; @@ -1689,6 +1695,7 @@ DE7D1831270279D9002D3A5D /* OSNotificationClasses.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OSNotificationClasses.h; sourceTree = ""; }; DE7D183327027A73002D3A5D /* OneSignalLog.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OneSignalLog.m; sourceTree = ""; }; DE7D183527027AA0002D3A5D /* OneSignalLog.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OneSignalLog.h; sourceTree = ""; }; + C2F81A6D904B47E3B5A02C11 /* OneSignalLogInternal.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OneSignalLogInternal.h; sourceTree = ""; }; DE7D183727027CC4002D3A5D /* OneSignalAttachmentHandler.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OneSignalAttachmentHandler.h; sourceTree = ""; }; DE7D183927027CD7002D3A5D /* OneSignalAttachmentHandler.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OneSignalAttachmentHandler.m; sourceTree = ""; }; DE7D1842270283B9002D3A5D /* UserNotifications.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UserNotifications.framework; path = Platforms/MacOSX.platform/Developer/SDKs/MacOSX11.3.sdk/System/Library/Frameworks/UserNotifications.framework; sourceTree = DEVELOPER_DIR; }; @@ -1789,6 +1796,10 @@ 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 = ""; }; + 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 = ""; }; + 658E6E9E6BC6BBF702BCBD33 /* OSRemoteLoggingControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSRemoteLoggingControllerTests.swift; sourceTree = ""; }; + 6972EE491A57C79EFE56D4C8 /* OSRemoteLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSRemoteLogger.swift; sourceTree = ""; }; DEF5CCF12539321A0003E9CC /* UnitTestApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = UnitTestApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; DEF5CCF32539321A0003E9CC /* AppDelegate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AppDelegate.h; sourceTree = ""; }; DEF5CCF42539321A0003E9CC /* AppDelegate.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AppDelegate.m; sourceTree = ""; }; @@ -2285,6 +2296,7 @@ 3C14E3AA2FAE54C006ED053 /* OneSignalLogHttpSender.swift */, 3C14E3A92FAE54C006ED053 /* IOSLogger.swift */, 3C14E3AC2FAE54C006ED053 /* OSLoggerPlatformProvider.swift */, + 6972EE491A57C79EFE56D4C8 /* OSRemoteLogger.swift */, ); path = Logging; sourceTree = ""; @@ -2665,6 +2677,7 @@ 4746E2A62B86B64100D6324C /* LiveActivitiesSwiftTests.swift */, 4746E2AA2B8775C400D6324C /* LiveActivitiesObjcTests.m */, 3C4319082F4CE9D90075492D /* SessionEndOutcomesRequestTests.swift */, + 658E6E9E6BC6BBF702BCBD33 /* OSRemoteLoggingControllerTests.swift */, ); path = UnitTests; sourceTree = ""; @@ -2697,6 +2710,8 @@ 3C448B9B2936ADFD002F96BC /* OSBackgroundTaskHandlerImpl.h */, 3C448B9C2936ADFD002F96BC /* OSBackgroundTaskHandlerImpl.m */, DEC08AFF2947D4E900C81DA3 /* OneSignalSwiftInterface.swift */, + C0462F96E1AADF655F3B3765 /* OSRemoteLoggingController.h */, + 8A72F938F8A3808AC1FF7F3C /* OSRemoteLoggingController.swift */, 3CC9A6352AFA26E7008F68FD /* PrivacyInfo.xcprivacy */, ); path = Source; @@ -2815,6 +2830,7 @@ 4529DF0B1FA932AC00CEAB1D /* OneSignalTrackFirebaseAnalytics.m */, DE7D1831270279D9002D3A5D /* OSNotificationClasses.h */, DE7D183527027AA0002D3A5D /* OneSignalLog.h */, + C2F81A6D904B47E3B5A02C11 /* OneSignalLogInternal.h */, DE7D183327027A73002D3A5D /* OneSignalLog.m */, 3CCF44BC299B17290021964D /* OneSignalWrapper.h */, 3CCF44BD299B17290021964D /* OneSignalWrapper.m */, @@ -3373,6 +3389,7 @@ DE971754274C48CF00FC409E /* OSPrivacyConsentController.h in Headers */, 3CE8CC4E2911ADD1000DB0D3 /* OSDeviceUtils.h in Headers */, DE7D183627027AA0002D3A5D /* OneSignalLog.h in Headers */, + A7B30D5F3E124B58A41C9D20 /* OneSignalLogInternal.h in Headers */, 3C44673F296D09CC0039A49E /* OneSignalMobileProvision.h in Headers */, 3CCF44BE299B17290021964D /* OneSignalWrapper.h in Headers */, DE7D182D270273B0002D3A5D /* OSNotification.h in Headers */, @@ -4379,6 +4396,7 @@ 9124120E1E73342200E41FD7 /* OneSignal.m in Sources */, 9124121E1E73342200E41FD7 /* OneSignalJailbreakDetection.m in Sources */, 3C14E3A42AFAE54C006ED053 /* OneSignalSwiftInterface.swift in Sources */, + B96A3B6BA8CC49EE4796D9BF /* OSRemoteLoggingController.swift in Sources */, 912412471E73369600E41FD7 /* OneSignalHelper.m in Sources */, CA8E19062193C76D009DA223 /* OSInAppMessagingHelpers.m in Sources */, 7AAA60682485D0420004FADE /* OSMigrationController.m in Sources */, @@ -4424,6 +4442,7 @@ 3C14E3B12FAE54C006ED053 /* OneSignalLogHttpSender.swift in Sources */, 3C4F9E4428A4466C009F453A /* OSOperationRepo.swift in Sources */, 3C14E3B02FAE54C006ED053 /* IOSLogger.swift in Sources */, + 7732574D325D34CC7C498199 /* OSRemoteLogger.swift in Sources */, 3C11518B289ADEEB00565C41 /* OSEventProducer.swift in Sources */, 3C115165289A259500565C41 /* OneSignalOSCore.docc in Sources */, 5BC1DE5E2C90B80E00CA8807 /* OSCondition.swift in Sources */, @@ -4530,6 +4549,7 @@ 9124123F1E73342200E41FD7 /* UIApplicationDelegate+OneSignal.m in Sources */, 7AECE59F23675F6300537907 /* OSFocusTimeProcessorFactory.m in Sources */, DEC08B012947D4E900C81DA3 /* OneSignalSwiftInterface.swift in Sources */, + 25898119922BDCDA7AF0B9CC /* OSRemoteLoggingController.swift in Sources */, CA1A6E7120DC2E73001C41B9 /* OneSignalDialogRequest.m in Sources */, 912412331E73342200E41FD7 /* OneSignalTracker.m in Sources */, 7A674F1C2360D82E001F9ACD /* OSBaseFocusTimeProcessor.m in Sources */, @@ -4624,6 +4644,7 @@ CA42CAC320D99CB90001F2F2 /* ProvisionalAuthorizationTests.m in Sources */, 5B58E4F8237CE7B4009401E0 /* UIDeviceOverrider.m in Sources */, 3C4319092F4CE9D90075492D /* SessionEndOutcomesRequestTests.swift in Sources */, + ACE2175908241BB46C9F1829 /* OSRemoteLoggingControllerTests.swift in Sources */, CA8E19022193C6B0009DA223 /* InAppMessagingIntegrationTests.m in Sources */, CAB4112B20852E4C005A70D1 /* DelayedConsentInitializationParameters.m in Sources */, 7AECE59223674A9700537907 /* OSAttributedFocusTimeProcessor.m in Sources */, @@ -4654,6 +4675,7 @@ 03CCCC852835F291004BF794 /* UIApplicationDelegateSwizzlingTests.m in Sources */, 4529DEEA1FA8360C00CEAB1D /* UIApplicationOverrider.m in Sources */, DEC08B022947D4E900C81DA3 /* OneSignalSwiftInterface.swift in Sources */, + 9EAF92032D0429FA35E04417 /* OSRemoteLoggingController.swift in Sources */, 7A93269E25AF4F0300BBEC27 /* OSPendingCallbacks.m in Sources */, 7AECE59823674AB700537907 /* OSUnattributedFocusTimeProcessor.m in Sources */, 7A5A818224897693002E07C8 /* MigrationTests.m in Sources */, diff --git a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCore.h b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCore.h index 4c8cdc8ef..4e22d1281 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCore.h +++ b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCore.h @@ -33,6 +33,7 @@ #import #import #import +#import #import #import #import diff --git a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalLog.m b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalLog.m index 2ed5b3896..a44a50eab 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalLog.m +++ b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalLog.m @@ -26,7 +26,9 @@ */ #import +#import #import "OneSignalLog.h" +#import "OneSignalLogInternal.h" #import "OSDialogInstanceManager.h" #import "OSCopyOnWriteSet.h" @@ -42,6 +44,8 @@ @implementation OneSignalLog static ONE_S_LOG_LEVEL _nsLogLevel = ONE_S_LL_WARN; static ONE_S_LOG_LEVEL _alertLogLevel = ONE_S_LL_NONE; +static os_unfair_lock _internalLogSinkLock = OS_UNFAIR_LOCK_INIT; +static NSObject *_internalLogSink; + (Class)Debug { return self; @@ -72,6 +76,20 @@ + (void)removeLogListener:(NSObject*_Nonnull)listener { [self.logListeners removeObject:listener]; } ++ (void)setInternalLogSink:(NSObject *)sink { + os_unfair_lock_lock(&_internalLogSinkLock); + _internalLogSink = sink; + os_unfair_lock_unlock(&_internalLogSinkLock); +} + ++ (void)removeInternalLogSink:(NSObject *)sink { + os_unfair_lock_lock(&_internalLogSinkLock); + if (_internalLogSink == sink) { + _internalLogSink = nil; + } + os_unfair_lock_unlock(&_internalLogSinkLock); +} + + (void)onesignalLog:(ONE_S_LOG_LEVEL)logLevel message:(NSString* _Nonnull)message { onesignal_Log(logLevel, message); } @@ -113,6 +131,15 @@ void onesignal_Log(ONE_S_LOG_LEVEL logLevel, NSString* message) { [[OSDialogInstanceManager sharedInstance] presentDialogWithTitle:levelString withMessage:message withActions:nil cancelTitle:NSLocalizedString(@"Close", @"Close button") withActionCompletion:nil]; } + os_unfair_lock_lock(&_internalLogSinkLock); + NSObject *internalLogSink = _internalLogSink; + os_unfair_lock_unlock(&_internalLogSinkLock); + [internalLogSink captureLogWithLevel:logLevel + message:message + exceptionType:nil + exceptionMessage:nil + exceptionStacktrace:nil]; + for (NSObject *listener in OneSignalLog.logListeners.allObjects) { if ([listener respondsToSelector:@selector(onLogEvent:)]) { OneSignalLogEvent *event = [[OneSignalLogEvent alloc] initWithLevel:logLevel entry:[levelString stringByAppendingString:message]]; diff --git a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalLogInternal.h b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalLogInternal.h new file mode 100644 index 000000000..6f0962e2d --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalLogInternal.h @@ -0,0 +1,49 @@ +/* + 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 "OneSignalLog.h" + +NS_ASSUME_NONNULL_BEGIN + +@protocol OSInternalLogSink + +- (void)captureLogWithLevel:(ONE_S_LOG_LEVEL)level + message:(NSString *)message + exceptionType:(nullable NSString *)exceptionType + exceptionMessage:(nullable NSString *)exceptionMessage + exceptionStacktrace:(nullable NSString *)exceptionStacktrace; + +@end + +@interface OneSignalLog (Internal) + ++ (void)setInternalLogSink:(NSObject *)sink NS_SWIFT_NAME(__setInternalLogSink(_:)); ++ (void)removeInternalLogSink:(NSObject *)sink NS_SWIFT_NAME(__removeInternalLogSink(_:)); + +@end + +NS_ASSUME_NONNULL_END diff --git a/iOS_SDK/OneSignalSDK/OneSignalCoreTests/LoggingTests.swift b/iOS_SDK/OneSignalSDK/OneSignalCoreTests/LoggingTests.swift index 953537141..b0a2cf8fe 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalCoreTests/LoggingTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalCoreTests/LoggingTests.swift @@ -43,6 +43,22 @@ class TestLogListener: NSObject, OSLogListener { } } +private final class TestInternalLogSink: NSObject, OSInternalLogSink { + var levels: [ONE_S_LOG_LEVEL] = [] + var messages: [String] = [] + + func captureLog( + with level: ONE_S_LOG_LEVEL, + message: String, + exceptionType: String?, + exceptionMessage: String?, + exceptionStacktrace: String? + ) { + levels.append(level) + messages.append(message) + } +} + final class LoggingTests: XCTestCase { override func setUpWithError() throws { OneSignalLog.setLogLevel(.LL_NONE) @@ -141,4 +157,16 @@ final class LoggingTests: XCTestCase { // Then XCTAssertEqual(calls, ["DEBUG: test"]) } + + func testInternalSinkReceivesRawLogAndCanBeRemoved() { + let sink = TestInternalLogSink() + OneSignalLog.__setInternalLogSink(sink) + + OneSignalLog.onesignalLog(.LL_WARN, message: "raw message") + OneSignalLog.__removeInternalLogSink(sink) + OneSignalLog.onesignalLog(.LL_ERROR, message: "not captured") + + XCTAssertEqual(sink.levels, [.LL_WARN]) + XCTAssertEqual(sink.messages, ["raw message"]) + } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSRemoteLogger.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSRemoteLogger.swift new file mode 100644 index 000000000..e8cd3d7e0 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/OSRemoteLogger.swift @@ -0,0 +1,170 @@ +/* + 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 + +public protocol OSRemoteLoggerProtocol: AnyObject { + var kmpVersion: String { get } + var crashStoragePath: String { get } + + func log(level: String, message: String) + func forceFlush(completion: @escaping () -> Void) + func shutdown() +} + +@_spi(OneSignalInternal) +public protocol OSStructuredRemoteLoggerProtocol: OSRemoteLoggerProtocol { + func log( + level: String, + message: String, + exceptionType: String?, + exceptionMessage: String?, + exceptionStacktrace: String? + ) +} + +#if !targetEnvironment(macCatalyst) + +@_implementationOnly import OneSignalKMP + +/// Owns the KMP-specific logger composition while exposing a platform-neutral +/// lifecycle API to the umbrella framework. +public final class OSRemoteLogger: OSRemoteLoggerProtocol { + private let telemetry: ILogTelemetryRemote + private let platformProvider: OSLoggerPlatformProvider + + public init( + installIdProvider: @escaping () -> String, + onesignalIdProvider: @escaping () -> String?, + pushSubscriptionIdProvider: @escaping () -> String?, + appStateProvider: @escaping () -> String, + featureFlagsProvider: @escaping () -> [String], + remoteLogLevelProvider: @escaping () -> String?, + exporterLoggingEnabledProvider: @escaping () -> Bool + ) { + let provider = OSLoggerPlatformProvider( + installIdProvider: installIdProvider, + onesignalIdProvider: onesignalIdProvider, + pushSubscriptionIdProvider: pushSubscriptionIdProvider, + appStateProvider: appStateProvider, + featureFlagsProvider: featureFlagsProvider, + remoteLogLevelProvider: remoteLogLevelProvider, + exporterLoggingEnabledProvider: exporterLoggingEnabledProvider + ) + let logger = IOSLogger() + self.platformProvider = provider + self.telemetry = LoggerFactory.shared.createRemoteTelemetry( + platformProvider: provider, + httpSender: OneSignalLogHttpSender( + logger: logger, + isDiagnosticsEnabled: exporterLoggingEnabledProvider + ) + ) + } + + public var kmpVersion: String { + LoggerBuildInfo.shared.KMP_VERSION + } + + public var crashStoragePath: String { + platformProvider.crashStoragePath + } + + public func log(level: String, message: String) { + log( + level: level, + message: message, + exceptionType: nil, + exceptionMessage: nil, + exceptionStacktrace: nil + ) + } + + public func log( + level: String, + message: String, + exceptionType: String?, + exceptionMessage: String?, + exceptionStacktrace: String? + ) { + LogLoggingHelper.shared.log( + telemetry: telemetry, + level: level, + message: message, + exceptionType: exceptionType, + exceptionMessage: exceptionMessage, + exceptionStacktrace: exceptionStacktrace, + completionHandler: { _ in } + ) + } + + public func forceFlush(completion: @escaping () -> Void) { + telemetry.forceFlush(completionHandler: { _ in completion() }) + } + + public func shutdown() { + telemetry.shutdown() + } +} + +@_spi(OneSignalInternal) +extension OSRemoteLogger: OSStructuredRemoteLoggerProtocol {} + +#else + +public final class OSRemoteLogger: OSRemoteLoggerProtocol { + public init( + installIdProvider: @escaping () -> String, + onesignalIdProvider: @escaping () -> String?, + pushSubscriptionIdProvider: @escaping () -> String?, + appStateProvider: @escaping () -> String, + featureFlagsProvider: @escaping () -> [String], + remoteLogLevelProvider: @escaping () -> String?, + exporterLoggingEnabledProvider: @escaping () -> Bool + ) {} + + public let kmpVersion = "unavailable" + public let crashStoragePath = "unavailable" + + public func log(level: String, message: String) {} + public func log( + level: String, + message: String, + exceptionType: String?, + exceptionMessage: String?, + exceptionStacktrace: String? + ) {} + public func forceFlush(completion: @escaping () -> Void) { + completion() + } + public func shutdown() {} +} + +@_spi(OneSignalInternal) +extension OSRemoteLogger: OSStructuredRemoteLoggerProtocol {} + +#endif diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift index eb7608b2d..d9850691f 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift @@ -690,7 +690,12 @@ extension OneSignalUserManagerImpl: OSUser { guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "onesignalId") else { return nil } - return _user?.identityModel.onesignalId + return internalOnesignalId + } + + @_spi(OneSignalInternal) + public var internalOnesignalId: String? { + _user?.identityModel.onesignalId } public func addObserver(_ observer: OSUserStateObserver) { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift index bfe416b46..a66d10394 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift @@ -31,7 +31,7 @@ import OneSignalCoreMocks import OneSignalUserMocks // Testable import OSCore to allow setting a different poll flush interval @testable import OneSignalOSCore -@testable import OneSignalUser +@_spi(OneSignalInternal) @testable import OneSignalUser final class OneSignalUserTests: XCTestCase { @@ -48,6 +48,15 @@ final class OneSignalUserTests: XCTestCase { override func tearDownWithError() throws { } + func testInternalOnesignalIdTracksCurrentUser() { + let manager = OneSignalUserManagerImpl.sharedInstance + OneSignalUserMocks.setUserManagerInternalUser(externalId: "user-a", onesignalId: "osid-a") + XCTAssertEqual(manager.internalOnesignalId, "osid-a") + + OneSignalUserMocks.setUserManagerInternalUser(externalId: "user-b", onesignalId: "osid-b") + XCTAssertEqual(manager.internalOnesignalId, "osid-b") + } + // Comparable to Android test: "externalId is backed by the identity model" func testLoginSetsExternalId() throws { /* Setup */ diff --git a/iOS_SDK/OneSignalSDK/Source/OSRemoteLoggingController.h b/iOS_SDK/OneSignalSDK/Source/OSRemoteLoggingController.h new file mode 100644 index 000000000..f197ff4c5 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/Source/OSRemoteLoggingController.h @@ -0,0 +1,40 @@ +/* + 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 + +NS_ASSUME_NONNULL_BEGIN + +@interface OSRemoteLoggingController : NSObject + ++ (void)configure; ++ (void)configureFromCacheForAppId:(nullable NSString *)appId; ++ (void)reset; + +@end + +NS_ASSUME_NONNULL_END diff --git a/iOS_SDK/OneSignalSDK/Source/OSRemoteLoggingController.swift b/iOS_SDK/OneSignalSDK/Source/OSRemoteLoggingController.swift new file mode 100644 index 000000000..6cdea28b9 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/Source/OSRemoteLoggingController.swift @@ -0,0 +1,451 @@ +/* + 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 +@_spi(OneSignalInternal) import OneSignalOSCore +@_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 + + 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() + if let saved = defaults.getSavedString(forKey: installIdKey, defaultValue: nil) { + return saved + } + let generated = UUID().uuidString + defaults.saveString(forKey: installIdKey, withValue: generated) + return generated + }() + + private let stateQueue = DispatchQueue(label: "com.onesignal.logger.remote-lifecycle") + private let appStateLock = NSLock() + private let notificationCenter: NotificationCenter + private let remoteLoggerFactory: RemoteLoggerFactory + private let usesScenes: () -> Bool + private let beginBackgroundTask: (String) -> Void + private let endBackgroundTask: (String) -> Void + private var configuration = OSRemoteLoggingConfiguration(remoteParams: [:]) + private var configurationGeneration = 0 + private var remoteLogger: OSStructuredRemoteLoggerProtocol? + private var appState = "unknown" + private var notificationTokens: [NSObjectProtocol] = [] + + init( + notificationCenter: NotificationCenter = .default, + usesScenes: @escaping () -> Bool = { OSBundleUtils.isAppUsingUIScene() }, + beginBackgroundTask: @escaping (String) -> Void = OSBackgroundTaskManager.beginBackgroundTask, + endBackgroundTask: @escaping (String) -> Void = OSBackgroundTaskManager.endBackgroundTask, + remoteLoggerFactory: @escaping RemoteLoggerFactory = { providers in + OSRemoteLogger( + installIdProvider: providers.installId, + onesignalIdProvider: providers.onesignalId, + pushSubscriptionIdProvider: providers.pushSubscriptionId, + appStateProvider: providers.appState, + featureFlagsProvider: providers.featureFlags, + remoteLogLevelProvider: providers.remoteLogLevel, + exporterLoggingEnabledProvider: providers.exporterLoggingEnabled + ) + } + ) { + self.notificationCenter = notificationCenter + self.usesScenes = usesScenes + self.beginBackgroundTask = beginBackgroundTask + self.endBackgroundTask = endBackgroundTask + self.remoteLoggerFactory = remoteLoggerFactory + super.init() + } + + @objc class func configure() { + let configuration = OSRemoteLoggingConfiguration.current + cache(configuration: configuration) + shared.configure(with: configuration) + } + + @objc(configureFromCacheForAppId:) + class func configureFromCache(appId: String?) { + guard let appId, + let cached = OneSignalUserDefaults.initStandard().getSavedDictionary( + forKey: cachedConfigurationKey, + defaultValue: nil + ), + cached[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)) + } + + @objc class func reset() { + shared.shutdown() + } + + private class 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 + ) + } + + func configure(remoteParams: [String: Any]) { + configure(with: OSRemoteLoggingConfiguration(remoteParams: remoteParams)) + } + + func captureLog( + with level: ONE_S_LOG_LEVEL, + message: String, + exceptionType: String?, + exceptionMessage: String?, + exceptionStacktrace: String? + ) { + stateQueue.async { [weak self] in + guard let self, + self.configuration.allows(level), + let remoteLogger = self.remoteLogger else { + return + } + remoteLogger.log( + level: OSRemoteLoggingConfiguration.levelName(level), + message: message, + exceptionType: exceptionType, + exceptionMessage: exceptionMessage, + exceptionStacktrace: exceptionStacktrace + ) + } + } + + func forceFlush(completion: @escaping () -> Void = {}) { + stateQueue.async { [weak self] in + guard let remoteLogger = self?.remoteLogger else { + completion() + return + } + remoteLogger.forceFlush(completion: completion) + } + } + + func shutdown() { + stateQueue.sync { + configurationGeneration += 1 + stopRemoteLogging() + } + } + + 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 { + return + } + + let providers = makeProviders(configuration: newConfiguration) + let newRemoteLogger = Self.onMain { + remoteLoggerFactory(providers) + } + var installed = false + stateQueue.sync { + guard self.configurationGeneration == startGeneration, + self.configuration.matches(newConfiguration), + self.remoteLogger == nil else { + return + } + self.remoteLogger = newRemoteLogger + installed = true + } + guard installed else { + newRemoteLogger.shutdown() + return + } + + logStartupDiagnostic(remoteLogger: newRemoteLogger) + stateQueue.sync { + guard self.remoteLogger === newRemoteLogger, + self.configuration.matches(newConfiguration) else { + return + } + OneSignalLog.__setInternalLogSink(self) + self.registerLifecycleObservers() + } + } + + private func stopRemoteLogging() { + OneSignalLog.__removeInternalLogSink(self) + notificationTokens.forEach(notificationCenter.removeObserver) + notificationTokens.removeAll() + let activeRemoteLogger = remoteLogger + remoteLogger = nil + activeRemoteLogger?.shutdown() + } + + private func registerLifecycleObservers() { + if usesScenes() { + observe(Notification.Name("UISceneDidActivateNotification"), appState: "foreground") + observe(Notification.Name("UISceneWillDeactivateNotification"), appState: "unknown") + observe(Notification.Name("UISceneDidEnterBackgroundNotification"), appState: "background", flush: true) + } else { + observe(UIApplication.didBecomeActiveNotification, appState: "foreground") + observe(UIApplication.willResignActiveNotification, appState: "unknown") + observe(UIApplication.didEnterBackgroundNotification, appState: "background", flush: true) + } + notificationTokens.append( + notificationCenter.addObserver( + forName: UIApplication.willTerminateNotification, + object: nil, + queue: nil + ) { [weak self] _ in + self?.flushForLifecycle(shutdownAfterFlush: true) + } + ) + } + + private func logStartupDiagnostic(remoteLogger: OSStructuredRemoteLoggerProtocol) { + OneSignalLog.onesignalLog( + .LL_WARN, + message: "OneSignal logging initialized: sdk=\(ONESIGNAL_VERSION), " + + "kmp=\(remoteLogger.kmpVersion), path=kmp, " + + "crash_dir=\(remoteLogger.crashStoragePath)" + ) + } + + private func makeProviders(configuration: OSRemoteLoggingConfiguration) -> OSRemoteLoggerProviders { + OSRemoteLoggerProviders( + installId: { Self.installId }, + onesignalId: { OneSignalUserManagerImpl.sharedInstance.internalOnesignalId }, + pushSubscriptionId: { OneSignalUserManagerImpl.sharedInstance.pushSubscriptionId }, + appState: { [weak self] in self?.currentAppState ?? "unknown" }, + featureFlags: { [] }, + remoteLogLevel: { configuration.logLevel }, + exporterLoggingEnabled: { false } + ) + } + + private var currentAppState: String { + appStateLock.lock() + defer { appStateLock.unlock() } + return appState + } + + private func updateAppState(_ value: String) { + appStateLock.lock() + appState = value + appStateLock.unlock() + } + + private func observe( + _ name: Notification.Name, + appState: String, + flush: Bool = false + ) { + notificationTokens.append( + notificationCenter.addObserver(forName: name, object: nil, queue: nil) { [weak self] _ in + self?.updateAppState(appState) + if flush { + self?.flushForLifecycle(shutdownAfterFlush: false) + } + } + ) + } + + private func flushForLifecycle(shutdownAfterFlush: Bool) { + let taskIdentifier = Self.backgroundTaskPrefix + UUID().uuidString + let endBackgroundTask = self.endBackgroundTask + beginBackgroundTask(taskIdentifier) + stateQueue.async { [weak self] in + guard let self, let remoteLogger = self.remoteLogger else { + endBackgroundTask(taskIdentifier) + return + } + remoteLogger.forceFlush { [weak self] in + guard let self else { + endBackgroundTask(taskIdentifier) + return + } + self.stateQueue.async { + if shutdownAfterFlush { + self.stopRemoteLogging() + } + endBackgroundTask(taskIdentifier) + } + } + } + } + + private static func currentApplicationState() -> String { + let state: UIApplication.State + if Thread.isMainThread { + state = UIApplication.shared.applicationState + } else { + state = DispatchQueue.main.sync { UIApplication.shared.applicationState } + } + switch state { + case .active: + return "foreground" + case .background: + return "background" + default: + return "unknown" + } + } + + private static func onMain(_ work: () -> T) -> T { + if Thread.isMainThread { + return work() + } + return DispatchQueue.main.sync(execute: work) + } + +} + +private extension OSRemoteLoggingConfiguration { + func matches(_ other: OSRemoteLoggingConfiguration) -> Bool { + logLevel == other.logLevel + } +} + +struct OSRemoteLoggerProviders { + let installId: () -> String + let onesignalId: () -> String? + let pushSubscriptionId: () -> String? + let appState: () -> String + let featureFlags: () -> [String] + let remoteLogLevel: () -> String? + let exporterLoggingEnabled: () -> Bool +} diff --git a/iOS_SDK/OneSignalSDK/Source/OneSignal.m b/iOS_SDK/OneSignalSDK/Source/OneSignal.m index 7a128f170..0b3313bf8 100755 --- a/iOS_SDK/OneSignalSDK/Source/OneSignal.m +++ b/iOS_SDK/OneSignalSDK/Source/OneSignal.m @@ -29,6 +29,7 @@ #import "OneSignalFramework.h" #import #import "OneSignalInternal.h" +#import "OSRemoteLoggingController.h" #import "OneSignalTracker.h" #import "OneSignalTrackIAP.h" #import "OneSignalJailbreakDetection.h" @@ -137,6 +138,7 @@ + (OneSignalReceiveReceiptsController*)receiveReceiptsController { //TODO: This is related to unit tests and will change with um tests + (void)clearStatics { + [OSRemoteLoggingController reset]; [OneSignalIdentifiers setCurrentAppId:nil]; launchOptions = false; appSettings = nil; @@ -233,6 +235,7 @@ + (void)logout { Note: wrappers may call this method with a null appId. */ + (void)initialize:(nonnull NSString*)newAppId withLaunchOptions:(nullable NSDictionary*)launchOptions { + [OSRemoteLoggingController configureFromCacheForAppId:newAppId ?: OneSignalIdentifiers.storedAppId]; [self setAppId:newAppId]; [self setLaunchOptions:launchOptions]; [self init]; @@ -283,7 +286,6 @@ + (BOOL)isValidAppId:(NSString*)appId { 1/2 steps in OneSignal init, relying on setAppId (usage order does not matter) Sets the iOS sepcific app settings Method must be called to successfully init OneSignal - Note: While this is called via `initialize`, it is also called directly from wrapper SDKs. */ + (void)setLaunchOptions:(nullable NSDictionary*)newLaunchOptions { [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:[NSString stringWithFormat:@"setLaunchOptions() called with launchOptions: %@!", launchOptions.description]]; @@ -585,7 +587,7 @@ + (void)init { OSBackgroundTaskManager.taskHandler = [OSBackgroundTaskHandlerImpl new]; [self registerForAPNsToken]; - + // Wrapper SDK's call init twice and pass null as the appId on the first call // the app ID is required to download parameters, so do not download params until the appID is provided if (!_didCallDownloadParameters && OneSignalIdentifiers.currentAppId && OneSignalIdentifiers.currentAppId != (id)[NSNull null]) @@ -654,6 +656,7 @@ + (void)handleAppIdChange:(NSString*)appId { initDone = false; _downloadedParameters = false; _didCallDownloadParameters = false; + [OSRemoteLoggingController reset]; let sharedUserDefaults = OneSignalUserDefaults.initShared; @@ -735,6 +738,7 @@ + (void)downloadIOSParamsWithAppId:(NSString *)appId { } [[OSRemoteParamController sharedController] saveRemoteParams:result]; + [OSRemoteLoggingController configure]; if ([[OSRemoteParamController sharedController] hasLocationKey]) { BOOL shared = [result[IOS_LOCATION_SHARED] boolValue]; let oneSignalLocation = NSClassFromString(ONE_SIGNAL_LOCATION_CLASS_NAME); diff --git a/iOS_SDK/OneSignalSDK/UnitTests/OSRemoteLoggingControllerTests.swift b/iOS_SDK/OneSignalSDK/UnitTests/OSRemoteLoggingControllerTests.swift new file mode 100644 index 000000000..dac5c59aa --- /dev/null +++ b/iOS_SDK/OneSignalSDK/UnitTests/OSRemoteLoggingControllerTests.swift @@ -0,0 +1,303 @@ +/* + 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 +@_spi(OneSignalInternal) import OneSignalOSCore +import UIKit +import XCTest + +final class OSRemoteLoggingControllerTests: XCTestCase { + private var controllers: [OSRemoteLoggingController] = [] + + override func tearDown() { + controllers.forEach { $0.shutdown() } + controllers.removeAll() + 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)) + + let invalidLevel = OSRemoteLoggingConfiguration( + remoteParams: ["logging_config": ["log_level": "OFF"]] + ) + XCTAssertFalse(invalidLevel.isRemoteLoggingEnabled) + } + + func testControllerRoutesLogsAndFlushesOnBackground() { + let notificationCenter = NotificationCenter() + let telemetry = RemoteTelemetrySpy() + telemetry.emitExpectation = expectation(description: "routes matching log") + telemetry.flushExpectation = expectation(description: "flushes on background") + let backgroundTaskEnded = expectation(description: "ends background task after flush") + var startedTask: String? + var endedTask: String? + let controller = makeController( + notificationCenter: notificationCenter, + beginBackgroundTask: { startedTask = $0 }, + endBackgroundTask: { + endedTask = $0 + backgroundTaskEnded.fulfill() + }, + remoteLoggerFactory: { _ in telemetry } + ) + + controller.configure( + remoteParams: ["logging_config": ["log_level": "ERROR"]] + ) + OneSignalLog.onesignalLog(.LL_INFO, message: "not uploaded") + OneSignalLog.onesignalLog(.LL_ERROR, message: "uploaded") + notificationCenter.post(name: UIApplication.didEnterBackgroundNotification, object: nil) + + wait( + for: [telemetry.emitExpectation!, telemetry.flushExpectation!, backgroundTaskEnded], + timeout: 2 + ) + + XCTAssertEqual(telemetry.messages, ["uploaded"]) + XCTAssertEqual(telemetry.levels, ["ERROR"]) + XCTAssertEqual(endedTask, startedTask) + } + + func testWarnUsesRawMessageWithoutConsolePrefix() { + let telemetry = RemoteTelemetrySpy() + telemetry.emitExpectation = expectation(description: "routes warning") + let controller = makeController(remoteLoggerFactory: { _ in telemetry }) + controller.configure(remoteParams: Self.remoteParams(level: "WARN")) + + OneSignalLog.onesignalLog(.LL_WARN, message: "warning body") + + wait(for: [telemetry.emitExpectation!], timeout: 2) + XCTAssertEqual(telemetry.messages, ["warning body"]) + XCTAssertEqual(telemetry.levels, ["WARN"]) + } + + func testInternalSinkForwardsStructuredExceptionFields() { + let telemetry = RemoteTelemetrySpy() + telemetry.emitExpectation = expectation(description: "routes structured exception") + let controller = makeController(remoteLoggerFactory: { _ in telemetry }) + controller.configure(remoteParams: Self.remoteParams(level: "ERROR")) + + controller.captureLog( + with: .LL_ERROR, + message: "failed", + exceptionType: "ExampleError", + exceptionMessage: "details", + exceptionStacktrace: "frame" + ) + + wait(for: [telemetry.emitExpectation!], timeout: 2) + XCTAssertEqual(telemetry.exceptionTypes, ["ExampleError"]) + XCTAssertEqual(telemetry.exceptionMessages, ["details"]) + XCTAssertEqual(telemetry.exceptionStacktraces, ["frame"]) + } + + func testDisablingConfigurationStopsRemoteLogging() { + let telemetry = RemoteTelemetrySpy() + let controller = makeController(remoteLoggerFactory: { _ in telemetry }) + controller.configure(remoteParams: Self.remoteParams(level: "ERROR")) + controller.configure(remoteParams: [:]) + telemetry.emitExpectation = expectation(description: "does not route after disable") + telemetry.emitExpectation?.isInverted = true + + OneSignalLog.onesignalLog(.LL_ERROR, message: "not uploaded") + + wait(for: [telemetry.emitExpectation!], timeout: 0.2) + XCTAssertEqual(telemetry.shutdownCount, 1) + XCTAssertTrue(telemetry.messages.isEmpty) + } + + func testTerminationFlushesBeforeShutdown() { + let notificationCenter = NotificationCenter() + let telemetry = RemoteTelemetrySpy() + telemetry.flushExpectation = expectation(description: "flushes on termination") + telemetry.shutdownExpectation = expectation(description: "shuts down after flush") + let controller = makeController( + notificationCenter: notificationCenter, + remoteLoggerFactory: { _ in telemetry } + ) + controller.configure(remoteParams: Self.remoteParams(level: "ERROR")) + + notificationCenter.post(name: UIApplication.willTerminateNotification, object: nil) + + wait(for: [telemetry.flushExpectation!, telemetry.shutdownExpectation!], timeout: 2) + XCTAssertEqual(telemetry.shutdownCount, 1) + } + + func testSceneAppsObserveOnlySceneLifecycle() { + let notificationCenter = NotificationCenter() + let telemetry = RemoteTelemetrySpy() + let controller = makeController( + notificationCenter: notificationCenter, + usesScenes: { true }, + remoteLoggerFactory: { _ in telemetry } + ) + controller.configure(remoteParams: Self.remoteParams(level: "ERROR")) + telemetry.flushExpectation = expectation(description: "ignores application background") + telemetry.flushExpectation?.isInverted = true + + notificationCenter.post(name: UIApplication.didEnterBackgroundNotification, object: nil) + wait(for: [telemetry.flushExpectation!], timeout: 0.2) + + telemetry.flushExpectation = expectation(description: "flushes on scene background") + notificationCenter.post(name: Notification.Name("UISceneDidEnterBackgroundNotification"), object: nil) + wait(for: [telemetry.flushExpectation!], timeout: 2) + } + + func testConstructsRemoteLoggerOnMainWhenConfiguredInBackground() { + let constructed = expectation(description: "constructs logger on main") + let configured = expectation(description: "finishes background configuration") + let telemetry = RemoteTelemetrySpy() + let controller = makeController { _ in + XCTAssertTrue(Thread.isMainThread) + constructed.fulfill() + return telemetry + } + + DispatchQueue.global().async { + controller.configure(remoteParams: Self.remoteParams(level: "ERROR")) + configured.fulfill() + } + + wait(for: [constructed, configured], timeout: 2) + } + + func testStartupDiagnosticCanResetControllerWithoutDeadlock() { + let reset = expectation(description: "resets from startup diagnostic listener") + let telemetry = RemoteTelemetrySpy() + let controller = makeController(remoteLoggerFactory: { _ in telemetry }) + let listener = ReentrantLogListener { + controller.shutdown() + reset.fulfill() + } + OneSignalLog.debug().__add(listener) + defer { OneSignalLog.debug().__remove(listener) } + + controller.configure(remoteParams: Self.remoteParams(level: "ERROR")) + + wait(for: [reset], timeout: 2) + XCTAssertEqual(telemetry.shutdownCount, 1) + } + + private func makeController( + notificationCenter: NotificationCenter = NotificationCenter(), + usesScenes: @escaping () -> Bool = { false }, + beginBackgroundTask: @escaping (String) -> Void = { _ in }, + endBackgroundTask: @escaping (String) -> Void = { _ in }, + remoteLoggerFactory: @escaping OSRemoteLoggingController.RemoteLoggerFactory + ) -> OSRemoteLoggingController { + let controller = OSRemoteLoggingController( + notificationCenter: notificationCenter, + usesScenes: usesScenes, + beginBackgroundTask: beginBackgroundTask, + endBackgroundTask: endBackgroundTask, + remoteLoggerFactory: remoteLoggerFactory + ) + controllers.append(controller) + return controller + } + + private static func remoteParams(level: String) -> [String: Any] { + ["logging_config": ["log_level": level]] + } +} + +private final class ReentrantLogListener: NSObject, OSLogListener { + private let onLog: () -> Void + + init(onLog: @escaping () -> Void) { + self.onLog = onLog + } + + func onLogEvent(_ event: OneSignalLogEvent) { + guard event.entry.contains("OneSignal logging initialized:") else { + return + } + onLog() + } +} + +private final class RemoteTelemetrySpy: OSStructuredRemoteLoggerProtocol { + private let lock = NSLock() + var emitExpectation: XCTestExpectation? + var flushExpectation: XCTestExpectation? + var shutdownExpectation: XCTestExpectation? + let kmpVersion = "test" + let crashStoragePath = "/test" + private(set) var levels: [String] = [] + private(set) var messages: [String] = [] + private(set) var exceptionTypes: [String?] = [] + private(set) var exceptionMessages: [String?] = [] + private(set) var exceptionStacktraces: [String?] = [] + private(set) var shutdownCount = 0 + + func log(level: String, message: String) { + log( + level: level, + message: message, + exceptionType: nil, + exceptionMessage: nil, + exceptionStacktrace: nil + ) + } + + func log( + level: String, + message: String, + exceptionType: String?, + exceptionMessage: String?, + exceptionStacktrace: String? + ) { + lock.lock() + levels.append(level) + messages.append(message) + exceptionTypes.append(exceptionType) + exceptionMessages.append(exceptionMessage) + exceptionStacktraces.append(exceptionStacktrace) + lock.unlock() + emitExpectation?.fulfill() + } + + func forceFlush(completion: @escaping () -> Void) { + flushExpectation?.fulfill() + completion() + } + + func shutdown() { + lock.lock() + shutdownCount += 1 + lock.unlock() + shutdownExpectation?.fulfill() + } +}