From bf7c24d6a2fa8a595c3aeb849da928587dd70a83 Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 11 Aug 2026 16:34:00 -0700 Subject: [PATCH 01/21] chore: normalize project.pbxproj ordering Canonical sort applied by the xcodeproj tool, so later commits in this stack show only their own file additions. No project changes. Co-authored-by: Cursor --- .../OneSignal.xcodeproj/project.pbxproj | 252 +++++++++--------- 1 file changed, 126 insertions(+), 126 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index 10f512fa6..9ccbf443b 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -1344,6 +1344,12 @@ 3C11518C289AF5E800565C41 /* OSModelChangedHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSModelChangedHandler.swift; sourceTree = ""; }; 3C14E39E2AFAE39B006ED053 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 3C14E3A02AFAE461006ED053 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; + 3C14E3A92FAE54C006ED053 /* IOSLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IOSLogger.swift; sourceTree = ""; }; + 3C14E3AA2FAE54C006ED053 /* OneSignalLogHttpSender.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OneSignalLogHttpSender.swift; sourceTree = ""; }; + 3C14E3AB2FAE54C006ED053 /* FileLogStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileLogStore.swift; sourceTree = ""; }; + 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 = ""; }; 3C19C6312E919F0C00D6731E /* OSRequestLiveActivityClicked.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSRequestLiveActivityClicked.swift; sourceTree = ""; }; 3C23A21A2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OneSignalIdentifiersFallbackTests.swift; sourceTree = ""; }; 3C23A21C2FCE0A83001D32E3 /* OSModelStoreRefreshTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSModelStoreRefreshTests.swift; sourceTree = ""; }; @@ -1783,12 +1789,6 @@ DEBAAEB62A4381AE00BF2C1C /* OSInAppMessageMigrationController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OSInAppMessageMigrationController.h; sourceTree = ""; }; DEBAAEB72A4381AE00BF2C1C /* OSInAppMessageMigrationController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OSInAppMessageMigrationController.m; sourceTree = ""; }; DEC08AFF2947D4E900C81DA3 /* OneSignalSwiftInterface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OneSignalSwiftInterface.swift; sourceTree = ""; }; - 3C14E3A92FAE54C006ED053 /* IOSLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IOSLogger.swift; sourceTree = ""; }; - 3C14E3AA2FAE54C006ED053 /* OneSignalLogHttpSender.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OneSignalLogHttpSender.swift; sourceTree = ""; }; - 3C14E3AB2FAE54C006ED053 /* FileLogStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileLogStore.swift; sourceTree = ""; }; - 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 = ""; }; 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 = ""; }; @@ -5410,14 +5410,6 @@ buildSettings = { APPLICATION_EXTENSION_API_ONLY = YES; BUILD_LIBRARY_FOR_DISTRIBUTION = YES; - "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", - ); - "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", - ); CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; @@ -5445,6 +5437,14 @@ DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; + "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", + ); + "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", + ); GCC_C_LANGUAGE_STANDARD = gnu11; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OneSignalOSCoreFramework/Info.plist; @@ -5478,14 +5478,6 @@ buildSettings = { APPLICATION_EXTENSION_API_ONLY = YES; BUILD_LIBRARY_FOR_DISTRIBUTION = YES; - "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", - ); - "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", - ); CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; @@ -5514,6 +5506,14 @@ DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; ENABLE_TESTABILITY = YES; + "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", + ); + "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", + ); GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; GCC_OPTIMIZATION_LEVEL = 0; @@ -6976,14 +6976,6 @@ buildSettings = { ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; BUNDLE_LOADER = "$(TEST_HOST)"; - "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", - ); - "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", - ); CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; @@ -7008,6 +7000,14 @@ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 99SW8E36CT; ENABLE_USER_SCRIPT_SANDBOXING = YES; + "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", + ); + "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", + ); GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 11.0; @@ -7030,14 +7030,6 @@ buildSettings = { ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; BUNDLE_LOADER = "$(TEST_HOST)"; - "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", - ); - "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", - ); CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; @@ -7062,6 +7054,14 @@ DEVELOPMENT_TEAM = 99SW8E36CT; ENABLE_TESTABILITY = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; + "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", + ); + "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", + ); GCC_C_LANGUAGE_STANDARD = gnu17; GCC_DYNAMIC_NO_PIC = NO; GCC_OPTIMIZATION_LEVEL = 0; @@ -7092,14 +7092,6 @@ buildSettings = { ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; BUNDLE_LOADER = "$(TEST_HOST)"; - "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", - ); - "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", - ); CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; @@ -7124,6 +7116,14 @@ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 99SW8E36CT; ENABLE_USER_SCRIPT_SANDBOXING = YES; + "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", + ); + "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", + ); GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 11.0; @@ -7191,14 +7191,6 @@ CA2951B72167F4120064227A /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", - ); - "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", - ); CLANG_ANALYZER_GCD_PERFORMANCE = YES; CLANG_ANALYZER_SECURITY_FLOATLOOPCOUNTER = YES; CLANG_ENABLE_CODE_COVERAGE = NO; @@ -7208,6 +7200,14 @@ "$(inherited)", "$(PROJECT_DIR)", ); + "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", + ); + "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", + ); IPHONEOS_DEPLOYMENT_TARGET = 9.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -7225,14 +7225,6 @@ CA2951B82167F4120064227A /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", - ); - "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", - ); BUILD_LIBRARY_FOR_DISTRIBUTION = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ENABLE_CODE_COVERAGE = NO; @@ -7249,6 +7241,14 @@ DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; + "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", + ); + "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", + ); GCC_WARN_64_TO_32_BIT_CONVERSION = NO; INFOPLIST_FILE = OneSignalFramework/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; @@ -7278,14 +7278,6 @@ CA2951B92167F4120064227A /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", - ); - "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", - ); BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_ANALYZER_NONNULL = YES; CLANG_ENABLE_MODULES = YES; @@ -7297,6 +7289,14 @@ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 99SW8E36CT; ENABLE_TESTABILITY = YES; + "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", + ); + "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", + ); GCC_OPTIMIZATION_LEVEL = 0; GCC_PREPROCESSOR_DEFINITIONS = OS_TEST; HEADER_SEARCH_PATHS = $CONFIGURATION_TEMP_DIR/UnitTests.build/DerivedSources; @@ -7395,14 +7395,6 @@ CA2951C32167FB950064227A /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", - ); - "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", - ); CLANG_ANALYZER_GCD_PERFORMANCE = YES; CLANG_ANALYZER_SECURITY_FLOATLOOPCOUNTER = YES; CLANG_ENABLE_CODE_COVERAGE = NO; @@ -7412,6 +7404,14 @@ "$(inherited)", "$(PROJECT_DIR)", ); + "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", + ); + "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", + ); IPHONEOS_DEPLOYMENT_TARGET = 9.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -7429,14 +7429,6 @@ CA2951C42167FB950064227A /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", - ); - "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", - ); BUILD_LIBRARY_FOR_DISTRIBUTION = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ENABLE_CODE_COVERAGE = YES; @@ -7454,6 +7446,14 @@ DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; ENABLE_TESTABILITY = YES; + "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", + ); + "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", + ); GCC_WARN_64_TO_32_BIT_CONVERSION = NO; INFOPLIST_FILE = OneSignalFramework/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; @@ -7484,14 +7484,6 @@ CA2951C52167FB950064227A /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", - ); - "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", - ); BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_ANALYZER_NONNULL = YES; CLANG_ENABLE_MODULES = YES; @@ -7503,6 +7495,14 @@ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 99SW8E36CT; ENABLE_TESTABILITY = YES; + "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", + ); + "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", + ); GCC_OPTIMIZATION_LEVEL = 0; GCC_PREPROCESSOR_DEFINITIONS = OS_TEST; HEADER_SEARCH_PATHS = $CONFIGURATION_TEMP_DIR/UnitTests.build/DerivedSources; @@ -7601,14 +7601,6 @@ DE3D8F3928C15839008C2BBF /* Test */ = { isa = XCBuildConfiguration; buildSettings = { - "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", - ); - "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", - ); CLANG_ANALYZER_GCD_PERFORMANCE = YES; CLANG_ANALYZER_SECURITY_FLOATLOOPCOUNTER = YES; CLANG_ENABLE_CODE_COVERAGE = NO; @@ -7618,6 +7610,14 @@ "$(inherited)", "$(PROJECT_DIR)", ); + "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", + ); + "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", + ); IPHONEOS_DEPLOYMENT_TARGET = 9.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -7635,14 +7635,6 @@ DE3D8F3A28C15839008C2BBF /* Test */ = { isa = XCBuildConfiguration; buildSettings = { - "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", - ); - "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", - ); BUILD_LIBRARY_FOR_DISTRIBUTION = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ENABLE_CODE_COVERAGE = YES; @@ -7660,6 +7652,14 @@ DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; ENABLE_TESTABILITY = YES; + "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", + ); + "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", + ); GCC_WARN_64_TO_32_BIT_CONVERSION = NO; INFOPLIST_FILE = OneSignalFramework/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; @@ -7690,14 +7690,6 @@ DE3D8F3B28C15839008C2BBF /* Test */ = { isa = XCBuildConfiguration; buildSettings = { - "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", - ); - "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", - ); BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_ANALYZER_NONNULL = YES; CLANG_ENABLE_MODULES = YES; @@ -7709,6 +7701,14 @@ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 99SW8E36CT; ENABLE_TESTABILITY = YES; + "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", + ); + "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", + ); GCC_OPTIMIZATION_LEVEL = 0; GCC_PREPROCESSOR_DEFINITIONS = OS_TEST; HEADER_SEARCH_PATHS = $CONFIGURATION_TEMP_DIR/UnitTests.build/DerivedSources; @@ -9205,14 +9205,6 @@ buildSettings = { APPLICATION_EXTENSION_API_ONLY = YES; BUILD_LIBRARY_FOR_DISTRIBUTION = YES; - "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", - ); - "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", - ); CLANG_ENABLE_MODULES = YES; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; @@ -9222,6 +9214,14 @@ DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; ENABLE_TESTABILITY = YES; + "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", + ); + "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", + ); GCC_PREPROCESSOR_DEFINITIONS = OS_TEST; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OneSignalOSCoreFramework/Info.plist; From 3421d8396efca5984413bdc081ba1e1b9af6addd Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 11 Aug 2026 16:42:20 -0700 Subject: [PATCH 02/21] fix: [PR2] read-your-write consistency defects Four faults in how a fetch waits for its own write to be readable: resolveConditionsWithID looked up waiters by condition id, but they are registered under the id passed to getRywTokenFromAwaitableCondition, so the lookup found nothing and the waiter it meant to release stayed blocked. It now scans every index for waiters on that condition. A waiter blocked on an unbounded semaphore, so a response that never arrived held the calling thread for the life of the process. Waits now time out and deregister rather than leaving an entry that the next token signals to nobody. OSIamFetchReadyCondition was a singleton pinned to the first id it ever saw, so after a user switch a fetch consulted the previous user's tokens. Conditions are now per id, with reset() as the test seam. hasSubscriptionUpdatePending was never lowered, so one in-session subscription change held every later fetch to waiting for a subscription token with no update behind it. The new optional onConditionSatisfied lets a condition lower a bar it raised once its waiter is released. Shared state moves behind the serial queue and locks throughout. Co-authored-by: Cursor --- .../OneSignal.xcodeproj/project.pbxproj | 8 + .../EarlyTriggerTrackingTests.swift | 2 +- .../IAMIntegrationTests.swift | 2 +- .../OSMessagingControllerUserStateTests.swift | 2 +- .../IamFetch/OSIamFetchReadyCondition.swift | 38 ++++- .../Source/Consistency/OSCondition.swift | 4 + .../Consistency/OSConsistencyManager.swift | 82 +++++---- .../ConsistencyManagerTestHelpers.swift | 7 + .../OSConsistencyManagerTests.swift | 103 ++++++++--- .../OSIamFetchReadyConditionTests.swift | 160 ++++++++++++++++++ .../OSOperationRepoFlushTests.swift | 19 --- .../XCTestCase+WaitUntil.swift | 51 ++++++ 12 files changed, 395 insertions(+), 83 deletions(-) create mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSIamFetchReadyConditionTests.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/XCTestCase+WaitUntil.swift diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index 9ccbf443b..658d2d80d 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -279,6 +279,7 @@ 475F474A2B8E3B4600EC05B3 /* OneSignalLiveActivities.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 475F471E2B8E398D00EC05B3 /* OneSignalLiveActivities.framework */; platformFilter = ios; }; 475F474F2B8E3B5400EC05B3 /* OneSignalLiveActivities.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 475F471E2B8E398D00EC05B3 /* OneSignalLiveActivities.framework */; }; 475F47502B8E3B5400EC05B3 /* OneSignalLiveActivities.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 475F471E2B8E398D00EC05B3 /* OneSignalLiveActivities.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 50C4F7CFEC46836131011004 /* XCTestCase+WaitUntil.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03A81CCA8DFA43C8AEA738B8 /* XCTestCase+WaitUntil.swift */; }; 5B053FBC2CAE07EB002F30C4 /* OneSignalOSCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3C115161289A259500565C41 /* OneSignalOSCore.framework */; }; 5B053FC32CAE0843002F30C4 /* OSConsistencyManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BC1DE672C90C23E00CA8807 /* OSConsistencyManagerTests.swift */; }; 5B58E4F8237CE7B4009401E0 /* UIDeviceOverrider.m in Sources */ = {isa = PBXBuildFile; fileRef = 5B58E4F6237CE7B4009401E0 /* UIDeviceOverrider.m */; }; @@ -394,6 +395,7 @@ CACBAAAA218A65AE000ACAA5 /* InAppMessagingTests.m in Sources */ = {isa = PBXBuildFile; fileRef = CACBAAA9218A65AE000ACAA5 /* InAppMessagingTests.m */; }; CACBAAAC218A662B000ACAA5 /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CACBAAAB218A662B000ACAA5 /* WebKit.framework */; }; CACBAAB4218A7113000ACAA5 /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CACBAAAB218A662B000ACAA5 /* WebKit.framework */; }; + DD2A89A8052E2D1912B0038B /* OSIamFetchReadyConditionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF4B19D1EC31C0750F13065A /* OSIamFetchReadyConditionTests.swift */; }; DE16C14424D3724700670EFA /* OneSignalLifecycleObserver.m in Sources */ = {isa = PBXBuildFile; fileRef = DE16C14324D3724700670EFA /* OneSignalLifecycleObserver.m */; }; DE16C14524D3724700670EFA /* OneSignalLifecycleObserver.m in Sources */ = {isa = PBXBuildFile; fileRef = DE16C14324D3724700670EFA /* OneSignalLifecycleObserver.m */; }; DE16C14724D3727200670EFA /* OneSignalLifecycleObserver.h in Headers */ = {isa = PBXBuildFile; fileRef = DE16C14624D3727200670EFA /* OneSignalLifecycleObserver.h */; }; @@ -1319,6 +1321,7 @@ 03866CBC2378A33B0009C1D8 /* OutcomeIntegrationTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OutcomeIntegrationTests.m; sourceTree = ""; }; 03866CBF2378A67B0009C1D8 /* RestClientAsserts.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RestClientAsserts.h; sourceTree = ""; }; 03866CC02378A67B0009C1D8 /* RestClientAsserts.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RestClientAsserts.m; sourceTree = ""; }; + 03A81CCA8DFA43C8AEA738B8 /* XCTestCase+WaitUntil.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "XCTestCase+WaitUntil.swift"; sourceTree = ""; }; 03CCCC7D2835D8CC004BF794 /* OneSignalUNUserNotificationCenterSwizzlingTest.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OneSignalUNUserNotificationCenterSwizzlingTest.m; sourceTree = ""; }; 03CCCC812835D90F004BF794 /* OneSignalUNUserNotificationCenterHelper.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OneSignalUNUserNotificationCenterHelper.m; sourceTree = ""; }; 03CCCC822835D90F004BF794 /* OneSignalUNUserNotificationCenterHelper.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OneSignalUNUserNotificationCenterHelper.h; sourceTree = ""; }; @@ -1834,6 +1837,7 @@ DEFB3E622BB731BD00E65DAD /* ActivityKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ActivityKit.framework; path = System/Library/Frameworks/ActivityKit.framework; sourceTree = SDKROOT; }; DEFB3E642BB7346D00E65DAD /* OSLiveActivities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLiveActivities.swift; sourceTree = ""; }; DEFB3E662BB735B500E65DAD /* OSStubLiveActivities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSStubLiveActivities.swift; sourceTree = ""; }; + FF4B19D1EC31C0750F13065A /* OSIamFetchReadyConditionTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSIamFetchReadyConditionTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -2560,6 +2564,8 @@ 3C23A21A2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift */, 3C23A21E2FCE0AA1001D32E3 /* OSResilientStorageTests.swift */, 3C23A21C2FCE0A83001D32E3 /* OSModelStoreRefreshTests.swift */, + FF4B19D1EC31C0750F13065A /* OSIamFetchReadyConditionTests.swift */, + 03A81CCA8DFA43C8AEA738B8 /* XCTestCase+WaitUntil.swift */, ); path = OneSignalOSCoreTests; sourceTree = ""; @@ -4585,6 +4591,8 @@ 3C427AC9301BB28A0059B8B7 /* OSOperationRepoFlushTests.swift in Sources */, 3C14E3B52FAE54C006ED053 /* OSLoggerAdaptersTests.swift in Sources */, 3C23A21B2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift in Sources */, + DD2A89A8052E2D1912B0038B /* OSIamFetchReadyConditionTests.swift in Sources */, + 50C4F7CFEC46836131011004 /* XCTestCase+WaitUntil.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/EarlyTriggerTrackingTests.swift b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/EarlyTriggerTrackingTests.swift index bedfe909e..d916c1f49 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/EarlyTriggerTrackingTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/EarlyTriggerTrackingTests.swift @@ -49,7 +49,7 @@ final class EarlyTriggerTrackingTests: XCTestCase { override func setUpWithError() throws { OneSignalCoreMocks.clearUserDefaults() OneSignalUserMocks.reset() - OSConsistencyManager.shared.reset() + ConsistencyManagerTestHelpers.reset() OSMessagingController.removeInstance() // Set up basic configuration diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IAMIntegrationTests.swift b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IAMIntegrationTests.swift index 9efcb6f51..f21259a50 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IAMIntegrationTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IAMIntegrationTests.swift @@ -41,7 +41,7 @@ final class IAMIntegrationTests: XCTestCase { override func setUpWithError() throws { OneSignalCoreMocks.clearUserDefaults() OneSignalUserMocks.reset() - OSConsistencyManager.shared.reset() + ConsistencyManagerTestHelpers.reset() // Temp. logging to help debug during testing OneSignalLog.setLogLevel(.LL_VERBOSE) } diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OSMessagingControllerUserStateTests.swift b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OSMessagingControllerUserStateTests.swift index 8d7ada7cb..9c73924d6 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OSMessagingControllerUserStateTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OSMessagingControllerUserStateTests.swift @@ -50,7 +50,7 @@ final class OSMessagingControllerUserStateTests: XCTestCase { override func setUpWithError() throws { OneSignalCoreMocks.clearUserDefaults() OneSignalUserMocks.reset() - OSConsistencyManager.shared.reset() + ConsistencyManagerTestHelpers.reset() OSMessagingController.removeInstance() // Set up basic configuration diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Consistency/IamFetch/OSIamFetchReadyCondition.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Consistency/IamFetch/OSIamFetchReadyCondition.swift index 66751e3a0..3075e9cbb 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Consistency/IamFetch/OSIamFetchReadyCondition.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Consistency/IamFetch/OSIamFetchReadyCondition.swift @@ -28,17 +28,31 @@ @objc public class OSIamFetchReadyCondition: NSObject, OSCondition { // the id used to index the token map (e.g. onesignalId) private let id: String + + private let stateLock = NSLock() private var hasSubscriptionUpdatePending: Bool = false - // Singleton shared instance initialized with default empty id - private static var instance: OSIamFetchReadyCondition? + private static let instancesLock = NSLock() + private static var instances: [String: OSIamFetchReadyCondition] = [:] - // Method to get or initialize the shared instance + /** + One condition per id, so a fetch waits on the same object the subscription listener armed, and a + fetch for a user who just switched in is not answered by the previous user's tokens. + */ @objc public static func sharedInstance(withId id: String) -> OSIamFetchReadyCondition { - if instance == nil { - instance = OSIamFetchReadyCondition(id: id) + return instancesLock.withLock { + if let existing = instances[id] { + return existing + } + let condition = OSIamFetchReadyCondition(id: id) + instances[id] = condition + return condition } - return instance! + } + + /// Test seam; the instances otherwise live as long as the process. + @objc public static func reset() { + instancesLock.withLock { instances = [:] } } // Private initializer to prevent external instantiation @@ -53,8 +67,16 @@ return OSIamFetchReadyCondition.CONDITIONID } + /// Raises the bar for the next fetch: an in-session subscription change is only readable once its + /// own token arrives, so waiting on the user token alone would fetch before the server can see it. public func setSubscriptionUpdatePending(value: Bool) { - hasSubscriptionUpdatePending = value + stateLock.withLock { hasSubscriptionUpdatePending = value } + } + + /// The fetch this was raised for has been released, so later fetches stop waiting on a subscription + /// token that has no update behind it. + @objc public func onConditionSatisfied() { + setSubscriptionUpdatePending(value: false) } public func isMet(indexedTokens: [String: [NSNumber: OSReadYourWriteData]]) -> Bool { @@ -71,7 +93,7 @@ return true } - if hasSubscriptionUpdatePending { + if stateLock.withLock({ hasSubscriptionUpdatePending }) { return userUpdateTokenSet && subscriptionTokenSet } return userUpdateTokenSet diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Consistency/OSCondition.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Consistency/OSCondition.swift index c63807819..ffef386ee 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Consistency/OSCondition.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Consistency/OSCondition.swift @@ -32,4 +32,8 @@ import Foundation var conditionId: String { get } func isMet(indexedTokens: [String: [NSNumber: OSReadYourWriteData]]) -> Bool func getNewestToken(indexedTokens: [String: [NSNumber: OSReadYourWriteData]]) -> OSReadYourWriteData? + + /// Called once a waiter on this condition has been released, so a condition that raised its own bar + /// for that wait can lower it again instead of holding every later waiter to it. + @objc optional func onConditionSatisfied() } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Consistency/OSConsistencyManager.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Consistency/OSConsistencyManager.swift index ac7129de8..cb081c415 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Consistency/OSConsistencyManager.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Consistency/OSConsistencyManager.swift @@ -32,17 +32,30 @@ import OneSignalCore // Singleton instance @objc public static let shared = OSConsistencyManager() - private let queue = DispatchQueue(label: "com.consistencyManager.queue") + // Serial, and the only place `indexedTokens` and `indexedConditions` may be touched. + // Non-private so test helpers can synchronize with it. + let queue = DispatchQueue(label: "com.consistencyManager.queue") private var indexedTokens: [String: [NSNumber: OSReadYourWriteData]] = [:] - private var indexedConditions: [String: [(OSCondition, DispatchSemaphore)]] = [:] // Index conditions by condition id + // Waiters, indexed by the id passed to getRywTokenFromAwaitableCondition. Non-private for tests. + var indexedConditions: [String: [(OSCondition, DispatchSemaphore)]] = [:] + + /** + How long a waiter blocks before proceeding with whatever token it has. A response that never arrives — + the device is offline, or the endpoint stopped returning `ryw_token` and has no call that resolves the + condition — would otherwise hold the calling thread for the life of the process. + Non-private so tests can shorten it. + */ + static var waitTimeout: DispatchTimeInterval = .seconds(30) // Private initializer to prevent multiple instances private override init() {} // Used for testing public func reset() { - indexedTokens = [:] - indexedConditions = [:] + queue.sync { + self.indexedTokens = [:] + self.indexedConditions = [:] + } } // Function to set the token in a thread-safe manner @@ -57,52 +70,63 @@ import OneSignalCore } } - // Register a condition and block the caller until the condition is met + /// Blocks the caller until the condition is met or `waitTimeout` elapses, then returns the newest + /// token the condition accepts, which is nil when it was released without one. @objc public func getRywTokenFromAwaitableCondition(_ condition: OSCondition, forId id: String) -> OSReadYourWriteData? { let semaphore = DispatchSemaphore(value: 0) queue.sync { - if self.indexedConditions[id] == nil { - self.indexedConditions[id] = [] - } - self.indexedConditions[id]?.append((condition, semaphore)) + self.indexedConditions[id, default: []].append((condition, semaphore)) self.checkConditionsAndComplete(forId: id) } - semaphore.wait() // Block until the condition is met + if semaphore.wait(timeout: .now() + OSConsistencyManager.waitTimeout) == .timedOut { + OneSignalLog.onesignalLog(.LL_WARN, message: "OSConsistencyManager timed out waiting on \(condition.conditionId) for id: \(id)") + // Deregister, or the entry outlives the wait and the next token signals nobody. + queue.sync { + self.indexedConditions[id]?.removeAll { $0.1 === semaphore } + } + } return queue.sync { return condition.getNewestToken(indexedTokens: self.indexedTokens) } } - // Method to resolve conditions by condition ID (e.g. OSIamFetchReadyCondition.ID) - @objc public func resolveConditionsWithID(id: String) { - guard let conditionList = indexedConditions[id] else { return } - var completedConditions: [(OSCondition, DispatchSemaphore)] = [] - for (condition, semaphore) in conditionList { - if condition.conditionId == id { - semaphore.signal() - completedConditions.append((condition, semaphore)) + /** + Releases every waiter on `conditionId`, whichever id it registered under. Callers reach for this when + a response came back with no `ryw_token` at all, which leaves the waiter with nothing left to wait for. + */ + @objc public func resolveConditionsWithID(id conditionId: String) { + queue.sync { + for indexId in Array(self.indexedConditions.keys) { + guard let waiters = self.indexedConditions[indexId] else { + continue + } + for (condition, semaphore) in waiters where condition.conditionId == conditionId { + OneSignalLog.onesignalLog(.LL_INFO, message: "Condition \(conditionId) resolved for id: \(indexId)") + self.release(condition, semaphore) + } + self.indexedConditions[indexId] = waiters.filter { $0.0.conditionId != conditionId } } } - indexedConditions[id]?.removeAll { condition, semaphore in - completedConditions.contains(where: { $0.0 === condition && $0.1 == semaphore }) - } } // Private method to check conditions for a specific id (unique ID like onesignalId) private func checkConditionsAndComplete(forId id: String) { - guard let conditionList = indexedConditions[id] else { return } - var completedConditions: [(OSCondition, DispatchSemaphore)] = [] - for (condition, semaphore) in conditionList { + guard let waiters = indexedConditions[id] else { return } + var stillWaiting: [(OSCondition, DispatchSemaphore)] = [] + for (condition, semaphore) in waiters { if condition.isMet(indexedTokens: indexedTokens) { OneSignalLog.onesignalLog(.LL_INFO, message: "Condition met for id: \(id)") - semaphore.signal() - completedConditions.append((condition, semaphore)) + release(condition, semaphore) } else { OneSignalLog.onesignalLog(.LL_INFO, message: "Condition not met for id: \(id)") + stillWaiting.append((condition, semaphore)) } } - indexedConditions[id]?.removeAll { condition, semaphore in - completedConditions.contains(where: { $0.0 === condition && $0.1 == semaphore }) - } + indexedConditions[id] = stillWaiting + } + + private func release(_ condition: OSCondition, _ semaphore: DispatchSemaphore) { + condition.onConditionSatisfied?() + semaphore.signal() } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/ConsistencyManagerTestHelpers.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/ConsistencyManagerTestHelpers.swift index 53d105943..8649a1a07 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/ConsistencyManagerTestHelpers.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/ConsistencyManagerTestHelpers.swift @@ -28,6 +28,13 @@ import OneSignalOSCore public class ConsistencyManagerTestHelpers { + /// Clears both halves of the read-your-write state: the manager's tokens and waiters, and the + /// per-id conditions, which otherwise carry a raised subscription bar into the next test. + public static func reset() { + OSConsistencyManager.shared.reset() + OSIamFetchReadyCondition.reset() + } + /// Unblocks the Consistency Manager, which allows fetching of IAMs for example. public static func setDefaultRywToken(id: String) { let key = OSIamFetchOffsetKey.userUpdate diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSConsistencyManagerTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSConsistencyManagerTests.swift index 9a686d644..2d27096d5 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSConsistencyManagerTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSConsistencyManagerTests.swift @@ -8,19 +8,23 @@ import Foundation import XCTest -import OneSignalOSCore +@testable import OneSignalOSCore class OSConsistencyManagerTests: XCTestCase { var consistencyManager: OSConsistencyManager! + private var defaultWaitTimeout: DispatchTimeInterval! override func setUp() { super.setUp() // Use the shared instance of OSConsistencyManager consistencyManager = OSConsistencyManager.shared + defaultWaitTimeout = OSConsistencyManager.waitTimeout } override func tearDown() { + OSConsistencyManager.waitTimeout = defaultWaitTimeout consistencyManager.reset() + OSIamFetchReadyCondition.reset() super.tearDown() } @@ -92,35 +96,28 @@ class OSConsistencyManagerTests: XCTestCase { // Test: registerCondition does not complete when condition is not met func testRegisterConditionDoesNotCompleteWhenConditionIsNotMet() { - // Given a condition that will never be met - let condition = TestUnmetCondition() + OSConsistencyManager.waitTimeout = .milliseconds(500) let id = "test_id" - let rywDelay = 500 as NSNumber - - // Start on a background queue to simulate async behavior - DispatchQueue.global().async { - // Register the condition asynchronously - let rywData = self.consistencyManager.getRywTokenFromAwaitableCondition(condition, forId: id) + let returned = expectation(description: "waiter returned") + var rywData: OSReadYourWriteData? - // Since the condition will never be met, rywToken should remain nil - XCTAssertNil(rywData) + DispatchQueue.global().async { + rywData = self.consistencyManager.getRywTokenFromAwaitableCondition(TestUnmetCondition(), forId: id) + returned.fulfill() + } + waitUntil("waiter registered") { self.consistencyManager.waiterCount == 1 } - // Set an unrelated token to verify that the unmet condition still doesn't complete - self.consistencyManager.setRywTokenAndDelay( + // A token for another id must not release this waiter + consistencyManager.setRywTokenAndDelay( id: "unrelated_id", key: OSIamFetchOffsetKey.userUpdate, - value: OSReadYourWriteData(rywToken: "unrelated", rywDelay: rywDelay) - ) - - // newest token should still be nil as the condition is not met - XCTAssertNil(rywData) - } + value: OSReadYourWriteData(rywToken: "unrelated", rywDelay: 500) + ) + XCTAssertEqual(consistencyManager.waiterCount, 1) - // Use a short delay to let the async behavior complete without waiting indefinitely - DispatchQueue.global().asyncAfter(deadline: .now() + 1.0) { - XCTAssertTrue(true) // Simulate some async action completing without hanging - } - } + wait(for: [returned], timeout: 2.0) + XCTAssertNil(rywData) + } func testSetRywTokenWithoutAnyCondition() { // Given @@ -292,6 +289,64 @@ class OSConsistencyManagerTests: XCTestCase { waitForExpectations(timeout: 2.0, handler: nil) } + + // MARK: - Releasing waiters + + /** + The fallback for a response that carried no `ryw_token`: the caller knows only the condition id, + while the waiter registered under the user's id, so resolving has to reach across the index. + */ + func testResolvingByConditionIdReleasesAWaiterRegisteredUnderAnotherId() { + let returned = expectation(description: "waiter returned") + DispatchQueue.global().async { + _ = self.consistencyManager.getRywTokenFromAwaitableCondition(TestUnmetCondition(), forId: "onesignal-id") + returned.fulfill() + } + waitUntil("waiter registered") { self.consistencyManager.waiterCount == 1 } + + consistencyManager.resolveConditionsWithID(id: TestUnmetCondition.CONDITIONID) + + wait(for: [returned], timeout: 2.0) + XCTAssertEqual(consistencyManager.waiterCount, 0) + } + + func testResolvingADifferentConditionLeavesTheWaiterWaiting() { + OSConsistencyManager.waitTimeout = .milliseconds(200) + let returned = expectation(description: "waiter returned") + DispatchQueue.global().async { + _ = self.consistencyManager.getRywTokenFromAwaitableCondition(TestUnmetCondition(), forId: "onesignal-id") + returned.fulfill() + } + waitUntil("waiter registered") { self.consistencyManager.waiterCount == 1 } + + consistencyManager.resolveConditionsWithID(id: "SomeOtherCondition") + + XCTAssertEqual(consistencyManager.waiterCount, 1) + // Let it time out rather than leaving the thread blocked past the end of the test. + wait(for: [returned], timeout: 2.0) + } + + /// A condition nothing ever meets must not hold its thread for the life of the process. + func testAWaiterGivesUpWhenItsConditionIsNeverMet() { + OSConsistencyManager.waitTimeout = .milliseconds(200) + let returned = expectation(description: "waiter returned") + var rywData: OSReadYourWriteData? + DispatchQueue.global().async { + rywData = self.consistencyManager.getRywTokenFromAwaitableCondition(TestUnmetCondition(), forId: "onesignal-id") + returned.fulfill() + } + + wait(for: [returned], timeout: 2.0) + XCTAssertNil(rywData) + XCTAssertEqual(consistencyManager.waiterCount, 0, "a waiter that gave up has to deregister") + } +} + +extension OSConsistencyManager { + /// Waiters registered right now. Tests poll it while the manager mutates on its own queue. + var waiterCount: Int { + return queue.sync { indexedConditions.values.reduce(0) { $0 + $1.count } } + } } // Mock implementation of OSCondition that simulates a condition that isn't met diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSIamFetchReadyConditionTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSIamFetchReadyConditionTests.swift new file mode 100644 index 000000000..0a7103ffa --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSIamFetchReadyConditionTests.swift @@ -0,0 +1,160 @@ +/* + 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 XCTest +@testable import OneSignalOSCore + +/// Covers what an IAM fetch waits for, and for whom. +final class OSIamFetchReadyConditionTests: XCTestCase { + + private let userA = "onesignal-id-a" + private let userB = "onesignal-id-b" + private var defaultWaitTimeout: DispatchTimeInterval! + + override func setUp() { + super.setUp() + defaultWaitTimeout = OSConsistencyManager.waitTimeout + OSConsistencyManager.shared.reset() + OSIamFetchReadyCondition.reset() + } + + override func tearDown() { + OSConsistencyManager.waitTimeout = defaultWaitTimeout + OSConsistencyManager.shared.reset() + OSIamFetchReadyCondition.reset() + super.tearDown() + } + + // MARK: - One condition per id + + func testTheSameIdGetsTheSameCondition() { + let first = OSIamFetchReadyCondition.sharedInstance(withId: userA) + let second = OSIamFetchReadyCondition.sharedInstance(withId: userA) + + XCTAssertTrue(first === second, "the fetch has to wait on the object the listener armed") + } + + /// A user switch must not be answered by the previous user's condition, which reads their tokens. + func testADifferentIdGetsItsOwnCondition() { + let forUserA = OSIamFetchReadyCondition.sharedInstance(withId: userA) + let forUserB = OSIamFetchReadyCondition.sharedInstance(withId: userB) + + XCTAssertFalse(forUserA === forUserB) + XCTAssertTrue(forUserA.isMet(indexedTokens: [userA: userCreateToken()])) + XCTAssertFalse(forUserB.isMet(indexedTokens: [userA: userCreateToken()]), + "the new user's fetch must not be released by the previous user's token") + } + + // MARK: - What the condition waits for + + func testAUserUpdateTokenIsEnoughWithNoSubscriptionUpdatePending() { + let condition = OSIamFetchReadyCondition.sharedInstance(withId: userA) + + XCTAssertTrue(condition.isMet(indexedTokens: [userA: userUpdateToken()])) + } + + func testAPendingSubscriptionUpdateAlsoWaitsForItsToken() { + let condition = OSIamFetchReadyCondition.sharedInstance(withId: userA) + condition.setSubscriptionUpdatePending(value: true) + + XCTAssertFalse(condition.isMet(indexedTokens: [userA: userUpdateToken()])) + XCTAssertTrue(condition.isMet(indexedTokens: [userA: userUpdateToken().merging(subscriptionToken()) { current, _ in current }])) + } + + /// The raised bar belongs to the fetch it was raised for; leaving it up makes every later fetch + /// wait on a subscription token that has no update behind it. + func testTheSubscriptionBarComesBackDownOnceTheFetchIsReleased() { + let condition = OSIamFetchReadyCondition.sharedInstance(withId: userA) + condition.setSubscriptionUpdatePending(value: true) + + condition.onConditionSatisfied() + + XCTAssertTrue(condition.isMet(indexedTokens: [userA: userUpdateToken()])) + } + + // MARK: - Through the Consistency Manager + + func testTheManagerLowersTheBarWhenItReleasesTheWaiter() { + let manager = OSConsistencyManager.shared + OSIamFetchReadyCondition.sharedInstance(withId: userA).setSubscriptionUpdatePending(value: true) + + let firstReturned = expectation(description: "first fetch released") + DispatchQueue.global().async { + _ = manager.getRywTokenFromAwaitableCondition(OSIamFetchReadyCondition.sharedInstance(withId: self.userA), forId: self.userA) + firstReturned.fulfill() + } + waitUntil("first fetch waiting") { manager.waiterCount == 1 } + + manager.setRywTokenAndDelay(id: userA, key: OSIamFetchOffsetKey.userUpdate, value: token("100")) + XCTAssertEqual(manager.waiterCount, 1, "a pending subscription update still owes a token") + + manager.setRywTokenAndDelay(id: userA, key: OSIamFetchOffsetKey.subscriptionUpdate, value: token("200")) + wait(for: [firstReturned], timeout: 2.0) + + // The next fetch has no subscription update behind it, so the user token alone releases it. + let secondReturned = expectation(description: "second fetch released") + DispatchQueue.global().async { + _ = manager.getRywTokenFromAwaitableCondition(OSIamFetchReadyCondition.sharedInstance(withId: self.userA), forId: self.userA) + secondReturned.fulfill() + } + wait(for: [secondReturned], timeout: 2.0) + } + + /// The `ryw_token`-missing fallback the executors call, which names the condition rather than the user. + func testResolvingTheConditionReleasesTheFetch() { + let manager = OSConsistencyManager.shared + let returned = expectation(description: "fetch released") + DispatchQueue.global().async { + _ = manager.getRywTokenFromAwaitableCondition(OSIamFetchReadyCondition.sharedInstance(withId: self.userA), forId: self.userA) + returned.fulfill() + } + waitUntil("fetch waiting") { manager.waiterCount == 1 } + + manager.resolveConditionsWithID(id: OSIamFetchReadyCondition.CONDITIONID) + + wait(for: [returned], timeout: 2.0) + } + + // MARK: - Helpers + + private func token(_ value: String) -> OSReadYourWriteData { + return OSReadYourWriteData(rywToken: value, rywDelay: 0) + } + + private func userCreateToken() -> [NSNumber: OSReadYourWriteData] { + return [NSNumber(value: OSIamFetchOffsetKey.userCreate.rawValue): token("create")] + } + + private func userUpdateToken() -> [NSNumber: OSReadYourWriteData] { + return [NSNumber(value: OSIamFetchOffsetKey.userUpdate.rawValue): token("update")] + } + + private func subscriptionToken() -> [NSNumber: OSReadYourWriteData] { + return [NSNumber(value: OSIamFetchOffsetKey.subscriptionUpdate.rawValue): token("subscription")] + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoFlushTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoFlushTests.swift index e8519205d..875a26ffb 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoFlushTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoFlushTests.swift @@ -138,25 +138,6 @@ final class OSOperationRepoFlushTests: XCTestCase { value: property ) } - - private func waitUntil( - _ description: String, - timeout: TimeInterval = 2.0, - file: StaticString = #filePath, - line: UInt = #line, - _ condition: @escaping () -> Bool - ) { - let exp = expectation(description: description) - let timer = Timer.scheduledTimer(withTimeInterval: 0.01, repeats: true) { timer in - if condition() { - timer.invalidate() - exp.fulfill() - } - } - let result = XCTWaiter.wait(for: [exp], timeout: timeout) - timer.invalidate() - XCTAssertEqual(result, .completed, "Timed out waiting for: \(description)", file: file, line: line) - } } private final class MockOperationExecutor: OSOperationExecutor { diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/XCTestCase+WaitUntil.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/XCTestCase+WaitUntil.swift new file mode 100644 index 000000000..629c7d95e --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/XCTestCase+WaitUntil.swift @@ -0,0 +1,51 @@ +/* + Modified MIT License + + Copyright 2025 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 XCTest + +extension XCTestCase { + /// Polls: the work runs on a private queue with no completion to hook. + func waitUntil( + _ description: String, + timeout: TimeInterval = 2.0, + file: StaticString = #filePath, + line: UInt = #line, + _ condition: @escaping () -> Bool + ) { + let exp = expectation(description: description) + let timer = Timer.scheduledTimer(withTimeInterval: 0.01, repeats: true) { timer in + if condition() { + timer.invalidate() + exp.fulfill() + } + } + let result = XCTWaiter.wait(for: [exp], timeout: timeout) + timer.invalidate() + XCTAssertEqual(result, .completed, "Timed out waiting for: \(description)", file: file, line: line) + } +} From 953ff22f51aaba3298bbe5fa2cc10cc4b6b61c60 Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 11 Aug 2026 18:25:54 -0700 Subject: [PATCH 03/21] fix: scope RYW resolve to one user and lower the bar on timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolveConditionsWithID walked every index and cleared every matching condition's subscription bar, so a response with no ryw_token for one user could unblock — and disarm — another. Resolve now takes the onesignal id and only touches that bucket. A timed-out waiter only deregistered itself, leaving hasSubscriptionUpdatePending up, so later IAM fetches for that id paid another full wait for a subscription token that was never coming. Timeout now runs the same onConditionSatisfied clear, if the waiter is still registered. Co-authored-by: Cursor --- .../Consistency/OSConsistencyManager.swift | 34 +++++++----- .../OSConsistencyManagerTests.swift | 37 ++++++++++--- .../OSIamFetchReadyConditionTests.swift | 52 ++++++++++++++++++- .../OSPropertyOperationExecutor.swift | 2 +- .../OSSubscriptionOperationExecutor.swift | 4 +- .../Source/Executors/OSUserExecutor.swift | 2 +- 6 files changed, 105 insertions(+), 26 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Consistency/OSConsistencyManager.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Consistency/OSConsistencyManager.swift index cb081c415..c7b29cb06 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Consistency/OSConsistencyManager.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Consistency/OSConsistencyManager.swift @@ -80,8 +80,15 @@ import OneSignalCore } if semaphore.wait(timeout: .now() + OSConsistencyManager.waitTimeout) == .timedOut { OneSignalLog.onesignalLog(.LL_WARN, message: "OSConsistencyManager timed out waiting on \(condition.conditionId) for id: \(id)") - // Deregister, or the entry outlives the wait and the next token signals nobody. queue.sync { + // Still registered? Then this timeout owns the clear. A concurrent met-path release + // already ran onConditionSatisfied and removed the entry. + guard self.indexedConditions[id]?.contains(where: { $0.1 === semaphore }) == true else { + return + } + // Lower any bar the condition raised for this wait; otherwise later fetches for this + // id keep paying the full timeout for a subscription token that is never coming. + condition.onConditionSatisfied?() self.indexedConditions[id]?.removeAll { $0.1 === semaphore } } } @@ -91,21 +98,22 @@ import OneSignalCore } /** - Releases every waiter on `conditionId`, whichever id it registered under. Callers reach for this when - a response came back with no `ryw_token` at all, which leaves the waiter with nothing left to wait for. + Releases waiters on `conditionId` that registered under `id` (e.g. onesignalId). Callers reach for + this when a response for that user came back with no `ryw_token`, which leaves those waiters with + nothing left to wait for. Scoped to `id` so a missing token for one user cannot unblock — or clear + the subscription bar of — another. */ - @objc public func resolveConditionsWithID(id conditionId: String) { + @objc(resolveConditionsWithConditionId:forId:) + public func resolveConditions(conditionId: String, forId id: String) { queue.sync { - for indexId in Array(self.indexedConditions.keys) { - guard let waiters = self.indexedConditions[indexId] else { - continue - } - for (condition, semaphore) in waiters where condition.conditionId == conditionId { - OneSignalLog.onesignalLog(.LL_INFO, message: "Condition \(conditionId) resolved for id: \(indexId)") - self.release(condition, semaphore) - } - self.indexedConditions[indexId] = waiters.filter { $0.0.conditionId != conditionId } + guard let waiters = self.indexedConditions[id] else { + return + } + for (condition, semaphore) in waiters where condition.conditionId == conditionId { + OneSignalLog.onesignalLog(.LL_INFO, message: "Condition \(conditionId) resolved for id: \(id)") + self.release(condition, semaphore) } + self.indexedConditions[id] = waiters.filter { $0.0.conditionId != conditionId } } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSConsistencyManagerTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSConsistencyManagerTests.swift index 2d27096d5..bf5c969f6 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSConsistencyManagerTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSConsistencyManagerTests.swift @@ -292,11 +292,8 @@ class OSConsistencyManagerTests: XCTestCase { // MARK: - Releasing waiters - /** - The fallback for a response that carried no `ryw_token`: the caller knows only the condition id, - while the waiter registered under the user's id, so resolving has to reach across the index. - */ - func testResolvingByConditionIdReleasesAWaiterRegisteredUnderAnotherId() { + /// A response with no `ryw_token` releases waiters for that user only. + func testResolvingByConditionIdReleasesWaitersForThatId() { let returned = expectation(description: "waiter returned") DispatchQueue.global().async { _ = self.consistencyManager.getRywTokenFromAwaitableCondition(TestUnmetCondition(), forId: "onesignal-id") @@ -304,12 +301,38 @@ class OSConsistencyManagerTests: XCTestCase { } waitUntil("waiter registered") { self.consistencyManager.waiterCount == 1 } - consistencyManager.resolveConditionsWithID(id: TestUnmetCondition.CONDITIONID) + consistencyManager.resolveConditions(conditionId: TestUnmetCondition.CONDITIONID, forId: "onesignal-id") wait(for: [returned], timeout: 2.0) XCTAssertEqual(consistencyManager.waiterCount, 0) } + /// A missing token for one user must not unblock a wait registered under another. + func testResolvingOneIdLeavesAnotherIdsWaiterWaiting() { + OSConsistencyManager.waitTimeout = .milliseconds(200) + let userA = "onesignal-id-a" + let userB = "onesignal-id-b" + + let aReturned = expectation(description: "user A waiter returned") + let bReturned = expectation(description: "user B waiter returned") + DispatchQueue.global().async { + _ = self.consistencyManager.getRywTokenFromAwaitableCondition(TestUnmetCondition(), forId: userA) + aReturned.fulfill() + } + DispatchQueue.global().async { + _ = self.consistencyManager.getRywTokenFromAwaitableCondition(TestUnmetCondition(), forId: userB) + bReturned.fulfill() + } + waitUntil("both waiters registered") { self.consistencyManager.waiterCount == 2 } + + consistencyManager.resolveConditions(conditionId: TestUnmetCondition.CONDITIONID, forId: userB) + + wait(for: [bReturned], timeout: 2.0) + XCTAssertEqual(consistencyManager.waiterCount, 1, "user A's waiter must still be registered") + // Let A time out rather than leaving the thread blocked past the end of the test. + wait(for: [aReturned], timeout: 2.0) + } + func testResolvingADifferentConditionLeavesTheWaiterWaiting() { OSConsistencyManager.waitTimeout = .milliseconds(200) let returned = expectation(description: "waiter returned") @@ -319,7 +342,7 @@ class OSConsistencyManagerTests: XCTestCase { } waitUntil("waiter registered") { self.consistencyManager.waiterCount == 1 } - consistencyManager.resolveConditionsWithID(id: "SomeOtherCondition") + consistencyManager.resolveConditions(conditionId: "SomeOtherCondition", forId: "onesignal-id") XCTAssertEqual(consistencyManager.waiterCount, 1) // Let it time out rather than leaving the thread blocked past the end of the test. diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSIamFetchReadyConditionTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSIamFetchReadyConditionTests.swift index 0a7103ffa..e2c7502bc 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSIamFetchReadyConditionTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSIamFetchReadyConditionTests.swift @@ -125,7 +125,7 @@ final class OSIamFetchReadyConditionTests: XCTestCase { wait(for: [secondReturned], timeout: 2.0) } - /// The `ryw_token`-missing fallback the executors call, which names the condition rather than the user. + /// The `ryw_token`-missing fallback the executors call for that user. func testResolvingTheConditionReleasesTheFetch() { let manager = OSConsistencyManager.shared let returned = expectation(description: "fetch released") @@ -135,11 +135,59 @@ final class OSIamFetchReadyConditionTests: XCTestCase { } waitUntil("fetch waiting") { manager.waiterCount == 1 } - manager.resolveConditionsWithID(id: OSIamFetchReadyCondition.CONDITIONID) + manager.resolveConditions(conditionId: OSIamFetchReadyCondition.CONDITIONID, forId: userA) wait(for: [returned], timeout: 2.0) } + /// A missing token for one user must not clear another user's raised subscription bar. + func testResolvingOneUserDoesNotLowerAnotherUsersSubscriptionBar() { + let manager = OSConsistencyManager.shared + let conditionA = OSIamFetchReadyCondition.sharedInstance(withId: userA) + let conditionB = OSIamFetchReadyCondition.sharedInstance(withId: userB) + conditionA.setSubscriptionUpdatePending(value: true) + conditionB.setSubscriptionUpdatePending(value: true) + + let bReturned = expectation(description: "user B fetch released") + DispatchQueue.global().async { + _ = manager.getRywTokenFromAwaitableCondition(conditionB, forId: self.userB) + bReturned.fulfill() + } + waitUntil("user B waiting") { manager.waiterCount == 1 } + + manager.resolveConditions(conditionId: OSIamFetchReadyCondition.CONDITIONID, forId: userB) + wait(for: [bReturned], timeout: 2.0) + + XCTAssertFalse(conditionA.isMet(indexedTokens: [userA: userUpdateToken()]), + "user A's subscription bar must still be up") + XCTAssertTrue(conditionB.isMet(indexedTokens: [userB: userUpdateToken()]), + "user B's bar comes down with its own resolve") + } + + /// Timing out must lower the bar, or every later fetch for that id pays another full wait. + func testTimingOutLowersTheSubscriptionBar() { + OSConsistencyManager.waitTimeout = .milliseconds(200) + let manager = OSConsistencyManager.shared + let condition = OSIamFetchReadyCondition.sharedInstance(withId: userA) + condition.setSubscriptionUpdatePending(value: true) + + let firstReturned = expectation(description: "first fetch timed out") + DispatchQueue.global().async { + _ = manager.getRywTokenFromAwaitableCondition(condition, forId: self.userA) + firstReturned.fulfill() + } + wait(for: [firstReturned], timeout: 2.0) + + manager.setRywTokenAndDelay(id: userA, key: OSIamFetchOffsetKey.userUpdate, value: token("100")) + + let secondReturned = expectation(description: "second fetch released by user token alone") + DispatchQueue.global().async { + _ = manager.getRywTokenFromAwaitableCondition(condition, forId: self.userA) + secondReturned.fulfill() + } + wait(for: [secondReturned], timeout: 2.0) + } + // MARK: - Helpers private func token(_ value: String) -> OSReadYourWriteData { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift index 88f29af73..0df165df2 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift @@ -276,7 +276,7 @@ class OSPropertyOperationExecutor: OSOperationExecutor { ) } else { // handle a potential regression where ryw_token is no longer returned by API - OSConsistencyManager.shared.resolveConditionsWithID(id: OSIamFetchReadyCondition.CONDITIONID) + OSConsistencyManager.shared.resolveConditions(conditionId: OSIamFetchReadyCondition.CONDITIONID, forId: onesignalId) } } } onFailure: { error in diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift index f100bbfd9..f8c985cce 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift @@ -311,7 +311,7 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { ) } else { // handle a potential regression where ryw_token is no longer returned by API - OSConsistencyManager.shared.resolveConditionsWithID(id: OSIamFetchReadyCondition.CONDITIONID) + OSConsistencyManager.shared.resolveConditions(conditionId: OSIamFetchReadyCondition.CONDITIONID, forId: onesignalId) } } @@ -431,7 +431,7 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { ) } else { // handle a potential regression where ryw_token is no longer returned by API - OSConsistencyManager.shared.resolveConditionsWithID(id: OSIamFetchReadyCondition.CONDITIONID) + OSConsistencyManager.shared.resolveConditions(conditionId: OSIamFetchReadyCondition.CONDITIONID, forId: onesignalId) } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift index 1b8d0e5b3..477f11540 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift @@ -271,7 +271,7 @@ extension OSUserExecutor { ) } else { // handle a potential regression where ryw_token is no longer returned by API - OSConsistencyManager.shared.resolveConditionsWithID(id: OSIamFetchReadyCondition.CONDITIONID) + OSConsistencyManager.shared.resolveConditions(conditionId: OSIamFetchReadyCondition.CONDITIONID, forId: onesignalId) } } } From c09d5a9c3cb977f0b7d90c6835ff81933989032b Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 11 Aug 2026 18:29:58 -0700 Subject: [PATCH 04/21] chore: tighten RYW comments to iOS comment style Cut hazard essays and cross-user narration down to short whys hitched to the action, and trim the resolveConditions doc to the contract. Co-authored-by: Cursor --- .../Source/Consistency/OSConsistencyManager.swift | 12 ++++-------- .../OSConsistencyManagerTests.swift | 6 +++--- .../OSIamFetchReadyConditionTests.swift | 4 ++-- 3 files changed, 9 insertions(+), 13 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Consistency/OSConsistencyManager.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Consistency/OSConsistencyManager.swift index c7b29cb06..50de9c001 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Consistency/OSConsistencyManager.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Consistency/OSConsistencyManager.swift @@ -81,13 +81,11 @@ import OneSignalCore if semaphore.wait(timeout: .now() + OSConsistencyManager.waitTimeout) == .timedOut { OneSignalLog.onesignalLog(.LL_WARN, message: "OSConsistencyManager timed out waiting on \(condition.conditionId) for id: \(id)") queue.sync { - // Still registered? Then this timeout owns the clear. A concurrent met-path release - // already ran onConditionSatisfied and removed the entry. + // Skip if a met-path release already removed this waiter. guard self.indexedConditions[id]?.contains(where: { $0.1 === semaphore }) == true else { return } - // Lower any bar the condition raised for this wait; otherwise later fetches for this - // id keep paying the full timeout for a subscription token that is never coming. + // Clear so later fetches for this id are not held to a subscription token that never arrives. condition.onConditionSatisfied?() self.indexedConditions[id]?.removeAll { $0.1 === semaphore } } @@ -98,10 +96,8 @@ import OneSignalCore } /** - Releases waiters on `conditionId` that registered under `id` (e.g. onesignalId). Callers reach for - this when a response for that user came back with no `ryw_token`, which leaves those waiters with - nothing left to wait for. Scoped to `id` so a missing token for one user cannot unblock — or clear - the subscription bar of — another. + Releases waiters on `conditionId` registered under `id` (e.g. onesignalId). Used when that user's + response carried no `ryw_token`, so those waiters have nothing left to wait for. */ @objc(resolveConditionsWithConditionId:forId:) public func resolveConditions(conditionId: String, forId id: String) { diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSConsistencyManagerTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSConsistencyManagerTests.swift index bf5c969f6..2db1a1378 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSConsistencyManagerTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSConsistencyManagerTests.swift @@ -307,7 +307,7 @@ class OSConsistencyManagerTests: XCTestCase { XCTAssertEqual(consistencyManager.waiterCount, 0) } - /// A missing token for one user must not unblock a wait registered under another. + /// Resolving user B leaves user A's waiter registered. func testResolvingOneIdLeavesAnotherIdsWaiterWaiting() { OSConsistencyManager.waitTimeout = .milliseconds(200) let userA = "onesignal-id-a" @@ -329,7 +329,7 @@ class OSConsistencyManagerTests: XCTestCase { wait(for: [bReturned], timeout: 2.0) XCTAssertEqual(consistencyManager.waiterCount, 1, "user A's waiter must still be registered") - // Let A time out rather than leaving the thread blocked past the end of the test. + // Drain A's timeout so the thread is not left blocked after the test. wait(for: [aReturned], timeout: 2.0) } @@ -345,7 +345,7 @@ class OSConsistencyManagerTests: XCTestCase { consistencyManager.resolveConditions(conditionId: "SomeOtherCondition", forId: "onesignal-id") XCTAssertEqual(consistencyManager.waiterCount, 1) - // Let it time out rather than leaving the thread blocked past the end of the test. + // Drain the timeout so the thread is not left blocked after the test. wait(for: [returned], timeout: 2.0) } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSIamFetchReadyConditionTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSIamFetchReadyConditionTests.swift index e2c7502bc..68ea0b19e 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSIamFetchReadyConditionTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSIamFetchReadyConditionTests.swift @@ -140,7 +140,7 @@ final class OSIamFetchReadyConditionTests: XCTestCase { wait(for: [returned], timeout: 2.0) } - /// A missing token for one user must not clear another user's raised subscription bar. + /// Resolving user B leaves user A's subscription bar raised. func testResolvingOneUserDoesNotLowerAnotherUsersSubscriptionBar() { let manager = OSConsistencyManager.shared let conditionA = OSIamFetchReadyCondition.sharedInstance(withId: userA) @@ -164,7 +164,7 @@ final class OSIamFetchReadyConditionTests: XCTestCase { "user B's bar comes down with its own resolve") } - /// Timing out must lower the bar, or every later fetch for that id pays another full wait. + /// After a timeout, a later fetch for the same id is released by the user token alone. func testTimingOutLowersTheSubscriptionBar() { OSConsistencyManager.waitTimeout = .milliseconds(200) let manager = OSConsistencyManager.shared From 1b80146883ad4cb6539989b63e53afe3e40742ad Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 11 Aug 2026 16:44:43 -0700 Subject: [PATCH 05/21] feat: [PR3] feature flags, JWT config, and the Identity Verification gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The foundation the rest of Identity Verification is built on. Nothing consumes the gate yet, so this changes no behavior beyond the params handling below. OSUserJwtConfig holds whether the app requires a token as a three-state answer — unknown until remote params say — because "not yet known" and "not required" have to be told apart before anything can be sent. OSFeatureManager carries the flags remote params enable, and OSIdentityVerificationService is the single place that answers whether Identity Verification is on. Remote params now hydrate that requirement from jwt_required. A response that omits the key means Identity Verification is off for the app, while an empty response answers nothing and leaves the cached requirement standing. Because the operation repo will hold every queued operation until the requirement is known, a params request that failed was going to cost a first-launch app every tag, session count and event for the rest of the session: nothing retried it. It now retries with a backoff over five attempts and, failing that, tries again on the next session. Replaces the unread require_user_id_auth key with jwt_required. The requiresUserAuth property it wrote is removed in the next PR. Co-authored-by: Cursor --- .../OneSignal.xcodeproj/project.pbxproj | 51 ++++ .../Source/OneSignalCommonDefines.h | 13 +- .../Jwt/OSIdentityVerificationService.swift | 115 ++++++++ .../Source/Jwt/OSUserJwtConfig.swift | 134 ++++++++++ .../Source/OSFeatureManager.swift | 102 ++++++++ .../Feature/OSFeatureManagerTests.swift | 116 +++++++++ .../OSIdentityVerificationServiceTests.swift | 245 ++++++++++++++++++ .../Jwt/OSUserJwtConfigTests.swift | 95 +++++++ iOS_SDK/OneSignalSDK/Source/OneSignal.m | 52 +++- 9 files changed, 919 insertions(+), 4 deletions(-) create mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Jwt/OSIdentityVerificationService.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Jwt/OSUserJwtConfig.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSFeatureManager.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/Feature/OSFeatureManagerTests.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/Jwt/OSIdentityVerificationServiceTests.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/Jwt/OSUserJwtConfigTests.swift diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index 658d2d80d..33a035cce 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -51,7 +51,9 @@ 03CCCC832835D90F004BF794 /* OneSignalUNUserNotificationCenterHelper.m in Sources */ = {isa = PBXBuildFile; fileRef = 03CCCC812835D90F004BF794 /* OneSignalUNUserNotificationCenterHelper.m */; }; 03CCCC852835F291004BF794 /* UIApplicationDelegateSwizzlingTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 03CCCC842835F291004BF794 /* UIApplicationDelegateSwizzlingTests.m */; }; 03E56DD328405F4A006AA1DA /* OneSignalAppDelegateOverrider.m in Sources */ = {isa = PBXBuildFile; fileRef = 03E56DD228405F4A006AA1DA /* OneSignalAppDelegateOverrider.m */; }; + 0AA11438FBF3A82D13824467 /* OSFeatureManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7DA9EEED644F1160CAD9A38 /* OSFeatureManager.swift */; }; 16664C4C25DDB195003B8A14 /* NSTimeZoneOverrider.m in Sources */ = {isa = PBXBuildFile; fileRef = 16664C4B25DDB195003B8A14 /* NSTimeZoneOverrider.m */; }; + 2F32272222E88DF0C2C18B53 /* OSFeatureManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42E4A83C6C0D0DF28CDECA90 /* OSFeatureManagerTests.swift */; }; 37E6B2BB19D9CAF300D0C601 /* UIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 37E6B2BA19D9CAF300D0C601 /* UIKit.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; 3C0151922C2E298F0079E076 /* OneSignalInAppMessages.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DEBAAE282A4211D900BF2C1C /* OneSignalInAppMessages.framework */; }; 3C01519C2C2E29F90079E076 /* IAMRequestTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 3C01519B2C2E29F90079E076 /* IAMRequestTests.m */; }; @@ -331,6 +333,7 @@ 7AFE856B2368DDB80091D6A5 /* OSFocusCallParams.m in Sources */ = {isa = PBXBuildFile; fileRef = 7AFE856A2368DDB80091D6A5 /* OSFocusCallParams.m */; }; 7AFE856C2368DDB80091D6A5 /* OSFocusCallParams.m in Sources */ = {isa = PBXBuildFile; fileRef = 7AFE856A2368DDB80091D6A5 /* OSFocusCallParams.m */; }; 7AFE856D2368DDB80091D6A5 /* OSFocusCallParams.m in Sources */ = {isa = PBXBuildFile; fileRef = 7AFE856A2368DDB80091D6A5 /* OSFocusCallParams.m */; }; + 8E949FF4C7A7A2C7182E53EA /* OSUserJwtConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9376A4957E9090C748BCB18 /* OSUserJwtConfigTests.swift */; }; 911E2CBD1E398AB3003112A4 /* UnitTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 911E2CBC1E398AB3003112A4 /* UnitTests.m */; }; 911E2CC51E398B53003112A4 /* SystemConfiguration.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3E08E2701D49A5C8002176DE /* SystemConfiguration.framework */; }; 911E2CC61E398B97003112A4 /* UIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 37E6B2BA19D9CAF300D0C601 /* UIKit.framework */; }; @@ -366,8 +369,10 @@ 9D34853A233D2E3600EB81C9 /* OneSignalLocationOverrider.m in Sources */ = {isa = PBXBuildFile; fileRef = 9D348539233D2E3600EB81C9 /* OneSignalLocationOverrider.m */; }; 9D59C2F82321C7720008ECCF /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CACBAAAB218A662B000ACAA5 /* WebKit.framework */; }; 9D59C2F92321C7780008ECCF /* CoreGraphics.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9D3300F923149DAE000F0A83 /* CoreGraphics.framework */; }; + 9FF50E2A40C88E4533033A38 /* OSIdentityVerificationServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4855B81F170253FB0C1749D /* OSIdentityVerificationServiceTests.swift */; }; A662399326850DDE00D52FD8 /* LanguageTest.m in Sources */ = {isa = PBXBuildFile; fileRef = A662399026850DDE00D52FD8 /* LanguageTest.m */; }; A66239952686612F00D52FD8 /* OneSignalFramework.h in Headers */ = {isa = PBXBuildFile; fileRef = 912411F01E73342200E41FD7 /* OneSignalFramework.h */; settings = {ATTRIBUTES = (Public, ); }; }; + BE737361D82E74544B7A1996 /* OSUserJwtConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6552F2A6DF7776B0582CFAEF /* OSUserJwtConfig.swift */; }; CA08FC871FE99BB4004C445F /* OneSignalClientOverrider.m in Sources */ = {isa = PBXBuildFile; fileRef = CA08FC831FE99BB4004C445F /* OneSignalClientOverrider.m */; }; CA1A6E6920DC2E31001C41B9 /* OneSignalDialogController.h in Headers */ = {isa = PBXBuildFile; fileRef = CA1A6E6720DC2E31001C41B9 /* OneSignalDialogController.h */; }; CA1A6E6A20DC2E31001C41B9 /* OneSignalDialogController.m in Sources */ = {isa = PBXBuildFile; fileRef = CA1A6E6820DC2E31001C41B9 /* OneSignalDialogController.m */; }; @@ -652,6 +657,7 @@ DEFB3E632BB731BD00E65DAD /* ActivityKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DEFB3E622BB731BD00E65DAD /* ActivityKit.framework */; platformFilter = ios; }; DEFB3E652BB7346D00E65DAD /* OSLiveActivities.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEFB3E642BB7346D00E65DAD /* OSLiveActivities.swift */; }; DEFB3E672BB735B500E65DAD /* OSStubLiveActivities.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEFB3E662BB735B500E65DAD /* OSStubLiveActivities.swift */; }; + ECD9DF65FB98056806A43541 /* OSIdentityVerificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C4EAEA1BC62D8FC57927511 /* OSIdentityVerificationService.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -1332,6 +1338,7 @@ 16664C5425DDB2CB003B8A14 /* NSTimeZoneOverrider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = NSTimeZoneOverrider.h; sourceTree = ""; }; 1AF75EAC1E8567FD0097B315 /* NSString+OneSignal.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSString+OneSignal.h"; sourceTree = ""; }; 1AF75EAD1E8567FD0097B315 /* NSString+OneSignal.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSString+OneSignal.m"; sourceTree = ""; }; + 1C4EAEA1BC62D8FC57927511 /* OSIdentityVerificationService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSIdentityVerificationService.swift; sourceTree = ""; }; 37747F9319147D6500558FAD /* libOneSignal.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libOneSignal.a; sourceTree = BUILT_PRODUCTS_DIR; }; 37E6B2BA19D9CAF300D0C601 /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; }; 3C01518E2C2E298E0079E076 /* OneSignalInAppMessagesTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OneSignalInAppMessagesTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -1483,6 +1490,7 @@ 3E2400381D4FFC31008BDE70 /* OneSignalFramework.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = OneSignalFramework.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 3E24003B1D4FFC31008BDE70 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 3E464ED91D88EE6A00DCF7E9 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = System/Library/Frameworks/Foundation.framework; sourceTree = SDKROOT; }; + 42E4A83C6C0D0DF28CDECA90 /* OSFeatureManagerTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSFeatureManagerTests.swift; sourceTree = ""; }; 4529DED01FA81EA800CEAB1D /* NSObjectOverrider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = NSObjectOverrider.h; sourceTree = ""; }; 4529DED11FA81EA800CEAB1D /* NSObjectOverrider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = NSObjectOverrider.m; sourceTree = ""; }; 4529DED31FA823B900CEAB1D /* TestHelperFunctions.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = TestHelperFunctions.h; sourceTree = ""; }; @@ -1533,6 +1541,7 @@ 5BC1DE612C90B85A00CA8807 /* OSIamFetchOffsetKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSIamFetchOffsetKey.swift; sourceTree = ""; }; 5BC1DE632C90BB9000CA8807 /* OSIamFetchReadyCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSIamFetchReadyCondition.swift; sourceTree = ""; }; 5BC1DE672C90C23E00CA8807 /* OSConsistencyManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSConsistencyManagerTests.swift; sourceTree = ""; }; + 6552F2A6DF7776B0582CFAEF /* OSUserJwtConfig.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSUserJwtConfig.swift; sourceTree = ""; }; 7A123294235DFE3B002B6CE3 /* OutcomeTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OutcomeTests.m; sourceTree = ""; }; 7A12EBD523060A6F005C4FA5 /* OSSessionManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OSSessionManager.m; sourceTree = ""; }; 7A12EBD623060A6F005C4FA5 /* OSSessionManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OSSessionManager.h; sourceTree = ""; }; @@ -1639,6 +1648,7 @@ 9D348538233D2DCF00EB81C9 /* OneSignalLocationOverrider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OneSignalLocationOverrider.h; sourceTree = ""; }; 9D348539233D2E3600EB81C9 /* OneSignalLocationOverrider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OneSignalLocationOverrider.m; sourceTree = ""; }; A662399026850DDE00D52FD8 /* LanguageTest.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = LanguageTest.m; sourceTree = ""; }; + C7DA9EEED644F1160CAD9A38 /* OSFeatureManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSFeatureManager.swift; sourceTree = ""; }; CA08FC821FE99BB4004C445F /* OneSignalClientOverrider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OneSignalClientOverrider.h; sourceTree = ""; }; CA08FC831FE99BB4004C445F /* OneSignalClientOverrider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OneSignalClientOverrider.m; sourceTree = ""; }; CA1A6E6720DC2E31001C41B9 /* OneSignalDialogController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OneSignalDialogController.h; sourceTree = ""; }; @@ -1837,6 +1847,8 @@ DEFB3E622BB731BD00E65DAD /* ActivityKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ActivityKit.framework; path = System/Library/Frameworks/ActivityKit.framework; sourceTree = SDKROOT; }; DEFB3E642BB7346D00E65DAD /* OSLiveActivities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLiveActivities.swift; sourceTree = ""; }; DEFB3E662BB735B500E65DAD /* OSStubLiveActivities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSStubLiveActivities.swift; sourceTree = ""; }; + E9376A4957E9090C748BCB18 /* OSUserJwtConfigTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSUserJwtConfigTests.swift; sourceTree = ""; }; + F4855B81F170253FB0C1749D /* OSIdentityVerificationServiceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSIdentityVerificationServiceTests.swift; sourceTree = ""; }; FF4B19D1EC31C0750F13065A /* OSIamFetchReadyConditionTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSIamFetchReadyConditionTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -2277,6 +2289,8 @@ 4710EA522B8FCFB200435356 /* OSDispatchQueue.swift */, DEFB3E642BB7346D00E65DAD /* OSLiveActivities.swift */, DEFB3E662BB735B500E65DAD /* OSStubLiveActivities.swift */, + A843B922174496E99F2D00A8 /* Jwt */, + C7DA9EEED644F1160CAD9A38 /* OSFeatureManager.swift */, ); path = Source; sourceTree = ""; @@ -2555,6 +2569,15 @@ path = OneSignalLiveActivitiesFramework; sourceTree = ""; }; + 4795885CE6CFFB1998AC7D09 /* Feature */ = { + isa = PBXGroup; + children = ( + 42E4A83C6C0D0DF28CDECA90 /* OSFeatureManagerTests.swift */, + ); + name = Feature; + path = Feature; + sourceTree = ""; + }; 5B053FB92CAE07EB002F30C4 /* OneSignalOSCoreTests */ = { isa = PBXGroup; children = ( @@ -2566,10 +2589,22 @@ 3C23A21C2FCE0A83001D32E3 /* OSModelStoreRefreshTests.swift */, FF4B19D1EC31C0750F13065A /* OSIamFetchReadyConditionTests.swift */, 03A81CCA8DFA43C8AEA738B8 /* XCTestCase+WaitUntil.swift */, + 4795885CE6CFFB1998AC7D09 /* Feature */, + 5B47CE0CE255AC7128442FFF /* Jwt */, ); path = OneSignalOSCoreTests; sourceTree = ""; }; + 5B47CE0CE255AC7128442FFF /* Jwt */ = { + isa = PBXGroup; + children = ( + F4855B81F170253FB0C1749D /* OSIdentityVerificationServiceTests.swift */, + E9376A4957E9090C748BCB18 /* OSUserJwtConfigTests.swift */, + ); + name = Jwt; + path = Jwt; + sourceTree = ""; + }; 5BC1DE652C90BC9F00CA8807 /* Consistency */ = { isa = PBXGroup; children = ( @@ -2721,6 +2756,16 @@ name = Categories; sourceTree = ""; }; + A843B922174496E99F2D00A8 /* Jwt */ = { + isa = PBXGroup; + children = ( + 1C4EAEA1BC62D8FC57927511 /* OSIdentityVerificationService.swift */, + 6552F2A6DF7776B0582CFAEF /* OSUserJwtConfig.swift */, + ); + name = Jwt; + path = Jwt; + sourceTree = ""; + }; DE51DDE2294262670073D5C4 /* RemoteParameters */ = { isa = PBXGroup; children = ( @@ -4452,6 +4497,9 @@ 3C11518D289AF5E800565C41 /* OSModelChangedHandler.swift in Sources */, 3C14E3B32FAE54C006ED053 /* OSLoggerPlatformProvider.swift in Sources */, 3C8E6DF928A6D89E0031E48A /* OSOperationExecutor.swift in Sources */, + ECD9DF65FB98056806A43541 /* OSIdentityVerificationService.swift in Sources */, + BE737361D82E74544B7A1996 /* OSUserJwtConfig.swift in Sources */, + 0AA11438FBF3A82D13824467 /* OSFeatureManager.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -4593,6 +4641,9 @@ 3C23A21B2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift in Sources */, DD2A89A8052E2D1912B0038B /* OSIamFetchReadyConditionTests.swift in Sources */, 50C4F7CFEC46836131011004 /* XCTestCase+WaitUntil.swift in Sources */, + 2F32272222E88DF0C2C18B53 /* OSFeatureManagerTests.swift in Sources */, + 9FF50E2A40C88E4533033A38 /* OSIdentityVerificationServiceTests.swift in Sources */, + 8E949FF4C7A7A2C7182E53EA /* OSUserJwtConfigTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h index 6b2c48a24..3dc2fab3e 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h +++ b/iOS_SDK/OneSignalSDK/OneSignalCore/Source/OneSignalCommonDefines.h @@ -77,6 +77,8 @@ // Remote Params #define OSUD_LOCATION_ENABLED @"OSUD_LOCATION_ENABLED" #define OSUD_REQUIRES_USER_PRIVACY_CONSENT @"OSUD_REQUIRES_USER_PRIVACY_CONSENT" +#define OSUD_USE_IDENTITY_VERIFICATION @"OSUD_USE_IDENTITY_VERIFICATION" +#define OSUD_SDK_FEATURE_FLAGS @"OSUD_SDK_FEATURE_FLAGS" // Remote Params - Receive Receipts #define OSUD_RECEIVE_RECEIPTS_ENABLED @"OS_ENABLE_RECEIVE_RECEIPTS" // * OSUD_RECEIVE_RECEIPTS_ENABLED // Outcomes @@ -135,7 +137,7 @@ #define IOS_USES_PROVISIONAL_AUTHORIZATION @"uses_provisional_auth" #define IOS_REQUIRES_EMAIL_AUTHENTICATION @"require_email_auth" #define IOS_REQUIRES_SMS_AUTHENTICATION @"require_sms_auth" -#define IOS_REQUIRES_USER_ID_AUTHENTICATION @"require_user_id_auth" +#define IOS_JWT_REQUIRED @"jwt_required" #define IOS_RECEIVE_RECEIPTS_ENABLE @"receive_receipts_enable" #define IOS_OUTCOMES_V2_SERVICE_ENABLE @"v2_enabled" #define IOS_LOCATION_SHARED @"location_shared" @@ -324,6 +326,15 @@ typedef enum {GET, POST, HEAD, PUT, DELETE, OPTIONS, CONNECT, TRACE, PATCH} HTTP #define OS_SNAPSHOT_ONESIGNAL_ID @"OS_SNAPSHOT_ONESIGNAL_ID" #define OS_SNAPSHOT_EXTERNAL_ID @"OS_SNAPSHOT_EXTERNAL_ID" +/* Identity Verification */ +#define OS_JWT_BEARER_TOKEN @"OS_JWT_BEARER_TOKEN" +#define OS_JWT_TOKEN_INVALID @"OS_JWT_TOKEN_INVALID" + +// Posted when work held for want of an answer can be reattempted: the requirement hydrated, or the app +// supplied a token. Listeners re-read the current state rather than take anything from the post. +#define OS_ON_JWT_CONFIG_HYDRATED @"OS_ON_JWT_CONFIG_HYDRATED" +#define OS_ON_USER_JWT_UPDATED @"OS_ON_USER_JWT_UPDATED" + // Models and Model Stores #define OS_IDENTITY_MODEL_KEY @"OS_IDENTITY_MODEL_KEY" #define OS_IDENTITY_MODEL_STORE_KEY @"OS_IDENTITY_MODEL_STORE_KEY" diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Jwt/OSIdentityVerificationService.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Jwt/OSIdentityVerificationService.swift new file mode 100644 index 000000000..6faffc2e2 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Jwt/OSIdentityVerificationService.swift @@ -0,0 +1,115 @@ +/* + 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 + +/// Who is waiting on hydration. Keyed so re-registering replaces rather than stacking a duplicate. +public enum OSJwtConfigHydratedObserver { + case userExecutor + case operationRepo + case userManager +} + +/** + Decides Identity Verification gating from the rollout feature flag and the app's `jwt_required` + setting. + + - `ivBehaviorActive`: whether IV behavior is in effect (JWT on requests, `external_id` alias, 401 + handling). + - `newCodePathsRun`: whether IV code paths should run at all (feature flag, or always when the app + requires auth). + - `requirement`: use when you must tell `unknown` apart from `off` — both booleans are `false` while + the requirement is unknown, which is fine for apps that do not require Identity Verification, but + callers that must not send an unsigned request should wait until it is known. + */ +public final class OSIdentityVerificationService { + private let featureManager: OSFeatureManagerProtocol + private let jwtConfig: OSUserJwtConfig + + private let handlerLock = NSLock() + // Ordered by registration: User executor's held Create User before Deltas that need its onesignal_id. + private var jwtConfigHydratedHandlers: [(observer: OSJwtConfigHydratedObserver, handler: (OSRequiresUserAuth) -> Void)] = [] + + /// The raw `jwt_required` value, including `unknown` before remote params arrive. + public var requirement: OSRequiresUserAuth { + return jwtConfig.requirement + } + + /// Whether Identity Verification behavior applies: JWT on requests, `external_id` alias, 401 handling. + public var ivBehaviorActive: Bool { + return jwtConfig.requirement == .on + } + + /// Whether the new Identity Verification code paths run at all. An app that requires auth is always in, + /// no matter how the rollout flag is set. + public var newCodePathsRun: Bool { + return featureManager.isEnabled(.identityVerification) || ivBehaviorActive + } + + public init(featureManager: OSFeatureManagerProtocol, jwtConfig: OSUserJwtConfig) { + self.featureManager = featureManager + self.jwtConfig = jwtConfig + jwtConfig.setOnHydratedHandler { [weak self] requirement in + self?.fireJwtConfigHydrated(requirement) + } + } + + /** + Fires on every hydration, including an unchanged value — deferred work waits on that. A handler + registered after `requirement` is already known runs immediately, since that hydration is not repeated. + */ + public func addOnJwtConfigHydratedHandler(for observer: OSJwtConfigHydratedObserver, _ handler: @escaping (OSRequiresUserAuth) -> Void) { + handlerLock.withLock { + if let index = jwtConfigHydratedHandlers.firstIndex(where: { $0.observer == observer }) { + jwtConfigHydratedHandlers[index] = (observer, handler) + } else { + jwtConfigHydratedHandlers.append((observer, handler)) + } + } + + let alreadyKnown = jwtConfig.requirement + guard alreadyKnown != .unknown else { + return + } + handler(alreadyKnown) + } + + public func removeOnJwtConfigHydratedHandler(for observer: OSJwtConfigHydratedObserver) { + handlerLock.withLock { + jwtConfigHydratedHandlers.removeAll { $0.observer == observer } + } + } + + private func fireJwtConfigHydrated(_ requirement: OSRequiresUserAuth) { + // Snapshot: a handler can register another, and handlers take locks of their own. + let handlers = handlerLock.withLock { jwtConfigHydratedHandlers } + for entry in handlers { + entry.handler(requirement) + } + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Jwt/OSUserJwtConfig.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Jwt/OSUserJwtConfig.swift new file mode 100644 index 000000000..c4ad37f66 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Jwt/OSUserJwtConfig.swift @@ -0,0 +1,134 @@ +/* + 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 + +/** + Whether the app requires Identity Verification, mirroring the `jwt_required` remote param. + `unknown` has to stay distinguishable from `off` so nothing is sent unsigned on behalf of an app + that turns out to require auth. + */ +public enum OSRequiresUserAuth: Int { + // Raw values are cached, and 0 is what UserDefaults returns for a missing integer, so `unknown` owns it. + case on = 1 + case off = -1 + case unknown = 0 +} + +/** + Holds the Identity Verification requirement and caches it across launches. + Deliberately knows nothing about gating: `OSIdentityVerificationService` makes every such decision + and is the only observer here. + */ +@objc(OSUserJwtConfig) +public final class OSUserJwtConfig: NSObject { + /** + Remote params hydrate the requirement from `OneSignal.m`, which runs before the User Manager is + started, and keeps running in sessions where it never starts at all because consent is pending. + Reaching the requirement through a shared instance keeps that path from constructing the User + Manager just to hand over a boolean. + + Only `OneSignal.m` and `OneSignalUserManagerImpl` should reference this. Everything below them — + the operation repo, the executors, the request layer — is handed the config when it is created, + which keeps the shared instance contained to the two places that cannot avoid it. + */ + @objc public static let shared = OSUserJwtConfig() + + private let lock = NSLock() + private var _requirement: OSRequiresUserAuth + private var onHydrated: ((OSRequiresUserAuth) -> Void)? + + public var requirement: OSRequiresUserAuth { + return lock.withLock { _requirement } + } + + public override init() { + _requirement = OSUserJwtConfig.cachedRequirement() + super.init() + } + + /** + Applies the requirement carried by a successful remote params response. A response that omits + `jwt_required` means the app has Identity Verification off, so callers pass `false` for it rather + than leaving the requirement unknown. A response with no body at all answers nothing, so callers + skip this and leave the cached requirement in place. + */ + @objc + public func hydrate(requiresUserAuth: Bool) { + let hydrated: OSRequiresUserAuth = requiresUserAuth ? .on : .off + // Keep the log and the handler out of the lock; either can re-enter and read the requirement. + let (previous, handler) = lock.withLock { () -> (OSRequiresUserAuth, ((OSRequiresUserAuth) -> Void)?) in + let previous = _requirement + _requirement = hydrated + // Written even when the value is unchanged, so a launch whose write was dropped by locked + // storage still ends up with the requirement on disk. + OneSignalUserDefaults.initShared().saveInteger(forKey: OSUD_USE_IDENTITY_VERIFICATION, withValue: hydrated.rawValue) + return (previous, onHydrated) + } + if previous != hydrated { + OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OSUserJwtConfig requirement changed from \(previous) to \(hydrated)") + } + // Fires even when the value is unchanged, because work deferred while the requirement was + // unknown is waiting on this to run. + handler?(hydrated) + } + + /** + Re-reads the cached requirement while it is still unknown. The read in `init` can land during an + app prewarm, before first unlock, when UserDefaults silently returns nothing. + */ + public func refreshIfUnknown() { + lock.withLock { + guard _requirement == .unknown else { + return + } + _requirement = OSUserJwtConfig.cachedRequirement() + } + } + + /// The Identity Verification service is the sole observer, so a second registration replaces the first. + func setOnHydratedHandler(_ handler: ((OSRequiresUserAuth) -> Void)?) { + lock.withLock { onHydrated = handler } + } + + /** + Test seam. The shared instance outlives a test case, so a test + that hydrates the requirement has to hand the next one an unhydrated config, and `hydrate` can + only reach `on` or `off`. Leaves the hydrated handler alone: the Identity Verification service + that registered it is built once and lives as long as the process. + */ + func resetRequirementToUnknownForTests() { + lock.withLock { _requirement = .unknown } + } + + private static func cachedRequirement() -> OSRequiresUserAuth { + let rawValue = OneSignalUserDefaults.initShared().getSavedInteger(forKey: OSUD_USE_IDENTITY_VERIFICATION, + defaultValue: OSRequiresUserAuth.unknown.rawValue) + return OSRequiresUserAuth(rawValue: rawValue) ?? .unknown + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSFeatureManager.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSFeatureManager.swift new file mode 100644 index 000000000..1283f7fd4 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSFeatureManager.swift @@ -0,0 +1,102 @@ +/* + Modified MIT License + + Copyright 2026 OneSignal + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + 1. The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + 2. All copies of substantial portions of the Software may only be used in connection + with services provided by OneSignal. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ + +import Foundation +import OneSignalCore + +/// Backend-driven feature switches keyed by remote-config identifiers. +public enum OSFeatureFlag: String { + case identityVerification = "sdk_identity_verification" +} + +public protocol OSFeatureManagerProtocol: AnyObject { + func isEnabled(_ feature: OSFeatureFlag) -> Bool +} + +/** + Resolves which features are enabled for this run. + Flags take effect as soon as they are set so a kill switch does not need a cold start; the set is + cached so a launch that has not yet fetched remote config keeps the last known values. + */ +public final class OSFeatureManager: OSFeatureManagerProtocol { + /// Shared for the same reason as `OSUserJwtConfig.shared`, and under the same rule: only + /// `OneSignalUserManagerImpl` references it today, and `OneSignal.m` will once remote params + /// deliver flag keys. Everything below them is handed the feature manager when it is created. + public static let shared = OSFeatureManager() + + private let lock = NSLock() + private var enabledKeys: Set + + public init() { + enabledKeys = OSFeatureManager.cachedKeys() + } + + /// Bypasses the cache so tests can force flags on. + public init(enabledKeys: Set) { + self.enabledKeys = Set(enabledKeys.map(OSFeatureManager.canonicalize)) + } + + public func isEnabled(_ feature: OSFeatureFlag) -> Bool { + return lock.withLock { + enabledKeys.contains(feature.rawValue) + } + } + + public func setEnabledFeatureKeys(_ keys: [String]) { + let canonical = Set(keys.map(OSFeatureManager.canonicalize)) + lock.withLock { + enabledKeys = canonical + OneSignalUserDefaults.initShared().saveObject(forKey: OSUD_SDK_FEATURE_FLAGS, withValue: Array(canonical)) + } + } + + /** + Re-reads the cached keys while none are known, closing the same prewarm gap as + `OSUserJwtConfig.refreshIfUnknown`. An app with every flag off reads the cache a second time, which + is cheaper than keeping enough state to tell that case apart from a read that came back empty. + */ + public func refreshIfEmpty() { + lock.withLock { + guard enabledKeys.isEmpty else { + return + } + enabledKeys = OSFeatureManager.cachedKeys() + } + } + + // Fold case so a differently cased remote key still matches a flag. + private static func canonicalize(_ key: String) -> String { + return key.lowercased() + } + + private static func cachedKeys() -> Set { + guard let cached = OneSignalUserDefaults.initShared().getSavedObject(forKey: OSUD_SDK_FEATURE_FLAGS, defaultValue: nil) as? [String] else { + return [] + } + return Set(cached.map(canonicalize)) + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/Feature/OSFeatureManagerTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/Feature/OSFeatureManagerTests.swift new file mode 100644 index 000000000..c469749e8 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/Feature/OSFeatureManagerTests.swift @@ -0,0 +1,116 @@ +/* + 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 XCTest +import OneSignalCore +@testable import OneSignalOSCore + +final class OSFeatureManagerTests: XCTestCase { + + override func setUp() { + super.setUp() + clearCachedKeys() + } + + override func tearDown() { + clearCachedKeys() + super.tearDown() + } + + private func clearCachedKeys() { + OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_SDK_FEATURE_FLAGS) + } + + func testFlagsAreOffWithoutRemoteConfig() { + XCTAssertFalse(OSFeatureManager().isEnabled(.identityVerification)) + } + + func testSettingTheKeyEnablesTheFlag() { + let featureManager = OSFeatureManager() + featureManager.setEnabledFeatureKeys([OSFeatureFlag.identityVerification.rawValue]) + + XCTAssertTrue(featureManager.isEnabled(.identityVerification)) + } + + func testEnabledKeysAreReadBackOnTheNextLaunch() { + OSFeatureManager().setEnabledFeatureKeys([OSFeatureFlag.identityVerification.rawValue]) + + XCTAssertTrue(OSFeatureManager().isEnabled(.identityVerification)) + } + + func testUnrecognizedKeysDontEnableAnything() { + let featureManager = OSFeatureManager() + featureManager.setEnabledFeatureKeys(["sdk_some_future_feature"]) + + XCTAssertFalse(featureManager.isEnabled(.identityVerification)) + } + + func testKeysMatchRegardlessOfCase() { + let featureManager = OSFeatureManager() + featureManager.setEnabledFeatureKeys(["SDK_Identity_Verification"]) + + XCTAssertTrue(featureManager.isEnabled(.identityVerification)) + } + + func testDroppingTheKeyTurnsTheFlagOffWithoutARelaunch() { + let featureManager = OSFeatureManager() + featureManager.setEnabledFeatureKeys([OSFeatureFlag.identityVerification.rawValue]) + + featureManager.setEnabledFeatureKeys([]) + + // The kill switch has to land in the current run, and it can't come back on the next one + XCTAssertFalse(featureManager.isEnabled(.identityVerification)) + XCTAssertFalse(OSFeatureManager().isEnabled(.identityVerification)) + } + + func testRefreshAdoptsCachedKeysThatTheInitialReadMissed() { + let featureManager = OSFeatureManager() + OSFeatureManager().setEnabledFeatureKeys([OSFeatureFlag.identityVerification.rawValue]) + + featureManager.refreshIfEmpty() + + XCTAssertTrue(featureManager.isEnabled(.identityVerification)) + } + + func testRefreshLeavesKeysThatAreAlreadyKnownAlone() { + let featureManager = OSFeatureManager(enabledKeys: [OSFeatureFlag.identityVerification.rawValue]) + OSFeatureManager().setEnabledFeatureKeys([]) + + featureManager.refreshIfEmpty() + + XCTAssertTrue(featureManager.isEnabled(.identityVerification)) + } + + func testKeysPassedToTheInitializerBypassTheCache() { + OSFeatureManager().setEnabledFeatureKeys([]) + + let featureManager = OSFeatureManager(enabledKeys: [OSFeatureFlag.identityVerification.rawValue]) + + XCTAssertTrue(featureManager.isEnabled(.identityVerification)) + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/Jwt/OSIdentityVerificationServiceTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/Jwt/OSIdentityVerificationServiceTests.swift new file mode 100644 index 000000000..1e9bc5a31 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/Jwt/OSIdentityVerificationServiceTests.swift @@ -0,0 +1,245 @@ +/* + Modified MIT License + + Copyright 2026 OneSignal + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + 1. The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + 2. All copies of substantial portions of the Software may only be used in connection + with services provided by OneSignal. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ + +import Foundation +import XCTest +import OneSignalCore +@testable import OneSignalOSCore + +final class OSIdentityVerificationServiceTests: XCTestCase { + + private var jwtConfig = OSUserJwtConfig() + private var featureManager = OSFeatureManager(enabledKeys: []) + + override func setUp() { + super.setUp() + clearCache() + jwtConfig = OSUserJwtConfig() + featureManager = OSFeatureManager(enabledKeys: []) + } + + override func tearDown() { + clearCache() + super.tearDown() + } + + private func clearCache() { + OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_USE_IDENTITY_VERIFICATION) + OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_SDK_FEATURE_FLAGS) + } + + private func makeService() -> OSIdentityVerificationService { + return OSIdentityVerificationService(featureManager: featureManager, jwtConfig: jwtConfig) + } + + // MARK: - Gates + + func testBothGatesAreOffForAnAppWithoutTheFlagOrTheRequirement() { + let service = makeService() + jwtConfig.hydrate(requiresUserAuth: false) + + XCTAssertFalse(service.newCodePathsRun) + XCTAssertFalse(service.ivBehaviorActive) + } + + func testTheFlagAloneRunsTheNewCodePathsWithoutTurningOnTheBehavior() { + featureManager = OSFeatureManager(enabledKeys: [OSFeatureFlag.identityVerification.rawValue]) + let service = makeService() + jwtConfig.hydrate(requiresUserAuth: false) + + XCTAssertTrue(service.newCodePathsRun) + XCTAssertFalse(service.ivBehaviorActive) + } + + func testAnAppThatRequiresAuthIsGatedInRegardlessOfTheFlag() { + let service = makeService() + jwtConfig.hydrate(requiresUserAuth: true) + + XCTAssertTrue(service.newCodePathsRun) + XCTAssertTrue(service.ivBehaviorActive) + } + + func testBothGatesAreOffBeforeRemoteParamsAreRead() { + let service = makeService() + + XCTAssertFalse(service.newCodePathsRun) + XCTAssertFalse(service.ivBehaviorActive) + } + + func testAnUnknownRequirementStaysVisibleWhileTheFlagRunsTheNewCodePaths() { + featureManager = OSFeatureManager(enabledKeys: [OSFeatureFlag.identityVerification.rawValue]) + let service = makeService() + + XCTAssertTrue(service.newCodePathsRun) + // Neither gate can tell unknown from off, so callers about to send unsigned work read this instead + XCTAssertFalse(service.ivBehaviorActive) + XCTAssertEqual(service.requirement, .unknown) + } + + func testGatesFollowTheFlagWithinTheSameRun() { + let realFeatureManager = OSFeatureManager() + let service = OSIdentityVerificationService(featureManager: realFeatureManager, jwtConfig: jwtConfig) + jwtConfig.hydrate(requiresUserAuth: false) + + realFeatureManager.setEnabledFeatureKeys([OSFeatureFlag.identityVerification.rawValue]) + XCTAssertTrue(service.newCodePathsRun) + + realFeatureManager.setEnabledFeatureKeys([]) + XCTAssertFalse(service.newCodePathsRun) + } + + // MARK: - Hydration handler + + func testTheHandlerRunsForEveryHydration() { + let service = makeService() + var requirements: [OSRequiresUserAuth] = [] + service.addOnJwtConfigHydratedHandler(for: .operationRepo) { requirements.append($0) } + + jwtConfig.hydrate(requiresUserAuth: true) + jwtConfig.hydrate(requiresUserAuth: true) + + // Work deferred while the requirement was unknown needs the repeat too, not just the change + XCTAssertEqual(requirements, [.on, .on]) + } + + func testTheHandlerReceivesTheHydratedRequirement() { + let service = makeService() + var requirement: OSRequiresUserAuth? + service.addOnJwtConfigHydratedHandler(for: .operationRepo) { requirement = $0 } + + jwtConfig.hydrate(requiresUserAuth: false) + + XCTAssertEqual(requirement, .off) + } + + func testRemovingTheHandlerStopsTheCallbacks() { + let service = makeService() + var callCount = 0 + service.addOnJwtConfigHydratedHandler(for: .operationRepo) { _ in callCount += 1 } + service.removeOnJwtConfigHydratedHandler(for: .operationRepo) + + jwtConfig.hydrate(requiresUserAuth: true) + + XCTAssertEqual(callCount, 0) + } + + func testAHandlerRegisteredAfterHydrationRunsImmediately() { + let service = makeService() + jwtConfig.hydrate(requiresUserAuth: true) + + var requirement: OSRequiresUserAuth? + service.addOnJwtConfigHydratedHandler(for: .operationRepo) { requirement = $0 } + + // Remote params can return before the repo subscribes, and that hydration does not come again + XCTAssertEqual(requirement, .on) + } + + func testAHandlerRegisteredBeforeRemoteParamsWaitsForThem() { + let service = makeService() + var callCount = 0 + + service.addOnJwtConfigHydratedHandler(for: .operationRepo) { _ in callCount += 1 } + + XCTAssertEqual(callCount, 0) + } + + func testHydratingAfterTheServiceIsReleasedIsANoOp() { + var service: OSIdentityVerificationService? = makeService() + var callCount = 0 + service?.addOnJwtConfigHydratedHandler(for: .operationRepo) { _ in callCount += 1 } + service = nil + + jwtConfig.hydrate(requiresUserAuth: true) + + XCTAssertEqual(callCount, 0) + } + + // MARK: - Multiple observers + + /// The User executor and the operation repo both wait on hydration; neither may displace the other. + func testEveryObserverIsNotified() { + let service = makeService() + var notified: [OSJwtConfigHydratedObserver] = [] + service.addOnJwtConfigHydratedHandler(for: .userExecutor) { _ in notified.append(.userExecutor) } + service.addOnJwtConfigHydratedHandler(for: .operationRepo) { _ in notified.append(.operationRepo) } + + jwtConfig.hydrate(requiresUserAuth: true) + + XCTAssertEqual(notified, [.userExecutor, .operationRepo]) + } + + /// Registration order, so a held Create User goes out before Deltas that need its `onesignal_id`. + func testObserversAreNotifiedInRegistrationOrder() { + let service = makeService() + var notified: [OSJwtConfigHydratedObserver] = [] + service.addOnJwtConfigHydratedHandler(for: .operationRepo) { _ in notified.append(.operationRepo) } + service.addOnJwtConfigHydratedHandler(for: .userExecutor) { _ in notified.append(.userExecutor) } + + jwtConfig.hydrate(requiresUserAuth: true) + + XCTAssertEqual(notified, [.operationRepo, .userExecutor]) + } + + /// A rebuilt observer replaces its own registration rather than leaving the old closure behind. + func testReRegisteringTheSameObserverReplacesIt() { + let service = makeService() + var firstCallCount = 0 + var secondCallCount = 0 + service.addOnJwtConfigHydratedHandler(for: .userExecutor) { _ in firstCallCount += 1 } + service.addOnJwtConfigHydratedHandler(for: .userExecutor) { _ in secondCallCount += 1 } + + jwtConfig.hydrate(requiresUserAuth: true) + + XCTAssertEqual(firstCallCount, 0) + XCTAssertEqual(secondCallCount, 1) + } + + /// Replacing keeps the original position, so ordering does not shift under a rebuild. + func testReplacingAnObserverKeepsItsPosition() { + let service = makeService() + var notified: [String] = [] + service.addOnJwtConfigHydratedHandler(for: .userExecutor) { _ in notified.append("user-executor-original") } + service.addOnJwtConfigHydratedHandler(for: .operationRepo) { _ in notified.append("operation-repo") } + service.addOnJwtConfigHydratedHandler(for: .userExecutor) { _ in notified.append("user-executor-replacement") } + + jwtConfig.hydrate(requiresUserAuth: true) + + XCTAssertEqual(notified, ["user-executor-replacement", "operation-repo"]) + } + + func testRemovingOneObserverLeavesTheOther() { + let service = makeService() + var notified: [OSJwtConfigHydratedObserver] = [] + service.addOnJwtConfigHydratedHandler(for: .userExecutor) { _ in notified.append(.userExecutor) } + service.addOnJwtConfigHydratedHandler(for: .operationRepo) { _ in notified.append(.operationRepo) } + service.removeOnJwtConfigHydratedHandler(for: .userExecutor) + + jwtConfig.hydrate(requiresUserAuth: true) + + XCTAssertEqual(notified, [.operationRepo]) + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/Jwt/OSUserJwtConfigTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/Jwt/OSUserJwtConfigTests.swift new file mode 100644 index 000000000..1d536c65a --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/Jwt/OSUserJwtConfigTests.swift @@ -0,0 +1,95 @@ +/* + 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 XCTest +import OneSignalCore +@testable import OneSignalOSCore + +final class OSUserJwtConfigTests: XCTestCase { + + override func setUp() { + super.setUp() + clearCachedRequirement() + } + + override func tearDown() { + clearCachedRequirement() + super.tearDown() + } + + private func clearCachedRequirement() { + OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_USE_IDENTITY_VERIFICATION) + } + + private func cacheRequirement(_ requirement: OSRequiresUserAuth) { + OneSignalUserDefaults.initShared().saveInteger(forKey: OSUD_USE_IDENTITY_VERIFICATION, withValue: requirement.rawValue) + } + + func testRequirementIsUnknownUntilRemoteParamsAreRead() { + XCTAssertEqual(OSUserJwtConfig().requirement, .unknown) + } + + func testHydratedRequirementIsReadBackOnTheNextLaunch() { + OSUserJwtConfig().hydrate(requiresUserAuth: true) + XCTAssertEqual(OSUserJwtConfig().requirement, .on) + + // `off` has to be cached as its own value, not as the absence of one + OSUserJwtConfig().hydrate(requiresUserAuth: false) + XCTAssertEqual(OSUserJwtConfig().requirement, .off) + } + + func testRefreshAdoptsACachedRequirementThatTheInitialReadMissed() { + let jwtConfig = OSUserJwtConfig() + cacheRequirement(.on) + + jwtConfig.refreshIfUnknown() + + XCTAssertEqual(jwtConfig.requirement, .on) + } + + func testRefreshLeavesAKnownRequirementAlone() { + let jwtConfig = OSUserJwtConfig() + jwtConfig.hydrate(requiresUserAuth: false) + cacheRequirement(.on) + + jwtConfig.refreshIfUnknown() + + XCTAssertEqual(jwtConfig.requirement, .off) + } + + func testHydrateCachesTheRequirementEvenWhenTheValueIsUnchanged() { + let jwtConfig = OSUserJwtConfig() + jwtConfig.hydrate(requiresUserAuth: true) + // Stands in for a write that locked storage dropped, leaving memory and disk out of step + clearCachedRequirement() + + jwtConfig.hydrate(requiresUserAuth: true) + + XCTAssertEqual(OSUserJwtConfig().requirement, .on) + } +} diff --git a/iOS_SDK/OneSignalSDK/Source/OneSignal.m b/iOS_SDK/OneSignalSDK/Source/OneSignal.m index 7a128f170..4e8be121d 100755 --- a/iOS_SDK/OneSignalSDK/Source/OneSignal.m +++ b/iOS_SDK/OneSignalSDK/Source/OneSignal.m @@ -127,6 +127,16 @@ + (BOOL)downloadedParameters { return _downloadedParameters; } +/* + Remote params carry `jwt_required`, and the operation repo holds every queued operation until it knows + that value. A first launch has no cached answer to fall back on, so a params request that fails and is + never retried costs the app every tag, session count and event for the rest of the session. Retry with + a backoff, then leave it to the next session. + */ +static int _downloadParametersAttempts = 0; +static const int MAX_DOWNLOAD_PARAMETERS_ATTEMPTS = 5; +static const NSTimeInterval DOWNLOAD_PARAMETERS_RETRY_BASE_SECONDS = 5.0; + static OneSignalReceiveReceiptsController* _receiveReceiptsController; + (OneSignalReceiveReceiptsController*)receiveReceiptsController { if (!_receiveReceiptsController) @@ -147,7 +157,8 @@ + (void)clearStatics { _downloadedParameters = false; _didCallDownloadParameters = false; - + _downloadParametersAttempts = 0; + // sessionLaunchTime = [NSDate date]; [OSOutcomes clearStatics]; @@ -359,6 +370,12 @@ + (void)startNewSessionInternal { if ([OneSignalConfig shouldAwaitAppIdAndLogMissingPrivacyConsentForMethod:nil]) return; + // A new session is a fresh chance at params after the launch attempt exhausted its retries + if (!_downloadedParameters && !_didCallDownloadParameters && OneSignalIdentifiers.currentAppId) { + _downloadParametersAttempts = 0; + [self downloadIOSParamsWithAppId:OneSignalIdentifiers.currentAppId]; + } + [OSOutcomes.sharedController clearOutcomes]; [[OSSessionManager sharedSessionManager] restartSessionIfNeeded]; @@ -654,6 +671,7 @@ + (void)handleAppIdChange:(NSString*)appId { initDone = false; _downloadedParameters = false; _didCallDownloadParameters = false; + _downloadParametersAttempts = 0; let sharedUserDefaults = OneSignalUserDefaults.initShared; @@ -665,6 +683,9 @@ + (void)handleAppIdChange:(NSString*)appId { [sharedUserDefaults removeValueForKey:OSUD_RECEIVE_RECEIPTS_ENABLED]; [sharedUserDefaults removeValueForKey:OS_PUSH_SUBSCRIPTION_MODEL_STORE_KEY]; + [sharedUserDefaults removeValueForKey:OSUD_USE_IDENTITY_VERIFICATION]; + [sharedUserDefaults removeValueForKey:OSUD_SDK_FEATURE_FLAGS]; + // Drop cached identifiers — a real app-id change invalidates them. [OSResilientStorage setStrings:@{ OSResilientStorage.keySubscriptionId: @"", @@ -710,6 +731,7 @@ + (void)setConsentGiven:(BOOL)granted { + (void)downloadIOSParamsWithAppId:(NSString *)appId { [OneSignalLog onesignalLog:ONE_S_LL_DEBUG message:@"Downloading iOS parameters for this application"]; _didCallDownloadParameters = true; + _downloadParametersAttempts++; // This will be nil unless we have a cached user // TODO: Commented out. This will init the User Manager too early, and userId is not needed anyway. // NSString *userId = OneSignalUserManagerImpl.sharedInstance.pushSubscriptionId; @@ -717,8 +739,12 @@ + (void)downloadIOSParamsWithAppId:(NSString *)appId { [OneSignalCoreImpl.sharedClient executeRequest:[OSRequestGetIosParams withUserId:userId appId:appId] onSuccess:^(NSDictionary *result) { - if (result[IOS_REQUIRES_USER_ID_AUTHENTICATION]) { - OneSignalUserManagerImpl.sharedInstance.requiresUserAuth = [result[IOS_REQUIRES_USER_ID_AUTHENTICATION] boolValue]; + // A response that omits the key means Identity Verification is off for this app; an empty + // response answers nothing, so the cached requirement stands + if (result != nil) { + id jwtRequired = result[IOS_JWT_REQUIRED]; + BOOL requiresUserAuth = jwtRequired != (id)[NSNull null] && [jwtRequired boolValue]; + [OSUserJwtConfig.shared hydrateWithRequiresUserAuth:requiresUserAuth]; } if (result[IOS_USES_PROVISIONAL_AUTHORIZATION] != (id)[NSNull null]) { @@ -759,9 +785,29 @@ + (void)downloadIOSParamsWithAppId:(NSString *)appId { } onFailure:^(OneSignalClientError *error) { _didCallDownloadParameters = false; + [self scheduleDownloadIOSParamsRetryWithAppId:appId]; }]; } ++ (void)scheduleDownloadIOSParamsRetryWithAppId:(NSString *)appId { + if (_downloadParametersAttempts >= MAX_DOWNLOAD_PARAMETERS_ATTEMPTS) { + [OneSignalLog onesignalLog:ONE_S_LL_WARN message:@"Could not download iOS parameters; retrying on the next session. Operations that wait on the Identity Verification setting stay queued until then."]; + return; + } + + // 5s, 10s, 20s, 40s across the remaining attempts + NSTimeInterval delay = DOWNLOAD_PARAMETERS_RETRY_BASE_SECONDS * (1 << MAX(_downloadParametersAttempts - 1, 0)); + [OneSignalLog onesignalLog:ONE_S_LL_DEBUG message:[NSString stringWithFormat:@"Retrying the iOS parameters download in %.0f seconds", delay]]; + + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(delay * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + // A session start or a re-init may have already succeeded or have one in flight + if (_downloadedParameters || _didCallDownloadParameters) + return; + + [self downloadIOSParamsWithAppId:appId]; + }); +} + //TODO: consolidate in one place. Where??? + (void)launchWebURL:(NSString*)openUrl { From 9ea39abde6173acf5104ebdc2d7bf6787e19368c Mon Sep 17 00:00:00 2001 From: Nan Date: Wed, 12 Aug 2026 09:50:19 -0700 Subject: [PATCH 06/21] fix: [PR3] tighten params retry class and hydration handler race Outer iOS-params retries now follow OSResponseStatusRetryable so classified 4xx are not re-armed for the session. Hydration handlers snapshot requirement under the same lock as registration. Co-authored-by: Cursor --- .../Source/Jwt/OSIdentityVerificationService.swift | 4 ++-- iOS_SDK/OneSignalSDK/Source/OneSignal.m | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Jwt/OSIdentityVerificationService.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Jwt/OSIdentityVerificationService.swift index 6faffc2e2..d649c342c 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Jwt/OSIdentityVerificationService.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Jwt/OSIdentityVerificationService.swift @@ -84,15 +84,15 @@ public final class OSIdentityVerificationService { registered after `requirement` is already known runs immediately, since that hydration is not repeated. */ public func addOnJwtConfigHydratedHandler(for observer: OSJwtConfigHydratedObserver, _ handler: @escaping (OSRequiresUserAuth) -> Void) { - handlerLock.withLock { + let alreadyKnown = handlerLock.withLock { () -> OSRequiresUserAuth in if let index = jwtConfigHydratedHandlers.firstIndex(where: { $0.observer == observer }) { jwtConfigHydratedHandlers[index] = (observer, handler) } else { jwtConfigHydratedHandlers.append((observer, handler)) } + return jwtConfig.requirement } - let alreadyKnown = jwtConfig.requirement guard alreadyKnown != .unknown else { return } diff --git a/iOS_SDK/OneSignalSDK/Source/OneSignal.m b/iOS_SDK/OneSignalSDK/Source/OneSignal.m index 4e8be121d..552daf87a 100755 --- a/iOS_SDK/OneSignalSDK/Source/OneSignal.m +++ b/iOS_SDK/OneSignalSDK/Source/OneSignal.m @@ -785,7 +785,11 @@ + (void)downloadIOSParamsWithAppId:(NSString *)appId { } onFailure:^(OneSignalClientError *error) { _didCallDownloadParameters = false; - [self scheduleDownloadIOSParamsRetryWithAppId:appId]; + if ([OSNetworkingUtils getResponseStatusType:error.code] == OSResponseStatusRetryable) { + [self scheduleDownloadIOSParamsRetryWithAppId:appId]; + } else { + [OneSignalLog onesignalLog:ONE_S_LL_WARN message:[NSString stringWithFormat:@"Could not download iOS parameters (HTTP %ld); not retrying this session.", (long)error.code]]; + } }]; } From 2f3cd34571ad325b8d5ab966e224e10f7624dd25 Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 11 Aug 2026 16:49:01 -0700 Subject: [PATCH 07/21] feat: [PR4] identity model JWT, the JWT repo, and the public API The surface an app talks to under Identity Verification: it hands the SDK a token for a user, and the SDK tells it when that token stopped being accepted. OSUserJwtRepo holds the token per external ID and remembers who has already been asked for one, so an app is asked once per user rather than once per rejected request. A listener registered after start or login still hears about an ask that already fired, since the alternative is an app that never learns it owes a token for the user it just logged in. Removes the beta JWT surface this replaces: requiresUserAuth, which nothing ever read, along with onJwtExpired and its handler typealiases. Anyone on the beta JWT API moves to addUserJwtInvalidatedListener and updateUserJwt. storeJwt lands here in the form the public API needs; the later PR that introduces the queues extends it to release the work held for want of a token. Co-authored-by: Cursor --- .../OneSignal.xcodeproj/project.pbxproj | 32 +++ .../OneSignalOSCoreMocks/OSCoreMocks.swift | 10 + .../Source/OSIdentityModel.swift | 79 +++++-- .../Source/OSIdentityModelRepo.swift | 61 ++++++ .../Source/OSUserJwtInvalidatedEvent.swift | 48 +++++ .../OneSignalUser/Source/OSUserJwtRepo.swift | 128 +++++++++++ .../Source/OneSignalUserManagerImpl+Jwt.swift | 78 +++++++ .../Source/OneSignalUserManagerImpl.swift | 67 ++++-- .../MockUserJwtInvalidatedListener.swift | 48 +++++ .../OneSignalUserMocks.swift | 12 ++ .../OSIdentityModelRepoTests.swift | 198 ++++++++++++++++++ .../OSIdentityModelTests.swift | 146 +++++++++++++ .../OSUserJwtRepoTests.swift | 196 +++++++++++++++++ .../OneSignalUserTests/UserJwtApiTests.swift | 182 ++++++++++++++++ iOS_SDK/OneSignalSDK/Source/OneSignal.m | 12 ++ .../OneSignalSDK/Source/OneSignalFramework.h | 5 + .../Source/OneSignalSwiftInterface.swift | 8 + 17 files changed, 1273 insertions(+), 37 deletions(-) create mode 100644 iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSUserJwtInvalidatedEvent.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSUserJwtRepo.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalUserMocks/MockUserJwtInvalidatedListener.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalUserTests/OSIdentityModelRepoTests.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalUserTests/OSIdentityModelTests.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalUserTests/OSUserJwtRepoTests.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtApiTests.swift diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index 33a035cce..50c5ad2c3 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -53,6 +53,8 @@ 03E56DD328405F4A006AA1DA /* OneSignalAppDelegateOverrider.m in Sources */ = {isa = PBXBuildFile; fileRef = 03E56DD228405F4A006AA1DA /* OneSignalAppDelegateOverrider.m */; }; 0AA11438FBF3A82D13824467 /* OSFeatureManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7DA9EEED644F1160CAD9A38 /* OSFeatureManager.swift */; }; 16664C4C25DDB195003B8A14 /* NSTimeZoneOverrider.m in Sources */ = {isa = PBXBuildFile; fileRef = 16664C4B25DDB195003B8A14 /* NSTimeZoneOverrider.m */; }; + 257E219608960B8545199057 /* OneSignalUserManagerImpl+Jwt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CCE2C93100CAFEE8EB39C77 /* OneSignalUserManagerImpl+Jwt.swift */; }; + 2DB99C76F3532383C3B81D09 /* OSUserJwtRepoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5809B47EB4F2478099697CE /* OSUserJwtRepoTests.swift */; }; 2F32272222E88DF0C2C18B53 /* OSFeatureManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42E4A83C6C0D0DF28CDECA90 /* OSFeatureManagerTests.swift */; }; 37E6B2BB19D9CAF300D0C601 /* UIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 37E6B2BA19D9CAF300D0C601 /* UIKit.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; 3C0151922C2E298F0079E076 /* OneSignalInAppMessages.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DEBAAE282A4211D900BF2C1C /* OneSignalInAppMessages.framework */; }; @@ -364,6 +366,8 @@ 918CB0301E73388E0067130F /* OneSignalFramework.h in CopyFiles */ = {isa = PBXBuildFile; fileRef = 912411F01E73342200E41FD7 /* OneSignalFramework.h */; }; 91C7725E1E7CCE1000D612D0 /* OneSignalInternal.h in Headers */ = {isa = PBXBuildFile; fileRef = 91C7725D1E7CCE1000D612D0 /* OneSignalInternal.h */; }; 91F60F7D1E80E4E400706E60 /* UncaughtExceptionHandler.m in Sources */ = {isa = PBXBuildFile; fileRef = 91F60F7C1E80E4E400706E60 /* UncaughtExceptionHandler.m */; }; + 9224347AAE3E092B5743380D /* UserJwtApiTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80DC5517E6EB5B26CF980CC5 /* UserJwtApiTests.swift */; }; + 94D9F5AFE86B16453A36C9A2 /* MockUserJwtInvalidatedListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F214EE6C5FE133672D6622F /* MockUserJwtInvalidatedListener.swift */; }; 9D3300FA23149DAE000F0A83 /* CoreGraphics.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9D3300F923149DAE000F0A83 /* CoreGraphics.framework */; }; 9D348537233C669E00EB81C9 /* CoreLocation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9D348536233C669E00EB81C9 /* CoreLocation.framework */; }; 9D34853A233D2E3600EB81C9 /* OneSignalLocationOverrider.m in Sources */ = {isa = PBXBuildFile; fileRef = 9D348539233D2E3600EB81C9 /* OneSignalLocationOverrider.m */; }; @@ -372,6 +376,7 @@ 9FF50E2A40C88E4533033A38 /* OSIdentityVerificationServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4855B81F170253FB0C1749D /* OSIdentityVerificationServiceTests.swift */; }; A662399326850DDE00D52FD8 /* LanguageTest.m in Sources */ = {isa = PBXBuildFile; fileRef = A662399026850DDE00D52FD8 /* LanguageTest.m */; }; A66239952686612F00D52FD8 /* OneSignalFramework.h in Headers */ = {isa = PBXBuildFile; fileRef = 912411F01E73342200E41FD7 /* OneSignalFramework.h */; settings = {ATTRIBUTES = (Public, ); }; }; + B5FBED8247288744EB484CB5 /* OSIdentityModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5953656ACCC21358BC0CF2F0 /* OSIdentityModelTests.swift */; }; BE737361D82E74544B7A1996 /* OSUserJwtConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6552F2A6DF7776B0582CFAEF /* OSUserJwtConfig.swift */; }; CA08FC871FE99BB4004C445F /* OneSignalClientOverrider.m in Sources */ = {isa = PBXBuildFile; fileRef = CA08FC831FE99BB4004C445F /* OneSignalClientOverrider.m */; }; CA1A6E6920DC2E31001C41B9 /* OneSignalDialogController.h in Headers */ = {isa = PBXBuildFile; fileRef = CA1A6E6720DC2E31001C41B9 /* OneSignalDialogController.h */; }; @@ -400,6 +405,8 @@ CACBAAAA218A65AE000ACAA5 /* InAppMessagingTests.m in Sources */ = {isa = PBXBuildFile; fileRef = CACBAAA9218A65AE000ACAA5 /* InAppMessagingTests.m */; }; CACBAAAC218A662B000ACAA5 /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CACBAAAB218A662B000ACAA5 /* WebKit.framework */; }; CACBAAB4218A7113000ACAA5 /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CACBAAAB218A662B000ACAA5 /* WebKit.framework */; }; + D465D9B81F58B242ADF14874 /* OSIdentityModelRepoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89DE5BB0EDD3964C20C5169F /* OSIdentityModelRepoTests.swift */; }; + DAF9C81134248FCDB0C12E5B /* OSUserJwtInvalidatedEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F2FC6C922FF8104F3197DD4 /* OSUserJwtInvalidatedEvent.swift */; }; DD2A89A8052E2D1912B0038B /* OSIamFetchReadyConditionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF4B19D1EC31C0750F13065A /* OSIamFetchReadyConditionTests.swift */; }; DE16C14424D3724700670EFA /* OneSignalLifecycleObserver.m in Sources */ = {isa = PBXBuildFile; fileRef = DE16C14324D3724700670EFA /* OneSignalLifecycleObserver.m */; }; DE16C14524D3724700670EFA /* OneSignalLifecycleObserver.m in Sources */ = {isa = PBXBuildFile; fileRef = DE16C14324D3724700670EFA /* OneSignalLifecycleObserver.m */; }; @@ -658,6 +665,7 @@ DEFB3E652BB7346D00E65DAD /* OSLiveActivities.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEFB3E642BB7346D00E65DAD /* OSLiveActivities.swift */; }; DEFB3E672BB735B500E65DAD /* OSStubLiveActivities.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEFB3E662BB735B500E65DAD /* OSStubLiveActivities.swift */; }; ECD9DF65FB98056806A43541 /* OSIdentityVerificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C4EAEA1BC62D8FC57927511 /* OSIdentityVerificationService.swift */; }; + FD1F1FCA05D555623DD53B54 /* OSUserJwtRepo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A8BBA843AFC81A4940CF7CC /* OSUserJwtRepo.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -1339,6 +1347,8 @@ 1AF75EAC1E8567FD0097B315 /* NSString+OneSignal.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSString+OneSignal.h"; sourceTree = ""; }; 1AF75EAD1E8567FD0097B315 /* NSString+OneSignal.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSString+OneSignal.m"; sourceTree = ""; }; 1C4EAEA1BC62D8FC57927511 /* OSIdentityVerificationService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSIdentityVerificationService.swift; sourceTree = ""; }; + 1F214EE6C5FE133672D6622F /* MockUserJwtInvalidatedListener.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MockUserJwtInvalidatedListener.swift; sourceTree = ""; }; + 2F2FC6C922FF8104F3197DD4 /* OSUserJwtInvalidatedEvent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSUserJwtInvalidatedEvent.swift; sourceTree = ""; }; 37747F9319147D6500558FAD /* libOneSignal.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libOneSignal.a; sourceTree = BUILT_PRODUCTS_DIR; }; 37E6B2BA19D9CAF300D0C601 /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; }; 3C01518E2C2E298E0079E076 /* OneSignalInAppMessagesTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OneSignalInAppMessagesTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -1531,6 +1541,8 @@ 475F471E2B8E398D00EC05B3 /* OneSignalLiveActivities.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = OneSignalLiveActivities.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 475F47202B8E398E00EC05B3 /* OneSignalLiveActivities.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OneSignalLiveActivities.h; sourceTree = ""; }; 475F47482B8E3A4400EC05B3 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 4CCE2C93100CAFEE8EB39C77 /* OneSignalUserManagerImpl+Jwt.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "OneSignalUserManagerImpl+Jwt.swift"; sourceTree = ""; }; + 5953656ACCC21358BC0CF2F0 /* OSIdentityModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSIdentityModelTests.swift; sourceTree = ""; }; 5B053FB82CAE07EB002F30C4 /* OneSignalOSCoreTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OneSignalOSCoreTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 5B58E4F3237CE7B3009401E0 /* UIDeviceOverrider.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = UIDeviceOverrider.h; sourceTree = ""; }; 5B58E4F6237CE7B4009401E0 /* UIDeviceOverrider.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = UIDeviceOverrider.m; sourceTree = ""; }; @@ -1542,6 +1554,7 @@ 5BC1DE632C90BB9000CA8807 /* OSIamFetchReadyCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSIamFetchReadyCondition.swift; sourceTree = ""; }; 5BC1DE672C90C23E00CA8807 /* OSConsistencyManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSConsistencyManagerTests.swift; sourceTree = ""; }; 6552F2A6DF7776B0582CFAEF /* OSUserJwtConfig.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSUserJwtConfig.swift; sourceTree = ""; }; + 6A8BBA843AFC81A4940CF7CC /* OSUserJwtRepo.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSUserJwtRepo.swift; sourceTree = ""; }; 7A123294235DFE3B002B6CE3 /* OutcomeTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OutcomeTests.m; sourceTree = ""; }; 7A12EBD523060A6F005C4FA5 /* OSSessionManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OSSessionManager.m; sourceTree = ""; }; 7A12EBD623060A6F005C4FA5 /* OSSessionManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OSSessionManager.h; sourceTree = ""; }; @@ -1615,6 +1628,8 @@ 7AF98691244A567B00C36EAE /* OSOutcomeEventsCache.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OSOutcomeEventsCache.m; sourceTree = ""; }; 7AFE856A2368DDB80091D6A5 /* OSFocusCallParams.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OSFocusCallParams.m; sourceTree = ""; }; 7AFE856E2368DDC50091D6A5 /* OSFocusCallParams.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OSFocusCallParams.h; sourceTree = ""; }; + 80DC5517E6EB5B26CF980CC5 /* UserJwtApiTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UserJwtApiTests.swift; sourceTree = ""; }; + 89DE5BB0EDD3964C20C5169F /* OSIdentityModelRepoTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSIdentityModelRepoTests.swift; sourceTree = ""; }; 911E2CBA1E398AB3003112A4 /* UnitTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = UnitTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 911E2CBC1E398AB3003112A4 /* UnitTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = UnitTests.m; sourceTree = ""; }; 911E2CBE1E398AB3003112A4 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -1648,6 +1663,7 @@ 9D348538233D2DCF00EB81C9 /* OneSignalLocationOverrider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OneSignalLocationOverrider.h; sourceTree = ""; }; 9D348539233D2E3600EB81C9 /* OneSignalLocationOverrider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OneSignalLocationOverrider.m; sourceTree = ""; }; A662399026850DDE00D52FD8 /* LanguageTest.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = LanguageTest.m; sourceTree = ""; }; + B5809B47EB4F2478099697CE /* OSUserJwtRepoTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSUserJwtRepoTests.swift; sourceTree = ""; }; C7DA9EEED644F1160CAD9A38 /* OSFeatureManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSFeatureManager.swift; sourceTree = ""; }; CA08FC821FE99BB4004C445F /* OneSignalClientOverrider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OneSignalClientOverrider.h; sourceTree = ""; }; CA08FC831FE99BB4004C445F /* OneSignalClientOverrider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OneSignalClientOverrider.m; sourceTree = ""; }; @@ -2398,6 +2414,7 @@ 3C87066F2BDE0957000D8CD2 /* MockUserRequests.swift */, 3C8706712BDEE076000D8CD2 /* MockUserDefines.swift */, 3CC063E52B6D7F96002BB07F /* OneSignalUserMocks.swift */, + 1F214EE6C5FE133672D6622F /* MockUserJwtInvalidatedListener.swift */, ); path = OneSignalUserMocks; sourceTree = ""; @@ -2413,6 +2430,10 @@ 3CB331672F281679000E1801 /* CustomEventsIntegrationTests.swift */, 3C67F7792BEB2B710085A0F0 /* SwitchUserIntegrationTests.swift */, 3CDE664B2BFC2A56006DA114 /* OneSignalUserObjcTests.m */, + 89DE5BB0EDD3964C20C5169F /* OSIdentityModelRepoTests.swift */, + 5953656ACCC21358BC0CF2F0 /* OSIdentityModelTests.swift */, + B5809B47EB4F2478099697CE /* OSUserJwtRepoTests.swift */, + 80DC5517E6EB5B26CF980CC5 /* UserJwtApiTests.swift */, ); path = OneSignalUserTests; sourceTree = ""; @@ -2803,6 +2824,9 @@ 3CF8629F28A1964F00776CA4 /* OSPropertiesModel.swift */, 3CE795F828DB99B500736BD4 /* OSSubscriptionModelStoreListener.swift */, 3C5117162B15C31E00563465 /* OSUserState.swift */, + 2F2FC6C922FF8104F3197DD4 /* OSUserJwtInvalidatedEvent.swift */, + 6A8BBA843AFC81A4940CF7CC /* OSUserJwtRepo.swift */, + 4CCE2C93100CAFEE8EB39C77 /* OneSignalUserManagerImpl+Jwt.swift */, ); path = Source; sourceTree = ""; @@ -4550,6 +4574,7 @@ 3C8706702BDE0957000D8CD2 /* MockUserRequests.swift in Sources */, 3C8706722BDEE076000D8CD2 /* MockUserDefines.swift in Sources */, 3CC063E62B6D7F96002BB07F /* OneSignalUserMocks.swift in Sources */, + 94D9F5AFE86B16453A36C9A2 /* MockUserJwtInvalidatedListener.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -4566,6 +4591,10 @@ 3CB3316A2F281692000E1801 /* OSCustomEventsExecutorTests.swift in Sources */, 3CA93BC4300AEFFA000724B3 /* SubscriptionUpdateRaceTests.swift in Sources */, 3CDE664C2BFC2A56006DA114 /* OneSignalUserObjcTests.m in Sources */, + D465D9B81F58B242ADF14874 /* OSIdentityModelRepoTests.swift in Sources */, + B5FBED8247288744EB484CB5 /* OSIdentityModelTests.swift in Sources */, + 2DB99C76F3532383C3B81D09 /* OSUserJwtRepoTests.swift in Sources */, + 9224347AAE3E092B5743380D /* UserJwtApiTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -4765,6 +4794,9 @@ 3CE9227A289FA88B001B1062 /* OSIdentityModelStoreListener.swift in Sources */, DE69E19F282ED8060090BB3D /* OneSignalUser.docc in Sources */, 3CA6CE0A28E4F19B00CA0585 /* OSUserRequest.swift in Sources */, + DAF9C81134248FCDB0C12E5B /* OSUserJwtInvalidatedEvent.swift in Sources */, + FD1F1FCA05D555623DD53B54 /* OSUserJwtRepo.swift in Sources */, + 257E219608960B8545199057 /* OneSignalUserManagerImpl+Jwt.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/OSCoreMocks.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/OSCoreMocks.swift index 6a144eef7..1f8cc7a27 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/OSCoreMocks.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/OSCoreMocks.swift @@ -34,6 +34,16 @@ public class OSCoreMocks: NSObject { public static func resetOperationRepo() { OSOperationRepo.sharedInstance.reset() } + + /// Puts the shared JWT config back to unhydrated. + public static func resetSharedJwtConfig() { + OSUserJwtConfig.shared.resetRequirementToUnknownForTests() + } + + /// Hydrates the shared JWT config. Non-IV tests hydrate `false` so the Operation Repo will flush. + public static func hydrateSharedJwtConfig(requiresUserAuth: Bool) { + OSUserJwtConfig.shared.hydrate(requiresUserAuth: requiresUserAuth) + } } extension OSOperationRepo { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModel.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModel.swift index 6e70b5057..bb5f0f67c 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModel.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModel.swift @@ -38,12 +38,55 @@ class OSIdentityModel: OSModel { return internalGetAlias(OS_EXTERNAL_ID) } - // All access to aliases should go through helper methods with locking + // All access to aliases and the JWT bearer token must go through the lock var aliases: [String: String] = [:] - private let aliasesLock = NSRecursiveLock() + private let lock = NSRecursiveLock() - // TODO: We need to make this token secure - public var jwtBearerToken: String? + // MARK: - JWT + + private var jwtBearerTokenLocked: String? + public var jwtBearerToken: String? { + get { + lock.withLock { jwtBearerTokenLocked } + } + set { + // Notify outside the lock: the change notifier fires synchronously into listeners that + // take locks of their own. + let changed = lock.withLock { + guard newValue != jwtBearerTokenLocked else { return false } + jwtBearerTokenLocked = newValue + return true + } + if changed { + self.set(property: OS_JWT_BEARER_TOKEN, newValue: newValue) + } + } + } + + /// Returns the bearer token if it is valid, otherwise nil, snapshots once + func getValidJwt() -> String? { + let token = jwtBearerToken + guard let token = token, !token.isEmpty, token != OS_JWT_TOKEN_INVALID else { + return nil + } + return token + } + + /// Returns `true` if the transition occurred, `false` if `rejectedToken` is no longer the stored + /// token. Comparing against the rejected token rather than the sentinel is what keeps a failure + /// response that was already in flight from parking the replacement supplied after it left. + @discardableResult + func invalidateJwtBearerToken(rejectedToken: String) -> Bool { + let changed = lock.withLock { + guard jwtBearerTokenLocked == rejectedToken else { return false } + jwtBearerTokenLocked = OS_JWT_TOKEN_INVALID + return true + } + if changed { + self.set(property: OS_JWT_BEARER_TOKEN, newValue: OS_JWT_TOKEN_INVALID) + } + return changed + } // MARK: - Initialization @@ -54,9 +97,10 @@ class OSIdentityModel: OSModel { } override func encode(with coder: NSCoder) { - aliasesLock.withLock { + lock.withLock { super.encode(with: coder) coder.encode(aliases, forKey: "aliases") + coder.encode(jwtBearerTokenLocked, forKey: OS_JWT_BEARER_TOKEN) } } @@ -66,19 +110,20 @@ class OSIdentityModel: OSModel { // log error return nil } + self.jwtBearerTokenLocked = coder.decodeObject(forKey: OS_JWT_BEARER_TOKEN) as? String self.aliases = aliases } /** Threadsafe getter for an alias */ private func internalGetAlias(_ label: String) -> String? { - aliasesLock.withLock { + lock.withLock { return self.aliases[label] } } /** Threadsafe setter or removal for aliases */ private func internalAddAliases(_ aliases: [String: String]) { - aliasesLock.withLock { + lock.withLock { for (label, id) in aliases { // Remove the alias if the ID field is "" self.aliases[label] = id.isEmpty ? nil : id @@ -91,7 +136,7 @@ class OSIdentityModel: OSModel { Called to clear the model's data in preparation for hydration via a fetch user call. */ func clearData() { - aliasesLock.withLock { + lock.withLock { self.aliases = [:] } } @@ -120,14 +165,20 @@ class OSIdentityModel: OSModel { let newExternalId = remoteAliases[OS_EXTERNAL_ID] internalAddAliases(remoteAliases) - fireUserStateChanged(newOnesignalId: newOnesignalId, newExternalId: newExternalId) + OSUserStateSnapshot.fireUserStateChanged(newOnesignalId: newOnesignalId, newExternalId: newExternalId) } +} - /** - Fires the user observer if `onesignal_id` OR `external_id` has changed from the previous snapshot (previous hydration). - */ - private func fireUserStateChanged(newOnesignalId: String?, newExternalId: String?) { - let prevOnesignalId = OneSignalUserDefaults.initShared().getSavedString(forKey: OS_SNAPSHOT_ONESIGNAL_ID, defaultValue: nil) +/** + Owns the last user state the app was told about, so the observer only hears real changes. + + Hydration is the usual source, but `logout` under Identity Verification also reports here: it creates + no user on the server, so there is no hydration to carry the news that nobody is signed in. + */ +enum OSUserStateSnapshot { + /// Fires the user observer if `onesignal_id` OR `external_id` differs from the last reported pair. + static func fireUserStateChanged(newOnesignalId: String?, newExternalId: String?) { + let prevOnesignalId = OneSignalUserDefaults.initShared().getSavedString(forKey: OS_SNAPSHOT_ONESIGNAL_ID, defaultValue: nil) let prevExternalId = OneSignalUserDefaults.initShared().getSavedString(forKey: OS_SNAPSHOT_EXTERNAL_ID, defaultValue: nil) guard prevOnesignalId != newOnesignalId || prevExternalId != newExternalId else { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModelRepo.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModelRepo.swift index 781fe4e8f..c1468632e 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModelRepo.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModelRepo.swift @@ -26,6 +26,7 @@ */ import Foundation +import OneSignalCore /** This class stores all Identity Models that are being used during an app session. @@ -52,4 +53,64 @@ class OSIdentityModelRepo { return models[modelId] } } + + func get(externalId: String) -> OSIdentityModel? { + lock.withLock { + return models.values.first { $0.externalId == externalId } + } + } + + /** + Repeated logins as the same user each create an Identity Model, so update them all. + This can be optimized in the future to re-use an Identity Model if multiple logins are made for the same user. + + Returns `false` if no Identity Model carries this external ID, in which case the token was not stored + anywhere and nothing can sign with it. + */ + @discardableResult + func updateJwtToken(externalId: String, token: String) -> Bool { + let matchingModels = modelsMatching(externalId: externalId) + guard !matchingModels.isEmpty else { + OneSignalLog.onesignalLog(.LL_ERROR, message: "OSIdentityModelRepo.updateJwtToken called for unknown external ID \(externalId)") + return false + } + for model in matchingModels { + model.jwtBearerToken = token + } + return true + } + + /// The token this user can currently sign with, or nil if there is none. + func validJwt(externalId: String) -> String? { + return modelsMatching(externalId: externalId).lazy.compactMap { $0.getValidJwt() }.first + } + + /** + Invalidates the token on every Identity Model with this external ID, since repeated logins as the + same user each create one. Only the models still holding `rejectedToken` transition, so a + replacement that landed while the rejected request was in flight survives. + + Returns `false` if no Identity Model carries this external ID, in which case there was nothing + to park and nothing to tell the app about. + */ + @discardableResult + func invalidateJwtToken(externalId: String, rejectedToken: String) -> Bool { + let matchingModels = modelsMatching(externalId: externalId) + guard !matchingModels.isEmpty else { + OneSignalLog.onesignalLog(.LL_ERROR, message: "OSIdentityModelRepo.invalidateJwtToken called for unknown external ID \(externalId)") + return false + } + for model in matchingModels { + model.invalidateJwtBearerToken(rejectedToken: rejectedToken) + } + return true + } + + /// Snapshot before touching the tokens: writing one fires the model's change notifier + /// synchronously into listeners that take locks of their own. + private func modelsMatching(externalId: String) -> [OSIdentityModel] { + lock.withLock { + models.values.filter { $0.externalId == externalId } + } + } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSUserJwtInvalidatedEvent.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSUserJwtInvalidatedEvent.swift new file mode 100644 index 000000000..35d7a7e33 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSUserJwtInvalidatedEvent.swift @@ -0,0 +1,48 @@ +/* + 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. + */ + +/** + Tells the app that the JWT it supplied for `externalId` is no longer accepted, so it should mint a + fresh one and hand it back through `OneSignal.updateUserJwt(externalId:token:)`. + */ +@objc public class OSUserJwtInvalidatedEvent: NSObject { + @objc public let externalId: String + + init(externalId: String) { + self.externalId = externalId + } + + @objc public func jsonRepresentation() -> NSDictionary { + return [ + "externalId": externalId + ] + } +} + +@objc public protocol OSUserJwtInvalidatedListener { + @objc func onUserJwtInvalidated(event: OSUserJwtInvalidatedEvent) +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSUserJwtRepo.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSUserJwtRepo.swift new file mode 100644 index 000000000..8adf8f164 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSUserJwtRepo.swift @@ -0,0 +1,128 @@ +/* + 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 + +/** + Identity Verification token access, keyed by `external_id`. Executors depend on this rather than on + the User Manager so that no JWT lookup reaches for a singleton. + */ +protocol OSUserJwtProviding: AnyObject { + /// The token this user can sign with, or nil if the SDK holds none. + func validJwt(externalId: String) -> String? + + /** + Asks the app for a token for `externalId`. + + Returns `true` if this call is the one that asked, which happens at most once per external ID + per session so a burst of concurrent callers does not fire the event repeatedly. + */ + @discardableResult + func askForToken(externalId: String) -> Bool + + /// Parks the rejected token and asks the app for a replacement. Returns `true` if this call asked. + @discardableResult + func invalidateJwt(externalId: String, rejectedToken: String) -> Bool +} + +final class OSUserJwtRepo: OSUserJwtProviding { + private let identityModelRepo: OSIdentityModelRepo + private let notifyInvalidated: (String) -> Void + + let lock = NSLock() + /** + External IDs the app has already been asked to re-sign. + + In memory only. A model decoded at launch can already hold the invalid sentinel, leaving nothing + to transition, so a fresh session has to be able to ask again — otherwise an app that was asked + in a previous run is never told it still owes a token. + */ + var askedForToken: Set = [] + + init(identityModelRepo: OSIdentityModelRepo, notifyInvalidated: @escaping (String) -> Void) { + self.identityModelRepo = identityModelRepo + self.notifyInvalidated = notifyInvalidated + } + + func validJwt(externalId: String) -> String? { + return identityModelRepo.validJwt(externalId: externalId) + } + + /** + Stores a token supplied by the app, and lets this user be asked again if it is ever rejected. + Returns `false` for a token that was not stored, so callers do not go looking for held work to release. + + An unusable token is ignored rather than stored: it would replace a good token with nothing to sign + with, and clearing the ask for it would have the SDK and the app trade asks and replies on every flush. + A token for an external ID the SDK has no Identity Model for lands nowhere, so it is not treated as an + answer either. + */ + @discardableResult + func updateJwt(externalId: String, token: String) -> Bool { + guard !token.isEmpty, token != OS_JWT_TOKEN_INVALID else { + OneSignalLog.onesignalLog(.LL_ERROR, message: "OSUserJwtRepo.updateJwt ignored an unusable token for \(externalId)") + return false + } + guard identityModelRepo.updateJwtToken(externalId: externalId, token: token) else { + return false + } + lock.withLock { _ = askedForToken.remove(externalId) } + return true + } + + @discardableResult + func askForToken(externalId: String) -> Bool { + guard lock.withLock({ askedForToken.insert(externalId).inserted }) else { + return false + } + notifyInvalidated(externalId) + return true + } + + /// External IDs already asked this session; cleared when a usable token is stored. + func pendingTokenAsks() -> [String] { + return lock.withLock { Array(askedForToken) } + } + + @discardableResult + func invalidateJwt(externalId: String, rejectedToken: String) -> Bool { + // No model for this user means the token could not have come from here. A Request stamped + // with an owner whose model was cleared for hydration lands here, and it retries once the + // aliases come back. + guard identityModelRepo.invalidateJwtToken(externalId: externalId, rejectedToken: rejectedToken) else { + return false + } + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSUserJwtRepo invalidated JWT for externalId: \(externalId)") + // A replacement that landed while the rejected Request was in flight leaves the token above + // untouched, and the retry signs with it, so there is nothing to ask the app for. + guard identityModelRepo.validJwt(externalId: externalId) == nil else { + return false + } + return askForToken(externalId: externalId) + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift new file mode 100644 index 000000000..eaf4c7784 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift @@ -0,0 +1,78 @@ +/* + 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 OneSignalCore +import OneSignalOSCore + +/** + The Identity Verification surface the app talks to: it hands the SDK a token for a user, and the SDK + tells it when that token stopped being accepted. + */ +extension OneSignalUserManagerImpl { + /** + Replays any ask that already fired this session, so a listener registered after `start` or `login` + still hears who currently owes a token. + */ + @objc + public func addUserJwtInvalidatedListener(_ listener: OSUserJwtInvalidatedListener) { + self.userJwtInvalidatedObserver.addObserver(listener) + let pending = userJwtRepo.pendingTokenAsks() + guard !pending.isEmpty else { + return + } + // Same queue as OSObservable; skip any ask answered between registration and delivery. + DispatchQueue.main.async { [weak self] in + guard let self else { + return + } + let stillPending = Set(self.userJwtRepo.pendingTokenAsks()) + for externalId in pending where stillPending.contains(externalId) { + listener.onUserJwtInvalidated(event: OSUserJwtInvalidatedEvent(externalId: externalId)) + } + } + } + + @objc + public func removeUserJwtInvalidatedListener(_ listener: OSUserJwtInvalidatedListener) { + self.userJwtInvalidatedObserver.removeObserver(listener) + } + + @objc + public func updateUserJwt(externalId: String, token: String) { + guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "updateUserJwt") else { + return + } + guard !externalId.isEmpty, !token.isEmpty, token != OS_JWT_TOKEN_INVALID else { + OneSignalLog.onesignalLog(.LL_ERROR, message: "OneSignal.updateUserJwt called with empty externalId or an unusable token. This is not allowed.") + return + } + // TODO: omit the token from this log before shipping — keep for testing. + OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OneSignal.updateUserJwt called for externalId: \(externalId) with token: \(token)") + + storeJwt(externalId: externalId, token: token) + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift index eb7608b2d..ca4457d62 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift @@ -37,6 +37,7 @@ import OneSignalNotifications var User: OSUser { get } func login(externalId: String, token: String?) func logout() + func updateUserJwt(externalId: String, token: String) // Location func setLocation(latitude: Float, longitude: Float) // Purchase Tracking @@ -84,10 +85,6 @@ import OneSignalNotifications */ func trackEvent(name: String, properties: [String: Any]?) // ^ TODO: After alpha feedback, confirm value type for properties dict - // JWT Token Expire - typealias OSJwtCompletionBlock = (_ newJwtToken: String) -> Void - typealias OSJwtExpiredHandler = (_ externalId: String, _ completion: OSJwtCompletionBlock) -> Void - func onJwtExpired(expiredHandler: @escaping OSJwtExpiredHandler) } /** @@ -131,11 +128,15 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { let newRecordsState = OSNewRecordsState() + // Shared instances: remote params hydrate them before this class is started, and a + // fresh instance here would read none of it. + let featureManager = OSFeatureManager.shared + let jwtConfig = OSUserJwtConfig.shared + let identityVerificationService: OSIdentityVerificationService + private let startQueue = DispatchQueue(label: "com.onesignal.user.start") var hasCalledStart = false - private var jwtExpiredHandler: OSJwtExpiredHandler? - var user: OSUserInternal { guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: nil) else { return _mockUser @@ -167,8 +168,6 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { propertiesModel: OSPropertiesModel(changeNotifier: OSEventProducer()), pushSubscriptionModel: OSSubscriptionModel(type: .push, address: nil, subscriptionId: nil, reachable: false, isDisabled: true, changeNotifier: OSEventProducer())) - @objc public var requiresUserAuth = false - // User State Observer private var _userStateChangesObserver: OSObservable? var userStateChangesObserver: OSObservable { @@ -181,6 +180,18 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { return userStateChangesObserver } + // JWT Invalidated Observer + private var _userJwtInvalidatedObserver: OSObservable? + var userJwtInvalidatedObserver: OSObservable { + if let observer = _userJwtInvalidatedObserver { + return observer + } + let userJwtInvalidatedObserver = OSObservable(change: #selector(OSUserJwtInvalidatedListener.onUserJwtInvalidated(event:))) + _userJwtInvalidatedObserver = userJwtInvalidatedObserver + + return userJwtInvalidatedObserver + } + // Model Stores let identityModelStore = OSModelStore(changeSubscription: OSEventProducer(), storeKey: OS_IDENTITY_MODEL_STORE_KEY).registerAsUserObserver() let propertiesModelStore = OSModelStore(changeSubscription: OSEventProducer(), storeKey: OS_PROPERTIES_MODEL_STORE_KEY).registerAsUserObserver() @@ -190,6 +201,7 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { let pushSubscriptionModelStore = OSModelStore(changeSubscription: OSEventProducer(), storeKey: OS_PUSH_SUBSCRIPTION_MODEL_STORE_KEY) // These must be initialized in init() + let userJwtRepo: OSUserJwtRepo let identityModelStoreListener: OSIdentityModelStoreListener let propertiesModelStoreListener: OSPropertiesModelStoreListener let subscriptionModelStoreListener: OSSubscriptionModelStoreListener @@ -203,6 +215,14 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { var customEventsExecutor: OSCustomEventsExecutor? private override init() { + let identityVerificationService = OSIdentityVerificationService(featureManager: featureManager, jwtConfig: jwtConfig) + // Goes through `sharedInstance` rather than capturing self: the observer it notifies is created + // lazily and must not be touched during init. + let userJwtRepo = OSUserJwtRepo(identityModelRepo: identityModelRepo) { externalId in + OneSignalUserManagerImpl.sharedInstance.userJwtInvalidatedObserver.notifyChange(OSUserJwtInvalidatedEvent(externalId: externalId)) + } + self.identityVerificationService = identityVerificationService + self.userJwtRepo = userJwtRepo self.identityModelStoreListener = OSIdentityModelStoreListener(store: identityModelStore) self.propertiesModelStoreListener = OSPropertiesModelStoreListener(store: propertiesModelStore) self.subscriptionModelStoreListener = OSSubscriptionModelStoreListener(store: subscriptionModelStore) @@ -233,6 +253,9 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { propertiesModelStore.refresh() subscriptionModelStore.refresh() pushSubscriptionModelStore.refresh() + // Same prewarm gap as the stores: init may have read UserDefaults while it was locked. + jwtConfig.refreshIfUnknown() + featureManager.refreshIfEmpty() OSNotificationsManager.delegate = self @@ -468,6 +491,19 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { createUserIfNil() } + /** + Stores a token for `externalId`, so that the pending ask for this user is cleared and a later + rejection can ask again. + + Every app-supplied token arrives here, from `login` as well as `updateUserJwt`. + */ + func storeJwt(externalId: String, token: String) { + guard userJwtRepo.updateJwt(externalId: externalId, token: token) else { + return + } + OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OneSignalUserManager stored a JWT for externalId: \(externalId)") + } + @objc public func clearAllModelsFromStores() { prepareForNewUser() @@ -579,17 +615,6 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { updatePropertiesDeltas(property: .purchases, value: purchases) } - private func fireJwtExpired() { - guard let externalId = user.identityModel.externalId, let jwtExpiredHandler = self.jwtExpiredHandler else { - return - } - jwtExpiredHandler(externalId) { [self] (newToken) in - guard user.identityModel.externalId == externalId else { - return - } - user.identityModel.jwtBearerToken = newToken - } - } } // MARK: - Sessions @@ -665,10 +690,6 @@ extension OneSignalUserManagerImpl { } extension OneSignalUserManagerImpl: OSUser { - public func onJwtExpired(expiredHandler: @escaping OSJwtExpiredHandler) { - jwtExpiredHandler = expiredHandler - } - public var User: OSUser { start() return self diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserMocks/MockUserJwtInvalidatedListener.swift b/iOS_SDK/OneSignalSDK/OneSignalUserMocks/MockUserJwtInvalidatedListener.swift new file mode 100644 index 000000000..bc8b3207f --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUserMocks/MockUserJwtInvalidatedListener.swift @@ -0,0 +1,48 @@ +/* + 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 OneSignalUser + +public class MockUserJwtInvalidatedListener: NSObject, OSUserJwtInvalidatedListener { + public private(set) var invalidatedExternalIds: [String] = [] + public var invalidatedCallbackWasCalled: Bool { + return !invalidatedExternalIds.isEmpty + } + + private var callback: (() -> Void)? + + public override init() { } + + public func setCallback(_ callback: @escaping () -> Void) { + self.callback = callback + } + + public func onUserJwtInvalidated(event: OSUserJwtInvalidatedEvent) { + invalidatedExternalIds.append(event.externalId) + callback?() + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserMocks/OneSignalUserMocks.swift b/iOS_SDK/OneSignalSDK/OneSignalUserMocks/OneSignalUserMocks.swift index da94af841..e626f423a 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserMocks/OneSignalUserMocks.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserMocks/OneSignalUserMocks.swift @@ -38,6 +38,9 @@ public class OneSignalUserMocks: NSObject { @objc public static func reset() { OSCoreMocks.resetOperationRepo() + OSCoreMocks.resetSharedJwtConfig() + // Hydrate `off` so the Operation Repo's unknown-requirement deferral does not stall non-IV tests. + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: false) OneSignalUserManagerImpl.sharedInstance.reset() } @@ -59,6 +62,14 @@ extension OSIdentityModelRepo { } } +extension OSUserJwtRepo { + /// Clears which external IDs have already been asked for a token, which lives for a session in + /// production and would otherwise silence the invalidated event in the next test. + func reset() { + lock.withLock { askedForToken.removeAll() } + } +} + extension OneSignalUserManagerImpl { /** User Manager needs to reset between tests until we dependency inject the User Manager. @@ -67,6 +78,7 @@ extension OneSignalUserManagerImpl { */ func reset() { identityModelRepo.reset() + userJwtRepo.reset() // Model store listeners unsubscribe to their models // User Manager start() will subscribe them diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OSIdentityModelRepoTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OSIdentityModelRepoTests.swift new file mode 100644 index 000000000..40bf57230 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OSIdentityModelRepoTests.swift @@ -0,0 +1,198 @@ +/* + 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 XCTest +import OneSignalCore +import OneSignalCoreMocks +import OneSignalUserMocks +@testable import OneSignalOSCore +@testable import OneSignalUser + +/// Covers the repo's external-ID lookups. The repo is constructible, so these build their own +/// instance rather than reaching through the User Manager's shared one. +final class OSIdentityModelRepoTests: XCTestCase { + + private var repo = OSIdentityModelRepo() + + override func setUpWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + OneSignalUserMocks.reset() + repo = OSIdentityModelRepo() + } + + override func tearDownWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + } + + @discardableResult + private func addModel(externalId: String?) -> OSIdentityModel { + let aliases = externalId.map { [OS_EXTERNAL_ID: $0] } + let model = OSIdentityModel(aliases: aliases, changeNotifier: OSEventProducer()) + repo.add(model: model) + return model + } + + // MARK: - get(externalId:) + + func testGetByExternalIdFindsTheMatchingModel() { + addModel(externalId: "user-a") + let userB = addModel(externalId: "user-b") + + XCTAssertIdentical(repo.get(externalId: "user-b"), userB) + } + + func testGetByExternalIdReturnsNilWhenNoModelMatches() { + addModel(externalId: "user-a") + + XCTAssertNil(repo.get(externalId: "user-b")) + } + + /// Anonymous users have no external ID, and must not be matched by one. + func testGetByExternalIdSkipsAnonymousModels() { + addModel(externalId: nil) + + XCTAssertNil(repo.get(externalId: "user-a")) + } + + // MARK: - updateJwtToken + + func testUpdateJwtTokenWritesTheTokenOntoTheMatchingModel() { + let userA = addModel(externalId: "user-a") + + repo.updateJwtToken(externalId: "user-a", token: "token-a") + + XCTAssertEqual(userA.jwtBearerToken, "token-a") + } + + /// Repeated logins as the same user can leave more than one model carrying that external ID; + /// a token that only reaches one of them would leave the others stuck on a rejected token. + func testUpdateJwtTokenWritesToEveryModelWithThatExternalId() { + let first = addModel(externalId: "user-a") + let second = addModel(externalId: "user-a") + + repo.updateJwtToken(externalId: "user-a", token: "token-a") + + XCTAssertEqual(first.jwtBearerToken, "token-a") + XCTAssertEqual(second.jwtBearerToken, "token-a") + } + + func testUpdateJwtTokenLeavesOtherUsersAlone() { + let userA = addModel(externalId: "user-a") + let userB = addModel(externalId: "user-b") + + repo.updateJwtToken(externalId: "user-a", token: "token-a") + + XCTAssertEqual(userA.jwtBearerToken, "token-a") + XCTAssertNil(userB.jwtBearerToken) + } + + /// A token supplied for a user the SDK has never seen is dropped rather than applied to whoever + /// happens to be current. + func testUpdateJwtTokenForAnUnknownExternalIdChangesNothing() { + let userA = addModel(externalId: "user-a") + + repo.updateJwtToken(externalId: "user-b", token: "token-b") + + XCTAssertNil(userA.jwtBearerToken) + } + + func testUpdateJwtTokenReplacesTheInvalidSentinel() { + let userA = addModel(externalId: "user-a") + userA.invalidateJwtBearerToken(rejectedToken: "token-a") + + repo.updateJwtToken(externalId: "user-a", token: "fresh-token") + + XCTAssertEqual(userA.getValidJwt(), "fresh-token") + } + + // MARK: - validJwt + + func testValidJwtReturnsTheStoredToken() { + let userA = addModel(externalId: "user-a") + userA.jwtBearerToken = "token-a" + + XCTAssertEqual(repo.validJwt(externalId: "user-a"), "token-a") + } + + func testValidJwtIsNilForAnUnknownExternalIdAndForAParkedToken() { + let userA = addModel(externalId: "user-a") + userA.jwtBearerToken = "token-a" + + XCTAssertNil(repo.validJwt(externalId: "user-b")) + + repo.invalidateJwtToken(externalId: "user-a", rejectedToken: "token-a") + XCTAssertNil(repo.validJwt(externalId: "user-a")) + } + + // MARK: - invalidateJwtToken + + /// A model left unparked would keep signing requests with a token the server already rejected. + func testInvalidateJwtTokenParksEveryModelWithThatExternalId() { + let first = addModel(externalId: "user-a") + let second = addModel(externalId: "user-a") + first.jwtBearerToken = "token-a" + second.jwtBearerToken = "token-a" + + repo.invalidateJwtToken(externalId: "user-a", rejectedToken: "token-a") + + XCTAssertNil(first.getValidJwt()) + XCTAssertNil(second.getValidJwt()) + } + + /// A login that landed while the rejected request was in flight leaves a newer token behind. + func testInvalidateJwtTokenLeavesAModelHoldingADifferentToken() { + let stale = addModel(externalId: "user-a") + let fresh = addModel(externalId: "user-a") + stale.jwtBearerToken = "token-a" + fresh.jwtBearerToken = "token-b" + + repo.invalidateJwtToken(externalId: "user-a", rejectedToken: "token-a") + + XCTAssertNil(stale.getValidJwt()) + XCTAssertEqual(fresh.getValidJwt(), "token-b") + } + + func testInvalidateJwtTokenLeavesOtherUsersAlone() { + let userA = addModel(externalId: "user-a") + let userB = addModel(externalId: "user-b") + userA.jwtBearerToken = "token-a" + userB.jwtBearerToken = "token-a" + + repo.invalidateJwtToken(externalId: "user-a", rejectedToken: "token-a") + + XCTAssertEqual(userB.getValidJwt(), "token-a") + } + + func testInvalidateJwtTokenForAnUnknownExternalIdChangesNothing() { + let userA = addModel(externalId: "user-a") + userA.jwtBearerToken = "token-a" + + repo.invalidateJwtToken(externalId: "user-b", rejectedToken: "token-a") + + XCTAssertEqual(userA.getValidJwt(), "token-a") + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OSIdentityModelTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OSIdentityModelTests.swift new file mode 100644 index 000000000..a29434e21 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OSIdentityModelTests.swift @@ -0,0 +1,146 @@ +/* + 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 XCTest +import OneSignalCore +import OneSignalCoreMocks +import OneSignalUserMocks +@testable import OneSignalOSCore +@testable import OneSignalUser + +/// Covers the JWT bearer token on `OSIdentityModel`: which tokens count as usable, the +/// compare-and-set on invalidation, and what survives an archive round trip. +final class OSIdentityModelTests: XCTestCase { + + override func setUpWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + OneSignalUserMocks.reset() + } + + override func tearDownWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + } + + private func makeModel(token: String? = nil) -> OSIdentityModel { + let model = OSIdentityModel(aliases: [:], changeNotifier: OSEventProducer()) + model.jwtBearerToken = token + return model + } + + private func archiveThenUnarchive(_ model: OSIdentityModel) throws -> OSIdentityModel { + let data = try NSKeyedArchiver.archivedData(withRootObject: model, requiringSecureCoding: false) + let unarchiver = try NSKeyedUnarchiver(forReadingFrom: data) + unarchiver.requiresSecureCoding = false + defer { unarchiver.finishDecoding() } + return try XCTUnwrap(unarchiver.decodeObject(forKey: NSKeyedArchiveRootObjectKey) as? OSIdentityModel) + } + + // MARK: - getValidJwt() + + func testGetValidJwtReturnsNilWhenTokenIsNil() { + XCTAssertNil(makeModel(token: nil).getValidJwt()) + } + + func testGetValidJwtReturnsNilWhenTokenIsEmptyString() { + XCTAssertNil(makeModel(token: "").getValidJwt()) + } + + func testGetValidJwtReturnsNilWhenTokenIsInvalidSentinel() { + XCTAssertNil(makeModel(token: OS_JWT_TOKEN_INVALID).getValidJwt()) + } + + func testGetValidJwtReturnsTokenWhenTokenIsValid() { + let token = "eyJhbGciOiJFUzI1NiJ9.payload.sig" + XCTAssertEqual(makeModel(token: token).getValidJwt(), token) + } + + // MARK: - invalidateJwtBearerToken(rejectedToken:) + + func testInvalidateReturnsTrueOnFirstTransitionAndSetsInvalidSentinel() { + let model = makeModel(token: "valid-token") + + XCTAssertTrue(model.invalidateJwtBearerToken(rejectedToken: "valid-token")) + XCTAssertEqual(model.jwtBearerToken, OS_JWT_TOKEN_INVALID) + } + + /// Two requests can be rejected at once; only one of them should tell the app to mint a token. + func testInvalidateReturnsFalseWhenAlreadyInvalid() { + let model = makeModel(token: "valid-token") + _ = model.invalidateJwtBearerToken(rejectedToken: "valid-token") + + XCTAssertFalse(model.invalidateJwtBearerToken(rejectedToken: "valid-token")) + XCTAssertEqual(model.jwtBearerToken, OS_JWT_TOKEN_INVALID) + } + + /// The app can supply a replacement while the rejected request is still in flight. + func testInvalidateLeavesAReplacementTokenAlone() { + let model = makeModel(token: "replacement-token") + + XCTAssertFalse(model.invalidateJwtBearerToken(rejectedToken: "stale-token")) + XCTAssertEqual(model.jwtBearerToken, "replacement-token") + } + + func testInvalidateDoesNothingWhenThereIsNoToken() { + let model = makeModel(token: nil) + + XCTAssertFalse(model.invalidateJwtBearerToken(rejectedToken: "stale-token")) + XCTAssertNil(model.jwtBearerToken) + } + + // MARK: - Persistence + + func testTokenSurvivesAnArchiveRoundTrip() throws { + let model = makeModel(token: "cached-token") + model.addAliases([OS_EXTERNAL_ID: "user-a"]) + + let decoded = try archiveThenUnarchive(model) + + XCTAssertEqual(decoded.jwtBearerToken, "cached-token") + XCTAssertEqual(decoded.externalId, "user-a") + } + + /// A model archived by a build that never encoded a token has no value under the key. + func testAModelArchivedWithoutATokenDecodesWithANilToken() throws { + let model = makeModel(token: nil) + model.addAliases([OS_ONESIGNAL_ID: "osid-a"]) + + let decoded = try archiveThenUnarchive(model) + + XCTAssertNil(decoded.jwtBearerToken) + XCTAssertEqual(decoded.onesignalId, "osid-a") + } + + func testTheInvalidSentinelIsWhatPersists() throws { + let model = makeModel(token: "valid-token") + model.invalidateJwtBearerToken(rejectedToken: "valid-token") + + let decoded = try archiveThenUnarchive(model) + + XCTAssertEqual(decoded.jwtBearerToken, OS_JWT_TOKEN_INVALID) + XCTAssertNil(decoded.getValidJwt()) + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OSUserJwtRepoTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OSUserJwtRepoTests.swift new file mode 100644 index 000000000..dc492a9f4 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OSUserJwtRepoTests.swift @@ -0,0 +1,196 @@ +/* + 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 XCTest +import OneSignalCore +import OneSignalOSCore +@testable import OneSignalUser + +/// Covers when the app is asked for a replacement token. Parking of the models themselves is +/// `OSIdentityModelRepoTests`. +final class OSUserJwtRepoTests: XCTestCase { + private var identityModelRepo: OSIdentityModelRepo! + private var repo: OSUserJwtRepo! + private var asked: [String] = [] + + override func setUp() { + super.setUp() + identityModelRepo = OSIdentityModelRepo() + asked = [] + repo = OSUserJwtRepo(identityModelRepo: identityModelRepo) { [weak self] externalId in + self?.asked.append(externalId) + } + } + + @discardableResult + private func addModel(externalId: String, token: String?) -> OSIdentityModel { + let model = OSIdentityModel(aliases: [OS_EXTERNAL_ID: externalId], changeNotifier: OSEventProducer()) + model.jwtBearerToken = token + identityModelRepo.add(model: model) + return model + } + + // MARK: - validJwt + + func testValidJwtReadsThroughToTheIdentityModel() { + addModel(externalId: "user-a", token: "token-a") + + XCTAssertEqual(repo.validJwt(externalId: "user-a"), "token-a") + XCTAssertNil(repo.validJwt(externalId: "user-b")) + } + + // MARK: - askForToken + + /// A Request that parks for want of a token has nothing to reject, so it asks directly. + func testAskingForATokenAsksOncePerExternalId() { + XCTAssertTrue(repo.askForToken(externalId: "user-a")) + XCTAssertFalse(repo.askForToken(externalId: "user-a")) + XCTAssertTrue(repo.askForToken(externalId: "user-b")) + + XCTAssertEqual(asked, ["user-a", "user-b"]) + } + + /// Whichever path asks first, the other stays quiet for the rest of the session. + func testAParkedRequestAndARejectedTokenShareTheOneAsk() { + addModel(externalId: "user-a", token: "token-a") + + XCTAssertTrue(repo.askForToken(externalId: "user-a")) + XCTAssertFalse(repo.invalidateJwt(externalId: "user-a", rejectedToken: "token-a")) + + XCTAssertEqual(asked, ["user-a"]) + } + + // MARK: - invalidateJwt + + func testInvalidatingParksTheTokenAndAsksTheApp() { + let model = addModel(externalId: "user-a", token: "token-a") + + XCTAssertTrue(repo.invalidateJwt(externalId: "user-a", rejectedToken: "token-a")) + XCTAssertNil(model.getValidJwt()) + XCTAssertEqual(asked, ["user-a"]) + } + + /// Several Requests can be rejected before the app answers; asking once is enough. + func testInvalidatingTwiceAsksOnce() { + addModel(externalId: "user-a", token: "token-a") + + XCTAssertTrue(repo.invalidateJwt(externalId: "user-a", rejectedToken: "token-a")) + XCTAssertFalse(repo.invalidateJwt(externalId: "user-a", rejectedToken: "token-a")) + XCTAssertEqual(asked, ["user-a"]) + } + + /// The replacement arrived while the rejected Request was in flight, so the retry can use it. + func testInvalidatingAStaleTokenLeavesTheReplacementAndDoesNotAsk() { + let model = addModel(externalId: "user-a", token: "token-b") + + XCTAssertFalse(repo.invalidateJwt(externalId: "user-a", rejectedToken: "token-a")) + XCTAssertEqual(model.getValidJwt(), "token-b") + XCTAssertTrue(asked.isEmpty) + } + + func testInvalidatingAnUnknownExternalIdAsksNobody() { + addModel(externalId: "user-a", token: "token-a") + + XCTAssertFalse(repo.invalidateJwt(externalId: "user-b", rejectedToken: "token-a")) + XCTAssertTrue(asked.isEmpty) + } + + /// A model restored from cache already holds the sentinel, so there is no transition left to make. + /// The app still has to be told once this session that it owes a token. + func testInvalidatingAnAlreadyParkedTokenStillAsksOnceThisSession() { + addModel(externalId: "user-a", token: OS_JWT_TOKEN_INVALID) + + XCTAssertTrue(repo.invalidateJwt(externalId: "user-a", rejectedToken: "token-a")) + XCTAssertEqual(asked, ["user-a"]) + } + + // MARK: - updateJwt + + func testANewTokenRearmsTheRequestForAnother() { + addModel(externalId: "user-a", token: "token-a") + _ = repo.invalidateJwt(externalId: "user-a", rejectedToken: "token-a") + + repo.updateJwt(externalId: "user-a", token: "token-b") + XCTAssertEqual(repo.validJwt(externalId: "user-a"), "token-b") + + XCTAssertTrue(repo.invalidateJwt(externalId: "user-a", rejectedToken: "token-b")) + XCTAssertEqual(asked, ["user-a", "user-a"]) + } + + /// An app answering with nothing usable must not be able to trade asks and replies with the SDK. + func testAnEmptyTokenDoesNotRearmTheRequestForAnother() { + addModel(externalId: "user-a", token: "token-a") + _ = repo.invalidateJwt(externalId: "user-a", rejectedToken: "token-a") + + XCTAssertFalse(repo.updateJwt(externalId: "user-a", token: "")) + + XCTAssertFalse(repo.askForToken(externalId: "user-a")) + XCTAssertEqual(asked, ["user-a"]) + } + + /// An app answering for an external ID the SDK has no model for stores nothing, so treating it as an + /// answer would have the SDK and the app trade asks and replies on every flush. + func testATokenForAnUnknownExternalIdIsNotStoredAndDoesNotRearmTheRequestForAnother() { + addModel(externalId: "user-a", token: "token-a") + _ = repo.invalidateJwt(externalId: "user-a", rejectedToken: "token-a") + + XCTAssertFalse(repo.updateJwt(externalId: "typo-id", token: "token-b")) + + XCTAssertNil(repo.validJwt(externalId: "user-a")) + XCTAssertFalse(repo.askForToken(externalId: "user-a")) + XCTAssertEqual(asked, ["user-a"]) + } + + /// The sentinel is what a rejection writes, so storing it as an update would park a working token. + func testTheInvalidSentinelIsIgnored() { + let model = addModel(externalId: "user-a", token: "token-a") + + XCTAssertFalse(repo.updateJwt(externalId: "user-a", token: OS_JWT_TOKEN_INVALID)) + + XCTAssertEqual(model.getValidJwt(), "token-a") + } + + /// A usable replacement must reach a user whose token was already rejected. + func testAReplacementTokenOverwritesTheParkedSentinel() { + addModel(externalId: "user-a", token: "token-a") + _ = repo.invalidateJwt(externalId: "user-a", rejectedToken: "token-a") + + repo.updateJwt(externalId: "user-a", token: "token-b") + + XCTAssertEqual(repo.validJwt(externalId: "user-a"), "token-b") + } + + func testUsersAreAskedForIndependently() { + addModel(externalId: "user-a", token: "token-a") + addModel(externalId: "user-b", token: "token-b") + + _ = repo.invalidateJwt(externalId: "user-a", rejectedToken: "token-a") + _ = repo.invalidateJwt(externalId: "user-b", rejectedToken: "token-b") + + XCTAssertEqual(asked, ["user-a", "user-b"]) + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtApiTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtApiTests.swift new file mode 100644 index 000000000..f24b44667 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtApiTests.swift @@ -0,0 +1,182 @@ +/* + 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 XCTest +import OneSignalCore +import OneSignalCoreMocks +import OneSignalUserMocks +@testable import OneSignalOSCore +@testable import OneSignalUser + +/** + Public JWT surface on the User Manager: store a token, and tell the app when it stopped being accepted. + Goes through `sharedInstance` because the JWT config and IV service are shared. + + Asks are driven through `userJwtRepo` the way a rejected Request is: there is no public invalidate API. + */ +final class UserJwtApiTests: XCTestCase { + + private var listener = MockUserJwtInvalidatedListener() + + override func setUpWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + OneSignalUserMocks.reset() + OneSignalIdentifiers.currentAppId = "test-app-id" + OneSignalCoreImpl.setSharedClient(MockOneSignalClient()) + + // Held strongly for the test's lifetime: OSObservable keeps observers weakly. + listener = MockUserJwtInvalidatedListener() + OneSignalUserManagerImpl.sharedInstance.addUserJwtInvalidatedListener(listener) + } + + override func tearDownWithError() throws { + OneSignalUserManagerImpl.sharedInstance.removeUserJwtInvalidatedListener(listener) + OneSignalCoreMocks.clearUserDefaults() + } + + /// `OSObservable` delivers on the main queue, so a block enqueued after the notification runs + /// once the notification has — deterministic, rather than waiting out a timeout. + private func drainMainQueue() { + let drained = expectation(description: "main queue drained") + DispatchQueue.main.async { drained.fulfill() } + waitForExpectations(timeout: 5) + } + + /// Parks the token and asks the app the way a 401 on a user Request does. + @discardableResult + private func invalidate(externalId: String, rejectedToken: String) -> Bool { + return OneSignalUserManagerImpl.sharedInstance.userJwtRepo.invalidateJwt( + externalId: externalId, + rejectedToken: rejectedToken + ) + } + + // MARK: - updateUserJwt + + func testUpdateUserJwtStoresTheTokenOnThatUsersIdentityModel() { + let user = OneSignalUserMocks.setUserManagerInternalUser(externalId: "user-a", onesignalId: "osid-a") + + OneSignalUserManagerImpl.sharedInstance.updateUserJwt(externalId: "user-a", token: "token-a") + + XCTAssertEqual(user.identityModel.getValidJwt(), "token-a") + } + + func testUpdateUserJwtForAnUnknownExternalIdLeavesTheCurrentUserAlone() { + let user = OneSignalUserMocks.setUserManagerInternalUser(externalId: "user-a", onesignalId: "osid-a") + + OneSignalUserManagerImpl.sharedInstance.updateUserJwt(externalId: "user-b", token: "token-b") + + XCTAssertNil(user.identityModel.jwtBearerToken) + } + + /// An empty token reads as no token at all, so it would leave the user unable to sign a request + /// and unable to be asked for another one. + func testUpdateUserJwtWithAnEmptyTokenLeavesThePreviousTokenInPlace() { + let user = OneSignalUserMocks.setUserManagerInternalUser(externalId: "user-a", onesignalId: "osid-a") + user.identityModel.jwtBearerToken = "token-a" + + OneSignalUserManagerImpl.sharedInstance.updateUserJwt(externalId: "user-a", token: "") + + XCTAssertEqual(user.identityModel.getValidJwt(), "token-a") + } + + /// Storing the sentinel would look like an already-invalidated token, so the app is never asked again. + func testUpdateUserJwtWithTheInvalidSentinelIsRejected() { + let user = OneSignalUserMocks.setUserManagerInternalUser(externalId: "user-a", onesignalId: "osid-a") + + OneSignalUserManagerImpl.sharedInstance.updateUserJwt(externalId: "user-a", token: OS_JWT_TOKEN_INVALID) + + XCTAssertNil(user.identityModel.jwtBearerToken) + } + + // MARK: - Invalidated listener + + func testInvalidatingAJwtParksTheTokenAndNotifiesTheApp() { + let user = OneSignalUserMocks.setUserManagerInternalUser(externalId: "user-a", onesignalId: "osid-a") + user.identityModel.jwtBearerToken = "token-a" + + invalidate(externalId: "user-a", rejectedToken: "token-a") + drainMainQueue() + + XCTAssertNil(user.identityModel.getValidJwt()) + XCTAssertEqual(listener.invalidatedExternalIds, ["user-a"]) + } + + /// Several requests can be rejected before the app supplies a new token; asking it once is enough. + func testInvalidatingAJwtTwiceNotifiesOnce() { + let user = OneSignalUserMocks.setUserManagerInternalUser(externalId: "user-a", onesignalId: "osid-a") + user.identityModel.jwtBearerToken = "token-a" + + invalidate(externalId: "user-a", rejectedToken: "token-a") + invalidate(externalId: "user-a", rejectedToken: "token-a") + drainMainQueue() + + XCTAssertEqual(listener.invalidatedExternalIds, ["user-a"]) + } + + func testARemovedListenerIsNotNotified() { + let user = OneSignalUserMocks.setUserManagerInternalUser(externalId: "user-a", onesignalId: "osid-a") + user.identityModel.jwtBearerToken = "token-a" + OneSignalUserManagerImpl.sharedInstance.removeUserJwtInvalidatedListener(listener) + + invalidate(externalId: "user-a", rejectedToken: "token-a") + drainMainQueue() + + XCTAssertTrue(listener.invalidatedExternalIds.isEmpty) + } + + /// An ask that fires before the app registers still has to reach a late listener. + func testAListenerAddedAfterAnAskStillHearsWhoOwesAToken() { + OneSignalUserManagerImpl.sharedInstance.removeUserJwtInvalidatedListener(listener) + let user = OneSignalUserMocks.setUserManagerInternalUser(externalId: "user-a", onesignalId: "osid-a") + user.identityModel.jwtBearerToken = "token-a" + + invalidate(externalId: "user-a", rejectedToken: "token-a") + drainMainQueue() + XCTAssertTrue(listener.invalidatedExternalIds.isEmpty) + + OneSignalUserManagerImpl.sharedInstance.addUserJwtInvalidatedListener(listener) + drainMainQueue() + + XCTAssertEqual(listener.invalidatedExternalIds, ["user-a"]) + } + + /// A token that lands before the late listener is delivered must not be asked for again. + func testALateListenerIsNotToldAboutAnAskThatWasAlreadyAnswered() { + OneSignalUserManagerImpl.sharedInstance.removeUserJwtInvalidatedListener(listener) + let user = OneSignalUserMocks.setUserManagerInternalUser(externalId: "user-a", onesignalId: "osid-a") + user.identityModel.jwtBearerToken = "token-a" + + invalidate(externalId: "user-a", rejectedToken: "token-a") + OneSignalUserManagerImpl.sharedInstance.updateUserJwt(externalId: "user-a", token: "token-b") + + OneSignalUserManagerImpl.sharedInstance.addUserJwtInvalidatedListener(listener) + drainMainQueue() + + XCTAssertTrue(listener.invalidatedExternalIds.isEmpty) + } +} diff --git a/iOS_SDK/OneSignalSDK/Source/OneSignal.m b/iOS_SDK/OneSignalSDK/Source/OneSignal.m index 552daf87a..437aa2550 100755 --- a/iOS_SDK/OneSignalSDK/Source/OneSignal.m +++ b/iOS_SDK/OneSignalSDK/Source/OneSignal.m @@ -190,6 +190,18 @@ + (void)login:(NSString * _Nonnull)externalId withToken:(NSString * _Nullable)to [OneSignalUserManagerImpl.sharedInstance loginWithExternalId:externalId token:token]; } ++ (void)addUserJwtInvalidatedListener:(id _Nonnull)listener { + [OneSignalUserManagerImpl.sharedInstance addUserJwtInvalidatedListener:listener]; +} + ++ (void)removeUserJwtInvalidatedListener:(id _Nonnull)listener { + [OneSignalUserManagerImpl.sharedInstance removeUserJwtInvalidatedListener:listener]; +} + ++ (void)updateUserJwt:(NSString * _Nonnull)externalId withToken:(NSString * _Nonnull)token { + [OneSignalUserManagerImpl.sharedInstance updateUserJwtWithExternalId:externalId token:token]; +} + + (void)logout { [OneSignalUserManagerImpl.sharedInstance logout]; } diff --git a/iOS_SDK/OneSignalSDK/Source/OneSignalFramework.h b/iOS_SDK/OneSignalSDK/Source/OneSignalFramework.h index 90b8b0d8a..59c5d71c2 100755 --- a/iOS_SDK/OneSignalSDK/Source/OneSignalFramework.h +++ b/iOS_SDK/OneSignalSDK/Source/OneSignalFramework.h @@ -52,6 +52,7 @@ // Forward declarations for Objective-C++ compatibility @protocol OSUser; @protocol OSLiveActivities; +@protocol OSUserJwtInvalidatedListener; #pragma clang diagnostic push #pragma clang diagnostic ignored "-Wstrict-prototypes" @@ -71,6 +72,10 @@ typedef void (^OSFailureBlock)(NSError* error); + (void)login:(NSString * _Nonnull)externalId; + (void)login:(NSString * _Nonnull)externalId withToken:(NSString * _Nullable)token NS_SWIFT_NAME(login(externalId:token:)); ++ (void)addUserJwtInvalidatedListener:(id _Nonnull)listener NS_REFINED_FOR_SWIFT; ++ (void)removeUserJwtInvalidatedListener:(id _Nonnull)listener NS_REFINED_FOR_SWIFT; ++ (void)updateUserJwt:(NSString * _Nonnull)externalId withToken:(NSString * _Nonnull)token +NS_SWIFT_NAME(updateUserJwt(externalId:token:)); + (void)logout; #pragma mark Notifications diff --git a/iOS_SDK/OneSignalSDK/Source/OneSignalSwiftInterface.swift b/iOS_SDK/OneSignalSDK/Source/OneSignalSwiftInterface.swift index bf399005c..756ec78ac 100644 --- a/iOS_SDK/OneSignalSDK/Source/OneSignalSwiftInterface.swift +++ b/iOS_SDK/OneSignalSDK/Source/OneSignalSwiftInterface.swift @@ -33,6 +33,14 @@ import OneSignalNotifications import OneSignalCore public extension OneSignal { + static func addUserJwtInvalidatedListener(_ listener: OSUserJwtInvalidatedListener) { + __add(listener) + } + + static func removeUserJwtInvalidatedListener(_ listener: OSUserJwtInvalidatedListener) { + __remove(listener) + } + static var User: OSUser { return __user() } From 5aea6710152c2aefdf2697503f1ffe7c31baf58e Mon Sep 17 00:00:00 2001 From: Nan Date: Wed, 12 Aug 2026 10:16:29 -0700 Subject: [PATCH 08/21] fix: [PR4] mark JWT bearer updates as local-only Use preventServerUpdate so the token never becomes an identity delta by accident of the alias cast failing. Co-authored-by: Cursor --- .../OneSignalSDK/OneSignalUser/Source/OSIdentityModel.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModel.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModel.swift index bb5f0f67c..6df132faf 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModel.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModel.swift @@ -58,7 +58,7 @@ class OSIdentityModel: OSModel { return true } if changed { - self.set(property: OS_JWT_BEARER_TOKEN, newValue: newValue) + self.set(property: OS_JWT_BEARER_TOKEN, newValue: newValue, preventServerUpdate: true) } } } @@ -83,7 +83,7 @@ class OSIdentityModel: OSModel { return true } if changed { - self.set(property: OS_JWT_BEARER_TOKEN, newValue: OS_JWT_TOKEN_INVALID) + self.set(property: OS_JWT_BEARER_TOKEN, newValue: OS_JWT_TOKEN_INVALID, preventServerUpdate: true) } return changed } From 2c267bf839f10e7a334dd7d053c4c7b541c9463a Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 11 Aug 2026 16:53:25 -0700 Subject: [PATCH 09/21] feat: [PR5] bind each Delta to the user that owns it A Delta records a change to a model but not who it was for, so anything built from one had to ask who the current user is. Between the change and the flush the app may have logged in as somebody else, and the queued work was then sent for whoever happened to be current. OSDelta now carries the external ID of the user it was made for, as a required argument so no call site can leave it out. The model store listeners take the operation repo by injection rather than reaching for the singleton, which is also what lets the next PR give the repo an Identity Verification-aware instance. Nothing reads the new field yet; the request pipeline picks it up next. Co-authored-by: Cursor --- .../OneSignal.xcodeproj/project.pbxproj | 8 + .../OneSignalOSCore/Source/OSDelta.swift | 7 +- .../Source/OSModelStoreListener.swift | 11 +- .../OneSignalOSCoreTests/OSDeltaTests.swift | 74 ++++++ .../OSOperationRepoFlushTests.swift | 1 + .../Source/OSIdentityModelStoreListener.swift | 10 +- .../OSPropertiesModelStoreListener.swift | 8 +- .../OSSubscriptionModelStoreListener.swift | 18 +- .../Source/OneSignalUserManagerImpl.swift | 14 +- .../DeltaOwnershipTests.swift | 212 ++++++++++++++++++ .../OSCustomEventsExecutorTests.swift | 1 + .../SubscriptionUpdateRaceTests.swift | 6 + .../UserConcurrencyTests.swift | 12 +- 13 files changed, 357 insertions(+), 25 deletions(-) create mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSDeltaTests.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalUserTests/DeltaOwnershipTests.swift diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index 50c5ad2c3..8bea2dbee 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -283,6 +283,7 @@ 475F474A2B8E3B4600EC05B3 /* OneSignalLiveActivities.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 475F471E2B8E398D00EC05B3 /* OneSignalLiveActivities.framework */; platformFilter = ios; }; 475F474F2B8E3B5400EC05B3 /* OneSignalLiveActivities.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 475F471E2B8E398D00EC05B3 /* OneSignalLiveActivities.framework */; }; 475F47502B8E3B5400EC05B3 /* OneSignalLiveActivities.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 475F471E2B8E398D00EC05B3 /* OneSignalLiveActivities.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 4E8E880086C66B4120CC0CD4 /* OSDeltaTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 557653D2007BFF86EA8342E4 /* OSDeltaTests.swift */; }; 50C4F7CFEC46836131011004 /* XCTestCase+WaitUntil.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03A81CCA8DFA43C8AEA738B8 /* XCTestCase+WaitUntil.swift */; }; 5B053FBC2CAE07EB002F30C4 /* OneSignalOSCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3C115161289A259500565C41 /* OneSignalOSCore.framework */; }; 5B053FC32CAE0843002F30C4 /* OSConsistencyManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BC1DE672C90C23E00CA8807 /* OSConsistencyManagerTests.swift */; }; @@ -368,6 +369,7 @@ 91F60F7D1E80E4E400706E60 /* UncaughtExceptionHandler.m in Sources */ = {isa = PBXBuildFile; fileRef = 91F60F7C1E80E4E400706E60 /* UncaughtExceptionHandler.m */; }; 9224347AAE3E092B5743380D /* UserJwtApiTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80DC5517E6EB5B26CF980CC5 /* UserJwtApiTests.swift */; }; 94D9F5AFE86B16453A36C9A2 /* MockUserJwtInvalidatedListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F214EE6C5FE133672D6622F /* MockUserJwtInvalidatedListener.swift */; }; + 99B1615D0132AFAA981A9AD3 /* DeltaOwnershipTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3881024646E7F0DE05158442 /* DeltaOwnershipTests.swift */; }; 9D3300FA23149DAE000F0A83 /* CoreGraphics.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9D3300F923149DAE000F0A83 /* CoreGraphics.framework */; }; 9D348537233C669E00EB81C9 /* CoreLocation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9D348536233C669E00EB81C9 /* CoreLocation.framework */; }; 9D34853A233D2E3600EB81C9 /* OneSignalLocationOverrider.m in Sources */ = {isa = PBXBuildFile; fileRef = 9D348539233D2E3600EB81C9 /* OneSignalLocationOverrider.m */; }; @@ -1351,6 +1353,7 @@ 2F2FC6C922FF8104F3197DD4 /* OSUserJwtInvalidatedEvent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSUserJwtInvalidatedEvent.swift; sourceTree = ""; }; 37747F9319147D6500558FAD /* libOneSignal.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libOneSignal.a; sourceTree = BUILT_PRODUCTS_DIR; }; 37E6B2BA19D9CAF300D0C601 /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; }; + 3881024646E7F0DE05158442 /* DeltaOwnershipTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DeltaOwnershipTests.swift; sourceTree = ""; }; 3C01518E2C2E298E0079E076 /* OneSignalInAppMessagesTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OneSignalInAppMessagesTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3C01519B2C2E29F90079E076 /* IAMRequestTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = IAMRequestTests.m; sourceTree = ""; }; 3C0EF49D28A1DBCB00E5434B /* OSUserInternalImpl.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OSUserInternalImpl.swift; sourceTree = ""; }; @@ -1542,6 +1545,7 @@ 475F47202B8E398E00EC05B3 /* OneSignalLiveActivities.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OneSignalLiveActivities.h; sourceTree = ""; }; 475F47482B8E3A4400EC05B3 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 4CCE2C93100CAFEE8EB39C77 /* OneSignalUserManagerImpl+Jwt.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "OneSignalUserManagerImpl+Jwt.swift"; sourceTree = ""; }; + 557653D2007BFF86EA8342E4 /* OSDeltaTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSDeltaTests.swift; sourceTree = ""; }; 5953656ACCC21358BC0CF2F0 /* OSIdentityModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSIdentityModelTests.swift; sourceTree = ""; }; 5B053FB82CAE07EB002F30C4 /* OneSignalOSCoreTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OneSignalOSCoreTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 5B58E4F3237CE7B3009401E0 /* UIDeviceOverrider.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = UIDeviceOverrider.h; sourceTree = ""; }; @@ -2434,6 +2438,7 @@ 5953656ACCC21358BC0CF2F0 /* OSIdentityModelTests.swift */, B5809B47EB4F2478099697CE /* OSUserJwtRepoTests.swift */, 80DC5517E6EB5B26CF980CC5 /* UserJwtApiTests.swift */, + 3881024646E7F0DE05158442 /* DeltaOwnershipTests.swift */, ); path = OneSignalUserTests; sourceTree = ""; @@ -2612,6 +2617,7 @@ 03A81CCA8DFA43C8AEA738B8 /* XCTestCase+WaitUntil.swift */, 4795885CE6CFFB1998AC7D09 /* Feature */, 5B47CE0CE255AC7128442FFF /* Jwt */, + 557653D2007BFF86EA8342E4 /* OSDeltaTests.swift */, ); path = OneSignalOSCoreTests; sourceTree = ""; @@ -4595,6 +4601,7 @@ B5FBED8247288744EB484CB5 /* OSIdentityModelTests.swift in Sources */, 2DB99C76F3532383C3B81D09 /* OSUserJwtRepoTests.swift in Sources */, 9224347AAE3E092B5743380D /* UserJwtApiTests.swift in Sources */, + 99B1615D0132AFAA981A9AD3 /* DeltaOwnershipTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -4673,6 +4680,7 @@ 2F32272222E88DF0C2C18B53 /* OSFeatureManagerTests.swift in Sources */, 9FF50E2A40C88E4533033A38 /* OSIdentityVerificationServiceTests.swift in Sources */, 8E949FF4C7A7A2C7182E53EA /* OSUserJwtConfigTests.swift in Sources */, + 4E8E880086C66B4120CC0CD4 /* OSDeltaTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSDelta.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSDelta.swift index 9cbba04e3..6feb7eced 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSDelta.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSDelta.swift @@ -36,6 +36,8 @@ open class OSDelta: NSObject, NSCoding { public let deltaId: String public let timestamp: Date public let identityModelId: String + /// The `external_id` of the user this change belongs to, `nil` if that user was anonymous. + public let externalId: String? public var model: OSModel public let property: String public let value: Any @@ -44,11 +46,12 @@ open class OSDelta: NSObject, NSCoding { return "" } - public init(name: String, identityModelId: String, model: OSModel, property: String, value: Any) { + public init(name: String, identityModelId: String, externalId: String?, model: OSModel, property: String, value: Any) { self.name = name self.deltaId = UUID().uuidString self.timestamp = Date() self.identityModelId = identityModelId + self.externalId = externalId self.model = model self.property = property self.value = value @@ -59,6 +62,7 @@ open class OSDelta: NSObject, NSCoding { coder.encode(deltaId, forKey: "deltaId") coder.encode(timestamp, forKey: "timestamp") coder.encode(identityModelId, forKey: "identityModelId") + coder.encode(externalId, forKey: "externalId") coder.encode(model, forKey: "model") coder.encode(property, forKey: "property") coder.encode(value, forKey: "value") @@ -81,6 +85,7 @@ open class OSDelta: NSObject, NSCoding { self.deltaId = deltaId self.timestamp = timestamp self.identityModelId = identityModelId + self.externalId = coder.decodeObject(forKey: "externalId") as? String self.model = model self.property = property self.value = value diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSModelStoreListener.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSModelStoreListener.swift index 540dcca40..3d3c046e4 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSModelStoreListener.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSModelStoreListener.swift @@ -33,7 +33,10 @@ public protocol OSModelStoreListener: OSModelStoreChangedHandler { var store: OSModelStore { get } - init(store: OSModelStore) + // Injected: the shared enqueue below lives in OSCore, which cannot reach the User Manager. + var operationRepo: OSOperationRepo { get } + + init(store: OSModelStore, operationRepo: OSOperationRepo) func getAddModelDelta(_ model: TModel) -> OSDelta? @@ -57,13 +60,13 @@ extension OSModelStoreListener { return } if let delta = getAddModelDelta(addedModel) { - OSOperationRepo.sharedInstance.enqueueDelta(delta) + operationRepo.enqueueDelta(delta) } } public func onUpdated(_ args: OSModelChangedArgs) { if let delta = getUpdateModelDelta(args) { - OSOperationRepo.sharedInstance.enqueueDelta(delta) + operationRepo.enqueueDelta(delta) } } @@ -74,7 +77,7 @@ extension OSModelStoreListener { return } if let delta = getRemoveModelDelta(removedModel) { - OSOperationRepo.sharedInstance.enqueueDelta(delta) + operationRepo.enqueueDelta(delta) } } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSDeltaTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSDeltaTests.swift new file mode 100644 index 000000000..ea887c944 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSDeltaTests.swift @@ -0,0 +1,74 @@ +/* + 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 XCTest +@testable import OneSignalOSCore + +/// Covers `OSDelta` archive round trips, including the owning user's external ID. +final class OSDeltaTests: XCTestCase { + + private func makeDelta(externalId: String?) -> OSDelta { + OSDelta( + name: "test_delta", + identityModelId: "identity-model-a", + externalId: externalId, + model: OSModel(changeNotifier: OSEventProducer()), + property: "language", + value: "en" + ) + } + + private func archiveThenUnarchive(_ delta: OSDelta) throws -> OSDelta { + let data = try NSKeyedArchiver.archivedData(withRootObject: delta, requiringSecureCoding: false) + let unarchiver = try NSKeyedUnarchiver(forReadingFrom: data) + unarchiver.requiresSecureCoding = false + defer { unarchiver.finishDecoding() } + return try XCTUnwrap(unarchiver.decodeObject(forKey: NSKeyedArchiveRootObjectKey) as? OSDelta) + } + + func testExternalIdSurvivesAnArchiveRoundTrip() throws { + let delta = makeDelta(externalId: "user-a") + + let decoded = try archiveThenUnarchive(delta) + + XCTAssertEqual(decoded.externalId, "user-a") + XCTAssertEqual(decoded.identityModelId, "identity-model-a") + XCTAssertEqual(decoded.deltaId, delta.deltaId) + } + + /// Decode must succeed when externalId is absent, or queued work is dropped on upgrade. + func testADeltaWithoutAnExternalIdStillDecodes() throws { + let delta = makeDelta(externalId: nil) + + let decoded = try archiveThenUnarchive(delta) + + XCTAssertNil(decoded.externalId) + XCTAssertEqual(decoded.identityModelId, "identity-model-a") + XCTAssertEqual(decoded.property, "language") + XCTAssertEqual(decoded.value as? String, "en") + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoFlushTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoFlushTests.swift index 875a26ffb..de6859a1d 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoFlushTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoFlushTests.swift @@ -133,6 +133,7 @@ final class OSOperationRepoFlushTests: XCTestCase { OSDelta( name: name, identityModelId: UUID().uuidString, + externalId: nil, model: OSModel(changeNotifier: OSEventProducer()), property: property, value: property diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModelStoreListener.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModelStoreListener.swift index 4edabb321..937403966 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModelStoreListener.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModelStoreListener.swift @@ -31,9 +31,11 @@ import OneSignalOSCore class OSIdentityModelStoreListener: OSModelStoreListener { var store: OSModelStore + let operationRepo: OSOperationRepo - required init(store: OSModelStore) { + required init(store: OSModelStore, operationRepo: OSOperationRepo) { self.store = store + self.operationRepo = operationRepo } func getAddModelDelta(_ model: OSIdentityModel) -> OSDelta? { @@ -50,6 +52,7 @@ class OSIdentityModelStoreListener: OSModelStoreListener { func getUpdateModelDelta(_ args: OSModelChangedArgs) -> OSDelta? { // TODO: Let users call addAliases with "" IDs? If so, this will change... guard + let identityModel = args.model as? OSIdentityModel, let aliasesDict = args.newValue as? [String: String], let (_, id) = aliasesDict.first else { @@ -60,8 +63,9 @@ class OSIdentityModelStoreListener: OSModelStoreListener { return OSDelta( name: name, - identityModelId: args.model.modelId, - model: args.model, + identityModelId: identityModel.modelId, + externalId: identityModel.externalId, + model: identityModel, property: args.property, value: args.newValue ) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSPropertiesModelStoreListener.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSPropertiesModelStoreListener.swift index d1cea8929..664d3de77 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSPropertiesModelStoreListener.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSPropertiesModelStoreListener.swift @@ -31,9 +31,11 @@ import OneSignalOSCore class OSPropertiesModelStoreListener: OSModelStoreListener { var store: OSModelStore + let operationRepo: OSOperationRepo - required init(store: OSModelStore) { + required init(store: OSModelStore, operationRepo: OSOperationRepo) { self.store = store + self.operationRepo = operationRepo } func getAddModelDelta(_ model: OSPropertiesModel) -> OSDelta? { @@ -51,9 +53,11 @@ class OSPropertiesModelStoreListener: OSModelStoreListener { OneSignalLog.onesignalLog(.LL_ERROR, message: "OSPropertiesModelStoreListener.getUpdateModelDelta encountered unsupported property: \(args.property) or no user instance") return nil } + let identityModel = userInstance.identityModel return OSDelta( name: OS_UPDATE_PROPERTIES_DELTA, - identityModelId: userInstance.identityModel.modelId, + identityModelId: identityModel.modelId, + externalId: identityModel.externalId, model: args.model, property: args.property, value: args.newValue diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModelStoreListener.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModelStoreListener.swift index a51afc244..8b800b5f4 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModelStoreListener.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModelStoreListener.swift @@ -31,9 +31,11 @@ import OneSignalOSCore class OSSubscriptionModelStoreListener: OSModelStoreListener { var store: OSModelStore + let operationRepo: OSOperationRepo - required init(store: OSModelStore) { + required init(store: OSModelStore, operationRepo: OSOperationRepo) { self.store = store + self.operationRepo = operationRepo } func getAddModelDelta(_ model: OSSubscriptionModel) -> OSDelta? { @@ -41,9 +43,11 @@ class OSSubscriptionModelStoreListener: OSModelStoreListener { OneSignalLog.onesignalLog(.LL_ERROR, message: "OSSubscriptionModelStoreListener.getAddModelDelta has no user instance") return nil } + let identityModel = userInstance.identityModel return OSDelta( name: OS_ADD_SUBSCRIPTION_DELTA, - identityModelId: userInstance.identityModel.modelId, + identityModelId: identityModel.modelId, + externalId: identityModel.externalId, model: model, property: model.type.rawValue, // push, email, sms value: model.address ?? "" @@ -58,9 +62,11 @@ class OSSubscriptionModelStoreListener: OSModelStoreListener { OneSignalLog.onesignalLog(.LL_ERROR, message: "OSSubscriptionModelStoreListener.getRemoveModelDelta has no user instance") return nil } + let identityModel = userInstance.identityModel return OSDelta( name: OS_REMOVE_SUBSCRIPTION_DELTA, - identityModelId: userInstance.identityModel.modelId, + identityModelId: identityModel.modelId, + externalId: identityModel.externalId, model: model, property: model.type.rawValue, // push, email, sms value: model.address ?? "" @@ -78,14 +84,16 @@ class OSSubscriptionModelStoreListener: OSModelStoreListener { OneSignalLog.onesignalLog(.LL_ERROR, message: "OSSubscriptionModelStoreListener.getUpdateModelDelta has no user instance") return nil } - if let onesignalId = userInstance.identityModel.onesignalId { + let identityModel = userInstance.identityModel + if let onesignalId = identityModel.onesignalId { let condition = OSIamFetchReadyCondition.sharedInstance(withId: onesignalId) condition.setSubscriptionUpdatePending(value: true) } return OSDelta( name: OS_UPDATE_SUBSCRIPTION_DELTA, - identityModelId: userInstance.identityModel.modelId, + identityModelId: identityModel.modelId, + externalId: identityModel.externalId, model: args.model, property: args.property, value: args.newValue diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift index ca4457d62..f58e983b3 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift @@ -128,6 +128,10 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { let newRecordsState = OSNewRecordsState() + // Injected into the model store listeners so a Delta is enqueued against a known repo rather + // than reaching for the singleton. A later PR replaces this with an owned instance. + let operationRepo = OSOperationRepo.sharedInstance + // Shared instances: remote params hydrate them before this class is started, and a // fresh instance here would read none of it. let featureManager = OSFeatureManager.shared @@ -223,10 +227,10 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { } self.identityVerificationService = identityVerificationService self.userJwtRepo = userJwtRepo - self.identityModelStoreListener = OSIdentityModelStoreListener(store: identityModelStore) - self.propertiesModelStoreListener = OSPropertiesModelStoreListener(store: propertiesModelStore) - self.subscriptionModelStoreListener = OSSubscriptionModelStoreListener(store: subscriptionModelStore) - self.pushSubscriptionModelStoreListener = OSSubscriptionModelStoreListener(store: pushSubscriptionModelStore) + self.identityModelStoreListener = OSIdentityModelStoreListener(store: identityModelStore, operationRepo: operationRepo) + self.propertiesModelStoreListener = OSPropertiesModelStoreListener(store: propertiesModelStore, operationRepo: operationRepo) + self.subscriptionModelStoreListener = OSSubscriptionModelStoreListener(store: subscriptionModelStore, operationRepo: operationRepo) + self.pushSubscriptionModelStoreListener = OSSubscriptionModelStoreListener(store: pushSubscriptionModelStore, operationRepo: operationRepo) self.pushSubscriptionImpl = OSPushSubscriptionImpl(pushSubscriptionModelStore: pushSubscriptionModelStore) } @@ -663,6 +667,7 @@ extension OneSignalUserManagerImpl { let delta = OSDelta( name: OS_UPDATE_PROPERTIES_DELTA, identityModelId: identityModel.modelId, + externalId: identityModel.externalId, model: propertiesModel, property: property.rawValue, value: value @@ -882,6 +887,7 @@ extension OneSignalUserManagerImpl: OSUser { let delta = OSDelta( name: OS_CUSTOM_EVENT_DELTA, identityModelId: identityModel.modelId, + externalId: identityModel.externalId, model: identityModel, property: name, value: processedProperties diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/DeltaOwnershipTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/DeltaOwnershipTests.swift new file mode 100644 index 000000000..3dcc825d3 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/DeltaOwnershipTests.swift @@ -0,0 +1,212 @@ +/* + 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 XCTest +import OneSignalCore +import OneSignalCoreMocks +import OneSignalUserMocks +@testable import OneSignalOSCore +@testable import OneSignalUser + +/// Deltas and the requests built from them stay bound to the user whose change produced them. +final class DeltaOwnershipTests: XCTestCase { + + private let userA = "user-a" + private let userB = "user-b" + private let emailAddress = "person@example.com" + + private var manager: OneSignalUserManagerImpl { + return OneSignalUserManagerImpl.sharedInstance + } + + override func setUpWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + OneSignalUserMocks.reset() + OneSignalIdentifiers.currentAppId = "test-app-id" + OneSignalCoreImpl.setSharedClient(MockOneSignalClient()) + // These tests drive the listeners and executors themselves, so keep start() from + // rebuilding the user underneath them. + manager.hasCalledStart = true + // Deltas have to stay in the repo queue long enough to be inspected. + OneSignalUserManagerImpl.sharedInstance.operationRepo.paused = true + } + + override func tearDownWithError() throws { + OneSignalUserManagerImpl.sharedInstance.operationRepo.paused = false + OneSignalCoreMocks.clearUserDefaults() + } + + // MARK: - Model store listeners + + func testPropertiesUpdateDeltaCarriesTheOwningUsersExternalId() throws { + let user = newUser(externalId: userA) + + let delta = try XCTUnwrap(manager.propertiesModelStoreListener.getUpdateModelDelta( + OSModelChangedArgs(model: user.propertiesModel, property: "tags", newValue: ["tag": "value"]) + )) + + XCTAssertEqual(delta.externalId, userA) + XCTAssertEqual(delta.identityModelId, user.identityModel.modelId) + } + + /// Anonymous work must stay stamped nil; filling it in later would attribute it to the wrong user. + func testPropertiesUpdateDeltaFromAnAnonymousUserHasNoExternalId() throws { + let user = newUser(externalId: nil) + + let delta = try XCTUnwrap(manager.propertiesModelStoreListener.getUpdateModelDelta( + OSModelChangedArgs(model: user.propertiesModel, property: "tags", newValue: ["tag": "value"]) + )) + + XCTAssertNil(delta.externalId) + XCTAssertEqual(delta.identityModelId, user.identityModel.modelId) + } + + func testAliasDeltaCarriesTheOwningUsersExternalId() throws { + let user = newUser(externalId: userA) + + let delta = try XCTUnwrap(manager.identityModelStoreListener.getUpdateModelDelta( + OSModelChangedArgs(model: user.identityModel, property: "aliases", newValue: ["my_alias": "my-alias-id"]) + )) + + XCTAssertEqual(delta.name, OS_ADD_ALIAS_DELTA) + XCTAssertEqual(delta.externalId, userA) + XCTAssertEqual(delta.identityModelId, user.identityModel.modelId) + } + + /// Stamped from the changed Identity Model, so a post-switch alias change keeps its owner. + func testAliasDeltaIsStampedFromTheChangedIdentityNotTheCurrentUser() throws { + let first = newUser(externalId: userA) + newUser(externalId: userB) + + let delta = try XCTUnwrap(manager.identityModelStoreListener.getUpdateModelDelta( + OSModelChangedArgs(model: first.identityModel, property: "aliases", newValue: ["my_alias": "my-alias-id"]) + )) + + XCTAssertEqual(delta.externalId, userA) + XCTAssertEqual(delta.identityModelId, first.identityModel.modelId) + } + + func testSubscriptionAddDeltaCarriesTheOwningUsersExternalId() throws { + let user = newUser(externalId: userA) + + let delta = try XCTUnwrap(manager.subscriptionModelStoreListener.getAddModelDelta(emailSubscriptionModel())) + + XCTAssertEqual(delta.externalId, userA) + XCTAssertEqual(delta.identityModelId, user.identityModel.modelId) + } + + func testSubscriptionRemoveDeltaCarriesTheOwningUsersExternalId() throws { + let user = newUser(externalId: userA) + + let delta = try XCTUnwrap(manager.subscriptionModelStoreListener.getRemoveModelDelta(emailSubscriptionModel())) + + XCTAssertEqual(delta.externalId, userA) + XCTAssertEqual(delta.identityModelId, user.identityModel.modelId) + } + + func testSubscriptionUpdateDeltaCarriesTheOwningUsersExternalId() throws { + let user = newUser(externalId: userA) + + let delta = try XCTUnwrap(manager.subscriptionModelStoreListener.getUpdateModelDelta( + OSModelChangedArgs(model: emailSubscriptionModel(), property: "enabled", newValue: true) + )) + + XCTAssertEqual(delta.externalId, userA) + XCTAssertEqual(delta.identityModelId, user.identityModel.modelId) + } + + // MARK: - Deltas the User Manager enqueues itself + + /// Session time and custom events skip the model stores, so they are stamped at enqueue. + func testDeltasEnqueuedByTheUserManagerCarryTheOwningUsersExternalId() throws { + let user = newUser(externalId: userA) + + manager.sendSessionTime(100) + manager.trackEvent(name: "test_event", properties: nil) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + let sessionDelta = try XCTUnwrap(queuedDelta( + named: OS_UPDATE_PROPERTIES_DELTA, + property: OSPropertiesSupportedProperty.session_time.rawValue + )) + let eventDelta = try XCTUnwrap(queuedDelta(named: OS_CUSTOM_EVENT_DELTA, property: "test_event")) + + XCTAssertEqual(sessionDelta.externalId, userA) + XCTAssertEqual(sessionDelta.identityModelId, user.identityModel.modelId) + XCTAssertEqual(eventDelta.externalId, userA) + XCTAssertEqual(eventDelta.identityModelId, user.identityModel.modelId) + } + + /// A queued Delta is already owned; a user switch must not rewrite it. + func testAQueuedDeltaKeepsItsOwnerAfterTheCurrentUserChanges() throws { + let first = newUser(externalId: userA) + + manager.sendSessionTime(100) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + newUser(externalId: userB) + + let delta = try XCTUnwrap(queuedDelta( + named: OS_UPDATE_PROPERTIES_DELTA, + property: OSPropertiesSupportedProperty.session_time.rawValue + )) + + XCTAssertEqual(delta.externalId, userA) + XCTAssertEqual(delta.identityModelId, first.identityModel.modelId) + } + + // MARK: - Helpers + + @discardableResult + private func newUser(externalId: String?) -> OSUserInternal { + return manager.setNewInternalUser( + externalId: externalId, + pushSubscriptionModel: OSSubscriptionModel( + type: .push, + address: "", + subscriptionId: UUID().uuidString, + reachable: false, + isDisabled: false, + changeNotifier: OSEventProducer() + ) + ) + } + + private func emailSubscriptionModel() -> OSSubscriptionModel { + return OSSubscriptionModel( + type: .email, + address: emailAddress, + subscriptionId: "test-subscription-id", + reachable: true, + isDisabled: false, + changeNotifier: OSEventProducer() + ) + } + + private func queuedDelta(named name: String, property: String) -> OSDelta? { + return OneSignalUserManagerImpl.sharedInstance.operationRepo.deltaQueue.first { $0.name == name && $0.property == property } + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSCustomEventsExecutorTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSCustomEventsExecutorTests.swift index 3ea47ff03..12d969d74 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSCustomEventsExecutorTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSCustomEventsExecutorTests.swift @@ -53,6 +53,7 @@ final class OSCustomEventsExecutorTests: XCTestCase { return OSDelta( name: OS_CUSTOM_EVENT_DELTA, identityModelId: identityModel.modelId, + externalId: identityModel.externalId, model: identityModel, property: name, value: properties ?? [:] diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/SubscriptionUpdateRaceTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/SubscriptionUpdateRaceTests.swift index 43c1746e9..39ad19648 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/SubscriptionUpdateRaceTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/SubscriptionUpdateRaceTests.swift @@ -93,6 +93,7 @@ final class SubscriptionUpdateRaceTests: XCTestCase { executor.enqueueDelta(OSDelta( name: OS_UPDATE_SUBSCRIPTION_DELTA, identityModelId: identityModelId, + externalId: nil, model: model, property: "notificationTypes", value: promptedNeverAnswered @@ -110,6 +111,7 @@ final class SubscriptionUpdateRaceTests: XCTestCase { executor.enqueueDelta(OSDelta( name: OS_UPDATE_SUBSCRIPTION_DELTA, identityModelId: identityModelId, + externalId: nil, model: model, property: "notificationTypes", value: subscribedNotificationTypes @@ -150,6 +152,7 @@ final class SubscriptionUpdateRaceTests: XCTestCase { executor.enqueueDelta(OSDelta( name: OS_UPDATE_SUBSCRIPTION_DELTA, identityModelId: identityModelId, + externalId: nil, model: model, property: "notificationTypes", value: promptedNeverAnswered @@ -171,6 +174,7 @@ final class SubscriptionUpdateRaceTests: XCTestCase { executor.enqueueDelta(OSDelta( name: OS_UPDATE_SUBSCRIPTION_DELTA, identityModelId: identityModelId, + externalId: nil, model: model, property: "notificationTypes", value: subscribedNotificationTypes @@ -214,6 +218,7 @@ final class SubscriptionUpdateRaceTests: XCTestCase { executor.enqueueDelta(OSDelta( name: OS_UPDATE_SUBSCRIPTION_DELTA, identityModelId: identityModelId, + externalId: nil, model: model, property: "notificationTypes", value: promptedNeverAnswered @@ -231,6 +236,7 @@ final class SubscriptionUpdateRaceTests: XCTestCase { executor.enqueueDelta(OSDelta( name: OS_UPDATE_SUBSCRIPTION_DELTA, identityModelId: identityModelId, + externalId: nil, model: model, property: "notificationTypes", value: subscribedNotificationTypes diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserConcurrencyTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserConcurrencyTests.swift index 94d3f4388..ac636e32e 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserConcurrencyTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserConcurrencyTests.swift @@ -99,8 +99,8 @@ final class UserConcurrencyTests: XCTestCase { DispatchQueue.concurrentPerform(iterations: 50) { _ in // 1. Enqueue Remove Subscription Deltas to the Operation Repo - OSOperationRepo.sharedInstance.enqueueDelta(OSDelta(name: OS_REMOVE_SUBSCRIPTION_DELTA, identityModelId: UUID().uuidString, model: OSSubscriptionModel(type: .email, address: nil, subscriptionId: UUID().uuidString, reachable: true, isDisabled: false, changeNotifier: OSEventProducer()), property: "email", value: "email")) - OSOperationRepo.sharedInstance.enqueueDelta(OSDelta(name: OS_REMOVE_SUBSCRIPTION_DELTA, identityModelId: UUID().uuidString, model: OSSubscriptionModel(type: .email, address: nil, subscriptionId: UUID().uuidString, reachable: true, isDisabled: false, changeNotifier: OSEventProducer()), property: "email", value: "email")) + OSOperationRepo.sharedInstance.enqueueDelta(OSDelta(name: OS_REMOVE_SUBSCRIPTION_DELTA, identityModelId: UUID().uuidString, externalId: nil, model: OSSubscriptionModel(type: .email, address: nil, subscriptionId: UUID().uuidString, reachable: true, isDisabled: false, changeNotifier: OSEventProducer()), property: "email", value: "email")) + OSOperationRepo.sharedInstance.enqueueDelta(OSDelta(name: OS_REMOVE_SUBSCRIPTION_DELTA, identityModelId: UUID().uuidString, externalId: nil, model: OSSubscriptionModel(type: .email, address: nil, subscriptionId: UUID().uuidString, reachable: true, isDisabled: false, changeNotifier: OSEventProducer()), property: "email", value: "email")) // 2. Flush Operation Repo OSOperationRepo.sharedInstance.addFlushDeltaQueueToDispatchQueue() @@ -138,8 +138,8 @@ final class UserConcurrencyTests: XCTestCase { DispatchQueue.concurrentPerform(iterations: 50) { _ in // 1. Enqueue Add Alias Deltas to the Operation Repo - OSOperationRepo.sharedInstance.enqueueDelta(OSDelta(name: OS_ADD_ALIAS_DELTA, identityModelId: UUID().uuidString, model: OSIdentityModel(aliases: [OS_ONESIGNAL_ID: UUID().uuidString], changeNotifier: OSEventProducer()), property: "aliases", value: aliases)) - OSOperationRepo.sharedInstance.enqueueDelta(OSDelta(name: OS_ADD_ALIAS_DELTA, identityModelId: UUID().uuidString, model: OSIdentityModel(aliases: [OS_ONESIGNAL_ID: UUID().uuidString], changeNotifier: OSEventProducer()), property: "aliases", value: aliases)) + OSOperationRepo.sharedInstance.enqueueDelta(OSDelta(name: OS_ADD_ALIAS_DELTA, identityModelId: UUID().uuidString, externalId: nil, model: OSIdentityModel(aliases: [OS_ONESIGNAL_ID: UUID().uuidString], changeNotifier: OSEventProducer()), property: "aliases", value: aliases)) + OSOperationRepo.sharedInstance.enqueueDelta(OSDelta(name: OS_ADD_ALIAS_DELTA, identityModelId: UUID().uuidString, externalId: nil, model: OSIdentityModel(aliases: [OS_ONESIGNAL_ID: UUID().uuidString], changeNotifier: OSEventProducer()), property: "aliases", value: aliases)) // 2. Flush Operation Repo OSOperationRepo.sharedInstance.addFlushDeltaQueueToDispatchQueue() @@ -179,8 +179,8 @@ final class UserConcurrencyTests: XCTestCase { DispatchQueue.concurrentPerform(iterations: 50) { _ in // 1. Enqueue Deltas to the Operation Repo - OSOperationRepo.sharedInstance.enqueueDelta(OSDelta(name: OS_UPDATE_PROPERTIES_DELTA, identityModelId: identityModel.modelId, model: OSPropertiesModel(changeNotifier: OSEventProducer()), property: "language", value: UUID().uuidString)) - OSOperationRepo.sharedInstance.enqueueDelta(OSDelta(name: OS_UPDATE_PROPERTIES_DELTA, identityModelId: identityModel.modelId, model: OSPropertiesModel(changeNotifier: OSEventProducer()), property: "language", value: UUID().uuidString)) + OSOperationRepo.sharedInstance.enqueueDelta(OSDelta(name: OS_UPDATE_PROPERTIES_DELTA, identityModelId: identityModel.modelId, externalId: identityModel.externalId, model: OSPropertiesModel(changeNotifier: OSEventProducer()), property: "language", value: UUID().uuidString)) + OSOperationRepo.sharedInstance.enqueueDelta(OSDelta(name: OS_UPDATE_PROPERTIES_DELTA, identityModelId: identityModel.modelId, externalId: identityModel.externalId, model: OSPropertiesModel(changeNotifier: OSEventProducer()), property: "language", value: UUID().uuidString)) // 2. Flush Operation Repo OSOperationRepo.sharedInstance.addFlushDeltaQueueToDispatchQueue() From b2a3c449b29dbc95d0235185d8831e1c3cc5d2e3 Mon Sep 17 00:00:00 2001 From: Nan Date: Wed, 12 Aug 2026 14:14:06 -0700 Subject: [PATCH 10/21] fix: [PR5] drop stale properties/subscription deltas instead of mis-stamping Refuse to stamp the current user when the changed model is no longer theirs so a concurrent login cannot attach the wrong external_id for PR6 auth. Properties require the current properties model; email/SMS add requires the model still be in the store. Remove and push updates keep stamping the current user. Co-authored-by: Cursor --- .../OSPropertiesModelStoreListener.swift | 6 +++-- .../OSSubscriptionModelStoreListener.swift | 8 ++++-- .../DeltaOwnershipTests.swift | 26 ++++++++++++++++++- 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSPropertiesModelStoreListener.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSPropertiesModelStoreListener.swift index 664d3de77..ec4d5da5b 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSPropertiesModelStoreListener.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSPropertiesModelStoreListener.swift @@ -47,10 +47,12 @@ class OSPropertiesModelStoreListener: OSModelStoreListener { } func getUpdateModelDelta(_ args: OSModelChangedArgs) -> OSDelta? { + // Drop if this isn't the current user's properties model, the user has switched since guard let _ = OSPropertiesSupportedProperty(rawValue: args.property), - let userInstance = OneSignalUserManagerImpl.sharedInstance._user + let userInstance = OneSignalUserManagerImpl.sharedInstance._user, + userInstance.propertiesModel.modelId == args.model.modelId else { - OneSignalLog.onesignalLog(.LL_ERROR, message: "OSPropertiesModelStoreListener.getUpdateModelDelta encountered unsupported property: \(args.property) or no user instance") + OneSignalLog.onesignalLog(.LL_ERROR, message: "OSPropertiesModelStoreListener.getUpdateModelDelta encountered unsupported property: \(args.property), no user instance, or a properties model that is not the current user's") return nil } let identityModel = userInstance.identityModel diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModelStoreListener.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModelStoreListener.swift index 8b800b5f4..4d8224442 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModelStoreListener.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModelStoreListener.swift @@ -39,8 +39,11 @@ class OSSubscriptionModelStoreListener: OSModelStoreListener { } func getAddModelDelta(_ model: OSSubscriptionModel) -> OSDelta? { - guard let userInstance = OneSignalUserManagerImpl.sharedInstance._user else { - OneSignalLog.onesignalLog(.LL_ERROR, message: "OSSubscriptionModelStoreListener.getAddModelDelta has no user instance") + // Stale - drop if the model is no longer in this store, the user has switched since + guard let userInstance = OneSignalUserManagerImpl.sharedInstance._user, + store.getModel(modelId: model.modelId) != nil + else { + OneSignalLog.onesignalLog(.LL_ERROR, message: "OSSubscriptionModelStoreListener.getAddModelDelta has no user instance or the model is not in the current store") return nil } let identityModel = userInstance.identityModel @@ -56,6 +59,7 @@ class OSSubscriptionModelStoreListener: OSModelStoreListener { /** The `property` and `value` is not needed for a remove operation, so just pass in some model data as placeholders. + Stamps the current user: `onRemoved` runs after the model has left the store, so membership can't be checked. */ func getRemoveModelDelta(_ model: OSSubscriptionModel) -> OSDelta? { guard let userInstance = OneSignalUserManagerImpl.sharedInstance._user else { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/DeltaOwnershipTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/DeltaOwnershipTests.swift index 3dcc825d3..b6fd2de5f 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/DeltaOwnershipTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/DeltaOwnershipTests.swift @@ -110,10 +110,24 @@ final class DeltaOwnershipTests: XCTestCase { XCTAssertEqual(delta.identityModelId, first.identityModel.modelId) } + /// Stale properties model after a switch: drop rather than stamp the new current user. + func testPropertiesUpdateDeltaIsDroppedWhenTheChangedModelIsNotTheCurrentUser() throws { + let first = newUser(externalId: userA) + newUser(externalId: userB) + + let delta = manager.propertiesModelStoreListener.getUpdateModelDelta( + OSModelChangedArgs(model: first.propertiesModel, property: "tags", newValue: ["tag": "value"]) + ) + + XCTAssertNil(delta) + } + func testSubscriptionAddDeltaCarriesTheOwningUsersExternalId() throws { let user = newUser(externalId: userA) + let model = emailSubscriptionModel() + manager.subscriptionModelStore.add(id: emailAddress, model: model, hydrating: true) - let delta = try XCTUnwrap(manager.subscriptionModelStoreListener.getAddModelDelta(emailSubscriptionModel())) + let delta = try XCTUnwrap(manager.subscriptionModelStoreListener.getAddModelDelta(model)) XCTAssertEqual(delta.externalId, userA) XCTAssertEqual(delta.identityModelId, user.identityModel.modelId) @@ -139,6 +153,16 @@ final class DeltaOwnershipTests: XCTestCase { XCTAssertEqual(delta.identityModelId, user.identityModel.modelId) } + /// Email/SMS models are cleared on switch but stay subscribed; refuse to stamp the new user. + func testSubscriptionAddDeltaIsDroppedWhenTheChangedModelIsNotInTheCurrentStore() throws { + newUser(externalId: userA) + let model = emailSubscriptionModel() + manager.subscriptionModelStore.add(id: emailAddress, model: model, hydrating: true) + newUser(externalId: userB) + + XCTAssertNil(manager.subscriptionModelStoreListener.getAddModelDelta(model)) + } + // MARK: - Deltas the User Manager enqueues itself /// Session time and custom events skip the model stores, so they are stamped at enqueue. From 5f40aae7953e822c057acf8a98f6a72ac3a86a38 Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 11 Aug 2026 16:55:36 -0700 Subject: [PATCH 11/21] feat: [PR6] Identity Verification for the request pipeline Everything that sends a user-scoped call now decides how to address and sign it in one place. OSRequestAuth answers, for a given user, which alias names them in the path and which token signs it, read together so the alias and the token can never come from different users. Under Identity Verification a user is addressed by external_id, which the app chooses, so those path segments are percent-encoded through OSUrlPath. Requests carry the identity model that owns them rather than reading whoever is current at send time. The operation repo holds queued work while the requirement is still unknown, since sending unsigned would be rejected and sending signed too early is not possible. Once the answer arrives the queue flushes. When Identity Verification is on, work belonging to no external ID is dropped rather than sent: an anonymous user is never created on the server, so that work has no user to belong to. Update Subscription is deliberately exempt from all of this. A push subscription belongs to the device, not the signed-in user, so it always goes out whether or not anyone is logged in and whether or not a token is valid; nothing about it is gated on auth. Logging out while Identity Verification is on internally disables the push subscription, since the replacement anonymous user is never created and the subscription would otherwise keep reporting under the logged-out user. Logging back in clears that, as does the requirement hydrating to off. Co-authored-by: Cursor --- .../OneSignal.xcodeproj/project.pbxproj | 36 ++ .../MockOneSignalClient.swift | 4 + .../Source/Jwt/OSAliasPair.swift | 40 ++ .../Source/OSOperationExecutor.swift | 6 + .../Source/OSOperationRepo.swift | 190 ++++++-- .../OneSignalOSCore/Source/OSUrlPath.swift | 47 ++ .../OneSignalOSCoreMocks/OSCoreMocks.swift | 18 +- .../OSOperationRepoFlushTests.swift | 102 ++-- ...erationRepoIdentityVerificationTests.swift | 266 +++++++++++ .../OSOperationRepoTestSupport.swift | 107 +++++ .../Executors/OSCustomEventsExecutor.swift | 48 +- .../OSIdentityOperationExecutor.swift | 53 ++- .../OSPropertyOperationExecutor.swift | 39 +- .../OSSubscriptionOperationExecutor.swift | 99 +++- .../Source/Executors/OSUserExecutor.swift | 153 +++++- .../OneSignalUser/Source/OSRequestAuth.swift | 249 ++++++++++ .../Source/OSSubscriptionModel.swift | 63 ++- .../Source/OneSignalUserManagerImpl.swift | 132 +++-- .../Source/Requests/OSRequestAddAliases.swift | 17 +- .../OSRequestCreateSubscription.swift | 17 +- .../Source/Requests/OSRequestCreateUser.swift | 25 +- .../Requests/OSRequestCustomEvents.swift | 29 +- .../OSRequestDeleteSubscription.swift | 19 +- ...OSRequestFetchIdentityBySubscription.swift | 6 +- .../Source/Requests/OSRequestFetchUser.swift | 12 +- .../Requests/OSRequestIdentifyUser.swift | 22 +- .../Requests/OSRequestRemoveAlias.swift | 18 +- .../OSRequestTransferSubscription.swift | 4 +- .../Requests/OSRequestUpdateProperties.swift | 18 +- .../OSRequestUpdateSubscription.swift | 35 +- .../Source/Requests/OSUserRequest.swift | 49 +- .../OneSignalUserMocks.swift | 5 +- .../CustomEventsIntegrationTests.swift | 12 +- .../DeltaOwnershipTests.swift | 49 ++ .../ExecutorAnonymousPurgeTests.swift | 358 ++++++++++++++ .../OSCustomEventsExecutorTests.swift | 2 +- .../SubscriptionUpdateRaceTests.swift | 10 +- .../Executors/UserExecutorTests.swift | 210 +++++++- .../OSRequestAuthTests.swift | 356 ++++++++++++++ .../OneSignalUserTests.swift | 2 +- .../RequestPathEncodingTests.swift | 125 +++++ .../SwitchUserIntegrationTests.swift | 2 +- .../UserConcurrencyTests.swift | 44 +- .../UserJwtLifecycleTests.swift | 450 ++++++++++++++++++ 44 files changed, 3257 insertions(+), 291 deletions(-) create mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Jwt/OSAliasPair.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSUrlPath.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoIdentityVerificationTests.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoTestSupport.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSRequestAuth.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/ExecutorAnonymousPurgeTests.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalUserTests/OSRequestAuthTests.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalUserTests/RequestPathEncodingTests.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtLifecycleTests.swift diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index 8bea2dbee..06891b51d 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -53,9 +53,13 @@ 03E56DD328405F4A006AA1DA /* OneSignalAppDelegateOverrider.m in Sources */ = {isa = PBXBuildFile; fileRef = 03E56DD228405F4A006AA1DA /* OneSignalAppDelegateOverrider.m */; }; 0AA11438FBF3A82D13824467 /* OSFeatureManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7DA9EEED644F1160CAD9A38 /* OSFeatureManager.swift */; }; 16664C4C25DDB195003B8A14 /* NSTimeZoneOverrider.m in Sources */ = {isa = PBXBuildFile; fileRef = 16664C4B25DDB195003B8A14 /* NSTimeZoneOverrider.m */; }; + 1B7E5A0AEB23050C398E6111 /* ExecutorAnonymousPurgeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE740F9E6B87D215510B5982 /* ExecutorAnonymousPurgeTests.swift */; }; + 23D66BEB40CE76DFF89744A3 /* RequestPathEncodingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3016921C1F6B7B7793F67567 /* RequestPathEncodingTests.swift */; }; 257E219608960B8545199057 /* OneSignalUserManagerImpl+Jwt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CCE2C93100CAFEE8EB39C77 /* OneSignalUserManagerImpl+Jwt.swift */; }; 2DB99C76F3532383C3B81D09 /* OSUserJwtRepoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5809B47EB4F2478099697CE /* OSUserJwtRepoTests.swift */; }; 2F32272222E88DF0C2C18B53 /* OSFeatureManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42E4A83C6C0D0DF28CDECA90 /* OSFeatureManagerTests.swift */; }; + 322B62F85070DCB5C5599D6D /* OSOperationRepoIdentityVerificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFBB451F692A15639A1A08AE /* OSOperationRepoIdentityVerificationTests.swift */; }; + 32601EF1960CD92605D1ABF9 /* OSRequestAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BF72AAEB5284C97B864A1A8 /* OSRequestAuth.swift */; }; 37E6B2BB19D9CAF300D0C601 /* UIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 37E6B2BA19D9CAF300D0C601 /* UIKit.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; 3C0151922C2E298F0079E076 /* OneSignalInAppMessages.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DEBAAE282A4211D900BF2C1C /* OneSignalInAppMessages.framework */; }; 3C01519C2C2E29F90079E076 /* IAMRequestTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 3C01519B2C2E29F90079E076 /* IAMRequestTests.m */; }; @@ -336,6 +340,8 @@ 7AFE856B2368DDB80091D6A5 /* OSFocusCallParams.m in Sources */ = {isa = PBXBuildFile; fileRef = 7AFE856A2368DDB80091D6A5 /* OSFocusCallParams.m */; }; 7AFE856C2368DDB80091D6A5 /* OSFocusCallParams.m in Sources */ = {isa = PBXBuildFile; fileRef = 7AFE856A2368DDB80091D6A5 /* OSFocusCallParams.m */; }; 7AFE856D2368DDB80091D6A5 /* OSFocusCallParams.m in Sources */ = {isa = PBXBuildFile; fileRef = 7AFE856A2368DDB80091D6A5 /* OSFocusCallParams.m */; }; + 7EB69F3B404D0AEF46EC1536 /* UserJwtLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BFE2F960129386AFA6D5F41 /* UserJwtLifecycleTests.swift */; }; + 8D2F4893453206700BB60F85 /* OSOperationRepoTestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5221EEDBA5A74BD565490D52 /* OSOperationRepoTestSupport.swift */; }; 8E949FF4C7A7A2C7182E53EA /* OSUserJwtConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9376A4957E9090C748BCB18 /* OSUserJwtConfigTests.swift */; }; 911E2CBD1E398AB3003112A4 /* UnitTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 911E2CBC1E398AB3003112A4 /* UnitTests.m */; }; 911E2CC51E398B53003112A4 /* SystemConfiguration.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3E08E2701D49A5C8002176DE /* SystemConfiguration.framework */; }; @@ -378,6 +384,8 @@ 9FF50E2A40C88E4533033A38 /* OSIdentityVerificationServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4855B81F170253FB0C1749D /* OSIdentityVerificationServiceTests.swift */; }; A662399326850DDE00D52FD8 /* LanguageTest.m in Sources */ = {isa = PBXBuildFile; fileRef = A662399026850DDE00D52FD8 /* LanguageTest.m */; }; A66239952686612F00D52FD8 /* OneSignalFramework.h in Headers */ = {isa = PBXBuildFile; fileRef = 912411F01E73342200E41FD7 /* OneSignalFramework.h */; settings = {ATTRIBUTES = (Public, ); }; }; + AAFA2D46E6C5FD3D14D39F27 /* OSRequestAuthTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047D8F5E1095A20C9C54FD33 /* OSRequestAuthTests.swift */; }; + ABAFB38CC150EE24ED595EF7 /* OSAliasPair.swift in Sources */ = {isa = PBXBuildFile; fileRef = F83E7BF2B518EA8B0B51B276 /* OSAliasPair.swift */; }; B5FBED8247288744EB484CB5 /* OSIdentityModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5953656ACCC21358BC0CF2F0 /* OSIdentityModelTests.swift */; }; BE737361D82E74544B7A1996 /* OSUserJwtConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6552F2A6DF7776B0582CFAEF /* OSUserJwtConfig.swift */; }; CA08FC871FE99BB4004C445F /* OneSignalClientOverrider.m in Sources */ = {isa = PBXBuildFile; fileRef = CA08FC831FE99BB4004C445F /* OneSignalClientOverrider.m */; }; @@ -407,6 +415,7 @@ CACBAAAA218A65AE000ACAA5 /* InAppMessagingTests.m in Sources */ = {isa = PBXBuildFile; fileRef = CACBAAA9218A65AE000ACAA5 /* InAppMessagingTests.m */; }; CACBAAAC218A662B000ACAA5 /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CACBAAAB218A662B000ACAA5 /* WebKit.framework */; }; CACBAAB4218A7113000ACAA5 /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CACBAAAB218A662B000ACAA5 /* WebKit.framework */; }; + D14FEB74CBF964F5BF0FD615 /* OSUrlPath.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8596A3E728FB90691E43BCA0 /* OSUrlPath.swift */; }; D465D9B81F58B242ADF14874 /* OSIdentityModelRepoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89DE5BB0EDD3964C20C5169F /* OSIdentityModelRepoTests.swift */; }; DAF9C81134248FCDB0C12E5B /* OSUserJwtInvalidatedEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F2FC6C922FF8104F3197DD4 /* OSUserJwtInvalidatedEvent.swift */; }; DD2A89A8052E2D1912B0038B /* OSIamFetchReadyConditionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF4B19D1EC31C0750F13065A /* OSIamFetchReadyConditionTests.swift */; }; @@ -1344,6 +1353,7 @@ 03CCCC842835F291004BF794 /* UIApplicationDelegateSwizzlingTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = UIApplicationDelegateSwizzlingTests.m; sourceTree = ""; }; 03E56DD128405F4A006AA1DA /* OneSignalAppDelegateOverrider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OneSignalAppDelegateOverrider.h; sourceTree = ""; }; 03E56DD228405F4A006AA1DA /* OneSignalAppDelegateOverrider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OneSignalAppDelegateOverrider.m; sourceTree = ""; }; + 047D8F5E1095A20C9C54FD33 /* OSRequestAuthTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSRequestAuthTests.swift; sourceTree = ""; }; 16664C4B25DDB195003B8A14 /* NSTimeZoneOverrider.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = NSTimeZoneOverrider.m; sourceTree = ""; }; 16664C5425DDB2CB003B8A14 /* NSTimeZoneOverrider.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = NSTimeZoneOverrider.h; sourceTree = ""; }; 1AF75EAC1E8567FD0097B315 /* NSString+OneSignal.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSString+OneSignal.h"; sourceTree = ""; }; @@ -1351,6 +1361,7 @@ 1C4EAEA1BC62D8FC57927511 /* OSIdentityVerificationService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSIdentityVerificationService.swift; sourceTree = ""; }; 1F214EE6C5FE133672D6622F /* MockUserJwtInvalidatedListener.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MockUserJwtInvalidatedListener.swift; sourceTree = ""; }; 2F2FC6C922FF8104F3197DD4 /* OSUserJwtInvalidatedEvent.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSUserJwtInvalidatedEvent.swift; sourceTree = ""; }; + 3016921C1F6B7B7793F67567 /* RequestPathEncodingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RequestPathEncodingTests.swift; sourceTree = ""; }; 37747F9319147D6500558FAD /* libOneSignal.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libOneSignal.a; sourceTree = BUILT_PRODUCTS_DIR; }; 37E6B2BA19D9CAF300D0C601 /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; }; 3881024646E7F0DE05158442 /* DeltaOwnershipTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DeltaOwnershipTests.swift; sourceTree = ""; }; @@ -1545,6 +1556,7 @@ 475F47202B8E398E00EC05B3 /* OneSignalLiveActivities.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OneSignalLiveActivities.h; sourceTree = ""; }; 475F47482B8E3A4400EC05B3 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 4CCE2C93100CAFEE8EB39C77 /* OneSignalUserManagerImpl+Jwt.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "OneSignalUserManagerImpl+Jwt.swift"; sourceTree = ""; }; + 5221EEDBA5A74BD565490D52 /* OSOperationRepoTestSupport.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSOperationRepoTestSupport.swift; sourceTree = ""; }; 557653D2007BFF86EA8342E4 /* OSDeltaTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSDeltaTests.swift; sourceTree = ""; }; 5953656ACCC21358BC0CF2F0 /* OSIdentityModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSIdentityModelTests.swift; sourceTree = ""; }; 5B053FB82CAE07EB002F30C4 /* OneSignalOSCoreTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OneSignalOSCoreTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -1557,6 +1569,7 @@ 5BC1DE612C90B85A00CA8807 /* OSIamFetchOffsetKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSIamFetchOffsetKey.swift; sourceTree = ""; }; 5BC1DE632C90BB9000CA8807 /* OSIamFetchReadyCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSIamFetchReadyCondition.swift; sourceTree = ""; }; 5BC1DE672C90C23E00CA8807 /* OSConsistencyManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSConsistencyManagerTests.swift; sourceTree = ""; }; + 5BFE2F960129386AFA6D5F41 /* UserJwtLifecycleTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UserJwtLifecycleTests.swift; sourceTree = ""; }; 6552F2A6DF7776B0582CFAEF /* OSUserJwtConfig.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSUserJwtConfig.swift; sourceTree = ""; }; 6A8BBA843AFC81A4940CF7CC /* OSUserJwtRepo.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSUserJwtRepo.swift; sourceTree = ""; }; 7A123294235DFE3B002B6CE3 /* OutcomeTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OutcomeTests.m; sourceTree = ""; }; @@ -1633,6 +1646,7 @@ 7AFE856A2368DDB80091D6A5 /* OSFocusCallParams.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OSFocusCallParams.m; sourceTree = ""; }; 7AFE856E2368DDC50091D6A5 /* OSFocusCallParams.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OSFocusCallParams.h; sourceTree = ""; }; 80DC5517E6EB5B26CF980CC5 /* UserJwtApiTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UserJwtApiTests.swift; sourceTree = ""; }; + 8596A3E728FB90691E43BCA0 /* OSUrlPath.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSUrlPath.swift; sourceTree = ""; }; 89DE5BB0EDD3964C20C5169F /* OSIdentityModelRepoTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSIdentityModelRepoTests.swift; sourceTree = ""; }; 911E2CBA1E398AB3003112A4 /* UnitTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = UnitTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 911E2CBC1E398AB3003112A4 /* UnitTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = UnitTests.m; sourceTree = ""; }; @@ -1657,6 +1671,7 @@ 91C7725D1E7CCE1000D612D0 /* OneSignalInternal.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OneSignalInternal.h; sourceTree = ""; }; 91F60F7B1E80E49A00706E60 /* UncaughtExceptionHandler.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = UncaughtExceptionHandler.h; sourceTree = ""; }; 91F60F7C1E80E4E400706E60 /* UncaughtExceptionHandler.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = UncaughtExceptionHandler.m; sourceTree = ""; }; + 9BF72AAEB5284C97B864A1A8 /* OSRequestAuth.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSRequestAuth.swift; sourceTree = ""; }; 9D1BD95D237663BF00A064F7 /* OSInfluenceDataDefines.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OSInfluenceDataDefines.h; sourceTree = ""; }; 9D1BD95E2379E7A900A064F7 /* OSOutcomeEvent.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OSOutcomeEvent.h; sourceTree = ""; }; 9D1BD95F2379E7C300A064F7 /* OSOutcomeEvent.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OSOutcomeEvent.m; sourceTree = ""; }; @@ -1869,7 +1884,10 @@ DEFB3E662BB735B500E65DAD /* OSStubLiveActivities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSStubLiveActivities.swift; sourceTree = ""; }; E9376A4957E9090C748BCB18 /* OSUserJwtConfigTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSUserJwtConfigTests.swift; sourceTree = ""; }; F4855B81F170253FB0C1749D /* OSIdentityVerificationServiceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSIdentityVerificationServiceTests.swift; sourceTree = ""; }; + F83E7BF2B518EA8B0B51B276 /* OSAliasPair.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSAliasPair.swift; sourceTree = ""; }; + FE740F9E6B87D215510B5982 /* ExecutorAnonymousPurgeTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ExecutorAnonymousPurgeTests.swift; sourceTree = ""; }; FF4B19D1EC31C0750F13065A /* OSIamFetchReadyConditionTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSIamFetchReadyConditionTests.swift; sourceTree = ""; }; + FFBB451F692A15639A1A08AE /* OSOperationRepoIdentityVerificationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSOperationRepoIdentityVerificationTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -2311,6 +2329,7 @@ DEFB3E662BB735B500E65DAD /* OSStubLiveActivities.swift */, A843B922174496E99F2D00A8 /* Jwt */, C7DA9EEED644F1160CAD9A38 /* OSFeatureManager.swift */, + 8596A3E728FB90691E43BCA0 /* OSUrlPath.swift */, ); path = Source; sourceTree = ""; @@ -2439,6 +2458,9 @@ B5809B47EB4F2478099697CE /* OSUserJwtRepoTests.swift */, 80DC5517E6EB5B26CF980CC5 /* UserJwtApiTests.swift */, 3881024646E7F0DE05158442 /* DeltaOwnershipTests.swift */, + 047D8F5E1095A20C9C54FD33 /* OSRequestAuthTests.swift */, + 3016921C1F6B7B7793F67567 /* RequestPathEncodingTests.swift */, + 5BFE2F960129386AFA6D5F41 /* UserJwtLifecycleTests.swift */, ); path = OneSignalUserTests; sourceTree = ""; @@ -2457,6 +2479,7 @@ 3CF11E3C2C6D6155002856F5 /* UserExecutorTests.swift */, 3CA93BC3300AEFFA000724B3 /* SubscriptionUpdateRaceTests.swift */, 3CB331692F281692000E1801 /* OSCustomEventsExecutorTests.swift */, + FE740F9E6B87D215510B5982 /* ExecutorAnonymousPurgeTests.swift */, ); path = Executors; sourceTree = ""; @@ -2618,6 +2641,8 @@ 4795885CE6CFFB1998AC7D09 /* Feature */, 5B47CE0CE255AC7128442FFF /* Jwt */, 557653D2007BFF86EA8342E4 /* OSDeltaTests.swift */, + FFBB451F692A15639A1A08AE /* OSOperationRepoIdentityVerificationTests.swift */, + 5221EEDBA5A74BD565490D52 /* OSOperationRepoTestSupport.swift */, ); path = OneSignalOSCoreTests; sourceTree = ""; @@ -2788,6 +2813,7 @@ children = ( 1C4EAEA1BC62D8FC57927511 /* OSIdentityVerificationService.swift */, 6552F2A6DF7776B0582CFAEF /* OSUserJwtConfig.swift */, + F83E7BF2B518EA8B0B51B276 /* OSAliasPair.swift */, ); name = Jwt; path = Jwt; @@ -2833,6 +2859,7 @@ 2F2FC6C922FF8104F3197DD4 /* OSUserJwtInvalidatedEvent.swift */, 6A8BBA843AFC81A4940CF7CC /* OSUserJwtRepo.swift */, 4CCE2C93100CAFEE8EB39C77 /* OneSignalUserManagerImpl+Jwt.swift */, + 9BF72AAEB5284C97B864A1A8 /* OSRequestAuth.swift */, ); path = Source; sourceTree = ""; @@ -4530,6 +4557,8 @@ ECD9DF65FB98056806A43541 /* OSIdentityVerificationService.swift in Sources */, BE737361D82E74544B7A1996 /* OSUserJwtConfig.swift in Sources */, 0AA11438FBF3A82D13824467 /* OSFeatureManager.swift in Sources */, + D14FEB74CBF964F5BF0FD615 /* OSUrlPath.swift in Sources */, + ABAFB38CC150EE24ED595EF7 /* OSAliasPair.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -4602,6 +4631,10 @@ 2DB99C76F3532383C3B81D09 /* OSUserJwtRepoTests.swift in Sources */, 9224347AAE3E092B5743380D /* UserJwtApiTests.swift in Sources */, 99B1615D0132AFAA981A9AD3 /* DeltaOwnershipTests.swift in Sources */, + 1B7E5A0AEB23050C398E6111 /* ExecutorAnonymousPurgeTests.swift in Sources */, + AAFA2D46E6C5FD3D14D39F27 /* OSRequestAuthTests.swift in Sources */, + 23D66BEB40CE76DFF89744A3 /* RequestPathEncodingTests.swift in Sources */, + 7EB69F3B404D0AEF46EC1536 /* UserJwtLifecycleTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -4681,6 +4714,8 @@ 9FF50E2A40C88E4533033A38 /* OSIdentityVerificationServiceTests.swift in Sources */, 8E949FF4C7A7A2C7182E53EA /* OSUserJwtConfigTests.swift in Sources */, 4E8E880086C66B4120CC0CD4 /* OSDeltaTests.swift in Sources */, + 322B62F85070DCB5C5599D6D /* OSOperationRepoIdentityVerificationTests.swift in Sources */, + 8D2F4893453206700BB60F85 /* OSOperationRepoTestSupport.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -4805,6 +4840,7 @@ DAF9C81134248FCDB0C12E5B /* OSUserJwtInvalidatedEvent.swift in Sources */, FD1F1FCA05D555623DD53B54 /* OSUserJwtRepo.swift in Sources */, 257E219608960B8545199057 /* OneSignalUserManagerImpl+Jwt.swift in Sources */, + 32601EF1960CD92605D1ABF9 /* OSRequestAuth.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/iOS_SDK/OneSignalSDK/OneSignalCoreMocks/MockOneSignalClient.swift b/iOS_SDK/OneSignalSDK/OneSignalCoreMocks/MockOneSignalClient.swift index a388fc6f9..d85ffbaa3 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalCoreMocks/MockOneSignalClient.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalCoreMocks/MockOneSignalClient.swift @@ -197,12 +197,16 @@ public class MockOneSignalClient: NSObject, IOneSignalClient { executionQueue.sync {} } + // A request has one outcome: whichever of these was called for it last. Otherwise a test could not + // override a default its setUp registered, nor let a retry succeed after the first attempt failed. public func setMockResponseForRequest(request: String, response: [String: Any]) { mockResponses[request] = response + mockFailureResponses.removeValue(forKey: request) } public func setMockFailureResponseForRequest(request: String, error: OneSignalClientError) { mockFailureResponses[request] = error + mockResponses.removeValue(forKey: request) } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Jwt/OSAliasPair.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Jwt/OSAliasPair.swift new file mode 100644 index 000000000..3db01bfc6 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Jwt/OSAliasPair.swift @@ -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. + */ + +/** + Requests address a user by one alias or the other depending on Identity Verification: `external_id` + when it is active, `onesignal_id` otherwise. + */ +@objc public class OSAliasPair: NSObject { + @objc public let label: String + @objc public let id: String + + public init(_ label: String, _ id: String) { + self.label = label + self.id = id + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationExecutor.swift index 4afcf0ec7..0c0a753c9 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationExecutor.swift @@ -36,4 +36,10 @@ public protocol OSOperationExecutor { func enqueueDelta(_ delta: OSDelta) func cacheDeltaQueue() func processDeltaQueue(inBackground: Bool) + + /** + Drop queued Deltas and Requests that belong to an anonymous user. Driven by `OSOperationRepo` + so the policy stays there; only the storage is per-executor. + */ + func removeOperationsWithoutExternalId() } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRepo.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRepo.swift index 223080697..a38b449ac 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRepo.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSOperationRepo.swift @@ -29,17 +29,26 @@ import Foundation import OneSignalCore /** - The OSOperationRepo is a static singleton. - OSDeltas are enqueued when model store observers observe changes to their models, and sorted to their appropriate executors. + Enqueues OSDeltas from model-store observers and routes them to executors. + + Also owns Identity Verification decisions for queued work: hold flushes until `requirement` is known, + and drop anonymous Deltas while IV is active — except push subscription updates, which stay unsigned + and have to keep flowing with or without an identified user. */ public class OSOperationRepo: NSObject { - public static let sharedInstance = OSOperationRepo() - private var hasCalledStart = false + private let identityVerificationService: OSIdentityVerificationService + + /** + Serial, and the only place `deltaQueue`, the executor registry, and the two start flags may be + touched once this instance is handed out — `init` runs before anything else can reach it. Every + private method here assumes it is already running on this queue. + Non-private so test helpers can synchronize with it. + */ + let dispatchQueue = DispatchQueue(label: "OneSignal.OSOperationRepo", target: .global()) - // The Operation Repo dispatch queue, serial. This synchronizes access to `deltaQueue` and flushing behavior. - private let dispatchQueue = DispatchQueue(label: "OneSignal.OSOperationRepo", target: .global()) + private var hasCalledStart = false + private var hasBegunObserving = false - // Maps delta names to the interfaces for the operation executors var deltasToExecutorMap: [String: OSOperationExecutor] = [:] var executors: [OSOperationExecutor] = [] var deltaQueue: [OSDelta] = [] // non-private for unit test access @@ -48,35 +57,96 @@ public class OSOperationRepo: NSObject { var pollIntervalMilliseconds = Int(POLL_INTERVAL_MS) public var paused = false + // Uncache in init so an enqueue before start cannot persist over a previous session's queue. + public init(identityVerificationService: OSIdentityVerificationService) { + self.identityVerificationService = identityVerificationService + super.init() + uncacheDeltaQueue() + } + /** - Initilize this Operation Repo. Read from the cache. Executors may not be available by this time. - If everything starts up on initialize(), order can matter, ideally not but it can. - Likely call init on this from oneSignal but exeuctors can come from diff modules. + Re-reads the cache while the in-memory queue is still empty. `init` can run during prewarm before + first unlock, when UserDefaults silently returns nothing — same gap as `OSModelStore.refresh`. */ + public func refreshIfEmpty() { + dispatchQueue.async { + guard self.deltaQueue.isEmpty else { + return + } + self.uncacheDeltaQueue() + } + } + + private func uncacheDeltaQueue() { + guard let cached = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_OPERATION_REPO_DELTA_QUEUE_KEY, defaultValue: []) as? [OSDelta] else { + OneSignalLog.onesignalLog(.LL_ERROR, message: "OSOperationRepo is unable to uncache the OSDelta queue.") + return + } + deltaQueue = cached + OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OSOperationRepo uncached deltaQueue: \(cached)") + } + public func start() { guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: nil) else { return } + dispatchQueue.async { + self.startPolling() + } + } + + /** + While `requirement` is unknown, returns without setting `hasCalledStart` so hydration can call + `start()` again once remote params answer. + */ + private func startPolling() { guard !hasCalledStart else { return } + + beginObserving() + + guard identityVerificationService.requirement != .unknown else { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSOperationRepo.start() deferred until the Identity Verification requirement is known") + return + } hasCalledStart = true OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OSOperationRepo calling start()") - // register as user observer + pollFlushQueue() + } + + // Subscribe ahead of the requirement gate so a never-hydrated session still hears late hydration. + private func beginObserving() { + guard !hasBegunObserving else { + return + } + hasBegunObserving = true + NotificationCenter.default.addObserver(self, selector: #selector(self.addFlushDeltaQueueToDispatchQueue), name: Notification.Name(OS_ON_USER_WILL_CHANGE), object: nil) - // Read the Deltas from cache, if any... - if let deltaQueue = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: OS_OPERATION_REPO_DELTA_QUEUE_KEY, defaultValue: []) as? [OSDelta] { - self.deltaQueue = deltaQueue - OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OSOperationRepo.start() with deltaQueue: \(deltaQueue)") - } else { - OneSignalLog.onesignalLog(.LL_ERROR, message: "OSOperationRepo.start() is unable to uncache the OSDelta queue.") + + // Callback rather than a repo dependency, which would cycle. + identityVerificationService.addOnJwtConfigHydratedHandler(for: .operationRepo) { [weak self] _ in + self?.onJwtConfigHydrated() } + } - pollFlushQueue() + /** + Runs on every hydration, including an unchanged value — deferred work is waiting on it. + + Hop onto `dispatchQueue`: `hydrate` calls this from whichever thread received remote params, and a + handler registered when the requirement is already cached fires synchronously from inside + `startPolling()`, where the hop defers this until that call finishes. + */ + private func onJwtConfigHydrated() { + dispatchQueue.async { + // Flush now rather than wait out a poll interval for work held since launch. + self.startPolling() + self.flushDeltaQueue() + } } private func pollFlushQueue() { @@ -87,23 +157,26 @@ public class OSOperationRepo: NSObject { } /** - Add and start an executor. + Registers before starting rather than after: once remote params are cached, `startPolling()` fires + the hydration handler synchronously and that flush reads the registry being written here. */ public func addExecutor(_ executor: OSOperationExecutor) { guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: nil) else { return } - start() - executors.append(executor) - for delta in executor.supportedDeltas { - deltasToExecutorMap[delta] = executor + dispatchQueue.async { + self.executors.append(executor) + for delta in executor.supportedDeltas { + self.deltasToExecutorMap[delta] = executor + } + self.startPolling() } } /** Enqueueing is driven by model changes and called manually by the User Manager to add session time, session count and purchase data. - + // TODO: We can make this method internal once there is no manual adding of a Delta except through stores. This can happen when session data and purchase data use the model / store / listener infrastructure. */ @@ -111,12 +184,19 @@ public class OSOperationRepo: NSObject { guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: nil) else { return } - start() + self.dispatchQueue.async { + self.startPolling() + + // Drop here too so it is never persisted; flush still covers deltas restored from cache. + guard !self.shouldDropAnonymousDelta(delta, ivActive: self.shouldDropAnonymousDeltas) else { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSOperationRepo dropping anonymous Delta, Identity Verification is required: \(delta)") + return + } + OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OSOperationRepo enqueueDelta: \(delta)") self.deltaQueue.append(delta) - // Persist the deltas (including new delta) to storage OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_OPERATION_REPO_DELTA_QUEUE_KEY, withValue: self.deltaQueue) if flush { @@ -131,6 +211,26 @@ public class OSOperationRepo: NSObject { } } + /// An anonymous Delta can never be signed, so drop it while Identity Verification is active. + private var shouldDropAnonymousDeltas: Bool { + return identityVerificationService.ivBehaviorActive + } + + /** + `OS_UPDATE_SUBSCRIPTION_DELTA` is exempt. In practice it is only ever the device's own push + subscription — nothing updates an email or SMS subscription model — and that channel exists before + any login and outlives every logout, so its token and device state have to keep flowing whether or + not a user is identified. Its endpoint is addressed by subscription ID and takes no user JWT. + + That leaves the exemption resting on the invariant that email and SMS subscriptions are only ever + added and removed, never updated. Should an update path for them appear, this has to narrow to the + push type, which the repo cannot see from here: `OSSubscriptionModel` lives in OneSignalUser, so + the Delta would have to carry the distinction the way it carries `externalId`. + */ + private func shouldDropAnonymousDelta(_ delta: OSDelta, ivActive: Bool) -> Bool { + return ivActive && delta.externalId == nil && delta.name != OS_UPDATE_SUBSCRIPTION_DELTA + } + private func flushDeltaQueue(inBackground: Bool = false) { guard !paused else { OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSOperationRepo not flushing queue due to being paused") @@ -141,29 +241,55 @@ public class OSOperationRepo: NSObject { return } + // Before the requirement gate so a first flush still registers the hydration handler. + self.startPolling() + + /* + Hold until `requirement` is known. `newCodePathsRun` / `ivBehaviorActive` both read false while + it is unknown. + */ + guard identityVerificationService.requirement != .unknown else { + let heldCount = self.deltaQueue.count + if heldCount > 0 { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSOperationRepo holding \(heldCount) Deltas until the requirement is known") + } + return + } + if inBackground { OSBackgroundTaskManager.beginBackgroundTask(OPERATION_REPO_BACKGROUND_TASK) } - self.start() - if !self.deltaQueue.isEmpty { OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OSOperationRepo flushDeltaQueue in background: \(inBackground) with queue: \(self.deltaQueue)") } + // Snapshot once so every Delta in this pass sees the same gate values. + let dropAnonymous = shouldDropAnonymousDeltas + var unmatched: [OSDelta] = [] for delta in self.deltaQueue { - if let executor = self.deltasToExecutorMap[delta.name] { + if shouldDropAnonymousDelta(delta, ivActive: dropAnonymous) { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSOperationRepo dropping anonymous Delta, Identity Verification is required: \(delta)") + } else if let executor = self.deltasToExecutorMap[delta.name] { executor.enqueueDelta(delta) } else { // Keep if no executor matches yet (module may not have started). unmatched.append(delta) } } - self.deltaQueue = unmatched + // Persist only when the queue changed: a no-op write before `refreshIfEmpty` can clobber a + // cache that prewarm failed to read. + if unmatched.count != self.deltaQueue.count { + self.deltaQueue = unmatched + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_OPERATION_REPO_DELTA_QUEUE_KEY, withValue: self.deltaQueue) + } - // Persist the deltas (including removed deltas) to storage after they are divvy'd up to executors. - OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_OPERATION_REPO_DELTA_QUEUE_KEY, withValue: self.deltaQueue) + if dropAnonymous { + for executor in self.executors { + executor.removeOperationsWithoutExternalId() + } + } for executor in self.executors { executor.cacheDeltaQueue() diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSUrlPath.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSUrlPath.swift new file mode 100644 index 000000000..e104d7d8d --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSUrlPath.swift @@ -0,0 +1,47 @@ +/* + 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 + +/// Shared path-segment encoding for Swift and ObjC request builders. +@objc(OSUrlPath) +public final class OSUrlPath: NSObject { + /** + Returns `value` percent-encoded for use as one path segment, or nil if it cannot be encoded. + + `urlUserAllowed` rather than `urlPathAllowed`, which leaves `/` alone: the values the SDK + interpolates into a path — `external_id`, alias labels, Live Activity types — come from the app, + and one containing a slash, `?`, `#` or `%` would otherwise reach a different endpoint than intended. + + Encode once, where the path is built. A value that has already been through this comes back with its + `%` escaped again. + */ + @objc(segment:) + public static func segment(_ value: String) -> String? { + return value.addingPercentEncoding(withAllowedCharacters: .urlUserAllowed) + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/OSCoreMocks.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/OSCoreMocks.swift index 1f8cc7a27..4dae89435 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/OSCoreMocks.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/OSCoreMocks.swift @@ -31,10 +31,6 @@ import OneSignalCore @objc public class OSCoreMocks: NSObject { - public static func resetOperationRepo() { - OSOperationRepo.sharedInstance.reset() - } - /// Puts the shared JWT config back to unhydrated. public static func resetSharedJwtConfig() { OSUserJwtConfig.shared.resetRequirementToUnknownForTests() @@ -48,13 +44,15 @@ public class OSCoreMocks: NSObject { extension OSOperationRepo { /** - The Operation Repo needs to reset between tests until we dependency inject the Operation Repo, - to prevent state from carrying over between tests. + Clears queue state between tests that reach the repo through the User Manager singleton. + Leaves `hasCalledStart` alone so the next `start()` does not schedule a second poller. */ - func reset() { - deltaQueue.removeAll() - executors.removeAll() - deltasToExecutorMap.removeAll() + public func reset() { + dispatchQueue.sync { + deltaQueue.removeAll() + executors.removeAll() + deltasToExecutorMap.removeAll() + } paused = false } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoFlushTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoFlushTests.swift index de6859a1d..0257b9260 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoFlushTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoFlushTests.swift @@ -1,7 +1,7 @@ /* Modified MIT License - Copyright 2026 OneSignal + Copyright 2025 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 @@ -37,17 +37,26 @@ final class OSOperationRepoFlushTests: XCTestCase { private let knownDelta = "test_known_delta" private let unknownDelta = "test_unknown_delta" + private var jwtConfig = OSUserJwtConfig() + private var repo: OSOperationRepo! + override func setUp() { super.setUp() OneSignalIdentifiers.currentAppId = "test-app-id" - resetOperationRepo() - // Pause so the poller (started by addExecutor/start) cannot flush mid-setup. - OSOperationRepo.sharedInstance.paused = true - OSOperationRepo.sharedInstance.pollIntervalMilliseconds = 60_000 + OSOperationRepoTestEnvironment.clearCache() + + jwtConfig = OSUserJwtConfig() + // Hydrate `off` before building the repo so routing tests are not held by unknown-requirement. + jwtConfig.hydrate(requiresUserAuth: false) + repo = OSOperationRepoTestEnvironment.makeRepo(jwtConfig: jwtConfig) + + // Pause so addExecutor/start cannot flush mid-setup. + repo.paused = true + repo.pollIntervalMilliseconds = 60_000 } override func tearDown() { - resetOperationRepo() + OSOperationRepoTestEnvironment.clearCache() super.tearDown() } @@ -56,21 +65,18 @@ final class OSOperationRepoFlushTests: XCTestCase { let processExpectation = expectation(description: "processDeltaQueue") executor.onProcessDeltaQueue = { processExpectation.fulfill() } - let repo = OSOperationRepo.sharedInstance repo.addExecutor(executor) - let deltaA = makeDelta(name: knownDelta, property: "a") - let deltaB = makeDelta(name: knownDelta, property: "b") - repo.enqueueDelta(deltaA) - repo.enqueueDelta(deltaB) - waitUntil("both deltas enqueued") { repo.deltaQueue.count == 2 } + repo.enqueueDelta(makeDelta(name: knownDelta, property: "a")) + repo.enqueueDelta(makeDelta(name: knownDelta, property: "b")) + waitUntil("both deltas enqueued") { self.repo.snapshotDeltaQueue().count == 2 } repo.paused = false repo.addFlushDeltaQueueToDispatchQueue() wait(for: [processExpectation], timeout: 2.0) XCTAssertEqual(executor.enqueued.map(\.property), ["a", "b"]) - XCTAssertTrue(repo.deltaQueue.isEmpty) + XCTAssertTrue(repo.snapshotDeltaQueue().isEmpty) } func testFlush_keepsUnmatchedDeltasInRepoQueue() { @@ -78,21 +84,18 @@ final class OSOperationRepoFlushTests: XCTestCase { let processExpectation = expectation(description: "processDeltaQueue") executor.onProcessDeltaQueue = { processExpectation.fulfill() } - let repo = OSOperationRepo.sharedInstance repo.addExecutor(executor) - let deltaA = makeDelta(name: unknownDelta, property: "a") - let deltaB = makeDelta(name: unknownDelta, property: "b") - repo.enqueueDelta(deltaA) - repo.enqueueDelta(deltaB) - waitUntil("both deltas enqueued") { repo.deltaQueue.count == 2 } + repo.enqueueDelta(makeDelta(name: unknownDelta, property: "a")) + repo.enqueueDelta(makeDelta(name: unknownDelta, property: "b")) + waitUntil("both deltas enqueued") { self.repo.snapshotDeltaQueue().count == 2 } repo.paused = false repo.addFlushDeltaQueueToDispatchQueue() wait(for: [processExpectation], timeout: 2.0) XCTAssertTrue(executor.enqueued.isEmpty) - XCTAssertEqual(repo.deltaQueue.map(\.property), ["a", "b"]) + XCTAssertEqual(repo.snapshotDeltaQueue().map(\.property), ["a", "b"]) } func testFlush_routesMatchedAndPreservesUnmatchedOrder() { @@ -100,7 +103,6 @@ final class OSOperationRepoFlushTests: XCTestCase { let processExpectation = expectation(description: "processDeltaQueue") executor.onProcessDeltaQueue = { processExpectation.fulfill() } - let repo = OSOperationRepo.sharedInstance repo.addExecutor(executor) // Interleaved matched/unmatched: assert dispatch order and retained queue order. @@ -109,54 +111,44 @@ final class OSOperationRepoFlushTests: XCTestCase { repo.enqueueDelta(makeDelta(name: knownDelta, property: "known-2")) repo.enqueueDelta(makeDelta(name: unknownDelta, property: "unknown-2")) repo.enqueueDelta(makeDelta(name: knownDelta, property: "known-3")) - waitUntil("all deltas enqueued") { repo.deltaQueue.count == 5 } + waitUntil("all deltas enqueued") { self.repo.snapshotDeltaQueue().count == 5 } repo.paused = false repo.addFlushDeltaQueueToDispatchQueue() wait(for: [processExpectation], timeout: 2.0) XCTAssertEqual(executor.enqueued.map(\.property), ["known-1", "known-2", "known-3"]) - XCTAssertEqual(repo.deltaQueue.map(\.property), ["unknown-1", "unknown-2"]) + XCTAssertEqual(repo.snapshotDeltaQueue().map(\.property), ["unknown-1", "unknown-2"]) } - // MARK: - Helpers + /** + Registration used to write the executor list and the name map on the caller's thread, where a + flush or another registration could tear them. Every executor added concurrently has to end up + routable. + */ + func testEveryExecutorRegisteredConcurrentlyIsRoutable() { + let names = (0..<50).map { "concurrent_delta_\($0)" } + let executors = names.map { MockOperationExecutor(supportedDeltas: [$0]) } - private func resetOperationRepo() { - let repo = OSOperationRepo.sharedInstance - repo.deltaQueue.removeAll() - repo.executors.removeAll() - repo.deltasToExecutorMap.removeAll() - repo.paused = false - } + DispatchQueue.concurrentPerform(iterations: executors.count) { index in + self.repo.addExecutor(executors[index]) + } - private func makeDelta(name: String, property: String) -> OSDelta { - OSDelta( - name: name, - identityModelId: UUID().uuidString, - externalId: nil, - model: OSModel(changeNotifier: OSEventProducer()), - property: property, - value: property - ) - } -} - -private final class MockOperationExecutor: OSOperationExecutor { - let supportedDeltas: [String] - private(set) var enqueued: [OSDelta] = [] - var onProcessDeltaQueue: (() -> Void)? + for name in names { + repo.enqueueDelta(makeDelta(name: name, property: name)) + } + waitUntil("all deltas enqueued") { self.repo.snapshotDeltaQueue().count == names.count } - init(supportedDeltas: [String]) { - self.supportedDeltas = supportedDeltas - } + repo.paused = false + repo.addFlushDeltaQueueToDispatchQueue() - func enqueueDelta(_ delta: OSDelta) { - enqueued.append(delta) + waitUntil("all deltas routed") { self.repo.snapshotDeltaQueue().isEmpty } + XCTAssertEqual(executors.map { $0.enqueued.map(\.property) }, names.map { [$0] }) } - func cacheDeltaQueue() {} + // MARK: - Helpers - func processDeltaQueue(inBackground: Bool) { - onProcessDeltaQueue?() + private func makeDelta(name: String, property: String) -> OSDelta { + OSOperationRepoTestEnvironment.makeDelta(name: name, externalId: nil, property: property) } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoIdentityVerificationTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoIdentityVerificationTests.swift new file mode 100644 index 000000000..8e748b0ef --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoIdentityVerificationTests.swift @@ -0,0 +1,266 @@ +/* + Modified MIT License + + Copyright 2025 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 XCTest +import OneSignalCore +@testable import OneSignalOSCore + +/// Covers Operation Repo Identity Verification: hold until `requirement` is known, drop unsigned work. +final class OSOperationRepoIdentityVerificationTests: XCTestCase { + + private let deltaName = "test_delta" + + private var jwtConfig = OSUserJwtConfig() + private var featureManager = OSFeatureManager(enabledKeys: []) + + override func setUp() { + super.setUp() + OneSignalIdentifiers.currentAppId = "test-app-id" + OSOperationRepoTestEnvironment.clearCache() + jwtConfig = OSUserJwtConfig() + featureManager = OSFeatureManager(enabledKeys: []) + } + + override func tearDown() { + OSOperationRepoTestEnvironment.clearCache() + super.tearDown() + } + + // MARK: - Deferral while the requirement is unknown + + func testNothingFlushesWhileTheRequirementIsUnknown() { + let repo = makeRepo() + let executor = MockOperationExecutor(supportedDeltas: [deltaName]) + let notProcessed = expectation(description: "processDeltaQueue is not called") + notProcessed.isInverted = true + executor.onProcessDeltaQueue = { notProcessed.fulfill() } + repo.addExecutor(executor) + + repo.enqueueDelta(makeDelta(externalId: "user-1", property: "a")) + repo.enqueueDelta(makeDelta(externalId: "user-1", property: "b")) + waitUntil("both deltas enqueued") { repo.snapshotDeltaQueue().count == 2 } + + repo.addFlushDeltaQueueToDispatchQueue() + + wait(for: [notProcessed], timeout: 0.5) + XCTAssertTrue(executor.enqueued.isEmpty) + XCTAssertEqual(repo.snapshotDeltaQueue().map(\.property), ["a", "b"]) + } + + /// Enqueue still persists while unknown; only flush waits. + func testDeltasEnqueuedWhileTheRequirementIsUnknownArePersisted() { + let repo = makeRepo() + repo.enqueueDelta(makeDelta(externalId: "user-1", property: "a")) + waitUntil("delta enqueued") { repo.snapshotDeltaQueue().count == 1 } + + let cached = OSOperationRepoTestEnvironment.cachedDeltaQueue() + XCTAssertEqual(cached?.map(\.property), ["a"]) + } + + /// Hydration must flush immediately rather than wait out a poll interval. + func testHydratingTheRequirementReleasesHeldDeltasImmediately() { + let repo = makeRepo() + let executor = MockOperationExecutor(supportedDeltas: [deltaName]) + let processed = flushExpectation(on: executor) + repo.addExecutor(executor) + + repo.enqueueDelta(makeDelta(externalId: "user-1", property: "a")) + waitUntil("delta enqueued") { repo.snapshotDeltaQueue().count == 1 } + + jwtConfig.hydrate(requiresUserAuth: false) + + wait(for: [processed], timeout: 2.0) + XCTAssertEqual(executor.enqueued.map(\.property), ["a"]) + XCTAssertTrue(repo.snapshotDeltaQueue().isEmpty) + } + + // MARK: - Anonymous suppression + + /// No `externalId` means nothing to sign with, so drop at enqueue while IV is required. + func testAnonymousDeltasAreDroppedAtEnqueueWhileIdentityVerificationIsRequired() { + // Hydrate first and pause so this asserts the enqueue drop, not a flush. + jwtConfig.hydrate(requiresUserAuth: true) + let repo = makeRepo() + repo.paused = true + + repo.enqueueDelta(makeDelta(externalId: nil, property: "anonymous")) + repo.enqueueDelta(makeDelta(externalId: "user-1", property: "identified")) + + // Identified Delta is the sync point; the queue is serial. + waitUntil("identified delta enqueued") { repo.snapshotDeltaQueue().count == 1 } + XCTAssertEqual(repo.snapshotDeltaQueue().map(\.property), ["identified"]) + } + + /// Flush must drop restored anonymous Deltas; they never pass through enqueue. + func testAnonymousDeltasRestoredFromTheCacheAreDroppedAtFlush() { + OSOperationRepoTestEnvironment.seedCachedDeltaQueue([ + makeDelta(externalId: nil, property: "anonymous"), + makeDelta(externalId: "user-1", property: "identified") + ]) + + let repo = makeRepo() + XCTAssertEqual(repo.snapshotDeltaQueue().count, 2, "the repo should restore both Deltas before judging them") + + let executor = MockOperationExecutor(supportedDeltas: [deltaName]) + let processed = flushExpectation(on: executor) + repo.addExecutor(executor) + + jwtConfig.hydrate(requiresUserAuth: true) + + wait(for: [processed], timeout: 2.0) + XCTAssertEqual(executor.enqueued.map(\.property), ["identified"]) + XCTAssertTrue(repo.snapshotDeltaQueue().isEmpty) + } + + /** + The push subscription has no owner to sign for before login or after logout, and its updates still + have to go out, so `OS_UPDATE_SUBSCRIPTION_DELTA` survives the enqueue drop. + */ + func testAnonymousSubscriptionUpdatesAreExemptFromTheEnqueueDrop() { + jwtConfig.hydrate(requiresUserAuth: true) + let repo = makeRepo() + repo.paused = true + + repo.enqueueDelta(OSOperationRepoTestEnvironment.makeDelta(name: OS_UPDATE_SUBSCRIPTION_DELTA, externalId: nil, property: "token")) + repo.enqueueDelta(makeDelta(externalId: nil, property: "anonymous")) + repo.enqueueDelta(makeDelta(externalId: "user-1", property: "identified")) + + // The identified Delta is the sync point; the queue is serial. + waitUntil("identified delta enqueued") { repo.snapshotDeltaQueue().count == 2 } + XCTAssertEqual(repo.snapshotDeltaQueue().map(\.property), ["token", "identified"]) + } + + /// Same exemption for a Delta restored from a previous session, which never passes through enqueue. + func testAnonymousSubscriptionUpdatesAreExemptFromTheFlushDrop() { + OSOperationRepoTestEnvironment.seedCachedDeltaQueue([ + OSOperationRepoTestEnvironment.makeDelta(name: OS_UPDATE_SUBSCRIPTION_DELTA, externalId: nil, property: "token"), + makeDelta(externalId: nil, property: "anonymous") + ]) + + let repo = makeRepo() + let executor = MockOperationExecutor(supportedDeltas: [OS_UPDATE_SUBSCRIPTION_DELTA, deltaName]) + let processed = flushExpectation(on: executor) + repo.addExecutor(executor) + + jwtConfig.hydrate(requiresUserAuth: true) + + wait(for: [processed], timeout: 2.0) + XCTAssertEqual(executor.enqueued.map(\.property), ["token"]) + } + + /// The rollout flag alone must not suppress; only `jwt_required` turns it on. + func testAnonymousDeltasSurviveWhenTheFlagIsOnButTheAppDoesNotRequireAuth() { + featureManager = OSFeatureManager(enabledKeys: [OSFeatureFlag.identityVerification.rawValue]) + + let repo = makeRepo() + let executor = MockOperationExecutor(supportedDeltas: [deltaName]) + let processed = flushExpectation(on: executor) + repo.addExecutor(executor) + + repo.enqueueDelta(makeDelta(externalId: nil, property: "anonymous")) + waitUntil("delta enqueued") { repo.snapshotDeltaQueue().count == 1 } + + jwtConfig.hydrate(requiresUserAuth: false) + + wait(for: [processed], timeout: 2.0) + XCTAssertEqual(executor.enqueued.map(\.property), ["anonymous"]) + } + + // MARK: - Purge when the requirement arrives as required + + /// Unsupported delta name on purpose so survival is by `externalId`, not routing. + func testLearningThatAuthIsRequiredDropsAnonymousDeltasAndKeepsIdentifiedOnes() { + OSOperationRepoTestEnvironment.seedCachedDeltaQueue([ + makeDelta(externalId: nil, property: "anonymous"), + makeDelta(externalId: "user-1", property: "identified") + ]) + + let repo = makeRepo() + let executor = MockOperationExecutor(supportedDeltas: ["some_other_delta"]) + let processed = flushExpectation(on: executor) + repo.addExecutor(executor) + + jwtConfig.hydrate(requiresUserAuth: true) + + wait(for: [processed], timeout: 2.0) + XCTAssertEqual(repo.snapshotDeltaQueue().map(\.property), ["identified"]) + + let cached = OSOperationRepoTestEnvironment.cachedDeltaQueue() + XCTAssertEqual(cached?.map(\.property), ["identified"], "the drop has to survive a restart") + } + + /// Executor caches hold last session's deltas, so the purge must reach them too. + func testFlushingWhileAuthIsRequiredDrivesThePurgeIntoExecutors() { + let repo = makeRepo() + let executor = MockOperationExecutor(supportedDeltas: [deltaName]) + repo.addExecutor(executor) + + jwtConfig.hydrate(requiresUserAuth: true) + + waitUntil("executor asked to purge") { executor.removeOperationsWithoutExternalIdCallCount >= 1 } + } + + func testFlushingWhileAuthIsNotRequiredLeavesAnonymousDeltasAlone() { + OSOperationRepoTestEnvironment.seedCachedDeltaQueue([ + makeDelta(externalId: nil, property: "anonymous") + ]) + + let repo = makeRepo() + let executor = MockOperationExecutor(supportedDeltas: ["some_other_delta"]) + let processed = flushExpectation(on: executor) + repo.addExecutor(executor) + + jwtConfig.hydrate(requiresUserAuth: false) + + wait(for: [processed], timeout: 2.0) + XCTAssertEqual(repo.snapshotDeltaQueue().map(\.property), ["anonymous"]) + XCTAssertEqual(executor.removeOperationsWithoutExternalIdCallCount, 0) + } + + // MARK: - Helpers + + /** + Fulfills once the executor is asked to process. Repeats are allowed: a handler that registers while + `hydrate` is running is delivered both by the fire and by `addOnJwtConfigHydratedHandler`'s catch-up, + so the same hydration can flush twice. These tests assert on what the flush did, not on how many ran. + */ + private func flushExpectation(on executor: MockOperationExecutor) -> XCTestExpectation { + let processed = expectation(description: "processDeltaQueue") + processed.assertForOverFulfill = false + executor.onProcessDeltaQueue = { processed.fulfill() } + return processed + } + + private func makeRepo() -> OSOperationRepo { + return OSOperationRepoTestEnvironment.makeRepo(jwtConfig: jwtConfig, featureManager: featureManager) + } + + private func makeDelta(externalId: String?, property: String) -> OSDelta { + return OSOperationRepoTestEnvironment.makeDelta(name: deltaName, externalId: externalId, property: property) + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoTestSupport.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoTestSupport.swift new file mode 100644 index 000000000..0c6e4beac --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoTestSupport.swift @@ -0,0 +1,107 @@ +/* + Modified MIT License + + Copyright 2025 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 XCTest +import OneSignalCore +@testable import OneSignalOSCore + +/** + Builds an Operation Repo per test. Clear the cache before constructing one (`init` uncachees); + seed the cache first when the test starts from a restored queue. + */ +enum OSOperationRepoTestEnvironment { + static func clearCache() { + OneSignalUserDefaults.initShared().removeValue(forKey: OS_OPERATION_REPO_DELTA_QUEUE_KEY) + OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_USE_IDENTITY_VERIFICATION) + OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_SDK_FEATURE_FLAGS) + } + + static func seedCachedDeltaQueue(_ deltas: [OSDelta]) { + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_OPERATION_REPO_DELTA_QUEUE_KEY, withValue: deltas) + } + + static func cachedDeltaQueue() -> [OSDelta]? { + let key = OS_OPERATION_REPO_DELTA_QUEUE_KEY + return OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: key, defaultValue: []) as? [OSDelta] + } + + // Pin the poller out of reach: DEBUG uses 100ms and would flush underneath expectations. + static func makeRepo(jwtConfig: OSUserJwtConfig, featureManager: OSFeatureManager = OSFeatureManager(enabledKeys: [])) -> OSOperationRepo { + let service = OSIdentityVerificationService(featureManager: featureManager, jwtConfig: jwtConfig) + let repo = OSOperationRepo(identityVerificationService: service) + repo.pollIntervalMilliseconds = 60_000 + return repo + } + + static func makeDelta(name: String, externalId: String?, property: String) -> OSDelta { + return OSDelta( + name: name, + identityModelId: UUID().uuidString, + externalId: externalId, + model: OSModel(changeNotifier: OSEventProducer()), + property: property, + value: property + ) + } +} + +extension OSOperationRepo { + /** + The queue as of right now. Tests poll it while the repo appends on its own queue, so reading + `deltaQueue` directly is a data race even when only the count is wanted. + */ + func snapshotDeltaQueue() -> [OSDelta] { + return dispatchQueue.sync { deltaQueue } + } +} + +/// Records what the Operation Repo hands it, so tests can assert on routing rather than on requests. +final class MockOperationExecutor: OSOperationExecutor { + let supportedDeltas: [String] + private(set) var enqueued: [OSDelta] = [] + private(set) var removeOperationsWithoutExternalIdCallCount = 0 + var onProcessDeltaQueue: (() -> Void)? + + init(supportedDeltas: [String]) { + self.supportedDeltas = supportedDeltas + } + + func enqueueDelta(_ delta: OSDelta) { + enqueued.append(delta) + } + + func cacheDeltaQueue() {} + + func processDeltaQueue(inBackground: Bool) { + onProcessDeltaQueue?() + } + + func removeOperationsWithoutExternalId() { + removeOperationsWithoutExternalIdCallCount += 1 + } +} \ No newline at end of file diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSCustomEventsExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSCustomEventsExecutor.swift index 7feb01a05..e6c372e61 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSCustomEventsExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSCustomEventsExecutor.swift @@ -49,12 +49,14 @@ class OSCustomEventsExecutor: OSOperationExecutor { private var deltaQueue: [OSDelta] = [] private var requestQueue: [OSRequestCustomEvents] = [] private let newRecordsState: OSNewRecordsState + private let auth: OSRequestAuthorizing // The executor dispatch queue, serial. This synchronizes access to `deltaQueue` and `requestQueue`. private let dispatchQueue = DispatchQueue(label: "OneSignal.OSCustomEventsExecutor", target: .global()) - init(newRecordsState: OSNewRecordsState) { + init(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing) { self.newRecordsState = newRecordsState + self.auth = auth // Read unfinished deltas and requests from cache, if any... uncacheDeltas() uncacheRequests() @@ -85,8 +87,8 @@ class OSCustomEventsExecutor: OSOperationExecutor { if let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(request.identityModel.modelId) { // 1. The identity model exist in the repo, set it to be the Request's model request.identityModel = identityModel - } else if request.prepareForExecution(newRecordsState: newRecordsState) { - // 2. The request can be sent, add the model to the repo + } else if request.ownerExternalId != nil || request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) { + // 2. The Request is owned, so a token can still arrive for it, or it can be sent as is; add the model to the repo OneSignalUserManagerImpl.sharedInstance.addIdentityModelToRepo(request.identityModel) } else { // 3. The identitymodel do not exist AND this request cannot be sent, drop this Request @@ -116,6 +118,24 @@ class OSCustomEventsExecutor: OSOperationExecutor { } } + func removeOperationsWithoutExternalId() { + self.dispatchQueue.async { + let remainingDeltas = self.deltaQueue.filter { $0.externalId != nil } + if remainingDeltas.count != self.deltaQueue.count { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSCustomEventsExecutor dropped \(self.deltaQueue.count - remainingDeltas.count) anonymous Deltas, Identity Verification is required") + self.deltaQueue = remainingDeltas + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_CUSTOM_EVENTS_EXECUTOR_DELTA_QUEUE_KEY, withValue: self.deltaQueue) + } + + let remainingRequests = self.requestQueue.filter { $0.ownerExternalId != nil } + if remainingRequests.count != self.requestQueue.count { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSCustomEventsExecutor dropped \(self.requestQueue.count - remainingRequests.count) anonymous Requests, Identity Verification is required") + self.requestQueue = remainingRequests + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_CUSTOM_EVENTS_EXECUTOR_REQUEST_QUEUE_KEY, withValue: self.requestQueue) + } + } + } + /// The `deltaQueue` can contain events for multiple users. They will remain as Deltas if there is no onesignal ID yet for its user. /// This method will be used in an upcoming release that combine multiple events. func processDeltaQueueWithBatching(inBackground: Bool) { @@ -127,8 +147,8 @@ class OSCustomEventsExecutor: OSOperationExecutor { } OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OSCustomEventsExecutor processDeltaQueue with queue: \(self.deltaQueue)") - // Holds mapping of identity model ID to the events for it - var combinedEvents: [String: [[String: Any]]] = [:] + // Holds mapping of identity model ID to the events for it, with the owner the Deltas stamped + var combinedEvents: [String: (events: [[String: Any]], ownerExternalId: String?)] = [:] // 1. Combine the events for every distinct user for (index, delta) in self.deltaQueue.enumerated().reversed() { @@ -154,20 +174,21 @@ class OSCustomEventsExecutor: OSOperationExecutor { EventConstants.payload: self.addSdkMetadata(properties: properties) ] - combinedEvents[identityModel.modelId, default: []].append(event) + combinedEvents[identityModel.modelId, default: ([], delta.externalId)].events.append(event) self.deltaQueue.remove(at: index) } // 2. Turn each user's events into a Request - for (modelId, events) in combinedEvents { + for (modelId, combined) in combinedEvents { guard let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(modelId) else { // This should never happen as we already checked this during Deltas processing above continue } let request = OSRequestCustomEvents( - events: events, - identityModel: identityModel + events: combined.events, + identityModel: identityModel, + ownerExternalId: combined.ownerExternalId ) self.requestQueue.append(request) } @@ -216,7 +237,8 @@ class OSCustomEventsExecutor: OSOperationExecutor { let request = OSRequestCustomEvents( events: [event], - identityModel: identityModel + identityModel: identityModel, + ownerExternalId: delta.externalId ) self.requestQueue.append(request) } @@ -262,7 +284,7 @@ class OSCustomEventsExecutor: OSOperationExecutor { guard !request.sentToClient else { return } - guard request.prepareForExecution(newRecordsState: newRecordsState) else { + guard request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) else { return } request.sentToClient = true @@ -284,7 +306,9 @@ class OSCustomEventsExecutor: OSOperationExecutor { OneSignalLog.onesignalLog(.LL_ERROR, message: "OSCustomEventsExecutor request failed with error: \(error.debugDescription)") self.dispatchQueue.async { let responseType = OSNetworkingUtils.getResponseStatusType(error.code) - if responseType != .retryable { + if responseType == .unauthorized, self.auth.handleUnauthorized(request) { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSCustomEventsExecutor holding \(request) for a new token") + } else if responseType != .retryable { // Fail, no retry, remove from cache and queue self.requestQueue.removeAll(where: { $0 == request}) OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_CUSTOM_EVENTS_EXECUTOR_REQUEST_QUEUE_KEY, withValue: self.requestQueue) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSIdentityOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSIdentityOperationExecutor.swift index 516ca0c02..46f357854 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSIdentityOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSIdentityOperationExecutor.swift @@ -35,12 +35,14 @@ class OSIdentityOperationExecutor: OSOperationExecutor { private var addRequestQueue: [OSRequestAddAliases] = [] private var removeRequestQueue: [OSRequestRemoveAlias] = [] private let newRecordsState: OSNewRecordsState + private let auth: OSRequestAuthorizing // The Identity executor dispatch queue, serial. This synchronizes access to the delta and request queues. private let dispatchQueue = DispatchQueue(label: "OneSignal.OSIdentityOperationExecutor", target: .global()) - init(newRecordsState: OSNewRecordsState) { + init(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing) { self.newRecordsState = newRecordsState + self.auth = auth // Read unfinished deltas and requests from cache, if any... uncacheDeltas() uncacheAddAliasRequests() @@ -74,9 +76,9 @@ class OSIdentityOperationExecutor: OSOperationExecutor { if let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(request.identityModel.modelId) { // 1. The model exists in the repo, so set it to be the Request's models request.identityModel = identityModel - } else if request.prepareForExecution(newRecordsState: newRecordsState) { - // 2. The request can be sent, add the model to the repo - OneSignalUserManagerImpl.sharedInstance.addIdentityModelToRepo(request.identityModel) + } else if request.ownerExternalId != nil || request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) { + // 2. The Request is owned, so a token can still arrive for it, or it can be sent as is; add the model to the repo + OneSignalUserManagerImpl.sharedInstance.addIdentityModelToRepo(request.identityModel) } else { // 3. The model do not exist AND this request cannot be sent, drop this Request OneSignalLog.onesignalLog(.LL_ERROR, message: "OSIdentityOperationExecutor.init dropped \(request)") @@ -97,8 +99,8 @@ class OSIdentityOperationExecutor: OSOperationExecutor { if let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(request.identityModel.modelId) { // 1. The model exists in the repo, so set it to be the Request's model request.identityModel = identityModel - } else if request.prepareForExecution(newRecordsState: newRecordsState) { - // 2. The request can be sent, add the model to the repo + } else if request.ownerExternalId != nil || request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) { + // 2. The Request is owned, so a token can still arrive for it, or it can be sent as is; add the model to the repo OneSignalUserManagerImpl.sharedInstance.addIdentityModelToRepo(request.identityModel) } else { // 3. The model does not exist AND this request cannot be sent, drop this Request @@ -126,6 +128,31 @@ class OSIdentityOperationExecutor: OSOperationExecutor { } } + func removeOperationsWithoutExternalId() { + self.dispatchQueue.async { + let remainingDeltas = self.deltaQueue.filter { $0.externalId != nil } + if remainingDeltas.count != self.deltaQueue.count { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSIdentityOperationExecutor dropped \(self.deltaQueue.count - remainingDeltas.count) anonymous Deltas, Identity Verification is required") + self.deltaQueue = remainingDeltas + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_IDENTITY_EXECUTOR_DELTA_QUEUE_KEY, withValue: self.deltaQueue) + } + + let remainingAdd = self.addRequestQueue.filter { $0.ownerExternalId != nil } + if remainingAdd.count != self.addRequestQueue.count { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSIdentityOperationExecutor dropped \(self.addRequestQueue.count - remainingAdd.count) anonymous add Requests, Identity Verification is required") + self.addRequestQueue = remainingAdd + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_IDENTITY_EXECUTOR_ADD_REQUEST_QUEUE_KEY, withValue: self.addRequestQueue) + } + + let remainingRemove = self.removeRequestQueue.filter { $0.ownerExternalId != nil } + if remainingRemove.count != self.removeRequestQueue.count { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSIdentityOperationExecutor dropped \(self.removeRequestQueue.count - remainingRemove.count) anonymous remove Requests, Identity Verification is required") + self.removeRequestQueue = remainingRemove + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_IDENTITY_EXECUTOR_REMOVE_REQUEST_QUEUE_KEY, withValue: self.removeRequestQueue) + } + } + } + func processDeltaQueue(inBackground: Bool) { self.dispatchQueue.async { if !self.deltaQueue.isEmpty { @@ -141,12 +168,12 @@ class OSIdentityOperationExecutor: OSOperationExecutor { switch delta.name { case OS_ADD_ALIAS_DELTA: - let request = OSRequestAddAliases(aliases: aliases, identityModel: model) + let request = OSRequestAddAliases(aliases: aliases, identityModel: model, ownerExternalId: delta.externalId) self.addRequestQueue.append(request) case OS_REMOVE_ALIAS_DELTA: for (label, _) in aliases { - let request = OSRequestRemoveAlias(labelToRemove: label, identityModel: model) + let request = OSRequestRemoveAlias(labelToRemove: label, identityModel: model, ownerExternalId: delta.externalId) self.removeRequestQueue.append(request) } @@ -193,7 +220,7 @@ class OSIdentityOperationExecutor: OSOperationExecutor { guard !request.sentToClient else { return } - guard request.prepareForExecution(newRecordsState: newRecordsState) else { + guard request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) else { return } request.sentToClient = true @@ -234,6 +261,8 @@ class OSIdentityOperationExecutor: OSOperationExecutor { // The subscription has been deleted along with the user, so remove the subscription_id but keep the same push subscription model OneSignalUserManagerImpl.sharedInstance.pushSubscriptionModel?.subscriptionId = nil OneSignalUserManagerImpl.sharedInstance._logout() + } else if responseType == .unauthorized, self.auth.handleUnauthorized(request) { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSIdentityOperationExecutor holding \(request) for a new token") } else if responseType != .retryable { // Fail, no retry, remove from cache and queue self.addRequestQueue.removeAll(where: { $0 == request}) @@ -250,7 +279,7 @@ class OSIdentityOperationExecutor: OSOperationExecutor { guard !request.sentToClient else { return } - guard request.prepareForExecution(newRecordsState: newRecordsState) else { + guard request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) else { return } request.sentToClient = true @@ -276,7 +305,9 @@ class OSIdentityOperationExecutor: OSOperationExecutor { OneSignalLog.onesignalLog(.LL_ERROR, message: "OSIdentityOperationExecutor remove alias request failed with error: \(error.debugDescription)") self.dispatchQueue.async { let responseType = OSNetworkingUtils.getResponseStatusType(error.code) - if responseType != .retryable { + if responseType == .unauthorized, self.auth.handleUnauthorized(request) { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSIdentityOperationExecutor holding \(request) for a new token") + } else if responseType != .retryable { // Fail, no retry, remove from cache and queue // A response of .missing could mean the alias doesn't exist on this user OR this user has been deleted self.removeRequestQueue.removeAll(where: { $0 == request}) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift index 0df165df2..0ba3b65a4 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift @@ -35,6 +35,11 @@ private struct OSCombinedProperties { var location: OSLocationPoint? var refreshDeviceMetadata = false + /// Carried from the Deltas so the Request inherits their stamped owner. The Deltas combined here + /// share one Identity Model; the last one wins if that model gained an `external_id` partway, which + /// keeps the combined work rather than dropping it. + var ownerExternalId: String? + // Items of Properties Deltas var sessionTime: Int = 0 var sessionCount: Int = 0 @@ -65,12 +70,14 @@ class OSPropertyOperationExecutor: OSOperationExecutor { private var deltaQueue: [OSDelta] = [] private var updateRequestQueue: [OSRequestUpdateProperties] = [] private let newRecordsState: OSNewRecordsState + private let auth: OSRequestAuthorizing // The property executor dispatch queue, serial. This synchronizes access to `deltaQueue` and `updateRequestQueue`. private let dispatchQueue = DispatchQueue(label: "OneSignal.OSPropertyOperationExecutor", target: .global()) - init(newRecordsState: OSNewRecordsState) { + init(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing) { self.newRecordsState = newRecordsState + self.auth = auth // Read unfinished deltas and requests from cache, if any... // Note that we should only have deltas for the current user as old ones are flushed.. uncacheDeltas() @@ -100,8 +107,8 @@ class OSPropertyOperationExecutor: OSOperationExecutor { if let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(request.identityModel.modelId) { // 1. The identity model exist in the repo, set it to be the Request's model request.identityModel = identityModel - } else if request.prepareForExecution(newRecordsState: newRecordsState) { - // 2. The request can be sent, add the model to the repo + } else if request.ownerExternalId != nil || request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) { + // 2. The Request is owned, so a token can still arrive for it, or it can be sent as is; add the model to the repo OneSignalUserManagerImpl.sharedInstance.addIdentityModelToRepo(request.identityModel) } else { // 3. The identitymodel do not exist AND this request cannot be sent, drop this Request @@ -129,6 +136,24 @@ class OSPropertyOperationExecutor: OSOperationExecutor { } } + func removeOperationsWithoutExternalId() { + self.dispatchQueue.async { + let remainingDeltas = self.deltaQueue.filter { $0.externalId != nil } + if remainingDeltas.count != self.deltaQueue.count { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSPropertyOperationExecutor dropped \(self.deltaQueue.count - remainingDeltas.count) anonymous Deltas, Identity Verification is required") + self.deltaQueue = remainingDeltas + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_PROPERTIES_EXECUTOR_DELTA_QUEUE_KEY, withValue: self.deltaQueue) + } + + let remainingRequests = self.updateRequestQueue.filter { $0.ownerExternalId != nil } + if remainingRequests.count != self.updateRequestQueue.count { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSPropertyOperationExecutor dropped \(self.updateRequestQueue.count - remainingRequests.count) anonymous Requests, Identity Verification is required") + self.updateRequestQueue = remainingRequests + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_PROPERTIES_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, withValue: self.updateRequestQueue) + } + } + } + /// The `deltaQueue` should only contain updates for one user. /// Even when login -> addTag -> login -> addTag are called in immediate succession. func processDeltaQueue(inBackground: Bool) { @@ -168,7 +193,8 @@ class OSPropertyOperationExecutor: OSOperationExecutor { } let request = OSRequestUpdateProperties( params: properties.jsonRepresentation(), - identityModel: identityModel + identityModel: identityModel, + ownerExternalId: properties.ownerExternalId ) self.updateRequestQueue.append(request) } @@ -186,6 +212,7 @@ class OSPropertyOperationExecutor: OSOperationExecutor { /// Helper method to combine the information in an `OSDelta` to the existing `OSCombinedProperties` so far. private func combineProperties(existing: OSCombinedProperties?, delta: OSDelta) -> OSCombinedProperties { var combinedProperties = existing ?? OSCombinedProperties() + combinedProperties.ownerExternalId = delta.externalId guard let property = OSPropertiesSupportedProperty(rawValue: delta.property) else { OneSignalLog.onesignalLog(.LL_ERROR, message: "OSPropertyOperationExecutor.combineProperties dropped unsupported property: \(delta.property)") @@ -235,7 +262,7 @@ class OSPropertyOperationExecutor: OSOperationExecutor { guard !request.sentToClient else { return } - guard request.prepareForExecution(newRecordsState: newRecordsState) else { + guard request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) else { return } request.sentToClient = true @@ -298,6 +325,8 @@ class OSPropertyOperationExecutor: OSOperationExecutor { // The subscription has been deleted along with the user, so remove the subscription_id but keep the same push subscription model OneSignalUserManagerImpl.sharedInstance.pushSubscriptionModel?.subscriptionId = nil OneSignalUserManagerImpl.sharedInstance._logout() + } else if responseType == .unauthorized, self.auth.handleUnauthorized(request) { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSPropertyOperationExecutor holding \(request) for a new token") } else if responseType != .retryable { // Fail, no retry, remove from cache and queue self.updateRequestQueue.removeAll(where: { $0 == request}) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift index f8c985cce..70b0d6d75 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift @@ -37,12 +37,14 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { private var updateRequestQueue: [OSRequestUpdateSubscription] = [] private var subscriptionModels: [String: OSSubscriptionModel] = [:] private let newRecordsState: OSNewRecordsState + private let auth: OSRequestAuthorizing // The Subscription executor dispatch queue, serial. This synchronizes access to the delta and request queues. private let dispatchQueue = DispatchQueue(label: "OneSignal.OSSubscriptionOperationExecutor", target: .global()) - init(newRecordsState: OSNewRecordsState) { + init(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing) { self.newRecordsState = newRecordsState + self.auth = auth // Read unfinished deltas and requests from cache, if any... uncacheDeltas() uncacheCreateSubscriptionRequests() @@ -91,8 +93,8 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { if let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(request.identityModel.modelId) { // a. The model exist in the repo request.identityModel = identityModel - } else if request.prepareForExecution(newRecordsState: newRecordsState) { - // b. The request can be sent, add the model to the repo + } else if request.ownerExternalId != nil || request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) { + // b. The Request is owned, so a token can still arrive for it, or it can be sent as is; add the model to the repo OneSignalUserManagerImpl.sharedInstance.addIdentityModelToRepo(request.identityModel) } else { // c. The model do not exist AND this request cannot be sent, drop this Request @@ -118,10 +120,12 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { } else if let subscriptionModel = subscriptionModels[request.subscriptionModel.modelId] { // 2. The model exists in the dict of seen subscription models request.subscriptionModel = subscriptionModel - } else if !request.prepareForExecution(newRecordsState: newRecordsState) { - // 3. The model does not exist AND this request cannot be sent, drop this Request + } else if request.ownerExternalId == nil, + !request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) { + // 3. The model does not exist AND no token can arrive to make this sendable, drop it OneSignalLog.onesignalLog(.LL_ERROR, message: "OSSubscriptionOperationExecutor.init dropped \(request)") removeRequestQueue.remove(at: index) + continue } } self.removeRequestQueue = removeRequestQueue @@ -141,11 +145,13 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { } else if let subscriptionModel = subscriptionModels[request.subscriptionModel.modelId] { // 2. The model exists in the dict of seen subscription models request.subscriptionModel = subscriptionModel - } else if !request.prepareForExecution(newRecordsState: newRecordsState) { + } else if !request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) { // 3. The models do not exist AND this request cannot be sent, drop this Request OneSignalLog.onesignalLog(.LL_ERROR, message: "OSSubscriptionOperationExecutor.init dropped \(request)") updateRequestQueue.remove(at: index) + continue } + request.identityModel = liveIdentityModel(request.identityModel) } self.updateRequestQueue = updateRequestQueue OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, withValue: self.updateRequestQueue) @@ -154,6 +160,21 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { } } + /** + Returns the repo's instance for this Identity Model, registering the decoded one if missing, + so every request for a user shares one instance. + */ + private func liveIdentityModel(_ identityModel: OSIdentityModel?) -> OSIdentityModel? { + guard let identityModel = identityModel else { + return nil + } + if let modelInRepo = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(identityModel.modelId) { + return modelInRepo + } + OneSignalUserManagerImpl.sharedInstance.addIdentityModelToRepo(identityModel) + return identityModel + } + /** Since there are 2 subscription stores, we need to check both stores for the model with a particular `modelId`. */ @@ -180,6 +201,39 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { } } + /** + Drops anonymous add/remove Deltas and Requests. Updates are kept — in practice only the device's + own push subscription is ever updated, and that has to keep reporting with or without an identified + user. See `OSOperationRepo.shouldDropAnonymousDelta` for what the exemption rests on. `logout()`'s + unsubscribe travels in `updateRequestQueue`, which is also left alone. + */ + func removeOperationsWithoutExternalId() { + self.dispatchQueue.async { + let remainingDeltas = self.deltaQueue.filter { + $0.externalId != nil || $0.name == OS_UPDATE_SUBSCRIPTION_DELTA + } + if remainingDeltas.count != self.deltaQueue.count { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSSubscriptionOperationExecutor dropped \(self.deltaQueue.count - remainingDeltas.count) anonymous Deltas, Identity Verification is required") + self.deltaQueue = remainingDeltas + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_DELTA_QUEUE_KEY, withValue: self.deltaQueue) + } + + let remainingAdd = self.addRequestQueue.filter { $0.ownerExternalId != nil } + if remainingAdd.count != self.addRequestQueue.count { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSSubscriptionOperationExecutor dropped \(self.addRequestQueue.count - remainingAdd.count) anonymous add Requests, Identity Verification is required") + self.addRequestQueue = remainingAdd + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_ADD_REQUEST_QUEUE_KEY, withValue: self.addRequestQueue) + } + + let remainingRemove = self.removeRequestQueue.filter { $0.ownerExternalId != nil } + if remainingRemove.count != self.removeRequestQueue.count { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSSubscriptionOperationExecutor dropped \(self.removeRequestQueue.count - remainingRemove.count) anonymous remove Requests, Identity Verification is required") + self.removeRequestQueue = remainingRemove + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_REMOVE_REQUEST_QUEUE_KEY, withValue: self.removeRequestQueue) + } + } + } + func processDeltaQueue(inBackground: Bool) { self.dispatchQueue.async { if !self.deltaQueue.isEmpty { @@ -192,13 +246,16 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { continue } + let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(delta.identityModelId) + switch delta.name { case OS_ADD_SUBSCRIPTION_DELTA: // Only create the request if the identity model exists - if let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(delta.identityModelId) { + if let identityModel = identityModel { let request = OSRequestCreateSubscription( subscriptionModel: subModel, - identityModel: identityModel + identityModel: identityModel, + ownerExternalId: delta.externalId ) self.addRequestQueue.append(request) } else { @@ -206,7 +263,8 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { } case OS_REMOVE_SUBSCRIPTION_DELTA: let request = OSRequestDeleteSubscription( - subscriptionModel: subModel + subscriptionModel: subModel, + ownerExternalId: delta.externalId ) self.removeRequestQueue.append(request) @@ -216,7 +274,7 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { self.updateRequestQueue.removeAll { request in !request.sentToClient && request.subscriptionModel.modelId == modelId } - let request = OSRequestUpdateSubscription(subscriptionModel: subModel) + let request = OSRequestUpdateSubscription(subscriptionModel: subModel, identityModel: identityModel) self.updateRequestQueue.append(request) default: @@ -240,7 +298,12 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { // Bypasses the operation repo to create a push subscription request func createPushSubscription(subscriptionModel: OSSubscriptionModel, identityModel: OSIdentityModel) { - let request = OSRequestCreateSubscription(subscriptionModel: subscriptionModel, identityModel: identityModel) + // No Delta to inherit ownership from, so read the owner directly. + let request = OSRequestCreateSubscription( + subscriptionModel: subscriptionModel, + identityModel: identityModel, + ownerExternalId: identityModel.externalId + ) self.dispatchQueue.async { self.addRequestQueue.append(request) OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_ADD_REQUEST_QUEUE_KEY, withValue: self.addRequestQueue) @@ -275,7 +338,7 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { guard !request.sentToClient else { return } - guard request.prepareForExecution(newRecordsState: newRecordsState) else { + guard request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) else { return } request.sentToClient = true @@ -338,6 +401,8 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { // The subscription has been deleted along with the user, so remove the subscription_id but keep the same push subscription model OneSignalUserManagerImpl.sharedInstance.pushSubscriptionModel?.subscriptionId = nil OneSignalUserManagerImpl.sharedInstance._logout() + } else if responseType == .unauthorized, self.auth.handleUnauthorized(request) { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSSubscriptionOperationExecutor holding \(request) for a new token") } else if responseType != .retryable { // Fail, no retry, remove from cache and queue self.addRequestQueue.removeAll(where: { $0 == request}) @@ -354,7 +419,7 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { guard !request.sentToClient else { return } - guard request.prepareForExecution(newRecordsState: newRecordsState) else { + guard request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) else { return } request.sentToClient = true @@ -380,7 +445,9 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { OneSignalLog.onesignalLog(.LL_ERROR, message: "OSSubscriptionOperationExecutor delete subscription request failed with error: \(error.debugDescription)") self.dispatchQueue.async { let responseType = OSNetworkingUtils.getResponseStatusType(error.code) - if responseType != .retryable { + if responseType == .unauthorized, self.auth.handleUnauthorized(request) { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSSubscriptionOperationExecutor holding \(request) for a new token") + } else if responseType != .retryable { // Fail, no retry, remove from cache and queue // If this request returns a missing status, that is ok as this is a delete request self.removeRequestQueue.removeAll(where: { $0 == request}) @@ -403,7 +470,7 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { guard !updateRequestQueue.contains(where: { $0 !== request && $0.sentToClient && $0.subscriptionModel.modelId == modelId }) else { return } - guard request.prepareForExecution(newRecordsState: newRecordsState) else { + guard request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) else { return } request.sentToClient = true @@ -420,7 +487,7 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { self.updateRequestQueue.removeAll(where: { $0 == request}) OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, withValue: self.updateRequestQueue) - if let onesignalId = OneSignalUserManagerImpl.sharedInstance.onesignalId { + if let onesignalId = request.identityModel?.onesignalId { if let rywToken = response?["ryw_token"] as? String { let rywDelay = response?["ryw_delay"] as? NSNumber diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift index 477f11540..18b4a1384 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift @@ -36,19 +36,90 @@ import OneSignalOSCore class OSUserExecutor { var userRequestQueue: [OSUserRequest] = [] private let newRecordsState: OSNewRecordsState + private let identityVerificationService: OSIdentityVerificationService + private let auth: OSRequestAuthorizing /// Delay by the "cool down" period plus a buffer of a set amount of milliseconds private let flushDelayMilliseconds = Int(OP_REPO_POST_CREATE_DELAY_SECONDS * 1_000 + 200) // TODO: This could come from a config, plist, method, remote params /// The User executor dispatch queue, serial. This synchronizes access to the request queues. private let dispatchQueue = DispatchQueue(label: "OneSignal.OSUserExecutor", target: .global()) - init(newRecordsState: OSNewRecordsState) { + init(newRecordsState: OSNewRecordsState, identityVerificationService: OSIdentityVerificationService, auth: OSRequestAuthorizing) { self.newRecordsState = newRecordsState + self.identityVerificationService = identityVerificationService + self.auth = auth uncacheUserRequests() migrateTransferSubscriptionRequests() + + identityVerificationService.addOnJwtConfigHydratedHandler(for: .userExecutor) { [weak self] _ in + // Including an unchanged value: Requests held while `requirement` was unknown wait on this. + self?.executePendingRequests() + } + executePendingRequests() } + /** + Reshapes the queue once `requirement` is known, so nothing that cannot be signed is sent: a Create User + with no `external_id` and every Fetch Identity By Subscription are dropped, and an Identify User — a + `login` that promoted an anonymous user while the requirement was still unknown — becomes the Create + User that login would have made, or is dropped if a later `login` has superseded it. + + Runs on every send because `refreshIfUnknown` can raise `requirement` with no event; reads the live + model because this executor sends nothing while `requirement` is unknown. + */ + private func reshapeInvalidRequests() { + guard identityVerificationService.ivBehaviorActive else { + return + } + + var reshaped: [OSUserRequest] = [] + var changed = false + + for request in userRequestQueue { + if let identifyUser = request as? OSRequestIdentifyUser { + changed = true + if let createUser = promotionAsCreateUser(identifyUser) { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSUserExecutor replaced \(identifyUser) with \(createUser), Identity Verification is required") + reshaped.append(createUser) + } else { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSUserExecutor dropped \(identifyUser), Identity Verification is required") + } + } else if isInvalidUnderIdentityVerification(request) { + changed = true + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSUserExecutor dropped \(request), Identity Verification is required") + } else { + reshaped.append(request) + } + } + + guard changed else { + return + } + userRequestQueue = reshaped + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_USER_EXECUTOR_USER_REQUEST_QUEUE_KEY, withValue: userRequestQueue) + } + + /// The Create User the promoting `login` would have made, or nil if that user is no longer the current one. + private func promotionAsCreateUser(_ request: OSRequestIdentifyUser) -> OSRequestCreateUser? { + guard let user = OneSignalUserManagerImpl.sharedInstance.currentUser(matching: request.identityModelToUpdate.modelId) else { + return nil + } + return OSRequestCreateUser( + identityModel: user.identityModel, + propertiesModel: user.propertiesModel, + pushSubscriptionModel: user.pushSubscriptionModel, + originalPushToken: user.pushSubscriptionModel.address + ) + } + + private func isInvalidUnderIdentityVerification(_ request: OSUserRequest) -> Bool { + if let createUser = request as? OSRequestCreateUser { + return createUser.identityModel.externalId == nil + } + return request is OSRequestFetchIdentityBySubscription + } + /// Read in requests from the cache, do not read in FetchUser requests as this is not needed. private func uncacheUserRequests() { var userRequestQueue: [OSUserRequest] = [] @@ -94,7 +165,7 @@ class OSUserExecutor { // 3. Both models don't exist yet // Drop the request if the identityModelToIdentify does not already exist AND the request is missing OSID // Otherwise, this request will forever fail `prepareForExecution` and block pending requests such as recovery calls to `logout` or `login` - guard request.prepareForExecution(newRecordsState: newRecordsState) else { + guard request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) else { OneSignalLog.onesignalLog(.LL_ERROR, message: "OSUserExecutor.start() dropped: \(request)") continue } @@ -164,33 +235,56 @@ class OSUserExecutor { } private func _executePendingRequests() { + // Hold until known: a Create User sent now would go out unsigned if `requirement` later becomes on. + guard identityVerificationService.requirement != .unknown else { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSUserExecutor holding \(self.userRequestQueue.count) Requests until the Identity Verification requirement is known") + return + } + reshapeInvalidRequests() + OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OSUserExecutor.executePendingRequests called with queue \(self.userRequestQueue)") + var awaitingToken = false + var executed = false + for request in self.userRequestQueue { // Return as soon as we reach an un-executable request - guard request.prepareForExecution(newRecordsState: self.newRecordsState) + guard request.prepareForExecution(newRecordsState: self.newRecordsState, auth: self.auth) else { + // Only the app can end this wait (`updateUserJwt` → `storeJwt`); do not poll for it. + // A login for another user behind this one must not be stranded, so step over it. + if self.auth.awaitsToken(request) { + awaitingToken = true + continue + } OneSignalLog.onesignalLog(.LL_WARN, message: "OSUserExecutor.executePendingRequests() is blocked by unexecutable request \(request)") executePendingRequests(withDelay: true) return } + // One Request per pass; its response re-enters here for the next. + executed = true if request.isKind(of: OSRequestFetchIdentityBySubscription.self), let fetchIdentityRequest = request as? OSRequestFetchIdentityBySubscription { self.executeFetchIdentityBySubscriptionRequest(fetchIdentityRequest) - return + break } else if request.isKind(of: OSRequestCreateUser.self), let createUserRequest = request as? OSRequestCreateUser { self.executeCreateUserRequest(createUserRequest) - return + break } else if request.isKind(of: OSRequestIdentifyUser.self), let identifyUserRequest = request as? OSRequestIdentifyUser { self.executeIdentifyUserRequest(identifyUserRequest) - return + break } else if request.isKind(of: OSRequestFetchUser.self), let fetchUserRequest = request as? OSRequestFetchUser { self.executeFetchUserRequest(fetchUserRequest) - return + break } else { OneSignalLog.onesignalLog(.LL_ERROR, message: "OSUserExecutor met incompatible Request type that cannot be executed.") } } + + // Wait-only pass: `storeJwt` / hydrate / a later enqueue wakes us. Do not reschedule. + if awaitingToken, !executed { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSUserExecutor has Requests waiting for a token") + } } } @@ -220,14 +314,23 @@ extension OSUserExecutor { return } - // Hook up push subscription model if exists, it may be updated with a subscription_id, etc. - if let modelId = request.pushSubscriptionModel?.modelId, - let pushSubscriptionModel = OneSignalUserManagerImpl.sharedInstance.pushSubscriptionModelStore.getModel(modelId: modelId) { - request.pushSubscriptionModel = pushSubscriptionModel - request.updatePushSubscriptionModel(pushSubscriptionModel) + if OneSignalUserManagerImpl.sharedInstance.currentUser(matching: request.identityModel.modelId) != nil { + // Refresh so a subscription_id / token that landed after enqueue is included. + if let modelId = request.pushSubscriptionModel?.modelId, + let pushSubscriptionModel = OneSignalUserManagerImpl.sharedInstance.pushSubscriptionModelStore.getModel(modelId: modelId) { + request.pushSubscriptionModel = pushSubscriptionModel + request.updatePushSubscriptionModel(pushSubscriptionModel) + } + } else if request.identityModel.externalId != nil { + // Identified but not current: omit push so a parked Create User can't transfer the device + // subscription after another login took it. Keep push for anonymous creates — the server + // requires a subscription, and with IV off those requests don't sit behind a later user. + request.parameters?.removeValue(forKey: "subscriptions") + request.pushSubscriptionModel = nil + request.originalPushToken = nil } - guard request.prepareForExecution(newRecordsState: newRecordsState) + guard request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) else { executePendingRequests(withDelay: true) return @@ -240,13 +343,12 @@ extension OSUserExecutor { // Create User's response won't send us the user's complete info if this user already exists if let response = response { - let shouldAddNewRecords = request.pushSubscriptionModel != nil // Parse the response for any data we need to update self.parseFetchUserResponse( response: response, identityModel: request.identityModel, originalPushToken: request.originalPushToken, - addNewRecords: shouldAddNewRecords + addNewRecords: request.addsNewRecords ) // If this user already exists and we logged into an external_id, fetch the user data @@ -275,15 +377,22 @@ extension OSUserExecutor { } } } - OSOperationRepo.sharedInstance.paused = false + OneSignalUserManagerImpl.sharedInstance.operationRepo.paused = false } onFailure: { error in OneSignalLog.onesignalLog(.LL_ERROR, message: "OSUserExecutor create user request failed with error: \(error.debugDescription)") let responseType = OSNetworkingUtils.getResponseStatusType(error.code) - if responseType != .retryable { + if responseType == .unauthorized, self.auth.handleUnauthorized(request) { + // Held rather than paused: `updateUserJwt` resumes work by flushing, which a paused Repo drops. + // Ordering does not need the pause — every Request for this user waits on an `onesignal_id`. + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSUserExecutor holding \(request) for a new token") + // A replacement token supplied while this was in flight has already released what it could; + // re-enter so that token is used now rather than waiting on another wake. + self.executePendingRequests() + } else if responseType != .retryable { // A failed create user request would leave the SDK in a bad state // Don't remove the request from cache and pause the operation repo // We will retry this request on a new session - OSOperationRepo.sharedInstance.paused = true + OneSignalUserManagerImpl.sharedInstance.operationRepo.paused = true request.sentToClient = false } } @@ -305,7 +414,7 @@ extension OSUserExecutor { } // newRecordsState is unused for this request - guard request.prepareForExecution(newRecordsState: newRecordsState) else { + guard request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) else { executePendingRequests(withDelay: true) return } @@ -358,7 +467,7 @@ extension OSUserExecutor { return } - guard request.prepareForExecution(newRecordsState: newRecordsState) else { + guard request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) else { executePendingRequests(withDelay: true) return } @@ -437,7 +546,7 @@ extension OSUserExecutor { return } - guard request.prepareForExecution(newRecordsState: newRecordsState) else { + guard request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) else { executePendingRequests(withDelay: true) return } @@ -489,6 +598,8 @@ extension OSUserExecutor { // The subscription has been deleted along with the user, so remove the subscription_id but keep the same push subscription model OneSignalUserManagerImpl.sharedInstance.pushSubscriptionModel?.subscriptionId = nil OneSignalUserManagerImpl.sharedInstance._logout() + } else if responseType == .unauthorized, self.auth.handleUnauthorized(request) { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSUserExecutor holding \(request) for a new token") } else if responseType != .retryable { // If the error is not retryable, remove from cache and queue self.removeFromQueue(request) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSRequestAuth.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSRequestAuth.swift new file mode 100644 index 000000000..42e780212 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSRequestAuth.swift @@ -0,0 +1,249 @@ +/* + Modified MIT License + + Copyright 2026 OneSignal + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + 1. The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + 2. All copies of substantial portions of the Software may only be used in connection + with services provided by OneSignal. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ + +import Foundation +import OneSignalCore +import OneSignalOSCore + +/** + The one place a Request's addressing alias and `Authorization` header are decided. + + While Identity Verification is not in effect every method here resolves what the SDK sent before it + existed: the `onesignal_id` alias and no header. + */ +protocol OSRequestAuthorizing: AnyObject { + /// Whether Identity Verification behavior applies, for the few Requests whose body changes with it. + var ivBehaviorActive: Bool { get } + + /** + Resolves the alias a user-scoped path should address, attaching a Bearer header when Identity + Verification is in effect. `legacyAlias` is what the Request addresses without it, which is + `onesignal_id` for everything except a Fetch User that was built to read some other alias. + + Returns nil when Identity Verification is in effect and the Request cannot be signed, leaving it in + the queue it already sits in. An owner with no usable token is parked and the app is asked for one; + `updateUserJwt` (via `storeJwt`) wakes the executor, or the requirement turning off resolves it and + sends. A Request with no owner at all is refused, since only the purge can resolve it. + */ + func authorizeUserScoped(_ request: OSUserRequest, legacyAlias: OSAliasPair) -> OSAliasPair? + + /// The same decision for endpoints that take a token but no alias, because their path names a + /// subscription or the app. Returns `false` under the same conditions as `authorizeUserScoped`, + /// except that a `sendsUnsigned` Request with no owner is allowed through. + func authorize(_ request: OSUserRequest) -> Bool + + /** + Returns `true` if the Request's owner has no token to sign with, which is why the two methods above + parked it. Reads only: it does not ask the app for a token, so call it after one of them has. + + Lets a caller that stops at its first unsendable Request tell "nothing can send until the app hands + over a token for this user" from "not addressable yet", which resolves on its own. + */ + func awaitsToken(_ request: OSUserRequest) -> Bool + + /** + Parks the token an unauthorized response rejected and clears `sentToClient` so the Request is + re-signed on a later flush. + + Returns `true` when the caller must leave the Request queued, `false` to fall through to its + existing non-retryable handling. + */ + func handleUnauthorized(_ request: OSUserRequest) -> Bool + + /** + The same alias and token decision for a user-scoped call that does not travel through the Request + queues, currently the in-app message fetch. Pass the ids of the user the call is for. + + Returns nil when it cannot be sent yet, having asked the app for a token if that is what is missing. + */ + func authorization(onesignalId: String?, externalId: String?) -> OSUserRequestAuthorization? +} + +/** + How another module should address and sign one user-scoped call. + + `alias` nil means address it the way it was addressed before Identity Verification: no user in the + path and nothing to sign with. + */ +@objc(OSUserRequestAuthorization) +public final class OSUserRequestAuthorization: NSObject { + @objc public let alias: OSAliasPair? + /// Merge into the request's headers. Empty unless the call is signed. + @objc public let headers: [String: String] + /// The token `headers` signs with, nil when the call is unsigned. + @objc public let token: String? + + fileprivate init(alias: OSAliasPair?, headers: [String: String] = [:], token: String? = nil) { + self.alias = alias + self.headers = headers + self.token = token + } +} + +final class OSRequestAuth: OSRequestAuthorizing { + private static let authorizationHeader = "Authorization" + private static let bearerPrefix = "Bearer " + private static let legacyAddressing = OSUserRequestAuthorization(alias: nil) + + private let identityVerificationService: OSIdentityVerificationService + private let jwt: OSUserJwtProviding + + var ivBehaviorActive: Bool { + return identityVerificationService.ivBehaviorActive + } + + init(identityVerificationService: OSIdentityVerificationService, jwt: OSUserJwtProviding) { + self.identityVerificationService = identityVerificationService + self.jwt = jwt + } + + func authorizeUserScoped(_ request: OSUserRequest, legacyAlias: OSAliasPair) -> OSAliasPair? { + guard ivBehaviorActive else { + return legacyAlias + } + guard let externalId = request.ownerExternalId else { + // A user-scoped path with no owner has nobody to sign for, and addressing it by + // `onesignal_id` would send it unsigned. The Operation Repo suppresses anonymous work long + // before it becomes a Request, and the purge clears leftovers on hydration, so this holds. + OneSignalLog.onesignalLog(.LL_ERROR, message: "OSRequestAuth: refusing \(request), it has no owner under Identity Verification") + return nil + } + guard let token = jwt.validJwt(externalId: externalId) else { + park(request, ownedBy: externalId) + return nil + } + setBearer(token, on: request) + return OSAliasPair(OS_EXTERNAL_ID, externalId) + } + + func authorize(_ request: OSUserRequest) -> Bool { + guard ivBehaviorActive else { + return true + } + guard let externalId = request.ownerExternalId else { + // Anything not exempt is a leftover the purge has yet to clear, and unsendable until it does. + guard request.sendsUnsigned else { + OneSignalLog.onesignalLog(.LL_ERROR, message: "OSRequestAuth: refusing \(request), it has no owner under Identity Verification") + return false + } + return true + } + guard let token = jwt.validJwt(externalId: externalId) else { + park(request, ownedBy: externalId) + return false + } + setBearer(token, on: request) + return true + } + + func awaitsToken(_ request: OSUserRequest) -> Bool { + guard ivBehaviorActive, let externalId = request.ownerExternalId else { + return false + } + return jwt.validJwt(externalId: externalId) == nil + } + + /** + Nothing else prompts the app when a Request merely parks: the invalidated event fires on a rejected + token, and a token the app never supplied — or supplied in a session that has since ended — leaves the + SDK holding none with nothing to reject. The repo keeps this to one ask per external ID per session. + */ + private func park(_ request: OSUserRequest, ownedBy externalId: String) { + // Log only on the ask that reaches the app; later prepareForExecution retries stay quiet. + if jwt.askForToken(externalId: externalId) { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSRequestAuth: holding \(request) until \(externalId) has a token") + } + } + + func handleUnauthorized(_ request: OSUserRequest) -> Bool { + guard ivBehaviorActive, + let externalId = request.ownerExternalId, + let rejectedToken = signedToken(of: request) + else { + return false + } + jwt.invalidateJwt(externalId: externalId, rejectedToken: rejectedToken) + removeBearer(from: request) + request.sentToClient = false + return true + } + + func authorization(onesignalId: String?, externalId: String?) -> OSUserRequestAuthorization? { + // Not decided yet: legacy vs onesignal_id vs external_id+JWT. Callers reattempt on hydration. + guard identityVerificationService.requirement != .unknown else { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSRequestAuth: holding a user-scoped call until the requirement is known") + return nil + } + // Requirement is known. Flag-only rollout still uses the pre-IV subscription path. + guard identityVerificationService.newCodePathsRun else { + return Self.legacyAddressing + } + guard ivBehaviorActive else { + // Nothing to address the call to until the server assigns an `onesignal_id`. + guard let onesignalId = onesignalId else { + return nil + } + return OSUserRequestAuthorization(alias: OSAliasPair(OS_ONESIGNAL_ID, onesignalId)) + } + // Under Identity Verification there is nothing the server will serve for a device with no + // identified user, so this waits for a login rather than falling back to `onesignal_id`. + guard let externalId = externalId else { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSRequestAuth: holding a user-scoped call until a user is identified") + return nil + } + guard let token = jwt.validJwt(externalId: externalId) else { + OneSignalLog.onesignalLog(.LL_DEBUG, message: "OSRequestAuth: holding a user-scoped call until \(externalId) has a token") + jwt.askForToken(externalId: externalId) + return nil + } + return OSUserRequestAuthorization(alias: OSAliasPair(OS_EXTERNAL_ID, externalId), + headers: [Self.authorizationHeader: Self.bearerPrefix + token], + token: token) + } + + private func setBearer(_ token: String, on request: OneSignalRequest) { + var headers = request.additionalHeaders ?? [String: String]() + headers[Self.authorizationHeader] = Self.bearerPrefix + token + request.additionalHeaders = headers + } + + private func removeBearer(from request: OneSignalRequest) { + var headers = request.additionalHeaders + headers?.removeValue(forKey: Self.authorizationHeader) + request.additionalHeaders = headers + } + + /// The token the Request carries, which is the one the server just rejected. + private func signedToken(of request: OneSignalRequest) -> String? { + guard let header = request.additionalHeaders?[Self.authorizationHeader], + header.hasPrefix(Self.bearerPrefix) + else { + return nil + } + return String(header.dropFirst(Self.bearerPrefix.count)) + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModel.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModel.swift index cc9dd2211..a12407f7b 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModel.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSSubscriptionModel.swift @@ -107,6 +107,7 @@ class OSSubscriptionModel: OSModel { var subscriptionId: String? var reachable: Bool var isDisabled: Bool + var isDisabledInternally: Bool var notificationTypes: Int var testType: Int? var deviceOs: String @@ -261,6 +262,54 @@ class OSSubscriptionModel: OSModel { } } + /** + Set by the SDK on `logout` under Identity Verification, where no anonymous user is created to send to. + Only `reportedEnablement` reads it, so `_isDisabled` and `notificationTypes` keep the app's own opt-in + state and clearing this restores what the app asked for. + + The app's push subscription observer does not fire — its opt-in preference has not changed, only what + the SDK reports while there is no user to report it for — but setting this does queue an Update + Subscription, which is how the server learns the device stopped listening. + */ + var _isDisabledInternally: Bool { + get { stateLock.withLock { state.isDisabledInternally } } + set { setDisabledInternally(newValue, sendUpdate: true) } + } + + /// Restores reporting without an Update Subscription, for `login`: its Create User already carries + /// the re-enabled subscription. + func clearDisabledInternallyForLogin() { + setDisabledInternally(false, sendUpdate: false) + } + + private func setDisabledInternally(_ disabled: Bool, sendUpdate: Bool) { + let oldValue = swapValue(\.isDisabledInternally, to: disabled) + guard disabled != oldValue else { + return + } + self.set(property: "isDisabledInternally", newValue: disabled, preventServerUpdate: !sendUpdate) + } + + /** + The `enabled` and `notification_types` to report, which the server reads as a pair. An internal + disable overrides both. `notificationTypes` is nil when there is no value to send. + + Taken from one snapshot so the two cannot disagree, and shared with Update Subscription so a + subscription reports the same thing however the Request was built. + */ + func reportedEnablement() -> (enabled: Bool, notificationTypes: Int?) { + return reportedEnablement(from: snapshot()) + } + + private func reportedEnablement(from state: State) -> (enabled: Bool, notificationTypes: Int?) { + guard !state.isDisabledInternally else { + return (false, -2) + } + let enabled = calculateIsEnabled(address: state.address, reachable: state.reachable, isDisabled: state.isDisabled) + // notificationTypes defaults to -1 instead of nil, don't send if it's -1 + return (enabled, state.notificationTypes == -1 ? nil : state.notificationTypes) + } + // Properties for push subscription var testType: Int? { get { stateLock.withLock { state.testType } } @@ -365,6 +414,7 @@ class OSSubscriptionModel: OSModel { subscriptionId: subscriptionId, reachable: reachable, isDisabled: isDisabled, + isDisabledInternally: false, notificationTypes: notificationTypes, testType: testType, deviceOs: UIDevice.current.systemVersion, @@ -386,6 +436,7 @@ class OSSubscriptionModel: OSModel { coder.encode(state.subscriptionId, forKey: "subscriptionId") coder.encode(state.reachable, forKey: "_reachable") coder.encode(state.isDisabled, forKey: "_isDisabled") + coder.encode(state.isDisabledInternally, forKey: "isDisabledInternally") coder.encode(state.notificationTypes, forKey: "notificationTypes") coder.encode(state.testType, forKey: "testType") coder.encode(state.deviceOs, forKey: "deviceOs") @@ -409,6 +460,9 @@ class OSSubscriptionModel: OSModel { subscriptionId: coder.decodeObject(forKey: "subscriptionId") as? String, reachable: coder.decodeBool(forKey: "_reachable"), isDisabled: coder.decodeBool(forKey: "_isDisabled"), + // A model archived while logged out under Identity Verification stays internally disabled + // until the next login clears it. + isDisabledInternally: coder.decodeBool(forKey: "isDisabledInternally"), notificationTypes: coder.decodeInteger(forKey: "notificationTypes"), testType: coder.decodeObject(forKey: "testType") as? Int, deviceOs: coder.decodeObject(forKey: "deviceOs") as? String ?? UIDevice.current.systemVersion, @@ -457,16 +511,17 @@ class OSSubscriptionModel: OSModel { json["id"] = state.subscriptionId json["type"] = state.type.rawValue json["token"] = state.address - json["enabled"] = calculateIsEnabled(address: state.address, reachable: state.reachable, isDisabled: state.isDisabled) json["test_type"] = state.testType json["device_os"] = state.deviceOs json["sdk"] = state.sdk json["device_model"] = state.deviceModel json["app_version"] = state.appVersion json["net_type"] = state.netType - // notificationTypes defaults to -1 instead of nil, don't send if it's -1 - if state.notificationTypes != -1 { - json["notification_types"] = state.notificationTypes + + let enablement = reportedEnablement(from: state) + json["enabled"] = enablement.enabled + if let notificationTypes = enablement.notificationTypes { + json["notification_types"] = notificationTypes } return json } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift index f58e983b3..308bc1d14 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift @@ -128,10 +128,6 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { let newRecordsState = OSNewRecordsState() - // Injected into the model store listeners so a Delta is enqueued against a known repo rather - // than reaching for the singleton. A later PR replaces this with an owned instance. - let operationRepo = OSOperationRepo.sharedInstance - // Shared instances: remote params hydrate them before this class is started, and a // fresh instance here would read none of it. let featureManager = OSFeatureManager.shared @@ -206,6 +202,8 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { // These must be initialized in init() let userJwtRepo: OSUserJwtRepo + let requestAuth: OSRequestAuthorizing + let operationRepo: OSOperationRepo let identityModelStoreListener: OSIdentityModelStoreListener let propertiesModelStoreListener: OSPropertiesModelStoreListener let subscriptionModelStoreListener: OSSubscriptionModelStoreListener @@ -220,6 +218,7 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { private override init() { let identityVerificationService = OSIdentityVerificationService(featureManager: featureManager, jwtConfig: jwtConfig) + let operationRepo = OSOperationRepo(identityVerificationService: identityVerificationService) // Goes through `sharedInstance` rather than capturing self: the observer it notifies is created // lazily and must not be touched during init. let userJwtRepo = OSUserJwtRepo(identityModelRepo: identityModelRepo) { externalId in @@ -227,6 +226,8 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { } self.identityVerificationService = identityVerificationService self.userJwtRepo = userJwtRepo + self.requestAuth = OSRequestAuth(identityVerificationService: identityVerificationService, jwt: userJwtRepo) + self.operationRepo = operationRepo self.identityModelStoreListener = OSIdentityModelStoreListener(store: identityModelStore, operationRepo: operationRepo) self.propertiesModelStoreListener = OSPropertiesModelStoreListener(store: propertiesModelStore, operationRepo: operationRepo) self.subscriptionModelStoreListener = OSSubscriptionModelStoreListener(store: subscriptionModelStore, operationRepo: operationRepo) @@ -260,6 +261,7 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { // Same prewarm gap as the stores: init may have read UserDefaults while it was locked. jwtConfig.refreshIfUnknown() featureManager.refreshIfEmpty() + operationRepo.refreshIfEmpty() OSNotificationsManager.delegate = self @@ -283,24 +285,44 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { // TODO: Update the push sub model with any new state from NotificationsManager + /* + Clears an internal disable that Identity Verification no longer needs: either the app turned + the requirement off while logged out, or `logout` guessed on while it was still unknown. + `login` is otherwise the only clear, and would leave the subscription silenced until the next + one. Sends an update, unlike the login path: nothing else will tell the server. + + Registered before the User executor's handler so anything it sends on this hydration already + carries the restored subscription — a Create User response hydrates `enabled` back onto the + app's own opt-in, which would make the silencing permanent. + */ + identityVerificationService.addOnJwtConfigHydratedHandler(for: .userManager) { [weak self] requirement in + guard requirement == .off else { + return + } + self?.pushSubscriptionModelStore.getModel(key: OS_PUSH_SUBSCRIPTION_MODEL_KEY)?._isDisabledInternally = false + } + // Setup the executors // The OSUserExecutor has to run first, before other executors - self.userExecutor = OSUserExecutor(newRecordsState: newRecordsState) - OSOperationRepo.sharedInstance.start() + self.userExecutor = OSUserExecutor(newRecordsState: newRecordsState, identityVerificationService: identityVerificationService, auth: requestAuth) // Cannot initialize these executors in `init` as they reference the sharedInstance - let propertyExecutor = OSPropertyOperationExecutor(newRecordsState: newRecordsState) - let identityExecutor = OSIdentityOperationExecutor(newRecordsState: newRecordsState) - let subscriptionExecutor = OSSubscriptionOperationExecutor(newRecordsState: newRecordsState) - let customEventsExecutor = OSCustomEventsExecutor(newRecordsState: newRecordsState) + let propertyExecutor = OSPropertyOperationExecutor(newRecordsState: newRecordsState, auth: requestAuth) + let identityExecutor = OSIdentityOperationExecutor(newRecordsState: newRecordsState, auth: requestAuth) + let subscriptionExecutor = OSSubscriptionOperationExecutor(newRecordsState: newRecordsState, auth: requestAuth) + let customEventsExecutor = OSCustomEventsExecutor(newRecordsState: newRecordsState, auth: requestAuth) self.propertyExecutor = propertyExecutor self.identityExecutor = identityExecutor self.subscriptionExecutor = subscriptionExecutor self.customEventsExecutor = customEventsExecutor - OSOperationRepo.sharedInstance.addExecutor(identityExecutor) - OSOperationRepo.sharedInstance.addExecutor(propertyExecutor) - OSOperationRepo.sharedInstance.addExecutor(subscriptionExecutor) - OSOperationRepo.sharedInstance.addExecutor(customEventsExecutor) + operationRepo.addExecutor(identityExecutor) + operationRepo.addExecutor(propertyExecutor) + operationRepo.addExecutor(subscriptionExecutor) + operationRepo.addExecutor(customEventsExecutor) + + // After the executors: a cached requirement makes `start()` flush right away, and the + // Deltas restored at launch can only route once the map above is populated. + operationRepo.start() // Path 2. There is a legacy player to migrate if let legacyPlayerId = OneSignalUserDefaults.initShared().getSavedString(forKey: OSUD_LEGACY_PLAYER_ID, defaultValue: nil) { @@ -357,10 +379,13 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { } OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OneSignal.User login called with externalId: \(externalId)") + // Ungated: a subscription internally disabled by a previous logout has to come back even if + // Identity Verification has since been turned off. + pushSubscriptionModelStore.getModel(key: OS_PUSH_SUBSCRIPTION_MODEL_KEY)?.clearDisabledInternallyForLogin() + // Logging into an identified user from an anonymous user - if let user = _user, user.isAnonymous { - user.identityModel.jwtBearerToken = token - identifyUser(externalId: externalId, currentUser: user) + if let user = _user, user.isAnonymous, canPromoteAnonymousUser { + identifyUser(externalId: externalId, currentUser: user, token: token) } else { // Logging into identified -> anon, identified -> identified, or nil -> identified _ = createNewUser(externalId: externalId, token: token) @@ -368,6 +393,18 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { } + /** + Whether `login` may promote the current anonymous user with Identify User instead of creating a new one. + + Identify User adds an `external_id` to a user that has none, and under Identity Verification no such + user is ever sent to the server, so every login has to create its user instead. While the requirement is + unknown this still promotes: the queue is held until it is known, and `OSUserExecutor` then turns the + promotion into the Create User it should have been if the app turns out to require auth. + */ + private var canPromoteAnonymousUser: Bool { + return !identityVerificationService.ivBehaviorActive + } + /** Converting a 3.x player to a 5.x user. There is a cached legacy player, so we will create the user based on the legacy player ID. */ @@ -403,6 +440,11 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { if let user = _user { guard user.identityModel.externalId != externalId || externalId == nil else { OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OneSignalUserManager.createNewUser: not creating new user due to logging into the same user.)") + // Re-logging in is how an app hands over a replacement token, so take it and let anything + // held for want of one go out. + if let externalId = externalId, let token = token { + storeJwt(externalId: externalId, token: token) + } return user } } @@ -415,7 +457,9 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { } let newUser = setNewInternalUser(externalId: externalId, pushSubscriptionModel: pushSubscriptionModel) - newUser.identityModel.jwtBearerToken = token + if let externalId = externalId, let token = token { + storeJwt(externalId: externalId, token: token) + } userExecutor!.createUser(newUser) return newUser } @@ -427,7 +471,7 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { 1. This externalId already exists on another user. We create a new SDK user and fetch that user's information. 2. This externalId doesn't exist on any users. We successfully identify the user, but we still create a new SDK user and fetch to update it. */ - private func identifyUser(externalId: String, currentUser: OSUserInternal) { + private func identifyUser(externalId: String, currentUser: OSUserInternal, token: String?) { guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: nil) else { return } @@ -439,6 +483,11 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { let pushSubscriptionModel = pushSubscriptionModelStore.getModel(key: OS_PUSH_SUBSCRIPTION_MODEL_KEY) prepareForNewUser() let newUser = setNewInternalUser(externalId: externalId, pushSubscriptionModel: pushSubscriptionModel) + // The token belongs on the model that carries `external_id`: the Fetch User this leads to is signed + // with it, as is the Create User this becomes if the requirement turns out to be on. + if let token = token { + storeJwt(externalId: externalId, token: token) + } // Now proceed to identify the previous user userExecutor!.identifyUser( @@ -486,7 +535,30 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { OneSignalLog.onesignalLog(.LL_DEBUG, message: "OneSignal.User logout called, but the user is currently anonymous, so not logging out.") return } + /* + The replacement anonymous user is never created on the server under Identity Verification, so two + things it would otherwise have done have to happen here: stop reporting the push subscription, which + still carries the logged-out user's subscription ID, and tell observers that nobody is signed in. + + Only the app's own `logout()`. `_logout()` also runs as 404 recovery, where the SDK is replacing a + user the server no longer has and the subscription should keep reporting. + + While the requirement is still unknown, guess on: the wrong guess over-silences until hydrate-to-off + restores reporting, and the other guess would keep delivering the logged-out user's pushes. Read + once so hydration cannot flip the two sides mid-logout. + */ + let shouldSilenceForLogout = identityVerificationService.requirement != .off + + if shouldSilenceForLogout { + // Before the switch, so the unsubscribe is stamped with the outgoing user. + user.pushSubscriptionModel._isDisabledInternally = true + } + _logout() + + if shouldSilenceForLogout { + OSUserStateSnapshot.fireUserStateChanged(newOnesignalId: nil, newExternalId: nil) + } } public func _logout() { @@ -496,16 +568,23 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { } /** - Stores a token for `externalId`, so that the pending ask for this user is cleared and a later - rejection can ask again. + Stores a token for `externalId` and releases everything held for want of one, so it goes out now: + the Repo's Deltas, the User executor's own queue (which is not Repo-driven), and — over the + notification — the work that travels through neither. - Every app-supplied token arrives here, from `login` as well as `updateUserJwt`. + Every app-supplied token arrives here, from `login` as well as `updateUserJwt`, so that the pending + ask for this user is cleared and a later rejection can ask again. */ func storeJwt(externalId: String, token: String) { guard userJwtRepo.updateJwt(externalId: externalId, token: token) else { return } OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OneSignalUserManager stored a JWT for externalId: \(externalId)") + guard identityVerificationService.newCodePathsRun else { + return + } + operationRepo.addFlushDeltaQueueToDispatchQueue() + userExecutor?.executePendingRequests() } @objc @@ -618,7 +697,6 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { } updatePropertiesDeltas(property: .purchases, value: purchases) } - } // MARK: - Sessions @@ -633,7 +711,7 @@ extension OneSignalUserManagerImpl { start() userExecutor!.executePendingRequests() - OSOperationRepo.sharedInstance.paused = false + operationRepo.paused = false updatePropertiesDeltas(property: .session_count, value: 1, flush: true) // Fetch the user's data if there is a onesignal_id @@ -672,7 +750,7 @@ extension OneSignalUserManagerImpl { property: property.rawValue, value: value ) - OSOperationRepo.sharedInstance.enqueueDelta(delta, flush: flush) + operationRepo.enqueueDelta(delta, flush: flush) } /// Time processors forward the session time to this method. @@ -690,7 +768,7 @@ extension OneSignalUserManagerImpl { */ @objc public func runBackgroundTasks() { - OSOperationRepo.sharedInstance.addFlushDeltaQueueToDispatchQueue(inBackground: true) + operationRepo.addFlushDeltaQueueToDispatchQueue(inBackground: true) } } @@ -892,7 +970,7 @@ extension OneSignalUserManagerImpl: OSUser { property: name, value: processedProperties ) - OSOperationRepo.sharedInstance.enqueueDelta(delta) + operationRepo.enqueueDelta(delta) } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestAddAliases.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestAddAliases.swift index a4ad372a0..9e3549e7c 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestAddAliases.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestAddAliases.swift @@ -38,23 +38,28 @@ class OSRequestAddAliases: OneSignalRequest, OSUserRequest { var identityModel: OSIdentityModel let aliases: [String: String] + /// See the ownership convention in `OSUserRequest.swift`. + let ownerExternalId: String? + /// requires a `onesignal_id` to send this request - func prepareForExecution(newRecordsState: OSNewRecordsState) -> Bool { + func prepareForExecution(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing) -> Bool { if let onesignalId = identityModel.onesignalId, newRecordsState.canAccess(onesignalId), - let appId = OneSignalIdentifiers.currentAppId + let appId = OneSignalIdentifiers.currentAppId, + let alias = auth.authorizeUserScoped(self, legacyAlias: OSAliasPair(OS_ONESIGNAL_ID, onesignalId)), + let aliasId = OSUrlPath.segment(alias.id) { - self.addJWTHeader(identityModel: identityModel) - self.path = "apps/\(appId)/users/by/\(OS_ONESIGNAL_ID)/\(onesignalId)/identity" + self.path = "apps/\(appId)/users/by/\(alias.label)/\(aliasId)/identity" return true } else { return false } } - init(aliases: [String: String], identityModel: OSIdentityModel) { + init(aliases: [String: String], identityModel: OSIdentityModel, ownerExternalId: String?) { self.identityModel = identityModel self.aliases = aliases + self.ownerExternalId = ownerExternalId self.stringDescription = "" super.init() self.parameters = ["identity": aliases] @@ -63,6 +68,7 @@ class OSRequestAddAliases: OneSignalRequest, OSUserRequest { func encode(with coder: NSCoder) { coder.encode(identityModel, forKey: "identityModel") + coder.encode(ownerExternalId, forKey: "ownerExternalId") coder.encode(aliases, forKey: "aliases") coder.encode(parameters, forKey: "parameters") coder.encode(method.rawValue, forKey: "method") // Encodes as String @@ -82,6 +88,7 @@ class OSRequestAddAliases: OneSignalRequest, OSUserRequest { } self.identityModel = identityModel self.aliases = aliases + self.ownerExternalId = coder.decodeObject(forKey: "ownerExternalId") as? String self.stringDescription = "" super.init() self.parameters = parameters diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestCreateSubscription.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestCreateSubscription.swift index e95281e5e..90467757d 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestCreateSubscription.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestCreateSubscription.swift @@ -43,23 +43,28 @@ class OSRequestCreateSubscription: OneSignalRequest, OSUserRequest { var subscriptionModel: OSSubscriptionModel var identityModel: OSIdentityModel + /// See the ownership convention in `OSUserRequest.swift`. + let ownerExternalId: String? + // Need the onesignal_id of the user - func prepareForExecution(newRecordsState: OSNewRecordsState) -> Bool { + func prepareForExecution(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing) -> Bool { if let onesignalId = identityModel.onesignalId, newRecordsState.canAccess(onesignalId), - let appId = OneSignalIdentifiers.currentAppId + let appId = OneSignalIdentifiers.currentAppId, + let alias = auth.authorizeUserScoped(self, legacyAlias: OSAliasPair(OS_ONESIGNAL_ID, onesignalId)), + let aliasId = OSUrlPath.segment(alias.id) { - self.addJWTHeader(identityModel: identityModel) - self.path = "apps/\(appId)/users/by/\(OS_ONESIGNAL_ID)/\(onesignalId)/subscriptions" + self.path = "apps/\(appId)/users/by/\(alias.label)/\(aliasId)/subscriptions" return true } else { return false } } - init(subscriptionModel: OSSubscriptionModel, identityModel: OSIdentityModel) { + init(subscriptionModel: OSSubscriptionModel, identityModel: OSIdentityModel, ownerExternalId: String?) { self.subscriptionModel = subscriptionModel self.identityModel = identityModel + self.ownerExternalId = ownerExternalId self.stringDescription = "" super.init() self.parameters = ["subscription": subscriptionModel.jsonRepresentation()] @@ -69,6 +74,7 @@ class OSRequestCreateSubscription: OneSignalRequest, OSUserRequest { func encode(with coder: NSCoder) { coder.encode(subscriptionModel, forKey: "subscriptionModel") coder.encode(identityModel, forKey: "identityModel") + coder.encode(ownerExternalId, forKey: "ownerExternalId") coder.encode(parameters, forKey: "parameters") coder.encode(method.rawValue, forKey: "method") // Encodes as String coder.encode(timestamp, forKey: "timestamp") @@ -87,6 +93,7 @@ class OSRequestCreateSubscription: OneSignalRequest, OSUserRequest { } self.subscriptionModel = subscriptionModel self.identityModel = identityModel + self.ownerExternalId = coder.decodeObject(forKey: "ownerExternalId") as? String self.stringDescription = "" super.init() self.parameters = parameters diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestCreateUser.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestCreateUser.swift index a74514f11..c992e6651 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestCreateUser.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestCreateUser.swift @@ -45,8 +45,20 @@ class OSRequestCreateUser: OneSignalRequest, OSUserRequest { var pushSubscriptionModel: OSSubscriptionModel? var originalPushToken: String? + /** + Whether the response's IDs should enter `newRecordsState`. + + `true` for a real create; `false` for the Identify-409 recovery Create, which only hydrates the + `onesignal_id` of a user that already exists. Stamped at init so stripping push later cannot + flip a real create into the recovery path. + */ + let addsNewRecords: Bool + + /// See the ownership convention in `OSUserRequest.swift`. + var ownerExternalId: String? { return identityModel.externalId } + /// Checks if the subscription ID can be accessed, if a subscription is being included in the request - func prepareForExecution(newRecordsState: OSNewRecordsState) -> Bool { + func prepareForExecution(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing) -> Bool { guard let appId = OneSignalIdentifiers.currentAppId else { OneSignalLog.onesignalLog(.LL_ERROR, message: "Cannot generate the create user request due to null app ID.") return false @@ -59,8 +71,13 @@ class OSRequestCreateUser: OneSignalRequest, OSUserRequest { return false } + // The path is app-scoped and the identity object already names the user, so there is no + // alias to swap — only the token. + guard auth.authorize(self) else { + return false + } + _ = self.addPushSubscriptionIdToAdditionalHeaders() - self.addJWTHeader(identityModel: identityModel) self.path = "apps/\(appId)/users" return true } @@ -79,6 +96,7 @@ class OSRequestCreateUser: OneSignalRequest, OSUserRequest { self.identityModel = identityModel self.pushSubscriptionModel = pushSubscriptionModel self.originalPushToken = originalPushToken + self.addsNewRecords = true self.stringDescription = "" super.init() @@ -104,6 +122,7 @@ class OSRequestCreateUser: OneSignalRequest, OSUserRequest { init(aliasLabel: String, aliasId: String, identityModel: OSIdentityModel) { self.identityModel = identityModel + self.addsNewRecords = false self.stringDescription = "" super.init() self.parameters = [ @@ -117,6 +136,7 @@ class OSRequestCreateUser: OneSignalRequest, OSUserRequest { coder.encode(identityModel, forKey: "identityModel") coder.encode(pushSubscriptionModel, forKey: "pushSubscriptionModel") coder.encode(originalPushToken, forKey: "originalPushToken") + coder.encode(addsNewRecords, forKey: "addsNewRecords") coder.encode(parameters, forKey: "parameters") coder.encode(method.rawValue, forKey: "method") // Encodes as String coder.encode(timestamp, forKey: "timestamp") @@ -135,6 +155,7 @@ class OSRequestCreateUser: OneSignalRequest, OSUserRequest { self.identityModel = identityModel self.pushSubscriptionModel = coder.decodeObject(forKey: "pushSubscriptionModel") as? OSSubscriptionModel self.originalPushToken = coder.decodeObject(forKey: "originalPushToken") as? String + self.addsNewRecords = coder.decodeBool(forKey: "addsNewRecords") self.stringDescription = "" super.init() self.parameters = parameters diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestCustomEvents.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestCustomEvents.swift index 83ec1e565..4104ff711 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestCustomEvents.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestCustomEvents.swift @@ -37,12 +37,19 @@ class OSRequestCustomEvents: OneSignalRequest, OSUserRequest { var identityModel: OSIdentityModel - func prepareForExecution(newRecordsState: OSNewRecordsState) -> Bool { + /// See the ownership convention in `OSUserRequest.swift`. + let ownerExternalId: String? + + func prepareForExecution(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing) -> Bool { if let onesignalId = identityModel.onesignalId, newRecordsState.canAccess(onesignalId), - let appId = OneSignalIdentifiers.currentAppId + let appId = OneSignalIdentifiers.currentAppId, + auth.authorize(self) { _ = self.addPushSubscriptionIdToAdditionalHeaders() + if auth.ivBehaviorActive, let externalId = ownerExternalId { + addExternalIdToEvents(externalId) + } self.path = "apps/\(appId)/custom_events" return true } else { @@ -50,8 +57,22 @@ class OSRequestCustomEvents: OneSignalRequest, OSUserRequest { } } - init(events: [[String: Any]], identityModel: OSIdentityModel) { + /// The path is app-scoped, so the owner rides in each event's body rather than in the path. + /// Written at send time rather than at init so a cached payload cannot outlive the gate. + private func addExternalIdToEvents(_ externalId: String) { + guard let events = self.parameters?["events"] as? [[String: Any]] else { + return + } + self.parameters?["events"] = events.map { event in + var event = event + event[OS_EXTERNAL_ID] = externalId + return event + } + } + + init(events: [[String: Any]], identityModel: OSIdentityModel, ownerExternalId: String?) { self.identityModel = identityModel + self.ownerExternalId = ownerExternalId self.stringDescription = "" super.init() self.parameters = [ @@ -62,6 +83,7 @@ class OSRequestCustomEvents: OneSignalRequest, OSUserRequest { func encode(with coder: NSCoder) { coder.encode(identityModel, forKey: "identityModel") + coder.encode(ownerExternalId, forKey: "ownerExternalId") coder.encode(parameters, forKey: "parameters") coder.encode(method.rawValue, forKey: "method") // Encodes as String coder.encode(timestamp, forKey: "timestamp") @@ -78,6 +100,7 @@ class OSRequestCustomEvents: OneSignalRequest, OSUserRequest { return nil } self.identityModel = identityModel + self.ownerExternalId = coder.decodeObject(forKey: "ownerExternalId") as? String self.stringDescription = "" super.init() self.parameters = parameters diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestDeleteSubscription.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestDeleteSubscription.swift index 43cd37e3b..cf162e809 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestDeleteSubscription.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestDeleteSubscription.swift @@ -42,11 +42,19 @@ class OSRequestDeleteSubscription: OneSignalRequest, OSUserRequest { var subscriptionModel: OSSubscriptionModel + /** + See the ownership convention in `OSUserRequest.swift`. Removing an email or SMS subscription is a + deliberate action on one user, so an anonymous one is dropped under Identity Verification even + though the path addresses a subscription rather than a user. + */ + let ownerExternalId: String? + // Need the subscription_id - func prepareForExecution(newRecordsState: OSNewRecordsState) -> Bool { + func prepareForExecution(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing) -> Bool { if let subscriptionId = subscriptionModel.subscriptionId, newRecordsState.canAccess(subscriptionId), - let appId = OneSignalIdentifiers.currentAppId + let appId = OneSignalIdentifiers.currentAppId, + auth.authorize(self) { self.path = "apps/\(appId)/subscriptions/\(subscriptionId)" return true @@ -55,8 +63,9 @@ class OSRequestDeleteSubscription: OneSignalRequest, OSUserRequest { } } - init(subscriptionModel: OSSubscriptionModel) { + init(subscriptionModel: OSSubscriptionModel, ownerExternalId: String?) { self.subscriptionModel = subscriptionModel + self.ownerExternalId = ownerExternalId self.stringDescription = "" super.init() self.method = DELETE @@ -64,6 +73,7 @@ class OSRequestDeleteSubscription: OneSignalRequest, OSUserRequest { func encode(with coder: NSCoder) { coder.encode(subscriptionModel, forKey: "subscriptionModel") + coder.encode(ownerExternalId, forKey: "ownerExternalId") coder.encode(method.rawValue, forKey: "method") // Encodes as String coder.encode(timestamp, forKey: "timestamp") } @@ -77,7 +87,8 @@ class OSRequestDeleteSubscription: OneSignalRequest, OSUserRequest { // Log error return nil } - self.subscriptionModel = subscriptionModel + self.subscriptionModel = subscriptionModel + self.ownerExternalId = coder.decodeObject(forKey: "ownerExternalId") as? String self.stringDescription = "" super.init() self.method = HTTPMethod(rawValue: rawMethod) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestFetchIdentityBySubscription.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestFetchIdentityBySubscription.swift index 207209f3e..e7ea9757a 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestFetchIdentityBySubscription.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestFetchIdentityBySubscription.swift @@ -39,7 +39,11 @@ class OSRequestFetchIdentityBySubscription: OneSignalRequest, OSUserRequest { var identityModel: OSIdentityModel var pushSubscriptionModel: OSSubscriptionModel - func prepareForExecution(newRecordsState: OSNewRecordsState) -> Bool { + /// Always nil, so this Request is never signed. It discovers which user owns a subscription during + /// the v4 upgrade, before there is an `external_id` to sign with. + var ownerExternalId: String? { return nil } + + func prepareForExecution(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing) -> Bool { // newRecordsState is unused for this request guard let appId = OneSignalIdentifiers.currentAppId else { OneSignalLog.onesignalLog(.LL_DEBUG, message: "Cannot generate the FetchIdentityBySubscription request due to null app ID.") diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestFetchUser.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestFetchUser.swift index 8bf9b2973..5c49b96be 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestFetchUser.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestFetchUser.swift @@ -44,15 +44,19 @@ class OSRequestFetchUser: OneSignalRequest, OSUserRequest { let aliasId: String let onNewSession: Bool - func prepareForExecution(newRecordsState: OSNewRecordsState) -> Bool { + /// See the ownership convention in `OSUserRequest.swift`. + var ownerExternalId: String? { return identityModel.externalId } + + func prepareForExecution(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing) -> Bool { guard let appId = OneSignalIdentifiers.currentAppId, - newRecordsState.canAccess(aliasId) + newRecordsState.canAccess(aliasId), + let alias = auth.authorizeUserScoped(self, legacyAlias: OSAliasPair(aliasLabel, aliasId)), + let encodedAliasId = OSUrlPath.segment(alias.id) else { OneSignalLog.onesignalLog(.LL_DEBUG, message: "Cannot generate the fetch user request for \(aliasLabel): \(aliasId) yet.") return false } - self.addJWTHeader(identityModel: identityModel) - self.path = "apps/\(appId)/users/by/\(aliasLabel)/\(aliasId)" + self.path = "apps/\(appId)/users/by/\(alias.label)/\(encodedAliasId)" return true } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestIdentifyUser.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestIdentifyUser.swift index 880b2e405..3fd6f4fbd 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestIdentifyUser.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestIdentifyUser.swift @@ -47,14 +47,24 @@ class OSRequestIdentifyUser: OneSignalRequest, OSUserRequest { let aliasLabel: String let aliasId: String - /// requires a `onesignal_id` to send this request - func prepareForExecution(newRecordsState: OSNewRecordsState) -> Bool { - if let onesignalId = identityModelToIdentify.onesignalId, + /** + Always nil, so this Request is never signed. It adds an `external_id` to an anonymous user, and + Identity Verification does not allow anonymous users — `login` goes straight to Create User + instead, and the purge drops any that are already queued. + */ + var ownerExternalId: String? { return nil } + + /// Requires a `onesignal_id`, and refuses outright once Identity Verification is active: there is no + /// signed way to promote an anonymous user, so this can only sit until the purge reshapes it. + func prepareForExecution(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing) -> Bool { + if !auth.ivBehaviorActive, + let onesignalId = identityModelToIdentify.onesignalId, newRecordsState.canAccess(onesignalId), - let appId = OneSignalIdentifiers.currentAppId + let appId = OneSignalIdentifiers.currentAppId, + let alias = auth.authorizeUserScoped(self, legacyAlias: OSAliasPair(OS_ONESIGNAL_ID, onesignalId)), + let aliasId = OSUrlPath.segment(alias.id) { - self.addJWTHeader(identityModel: identityModelToIdentify) - self.path = "apps/\(appId)/users/by/\(OS_ONESIGNAL_ID)/\(onesignalId)/identity" + self.path = "apps/\(appId)/users/by/\(alias.label)/\(aliasId)/identity" return true } else { // self.path is non-nil, so set to empty string diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestRemoveAlias.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestRemoveAlias.swift index 207cebd5a..ff5a986d6 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestRemoveAlias.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestRemoveAlias.swift @@ -38,22 +38,28 @@ class OSRequestRemoveAlias: OneSignalRequest, OSUserRequest { let labelToRemove: String var identityModel: OSIdentityModel - func prepareForExecution(newRecordsState: OSNewRecordsState) -> Bool { + /// See the ownership convention in `OSUserRequest.swift`. + let ownerExternalId: String? + + func prepareForExecution(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing) -> Bool { if let onesignalId = identityModel.onesignalId, newRecordsState.canAccess(onesignalId), - let appId = OneSignalIdentifiers.currentAppId + let appId = OneSignalIdentifiers.currentAppId, + let alias = auth.authorizeUserScoped(self, legacyAlias: OSAliasPair(OS_ONESIGNAL_ID, onesignalId)), + let aliasId = OSUrlPath.segment(alias.id), + let encodedLabelToRemove = OSUrlPath.segment(labelToRemove) { - self.addJWTHeader(identityModel: identityModel) - self.path = "apps/\(appId)/users/by/\(OS_ONESIGNAL_ID)/\(onesignalId)/identity/\(labelToRemove)" + self.path = "apps/\(appId)/users/by/\(alias.label)/\(aliasId)/identity/\(encodedLabelToRemove)" return true } else { return false } } - init(labelToRemove: String, identityModel: OSIdentityModel) { + init(labelToRemove: String, identityModel: OSIdentityModel, ownerExternalId: String?) { self.labelToRemove = labelToRemove self.identityModel = identityModel + self.ownerExternalId = ownerExternalId self.stringDescription = "OSRequestRemoveAlias with aliasLabel: \(labelToRemove)" super.init() self.method = DELETE @@ -62,6 +68,7 @@ class OSRequestRemoveAlias: OneSignalRequest, OSUserRequest { func encode(with coder: NSCoder) { coder.encode(labelToRemove, forKey: "labelToRemove") coder.encode(identityModel, forKey: "identityModel") + coder.encode(ownerExternalId, forKey: "ownerExternalId") coder.encode(method.rawValue, forKey: "method") // Encodes as String coder.encode(timestamp, forKey: "timestamp") } @@ -78,6 +85,7 @@ class OSRequestRemoveAlias: OneSignalRequest, OSUserRequest { } self.labelToRemove = labelToRemove self.identityModel = identityModel + self.ownerExternalId = coder.decodeObject(forKey: "ownerExternalId") as? String self.stringDescription = "OSRequestRemoveAlias with aliasLabel: \(labelToRemove)" super.init() self.method = HTTPMethod(rawValue: rawMethod) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestTransferSubscription.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestTransferSubscription.swift index df0e589a8..1e074cc69 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestTransferSubscription.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestTransferSubscription.swift @@ -43,7 +43,9 @@ class OSRequestTransferSubscription: OneSignalRequest, OSUserRequest { let aliasLabel: String let aliasId: String - func prepareForExecution(newRecordsState: OSNewRecordsState) -> Bool { + var ownerExternalId: String? { return nil } + + func prepareForExecution(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing) -> Bool { return false } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestUpdateProperties.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestUpdateProperties.swift index aed93ee04..96dd3d93c 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestUpdateProperties.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestUpdateProperties.swift @@ -37,24 +37,29 @@ class OSRequestUpdateProperties: OneSignalRequest, OSUserRequest { var identityModel: OSIdentityModel + /// See the ownership convention in `OSUserRequest.swift`. + let ownerExternalId: String? + // TODO: Decide if addPushSubscriptionIdToAdditionalHeadersIfNeeded should block. // Note Android adds it to requests, if the push sub ID exists - func prepareForExecution(newRecordsState: OSNewRecordsState) -> Bool { + func prepareForExecution(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing) -> Bool { if let onesignalId = identityModel.onesignalId, newRecordsState.canAccess(onesignalId), - let appId = OneSignalIdentifiers.currentAppId + let appId = OneSignalIdentifiers.currentAppId, + let alias = auth.authorizeUserScoped(self, legacyAlias: OSAliasPair(OS_ONESIGNAL_ID, onesignalId)), + let aliasId = OSUrlPath.segment(alias.id) { _ = self.addPushSubscriptionIdToAdditionalHeaders() - self.addJWTHeader(identityModel: identityModel) - self.path = "apps/\(appId)/users/by/\(OS_ONESIGNAL_ID)/\(onesignalId)" + self.path = "apps/\(appId)/users/by/\(alias.label)/\(aliasId)" return true } else { return false } } - init(params: [String: Any], identityModel: OSIdentityModel) { + init(params: [String: Any], identityModel: OSIdentityModel, ownerExternalId: String?) { self.identityModel = identityModel + self.ownerExternalId = ownerExternalId self.stringDescription = "" super.init() self.parameters = params @@ -63,6 +68,7 @@ class OSRequestUpdateProperties: OneSignalRequest, OSUserRequest { func encode(with coder: NSCoder) { coder.encode(identityModel, forKey: "identityModel") + coder.encode(ownerExternalId, forKey: "ownerExternalId") coder.encode(parameters, forKey: "parameters") coder.encode(method.rawValue, forKey: "method") // Encodes as String coder.encode(timestamp, forKey: "timestamp") @@ -79,6 +85,8 @@ class OSRequestUpdateProperties: OneSignalRequest, OSUserRequest { return nil } self.identityModel = identityModel + // Absent in caches written before ownership was stamped; nil reads as anonymous. + self.ownerExternalId = coder.decodeObject(forKey: "ownerExternalId") as? String self.stringDescription = "" super.init() self.parameters = parameters diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestUpdateSubscription.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestUpdateSubscription.swift index 3f211fad5..55fe178d0 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestUpdateSubscription.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestUpdateSubscription.swift @@ -39,12 +39,29 @@ class OSRequestUpdateSubscription: OneSignalRequest, OSUserRequest { } var subscriptionModel: OSSubscriptionModel + /// The user this update was made for; used to file the response's RYW token under their + /// `onesignal_id`. `nil` drops the token. Held as the model because that ID may not exist + /// yet when the request is built. + var identityModel: OSIdentityModel? + + /** + Always nil, so this Request is never signed. Its path names a subscription rather than a user and the + endpoint ignores the token, while owning it would stall the device's push token and notification types + behind an identified user whose token went invalid. + + The Delta it comes from is owned, which is what decides whether the update survives the anonymous purge. + */ + var ownerExternalId: String? { return nil } + + /// The one Request Identity Verification lets through unowned, for the reason `ownerExternalId` gives. + var sendsUnsigned: Bool { return true } // Need the subscription_id - func prepareForExecution(newRecordsState: OSNewRecordsState) -> Bool { + func prepareForExecution(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing) -> Bool { if let subscriptionId = subscriptionModel.subscriptionId, newRecordsState.canAccess(subscriptionId), - let appId = OneSignalIdentifiers.currentAppId + let appId = OneSignalIdentifiers.currentAppId, + auth.authorize(self) { self.path = "apps/\(appId)/subscriptions/\(subscriptionId)" // Refresh so a stale snapshot queued earlier can't overwrite newer local state. @@ -62,16 +79,18 @@ class OSRequestUpdateSubscription: OneSignalRequest, OSUserRequest { subscriptionParams["device_os"] = subscriptionModel.deviceOs subscriptionParams["sdk"] = subscriptionModel.sdk subscriptionParams["app_version"] = subscriptionModel.appVersion - // notificationTypes defaults to -1 instead of nil, don't send if it's -1 - if subscriptionModel.notificationTypes != -1 { - subscriptionParams["notification_types"] = subscriptionModel.notificationTypes + + let enablement = subscriptionModel.reportedEnablement() + subscriptionParams["enabled"] = enablement.enabled + if let notificationTypes = enablement.notificationTypes { + subscriptionParams["notification_types"] = notificationTypes } - subscriptionParams["enabled"] = subscriptionModel.enabled self.parameters = ["subscription": subscriptionParams] } - init(subscriptionModel: OSSubscriptionModel) { + init(subscriptionModel: OSSubscriptionModel, identityModel: OSIdentityModel?) { self.subscriptionModel = subscriptionModel + self.identityModel = identityModel self.stringDescription = "OSRequestUpdateSubscription with model: \(subscriptionModel.modelId)" super.init() refreshParametersFromLiveModel() @@ -80,6 +99,7 @@ class OSRequestUpdateSubscription: OneSignalRequest, OSUserRequest { func encode(with coder: NSCoder) { coder.encode(subscriptionModel, forKey: "subscriptionModel") + coder.encode(identityModel, forKey: "identityModel") coder.encode(parameters, forKey: "parameters") coder.encode(method.rawValue, forKey: "method") // Encodes as String coder.encode(timestamp, forKey: "timestamp") @@ -96,6 +116,7 @@ class OSRequestUpdateSubscription: OneSignalRequest, OSUserRequest { return nil } self.subscriptionModel = subscriptionModel + self.identityModel = coder.decodeObject(forKey: "identityModel") as? OSIdentityModel self.stringDescription = "OSRequestUpdateSubscription with parameters: \(parameters)" super.init() self.parameters = parameters diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSUserRequest.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSUserRequest.swift index 133473ba8..7842b4708 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSUserRequest.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSUserRequest.swift @@ -30,19 +30,48 @@ import OneSignalOSCore protocol OSUserRequest: OneSignalRequest, NSCoding { var sentToClient: Bool { get set } - func prepareForExecution(newRecordsState: OSNewRecordsState) -> Bool + + /// The user this Request belongs to; also selects its token. See the ownership convention below. + var ownerExternalId: String? { get } + + /// Whether this Request may still be sent with no `Authorization` header once Identity Verification + /// is in effect. Only Update Subscription may: its path names a subscription rather than a user, so + /// there is no user for the server to authorize. Everything else with no owner is refused. + var sendsUnsigned: Bool { get } + + /// Builds the path and resolves authorization. `false` leaves the Request queued, whether it is + /// waiting on a record it cannot address yet or on a token it cannot sign with yet. A caller deciding + /// whether to *discard* a cached Request must not read `false` as permanent: an owned Request becomes + /// sendable once `updateUserJwt` supplies its token. + func prepareForExecution(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing) -> Bool } -internal extension OneSignalRequest { - func addJWTHeader(identityModel: OSIdentityModel) { -// guard let token = identityModel.jwtBearerToken else { -// return -// } -// var additionalHeaders = self.additionalHeaders ?? [String:String]() -// additionalHeaders["Authorization"] = "Bearer \(token)" -// self.additionalHeaders = additionalHeaders - } +extension OSUserRequest { + var sendsUnsigned: Bool { return false } +} + +/* + Ownership convention: a Request that Identity Verification can purge stores `ownerExternalId`, the + owner's `external_id` as of when the Request was built, and both the purge and the token lookup + judge it by that rather than by its `identityModel`. + + The live model cannot answer the question. `clearUserData` empties an Identity Model's aliases before + a fetch response hydrates them, so for that window an identified user reads as anonymous and a purge + running alongside it would delete signed work. The stamp also matches how `OSDelta` carries + `externalId`, which keeps a Delta and the Request built from it judged the same way. + + nil means anonymous, including for caches written before ownership was stamped. + + Create User and Fetch User are not built from a Delta and the purge does not consider them, so they + read the owner off their Identity Model. Nothing is in flight when a purge runs, and the User + executor sends nothing while the requirement is unknown, so the live read is sound there. + Three Requests are nil by construction and so are never signed: Identify User and Fetch Identity By + Subscription both address a user that has no `external_id` yet, and Update Subscription is the + device's own push subscription. Each says why at its declaration. + */ + +internal extension OneSignalRequest { /** Returns if the `OneSignal-Subscription-Id` header was added successfully. */ func addPushSubscriptionIdToAdditionalHeaders() -> Bool { if let pushSubscriptionId = OneSignalUserManagerImpl.sharedInstance.pushSubscriptionId { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserMocks/OneSignalUserMocks.swift b/iOS_SDK/OneSignalSDK/OneSignalUserMocks/OneSignalUserMocks.swift index e626f423a..430d25b0f 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserMocks/OneSignalUserMocks.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserMocks/OneSignalUserMocks.swift @@ -37,7 +37,10 @@ public class OneSignalUserMocks: NSObject { // TODO: create mocked server responses to user requests @objc public static func reset() { - OSCoreMocks.resetOperationRepo() + // Drop the previous test's handlers before the hydrate below, or leftover Requests hydrate shared models. + OneSignalUserManagerImpl.sharedInstance.identityVerificationService.removeOnJwtConfigHydratedHandler(for: .userExecutor) + OneSignalUserManagerImpl.sharedInstance.identityVerificationService.removeOnJwtConfigHydratedHandler(for: .userManager) + OneSignalUserManagerImpl.sharedInstance.operationRepo.reset() OSCoreMocks.resetSharedJwtConfig() // Hydrate `off` so the Operation Repo's unknown-requirement deferral does not stall non-IV tests. OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: false) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/CustomEventsIntegrationTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/CustomEventsIntegrationTests.swift index e64b057dc..67eebcc75 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/CustomEventsIntegrationTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/CustomEventsIntegrationTests.swift @@ -62,7 +62,7 @@ final class CustomEventsIntegrationTests: XCTestCase { /* When */ userManager.trackEvent(name: "test_event", properties: properties) - OSOperationRepo.sharedInstance.addFlushDeltaQueueToDispatchQueue() + OneSignalUserManagerImpl.sharedInstance.operationRepo.addFlushDeltaQueueToDispatchQueue() OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) /* Then */ @@ -80,7 +80,7 @@ final class CustomEventsIntegrationTests: XCTestCase { /* When */ userManager.trackEvent(name: "test_event", properties: nil) - OSOperationRepo.sharedInstance.addFlushDeltaQueueToDispatchQueue() + OneSignalUserManagerImpl.sharedInstance.operationRepo.addFlushDeltaQueueToDispatchQueue() OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) /* Then */ @@ -98,7 +98,7 @@ final class CustomEventsIntegrationTests: XCTestCase { /* When */ userManager.trackEvent(name: "test_event", properties: [:]) - OSOperationRepo.sharedInstance.addFlushDeltaQueueToDispatchQueue() + OneSignalUserManagerImpl.sharedInstance.operationRepo.addFlushDeltaQueueToDispatchQueue() OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) /* Then */ @@ -119,7 +119,7 @@ final class CustomEventsIntegrationTests: XCTestCase { /* When */ userManager.trackEvent(name: "test_event", properties: invalidProperties) - OSOperationRepo.sharedInstance.addFlushDeltaQueueToDispatchQueue() + OneSignalUserManagerImpl.sharedInstance.operationRepo.addFlushDeltaQueueToDispatchQueue() OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) /* Then - No request should be made */ @@ -154,7 +154,7 @@ final class CustomEventsIntegrationTests: XCTestCase { /* When */ userManager.trackEvent(name: "complex_event", properties: complexProperties) - OSOperationRepo.sharedInstance.addFlushDeltaQueueToDispatchQueue() + OneSignalUserManagerImpl.sharedInstance.operationRepo.addFlushDeltaQueueToDispatchQueue() OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) /* Then */ @@ -220,7 +220,7 @@ final class CustomEventsIntegrationTests: XCTestCase { /* When */ userManager.trackEvent(name: "array_event", properties: properties) - OSOperationRepo.sharedInstance.addFlushDeltaQueueToDispatchQueue() + OneSignalUserManagerImpl.sharedInstance.operationRepo.addFlushDeltaQueueToDispatchQueue() OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) /* Then */ diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/DeltaOwnershipTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/DeltaOwnershipTests.swift index b6fd2de5f..24f8fc772 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/DeltaOwnershipTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/DeltaOwnershipTests.swift @@ -202,6 +202,36 @@ final class DeltaOwnershipTests: XCTestCase { XCTAssertEqual(delta.identityModelId, first.identityModel.modelId) } + // MARK: - Requests built from a Delta + + func testUpdateSubscriptionRequestIsBoundToTheDeltasOwner() throws { + let client = executingClient() + let user = newUser(externalId: userA) + let executor = OSSubscriptionOperationExecutor(newRecordsState: OSNewRecordsState(), auth: OneSignalUserManagerImpl.sharedInstance.requestAuth) + + executor.enqueueDelta(subscriptionUpdateDelta(owner: user.identityModel)) + executor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + let request = try XCTUnwrap(client.executedRequests.compactMap { $0 as? OSRequestUpdateSubscription }.first) + XCTAssertTrue(request.identityModel === user.identityModel) + } + + /// Unknown owner still sends; only the RYW token is dropped, not misfiled under the current user. + func testASubscriptionRequestStillSendsWhenTheOwningIdentityIsUnknown() throws { + let client = executingClient() + newUser(externalId: userB) + let unknownOwner = OSIdentityModel(aliases: [OS_EXTERNAL_ID: userA], changeNotifier: OSEventProducer()) + let executor = OSSubscriptionOperationExecutor(newRecordsState: OSNewRecordsState(), auth: OneSignalUserManagerImpl.sharedInstance.requestAuth) + + executor.enqueueDelta(subscriptionUpdateDelta(owner: unknownOwner)) + executor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + let request = try XCTUnwrap(client.executedRequests.compactMap { $0 as? OSRequestUpdateSubscription }.first) + XCTAssertNil(request.identityModel) + } + // MARK: - Helpers @discardableResult @@ -219,6 +249,13 @@ final class DeltaOwnershipTests: XCTestCase { ) } + private func executingClient() -> MockOneSignalClient { + let client = MockOneSignalClient() + client.fireSuccessForAllRequests = true + OneSignalCoreImpl.setSharedClient(client) + return client + } + private func emailSubscriptionModel() -> OSSubscriptionModel { return OSSubscriptionModel( type: .email, @@ -230,6 +267,18 @@ final class DeltaOwnershipTests: XCTestCase { ) } + private func subscriptionUpdateDelta(owner: OSIdentityModel) -> OSDelta { + let model = emailSubscriptionModel() + return OSDelta( + name: OS_UPDATE_SUBSCRIPTION_DELTA, + identityModelId: owner.modelId, + externalId: owner.externalId, + model: model, + property: model.type.rawValue, + value: model.address ?? "" + ) + } + private func queuedDelta(named name: String, property: String) -> OSDelta? { return OneSignalUserManagerImpl.sharedInstance.operationRepo.deltaQueue.first { $0.name == name && $0.property == property } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/ExecutorAnonymousPurgeTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/ExecutorAnonymousPurgeTests.swift new file mode 100644 index 000000000..b23a70d4e --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/ExecutorAnonymousPurgeTests.swift @@ -0,0 +1,358 @@ +/* + 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 XCTest +import OneSignalCore +import OneSignalOSCore +import OneSignalCoreMocks +import OneSignalOSCoreMocks +import OneSignalUserMocks +@testable import OneSignalUser + +/** + What each executor drops from its queues when Identity Verification turns out to be required. + + Only ownership is judged: a Delta or Request carries the `external_id` of the user it was built for, and + one carrying none can never be signed. The auth layer refuses to send those, so the purge is what keeps + them from sitting in the queues unsendable for the rest of the session. + */ +final class ExecutorAnonymousPurgeTests: XCTestCase { + private let anonymousOSID = "test-anonymous-onesignal-id" + private let ownedToken = "token-a" + private let anonymousSubscriptionId = "test-anonymous-subscription-id" + private let ownedSubscriptionId = "test-owned-subscription-id" + + private var client = MockOneSignalClient() + private var newRecordsState = MockNewRecordsState() + private var anonymous = OSIdentityModel(aliases: nil, changeNotifier: OSEventProducer()) + private var owned = OSIdentityModel(aliases: nil, changeNotifier: OSEventProducer()) + + override func setUpWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + OneSignalUserMocks.reset() + OneSignalIdentifiers.currentAppId = "test-app-id" + + client = MockOneSignalClient() + client.fireSuccessForAllRequests = true + OneSignalCoreImpl.setSharedClient(client) + newRecordsState = MockNewRecordsState() + + // The purge only ever runs because the requirement came back requiring auth. + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + anonymous = addUserToRepo(externalId: nil, onesignalId: anonymousOSID) + owned = addUserToRepo(externalId: userA_EUID, onesignalId: userA_OSID) + } + + override func tearDownWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + } + + // MARK: - Setup helpers + + /// A user the executors can resolve Deltas and Requests against. The identified one can sign. + private func addUserToRepo(externalId: String?, onesignalId: String) -> OSIdentityModel { + var aliases = [OS_ONESIGNAL_ID: onesignalId] + if let externalId = externalId { + aliases[OS_EXTERNAL_ID] = externalId + } + let model = OSIdentityModel(aliases: aliases, changeNotifier: OSEventProducer()) + if externalId != nil { + model.jwtBearerToken = ownedToken + } + OneSignalUserManagerImpl.sharedInstance.addIdentityModelToRepo(model) + return model + } + + /// Holds both users' ids, and any others passed, so nothing can be sent while they propagate. The + /// Requests the Deltas became then wait in the executor's queues, which is where the purge is visible. + private func holdIds(_ ids: String...) { + for id in [anonymousOSID, userA_OSID] + ids { + newRecordsState.add(id) + } + } + + private var auth: OSRequestAuthorizing { + return OneSignalUserManagerImpl.sharedInstance.requestAuth + } + + private func delta(_ name: String, for identityModel: OSIdentityModel, model: OSModel, property: String, value: Any) -> OSDelta { + return OSDelta( + name: name, + identityModelId: identityModel.modelId, + externalId: identityModel.externalId, + model: model, + property: property, + value: value + ) + } + + private func subscription(id: String) -> OSSubscriptionModel { + return OSSubscriptionModel( + type: .email, + address: "\(id)@example.com", + subscriptionId: id, + reachable: true, + isDisabled: false, + changeNotifier: OSEventProducer() + ) + } + + // MARK: - Assertion helpers + + private func cachedRequestOwners(_ key: String, of type: T.Type) -> [String?] { + let requests = OneSignalUserDefaults.initShared().getSavedCodeableData(forKey: key, defaultValue: []) as? [T] ?? [] + return requests.map { $0.ownerExternalId } + } + + private func executedPaths() -> [String] { + return client.executedRequests.map { $0.path } + } + + private func userPath(_ suffix: String = "") -> String { + return "apps/test-app-id/users/by/\(OS_EXTERNAL_ID)/\(userA_EUID)" + suffix + } + + // MARK: - Properties + + func testThePropertyExecutorSendsOnlyTheIdentifiedUsersUpdate() { + let executor = OSPropertyOperationExecutor(newRecordsState: newRecordsState, auth: auth) + executor.enqueueDelta(propertiesDelta(for: anonymous)) + executor.enqueueDelta(propertiesDelta(for: owned)) + + executor.removeOperationsWithoutExternalId() + executor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestUpdateProperties.self, expectedCount: 1)) + XCTAssertEqual(executedPaths(), [userPath()]) + } + + func testThePropertyExecutorDropsTheAnonymousUpdateRequest() { + holdIds() + let executor = OSPropertyOperationExecutor(newRecordsState: newRecordsState, auth: auth) + executor.enqueueDelta(propertiesDelta(for: anonymous)) + executor.enqueueDelta(propertiesDelta(for: owned)) + executor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + XCTAssertEqual(client.executedRequests.count, 0) + + executor.removeOperationsWithoutExternalId() + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + XCTAssertEqual(cachedRequestOwners(OS_PROPERTIES_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, of: OSRequestUpdateProperties.self), [userA_EUID]) + } + + /// The purge writes through to the cache, so a relaunch reads back the identified user's Request alone. + func testAnAnonymousUpdateRequestIsNotRestoredAfterThePurge() { + holdIds() + let executor = OSPropertyOperationExecutor(newRecordsState: newRecordsState, auth: auth) + executor.enqueueDelta(propertiesDelta(for: anonymous)) + executor.enqueueDelta(propertiesDelta(for: owned)) + executor.processDeltaQueue(inBackground: false) + executor.removeOperationsWithoutExternalId() + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + // A records state with nothing held stands in for ids that have since propagated. + newRecordsState = MockNewRecordsState() + let relaunched = OSPropertyOperationExecutor(newRecordsState: newRecordsState, auth: auth) + relaunched.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestUpdateProperties.self, expectedCount: 1)) + XCTAssertEqual(executedPaths(), [userPath()]) + } + + private func propertiesDelta(for identityModel: OSIdentityModel) -> OSDelta { + return delta( + OS_UPDATE_PROPERTIES_DELTA, + for: identityModel, + model: OSModel(changeNotifier: OSEventProducer()), + property: "language", + value: "en" + ) + } + + // MARK: - Custom events + + func testTheCustomEventsExecutorSendsOnlyTheIdentifiedUsersEvents() { + let executor = OSCustomEventsExecutor(newRecordsState: newRecordsState, auth: auth) + executor.enqueueDelta(customEventDelta(for: anonymous)) + executor.enqueueDelta(customEventDelta(for: owned)) + + executor.removeOperationsWithoutExternalId() + executor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + // The path names the app rather than the user, so the events themselves say who survived. + XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestCustomEvents.self, expectedCount: 1)) + let events = client.executedRequests.first?.parameters?["events"] as? [[String: Any]] + XCTAssertEqual(events?.count, 1) + XCTAssertEqual(events?.first?["onesignal_id"] as? String, userA_OSID) + } + + func testTheCustomEventsExecutorDropsTheAnonymousEventsRequest() { + holdIds() + let executor = OSCustomEventsExecutor(newRecordsState: newRecordsState, auth: auth) + executor.enqueueDelta(customEventDelta(for: anonymous)) + executor.enqueueDelta(customEventDelta(for: owned)) + executor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + XCTAssertEqual(client.executedRequests.count, 0) + + executor.removeOperationsWithoutExternalId() + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + XCTAssertEqual(cachedRequestOwners(OS_CUSTOM_EVENTS_EXECUTOR_REQUEST_QUEUE_KEY, of: OSRequestCustomEvents.self), [userA_EUID]) + } + + private func customEventDelta(for identityModel: OSIdentityModel) -> OSDelta { + return delta( + OS_CUSTOM_EVENT_DELTA, + for: identityModel, + model: OSModel(changeNotifier: OSEventProducer()), + property: "test_event", + value: ["test_property": "test-value"] + ) + } + + // MARK: - Identity + + func testTheIdentityExecutorSendsOnlyTheIdentifiedUsersAlias() { + let executor = OSIdentityOperationExecutor(newRecordsState: newRecordsState, auth: auth) + executor.enqueueDelta(addAliasDelta(for: anonymous)) + executor.enqueueDelta(addAliasDelta(for: owned)) + + executor.removeOperationsWithoutExternalId() + executor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestAddAliases.self, expectedCount: 1)) + XCTAssertEqual(executedPaths(), [userPath("/identity")]) + } + + func testTheIdentityExecutorDropsBothAnonymousAliasRequests() { + holdIds() + let executor = OSIdentityOperationExecutor(newRecordsState: newRecordsState, auth: auth) + for identity in [anonymous, owned] { + executor.enqueueDelta(addAliasDelta(for: identity)) + executor.enqueueDelta(removeAliasDelta(for: identity)) + } + executor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + XCTAssertEqual(client.executedRequests.count, 0) + + executor.removeOperationsWithoutExternalId() + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + XCTAssertEqual(cachedRequestOwners(OS_IDENTITY_EXECUTOR_ADD_REQUEST_QUEUE_KEY, of: OSRequestAddAliases.self), [userA_EUID]) + XCTAssertEqual(cachedRequestOwners(OS_IDENTITY_EXECUTOR_REMOVE_REQUEST_QUEUE_KEY, of: OSRequestRemoveAlias.self), [userA_EUID]) + } + + private func addAliasDelta(for identityModel: OSIdentityModel) -> OSDelta { + return delta( + OS_ADD_ALIAS_DELTA, + for: identityModel, + model: identityModel, + property: "aliases", + value: ["test_alias_label": "test-alias-id"] + ) + } + + private func removeAliasDelta(for identityModel: OSIdentityModel) -> OSDelta { + return delta( + OS_REMOVE_ALIAS_DELTA, + for: identityModel, + model: identityModel, + property: "aliases", + value: ["test_alias_label": ""] + ) + } + + // MARK: - Subscriptions + + func testTheSubscriptionExecutorSendsOnlyTheIdentifiedUsersNewSubscription() { + let executor = OSSubscriptionOperationExecutor(newRecordsState: newRecordsState, auth: auth) + executor.enqueueDelta(addSubscriptionDelta(for: anonymous, subscriptionId: anonymousSubscriptionId)) + executor.enqueueDelta(addSubscriptionDelta(for: owned, subscriptionId: ownedSubscriptionId)) + + executor.removeOperationsWithoutExternalId() + executor.processDeltaQueue(inBackground: false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestCreateSubscription.self, expectedCount: 1)) + XCTAssertEqual(executedPaths(), [userPath("/subscriptions")]) + } + + func testTheSubscriptionExecutorDropsTheAnonymousAddAndDeleteRequests() { + holdIds(anonymousSubscriptionId, ownedSubscriptionId) + let executor = OSSubscriptionOperationExecutor(newRecordsState: newRecordsState, auth: auth) + enqueueSubscriptionWork(on: executor) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + XCTAssertEqual(client.executedRequests.count, 0) + + executor.removeOperationsWithoutExternalId() + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + XCTAssertEqual(cachedRequestOwners(OS_SUBSCRIPTION_EXECUTOR_ADD_REQUEST_QUEUE_KEY, of: OSRequestCreateSubscription.self), [userA_EUID]) + XCTAssertEqual(cachedRequestOwners(OS_SUBSCRIPTION_EXECUTOR_REMOVE_REQUEST_QUEUE_KEY, of: OSRequestDeleteSubscription.self), [userA_EUID]) + } + + /// An Update Subscription is addressed by subscription ID and never signed, so it has no owner to be + /// judged by and the purge has to leave that queue alone: `logout()`'s unsubscribe travels in it. + func testTheSubscriptionExecutorKeepsEveryUpdateRequest() { + holdIds(anonymousSubscriptionId, ownedSubscriptionId) + let executor = OSSubscriptionOperationExecutor(newRecordsState: newRecordsState, auth: auth) + enqueueSubscriptionWork(on: executor) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + executor.removeOperationsWithoutExternalId() + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + let updateOwners = cachedRequestOwners(OS_SUBSCRIPTION_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, of: OSRequestUpdateSubscription.self) + XCTAssertEqual(updateOwners.count, 2) + XCTAssertTrue(updateOwners.allSatisfy { $0 == nil }) + } + + /// An add, a delete and an update for each user, turned into Requests that cannot be sent yet. + private func enqueueSubscriptionWork(on executor: OSSubscriptionOperationExecutor) { + for (identity, subscriptionId) in [(anonymous, anonymousSubscriptionId), (owned, ownedSubscriptionId)] { + let model = subscription(id: subscriptionId) + for name in [OS_ADD_SUBSCRIPTION_DELTA, OS_REMOVE_SUBSCRIPTION_DELTA, OS_UPDATE_SUBSCRIPTION_DELTA] { + executor.enqueueDelta(subscriptionDelta(name, for: identity, subscription: model)) + } + } + executor.processDeltaQueue(inBackground: false) + } + + private func addSubscriptionDelta(for identityModel: OSIdentityModel, subscriptionId: String) -> OSDelta { + return subscriptionDelta(OS_ADD_SUBSCRIPTION_DELTA, for: identityModel, subscription: subscription(id: subscriptionId)) + } + + private func subscriptionDelta(_ name: String, for identityModel: OSIdentityModel, subscription: OSSubscriptionModel) -> OSDelta { + return delta(name, for: identityModel, model: subscription, property: "optedIn", value: true) + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSCustomEventsExecutorTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSCustomEventsExecutorTests.swift index 12d969d74..f3972f321 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSCustomEventsExecutorTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/OSCustomEventsExecutorTests.swift @@ -40,7 +40,7 @@ private class CustomEventsMocks { init() { OneSignalCoreImpl.setSharedClient(client) - customEventsExecutor = OSCustomEventsExecutor(newRecordsState: newRecordsState) + customEventsExecutor = OSCustomEventsExecutor(newRecordsState: newRecordsState, auth: OneSignalUserManagerImpl.sharedInstance.requestAuth) } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/SubscriptionUpdateRaceTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/SubscriptionUpdateRaceTests.swift index 39ad19648..9fe199759 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/SubscriptionUpdateRaceTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/SubscriptionUpdateRaceTests.swift @@ -59,7 +59,7 @@ final class SubscriptionUpdateRaceTests: XCTestCase { let model = makePushSubscriptionModel(notificationTypes: promptedNeverAnswered, subscriptionId: subscriptionId) XCTAssertFalse(model.enabled) - let request = OSRequestUpdateSubscription(subscriptionModel: model) + let request = OSRequestUpdateSubscription(subscriptionModel: model, identityModel: nil) let atInit = try XCTUnwrap(request.parameters?["subscription"] as? [String: Any]) XCTAssertEqual(atInit["notification_types"] as? Int, promptedNeverAnswered) XCTAssertEqual(atInit["enabled"] as? Bool, false) @@ -68,7 +68,7 @@ final class SubscriptionUpdateRaceTests: XCTestCase { model.notificationTypes = subscribedNotificationTypes XCTAssertTrue(model.enabled) - XCTAssertTrue(request.prepareForExecution(newRecordsState: OSNewRecordsState())) + XCTAssertTrue(request.prepareForExecution(newRecordsState: OSNewRecordsState(), auth: OneSignalUserManagerImpl.sharedInstance.requestAuth)) let refreshed = try XCTUnwrap(request.parameters?["subscription"] as? [String: Any]) XCTAssertEqual(refreshed["notification_types"] as? Int, subscribedNotificationTypes) @@ -85,7 +85,7 @@ final class SubscriptionUpdateRaceTests: XCTestCase { client.fireSuccessForAllRequests = true OneSignalCoreImpl.setSharedClient(client) - let executor = OSSubscriptionOperationExecutor(newRecordsState: OSNewRecordsState()) + let executor = OSSubscriptionOperationExecutor(newRecordsState: OSNewRecordsState(), auth: OneSignalUserManagerImpl.sharedInstance.requestAuth) // Without a subscriptionId, prepareForExecution keeps the update pending. let model = makePushSubscriptionModel(notificationTypes: promptedNeverAnswered, subscriptionId: nil) let identityModelId = UUID().uuidString @@ -145,7 +145,7 @@ final class SubscriptionUpdateRaceTests: XCTestCase { client.fireSuccessForAllRequests = true OneSignalCoreImpl.setSharedClient(client) - let executor = OSSubscriptionOperationExecutor(newRecordsState: OSNewRecordsState()) + let executor = OSSubscriptionOperationExecutor(newRecordsState: OSNewRecordsState(), auth: OneSignalUserManagerImpl.sharedInstance.requestAuth) let model = makePushSubscriptionModel(notificationTypes: promptedNeverAnswered, subscriptionId: subscriptionId) let identityModelId = UUID().uuidString @@ -204,7 +204,7 @@ final class SubscriptionUpdateRaceTests: XCTestCase { client.fireSuccessForAllRequests = true OneSignalCoreImpl.setSharedClient(client) - let executor = OSSubscriptionOperationExecutor(newRecordsState: OSNewRecordsState()) + let executor = OSSubscriptionOperationExecutor(newRecordsState: OSNewRecordsState(), auth: OneSignalUserManagerImpl.sharedInstance.requestAuth) let model = makePushSubscriptionModel(notificationTypes: promptedNeverAnswered, subscriptionId: subscriptionId) let identityModelId = UUID().uuidString diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/UserExecutorTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/UserExecutorTests.swift index 703079453..570986fe9 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/UserExecutorTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/UserExecutorTests.swift @@ -39,9 +39,11 @@ private class Mocks { let newRecordsState = MockNewRecordsState() let userExecutor: OSUserExecutor - init() { + /// Stub before building the executor so a seeded Request cache cannot race the init-time send. + init(stubResponses: (MockOneSignalClient) -> Void = { _ in }) { OneSignalCoreImpl.setSharedClient(client) - userExecutor = OSUserExecutor(newRecordsState: newRecordsState) + stubResponses(client) + userExecutor = OSUserExecutor(newRecordsState: newRecordsState, identityVerificationService: OneSignalUserManagerImpl.sharedInstance.identityVerificationService, auth: OneSignalUserManagerImpl.sharedInstance.requestAuth) } func createUserInstance(externalId: String) -> OSUserInternal { @@ -69,9 +71,12 @@ final class UserExecutorTests: XCTestCase { /* Setup */ let mocks = Mocks() MockUserRequests.setDefaultCreateUserResponses(with: mocks.client, externalId: userA_EUID, subscriptionId: "push-sub-id") + let user = mocks.createUserInstance(externalId: userA_EUID) + // Current so Create User keeps push; otherwise a prior-user create omits subscriptions. + OneSignalUserManagerImpl.sharedInstance._user = user /* When */ - mocks.userExecutor.createUser(mocks.createUserInstance(externalId: userA_EUID)) + mocks.userExecutor.createUser(user) OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) /* Then */ @@ -79,6 +84,58 @@ final class UserExecutorTests: XCTestCase { XCTAssertTrue(mocks.newRecordsState.contains("push-sub-id")) } + /// A Create User for a prior login must not include the device push subscription, which the + /// current user now owns — sending it would transfer that push on the server. + func testCreateUser_forPriorIdentifiedUser_omitsPushSubscription() { + /* Setup */ + let mocks = Mocks() + MockUserRequests.setDefaultCreateUserResponses(with: mocks.client, externalId: userA_EUID, subscriptionId: "push-sub-id") + + let sharedPush = OSSubscriptionModel( + type: .push, + address: "test-push-token", + subscriptionId: "shared-push-id", + reachable: true, + isDisabled: false, + changeNotifier: OSEventProducer() + ) + let priorIdentity = OSIdentityModel(aliases: [OS_EXTERNAL_ID: userA_EUID], changeNotifier: OSEventProducer()) + let priorProperties = OSPropertiesModel(changeNotifier: OSEventProducer()) + let priorCreate = OSRequestCreateUser( + identityModel: priorIdentity, + propertiesModel: priorProperties, + pushSubscriptionModel: sharedPush, + originalPushToken: sharedPush.address + ) + XCTAssertNotNil(priorCreate.parameters?["subscriptions"]) + + // Device push now belongs to the current user (B); the parked create still holds the same model. + let currentUser = mocks.createUserInstance(externalId: userB_EUID) + currentUser.pushSubscriptionModel.subscriptionId = "shared-push-id" + OneSignalUserManagerImpl.sharedInstance._user = currentUser + OneSignalUserManagerImpl.sharedInstance.pushSubscriptionModelStore.add( + id: OS_PUSH_SUBSCRIPTION_MODEL_KEY, + model: sharedPush, + hydrating: false + ) + + /* When */ + mocks.userExecutor.executeCreateUserRequest(priorCreate) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Then */ + guard let sent = mocks.client.executedRequests.compactMap({ $0 as? OSRequestCreateUser }).first else { + XCTFail("Expected Create User to be sent") + return + } + XCTAssertNil(sent.parameters?["subscriptions"], "Must not transfer the current user's push to a prior Create User") + // Built as a real create, so cool-down the onesignal_id even though push was omitted at send. + XCTAssertTrue(mocks.newRecordsState.contains(userA_OSID)) + XCTAssertFalse(mocks.newRecordsState.contains("push-sub-id")) + XCTAssertFalse(mocks.newRecordsState.contains("shared-push-id")) + } + + /// Identify-409 recovery Create only hydrates an existing user, so its IDs are not new. func testCreateUser_withoutPushSubscription_doesNot_addToNewRecords() { /* Setup */ let mocks = Mocks() @@ -244,4 +301,151 @@ final class UserExecutorTests: XCTestCase { XCTAssertNil(currentUser.identityModel.aliases["stale_label"]) XCTAssertEqual(currentUser.identityModel.externalId, userA_EUID) } + + // MARK: - Identity Verification + + /// Cached Create User with no `external_id` must not go out once Identity Verification is required. + func testAnonymousCachedCreateUserIsDroppedWhenIdentityVerificationIsRequired() { + /* Setup */ + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + cacheUserRequests([makeAnonymousCreateUserRequest()]) + + /* When */ + let mocks = Mocks() + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Then */ + XCTAssertFalse(mocks.client.hasExecutedRequestOfType(OSRequestCreateUser.self)) + } + + /// Same restored Create User goes out when Identity Verification is off. + func testAnonymousCachedCreateUserIsSentWhenIdentityVerificationIsOff() { + /* Setup */ + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: false) + cacheUserRequests([makeAnonymousCreateUserRequest()]) + + /* When */ + let mocks = Mocks { MockUserRequests.setDefaultCreateAnonUserResponses(with: $0) } + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Then */ + XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestCreateUser.self)) + } + + /// Nothing is sent while `requirement` is unknown; hydration releases the held Requests. + func testRequestsAreHeldUntilTheRequirementIsKnown() { + /* Setup */ + OSCoreMocks.resetSharedJwtConfig() + let mocks = Mocks() + MockUserRequests.setDefaultCreateUserResponses(with: mocks.client, externalId: userA_EUID) + + /* When */ + mocks.userExecutor.createUser(mocks.createUserInstance(externalId: userA_EUID)) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Then */ + XCTAssertFalse(mocks.client.hasExecutedRequestOfType(OSRequestCreateUser.self)) + + /* When the requirement arrives */ + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Then */ + XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestCreateUser.self)) + } + + /// Identify User promotes an anonymous user, which Identity Verification does not allow. A restored one + /// belongs to a user a later `login` has already replaced, so there is no login left to carry over. + func testRestoredIdentifyUserIsDroppedWhenIdentityVerificationIsRequired() { + /* Setup */ + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + cacheUserRequests([makeIdentifyUserRequest()]) + + /* When */ + let mocks = Mocks { MockUserRequests.setDefaultIdentifyUserResponses(with: $0, externalId: userA_EUID, conflicted: false) } + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Then */ + XCTAssertFalse(mocks.client.hasExecutedRequestOfType(OSRequestIdentifyUser.self)) + XCTAssertFalse(mocks.client.hasExecutedRequestOfType(OSRequestCreateUser.self)) + } + + /// Same restored Identify User goes out when Identity Verification is off. + func testRestoredIdentifyUserIsSentWhenIdentityVerificationIsOff() { + /* Setup */ + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: false) + cacheUserRequests([makeIdentifyUserRequest()]) + + /* When */ + let mocks = Mocks { MockUserRequests.setDefaultIdentifyUserResponses(with: $0, externalId: userA_EUID, conflicted: false) } + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Then */ + XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestIdentifyUser.self)) + } + + /// `login` promotes while the requirement is still unknown, so turning out to require auth must not + /// strand that login: it becomes the Create User it would have been. + func testInSessionIdentifyUserBecomesACreateUserWhenIdentityVerificationIsRequired() { + /* Setup */ + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + let mocks = Mocks() + MockUserRequests.setDefaultIdentifyUserResponses(with: mocks.client, externalId: userA_EUID, conflicted: false) + MockUserRequests.setDefaultCreateUserResponses(with: mocks.client, externalId: userA_EUID) + + let anonIdentityModel = OSIdentityModel(aliases: [OS_ONESIGNAL_ID: userA_OSID], changeNotifier: OSEventProducer()) + let user = OneSignalUserMocks.setUserManagerInternalUser(externalId: userA_EUID, onesignalId: nil) + user.identityModel.jwtBearerToken = "token-a" + + /* When */ + mocks.userExecutor.identifyUser(externalId: userA_EUID, identityModelToIdentify: anonIdentityModel, identityModelToUpdate: user.identityModel) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Then */ + XCTAssertFalse(mocks.client.hasExecutedRequestOfType(OSRequestIdentifyUser.self)) + XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestCreateUser.self)) + } + + /// A promotion whose user a later `login` has already replaced has no login left to carry over. + func testInSessionIdentifyUserForAReplacedUserIsDroppedWhenIdentityVerificationIsRequired() { + /* Setup */ + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + let mocks = Mocks() + MockUserRequests.setDefaultIdentifyUserResponses(with: mocks.client, externalId: userA_EUID, conflicted: false) + + let anonIdentityModel = OSIdentityModel(aliases: [OS_ONESIGNAL_ID: userA_OSID], changeNotifier: OSEventProducer()) + let replacedIdentityModel = OSIdentityModel(aliases: [OS_EXTERNAL_ID: userA_EUID], changeNotifier: OSEventProducer()) + _ = OneSignalUserMocks.setUserManagerInternalUser(externalId: userB_EUID, onesignalId: nil) + + /* When */ + mocks.userExecutor.identifyUser(externalId: userA_EUID, identityModelToIdentify: anonIdentityModel, identityModelToUpdate: replacedIdentityModel) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Then */ + XCTAssertFalse(mocks.client.hasExecutedRequestOfType(OSRequestIdentifyUser.self)) + XCTAssertFalse(mocks.client.hasExecutedRequestOfType(OSRequestCreateUser.self)) + } + + private func cacheUserRequests(_ requests: [OSUserRequest]) { + OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_USER_EXECUTOR_USER_REQUEST_QUEUE_KEY, withValue: requests) + } + + private func makeIdentifyUserRequest() -> OSRequestIdentifyUser { + return OSRequestIdentifyUser( + aliasLabel: OS_EXTERNAL_ID, + aliasId: userA_EUID, + identityModelToIdentify: OSIdentityModel(aliases: [OS_ONESIGNAL_ID: userA_OSID], changeNotifier: OSEventProducer()), + identityModelToUpdate: OSIdentityModel(aliases: [OS_EXTERNAL_ID: userA_EUID], changeNotifier: OSEventProducer()) + ) + } + + private func makeAnonymousCreateUserRequest() -> OSRequestCreateUser { + let pushModel = OSSubscriptionModel(type: .push, address: nil, subscriptionId: nil, reachable: false, isDisabled: false, changeNotifier: OSEventProducer()) + return OSRequestCreateUser( + identityModel: OSIdentityModel(aliases: [OS_ONESIGNAL_ID: userA_OSID], changeNotifier: OSEventProducer()), + propertiesModel: OSPropertiesModel(changeNotifier: OSEventProducer()), + pushSubscriptionModel: pushModel, + originalPushToken: nil + ) + } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OSRequestAuthTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OSRequestAuthTests.swift new file mode 100644 index 000000000..b7574e446 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OSRequestAuthTests.swift @@ -0,0 +1,356 @@ +/* + 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 XCTest +import OneSignalCore +import OneSignalOSCore +@testable import OneSignalUser + +/// A stand-in for whichever concrete Request is being authorized; only ownership and the header matter here. +/// Named for the runtime because `OSUserRequest` requires `NSCoding` and a private class has no stable name. +@objc(OSStubUserRequest) +private class StubUserRequest: OneSignalRequest, OSUserRequest { + var sentToClient = false + let ownerExternalId: String? + let sendsUnsigned: Bool + + init(ownerExternalId: String?, sendsUnsigned: Bool = false) { + self.ownerExternalId = ownerExternalId + self.sendsUnsigned = sendsUnsigned + super.init() + } + + func prepareForExecution(newRecordsState: OSNewRecordsState, auth: OSRequestAuthorizing) -> Bool { + return true + } + + func encode(with coder: NSCoder) { + coder.encode(ownerExternalId, forKey: "ownerExternalId") + coder.encode(sendsUnsigned, forKey: "sendsUnsigned") + } + + required init?(coder: NSCoder) { + self.ownerExternalId = coder.decodeObject(forKey: "ownerExternalId") as? String + self.sendsUnsigned = coder.decodeBool(forKey: "sendsUnsigned") + super.init() + } + + var authorizationHeader: String? { + return additionalHeaders?["Authorization"] + } +} + +private class StubJwtProvider: OSUserJwtProviding { + var tokens: [String: String] = [:] + private(set) var invalidatedCalls: [(externalId: String, rejectedToken: String)] = [] + private(set) var askedFor: [String] = [] + + func validJwt(externalId: String) -> String? { + return tokens[externalId] + } + + @discardableResult + func askForToken(externalId: String) -> Bool { + askedFor.append(externalId) + return true + } + + @discardableResult + func invalidateJwt(externalId: String, rejectedToken: String) -> Bool { + invalidatedCalls.append((externalId, rejectedToken)) + tokens.removeValue(forKey: externalId) + return true + } +} + +final class OSRequestAuthTests: XCTestCase { + private var jwtConfig: OSUserJwtConfig! + private var jwt: StubJwtProvider! + + override func setUp() { + super.setUp() + OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_USE_IDENTITY_VERIFICATION) + jwtConfig = OSUserJwtConfig() + jwt = StubJwtProvider() + } + + override func tearDown() { + OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_USE_IDENTITY_VERIFICATION) + super.tearDown() + } + + private func makeAuth(requiresUserAuth: Bool) -> OSRequestAuth { + jwtConfig.hydrate(requiresUserAuth: requiresUserAuth) + return makeAuth(enabledKeys: []) + } + + /// For the cases that turn on the rollout flag, or leave the requirement unhydrated, or both. + private func makeAuth(enabledKeys: Set) -> OSRequestAuth { + let service = OSIdentityVerificationService(featureManager: OSFeatureManager(enabledKeys: enabledKeys), jwtConfig: jwtConfig) + return OSRequestAuth(identityVerificationService: service, jwt: jwt) + } + + // MARK: - authorizeUserScoped + + func testUserScopedKeepsTheLegacyAliasAndSendsNoHeaderWhileIdentityVerificationIsOff() { + let auth = makeAuth(requiresUserAuth: false) + jwt.tokens["user-a"] = "token-a" + let request = StubUserRequest(ownerExternalId: "user-a") + + let alias = auth.authorizeUserScoped(request, legacyAlias: OSAliasPair(OS_ONESIGNAL_ID, "osid-a")) + + XCTAssertEqual(alias?.label, OS_ONESIGNAL_ID) + XCTAssertEqual(alias?.id, "osid-a") + XCTAssertNil(request.authorizationHeader) + } + + func testUserScopedSwapsToExternalIdAndSignsWhileIdentityVerificationIsOn() { + let auth = makeAuth(requiresUserAuth: true) + jwt.tokens["user-a"] = "token-a" + let request = StubUserRequest(ownerExternalId: "user-a") + + let alias = auth.authorizeUserScoped(request, legacyAlias: OSAliasPair(OS_ONESIGNAL_ID, "osid-a")) + + XCTAssertEqual(alias?.label, OS_EXTERNAL_ID) + XCTAssertEqual(alias?.id, "user-a") + XCTAssertEqual(request.authorizationHeader, "Bearer token-a") + } + + /// nil is the park signal: the caller leaves the Request queued rather than sending it unsigned. + func testUserScopedReturnsNilWhenTheOwnerHasNoToken() { + let auth = makeAuth(requiresUserAuth: true) + let request = StubUserRequest(ownerExternalId: "user-a") + + XCTAssertNil(auth.authorizeUserScoped(request, legacyAlias: OSAliasPair(OS_ONESIGNAL_ID, "osid-a"))) + XCTAssertNil(request.authorizationHeader) + // Nothing else prompts the app for a token the SDK never held, so parking has to. + XCTAssertEqual(jwt.askedFor, ["user-a"]) + } + + /// Addressing it by `onesignal_id` would send a user-scoped path unsigned, so it waits for the purge. + func testUserScopedRefusesAnUnownedRequest() { + let auth = makeAuth(requiresUserAuth: true) + let request = StubUserRequest(ownerExternalId: nil) + + XCTAssertNil(auth.authorizeUserScoped(request, legacyAlias: OSAliasPair(OS_ONESIGNAL_ID, "osid-a"))) + XCTAssertNil(request.authorizationHeader) + // There is no owner to ask on behalf of, so the app must not be prompted. + XCTAssertTrue(jwt.askedFor.isEmpty) + } + + func testUserScopedKeepsAddressingAnUnownedRequestWhileIdentityVerificationIsOff() { + let auth = makeAuth(requiresUserAuth: false) + let request = StubUserRequest(ownerExternalId: nil) + + let alias = auth.authorizeUserScoped(request, legacyAlias: OSAliasPair(OS_ONESIGNAL_ID, "osid-a")) + + XCTAssertEqual(alias?.label, OS_ONESIGNAL_ID) + XCTAssertEqual(alias?.id, "osid-a") + } + + /// Fetch User can be built to read an alias other than `onesignal_id`, and that survives the gate being off. + func testUserScopedPreservesACallerSuppliedLegacyAlias() { + let auth = makeAuth(requiresUserAuth: false) + let request = StubUserRequest(ownerExternalId: "user-a") + + let alias = auth.authorizeUserScoped(request, legacyAlias: OSAliasPair(OS_EXTERNAL_ID, "user-a")) + + XCTAssertEqual(alias?.label, OS_EXTERNAL_ID) + XCTAssertEqual(alias?.id, "user-a") + } + + // MARK: - authorize + + func testAuthorizeSendsNoHeaderWhileIdentityVerificationIsOff() { + let auth = makeAuth(requiresUserAuth: false) + jwt.tokens["user-a"] = "token-a" + let request = StubUserRequest(ownerExternalId: "user-a") + + XCTAssertTrue(auth.authorize(request)) + XCTAssertNil(request.authorizationHeader) + } + + func testAuthorizeSignsAnOwnedRequestWhileIdentityVerificationIsOn() { + let auth = makeAuth(requiresUserAuth: true) + jwt.tokens["user-a"] = "token-a" + let request = StubUserRequest(ownerExternalId: "user-a") + + XCTAssertTrue(auth.authorize(request)) + XCTAssertEqual(request.authorizationHeader, "Bearer token-a") + } + + func testAuthorizeParksAnOwnedRequestWithNoToken() { + let auth = makeAuth(requiresUserAuth: true) + let request = StubUserRequest(ownerExternalId: "user-a") + + XCTAssertFalse(auth.authorize(request)) + XCTAssertEqual(jwt.askedFor, ["user-a"]) + } + + /// A signed Request must not re-ask: the app has already answered for this user. + func testAuthorizeDoesNotAskWhenTheOwnerHasAToken() { + let auth = makeAuth(requiresUserAuth: true) + jwt.tokens["user-a"] = "token-a" + let request = StubUserRequest(ownerExternalId: "user-a") + + XCTAssertTrue(auth.authorize(request)) + XCTAssertTrue(jwt.askedFor.isEmpty) + } + + /// The push subscription update has no owner and must keep flowing under Identity Verification. + func testAuthorizeSendsAnUnownedRequestUnsignedWhenItIsExempt() { + let auth = makeAuth(requiresUserAuth: true) + let request = StubUserRequest(ownerExternalId: nil, sendsUnsigned: true) + + XCTAssertTrue(auth.authorize(request)) + XCTAssertNil(request.authorizationHeader) + } + + /// Everything else with no owner is a leftover the purge has yet to clear, and must not go out unsigned. + func testAuthorizeRefusesAnUnownedRequestThatIsNotExempt() { + let auth = makeAuth(requiresUserAuth: true) + let request = StubUserRequest(ownerExternalId: nil) + + XCTAssertFalse(auth.authorize(request)) + XCTAssertTrue(jwt.askedFor.isEmpty) + } + + func testAuthorizeSendsAnUnownedRequestWhileIdentityVerificationIsOff() { + let auth = makeAuth(requiresUserAuth: false) + let request = StubUserRequest(ownerExternalId: nil) + + XCTAssertTrue(auth.authorize(request)) + XCTAssertNil(request.authorizationHeader) + } + + // MARK: - handleUnauthorized + + func testHandleUnauthorizedInvalidatesTheSignedTokenAndRequeuesTheRequest() { + let auth = makeAuth(requiresUserAuth: true) + jwt.tokens["user-a"] = "token-a" + let request = StubUserRequest(ownerExternalId: "user-a") + _ = auth.authorize(request) + request.sentToClient = true + + XCTAssertTrue(auth.handleUnauthorized(request)) + + // The token that went out is the one invalidated, and the Request is left ready to re-sign. + XCTAssertEqual(jwt.invalidatedCalls.count, 1) + XCTAssertEqual(jwt.invalidatedCalls.first?.externalId, "user-a") + XCTAssertEqual(jwt.invalidatedCalls.first?.rejectedToken, "token-a") + XCTAssertNil(request.authorizationHeader) + XCTAssertFalse(request.sentToClient) + } + + /// The header carries the token, so a Request sent before the app supplied one has nothing to reject. + func testHandleUnauthorizedDeclinesAnUnsignedRequest() { + let auth = makeAuth(requiresUserAuth: true) + let request = StubUserRequest(ownerExternalId: "user-a") + request.sentToClient = true + + XCTAssertFalse(auth.handleUnauthorized(request)) + XCTAssertTrue(jwt.invalidatedCalls.isEmpty) + XCTAssertTrue(request.sentToClient) + } + + /// With the gate off a 401 stays on the executor's existing non-retryable path. + func testHandleUnauthorizedDeclinesWhileIdentityVerificationIsOff() { + let auth = makeAuth(requiresUserAuth: false) + let request = StubUserRequest(ownerExternalId: "user-a") + request.additionalHeaders = ["Authorization": "Bearer token-a"] + request.sentToClient = true + + XCTAssertFalse(auth.handleUnauthorized(request)) + XCTAssertTrue(jwt.invalidatedCalls.isEmpty) + XCTAssertTrue(request.sentToClient) + } + + // MARK: - authorization, for callers outside the Request queues + + /// The in-app message fetch addressed the subscription on its own before Identity Verification. + func testAuthorizationCarriesNoUserWhileTheNewCodePathsAreOff() { + let auth = makeAuth(requiresUserAuth: false) + jwt.tokens["user-a"] = "token-a" + + let authorization = auth.authorization(onesignalId: "osid-a", externalId: "user-a") + + XCTAssertNotNil(authorization) + XCTAssertNil(authorization?.alias) + XCTAssertEqual(authorization?.headers, [:]) + XCTAssertNil(authorization?.token) + } + + func testAuthorizationAddressesTheOnesignalIdWhileIdentityVerificationIsOff() { + jwtConfig.hydrate(requiresUserAuth: false) + let auth = makeAuth(enabledKeys: [OSFeatureFlag.identityVerification.rawValue]) + jwt.tokens["user-a"] = "token-a" + + let authorization = auth.authorization(onesignalId: "osid-a", externalId: "user-a") + + XCTAssertEqual(authorization?.alias?.label, OS_ONESIGNAL_ID) + XCTAssertEqual(authorization?.alias?.id, "osid-a") + XCTAssertEqual(authorization?.headers, [:]) + XCTAssertNil(authorization?.token) + } + + func testAuthorizationAddressesTheExternalIdAndSignsWhileIdentityVerificationIsOn() { + let auth = makeAuth(requiresUserAuth: true) + jwt.tokens["user-a"] = "token-a" + + let authorization = auth.authorization(onesignalId: "osid-a", externalId: "user-a") + + XCTAssertEqual(authorization?.alias?.label, OS_EXTERNAL_ID) + XCTAssertEqual(authorization?.alias?.id, "user-a") + XCTAssertEqual(authorization?.headers, ["Authorization": "Bearer token-a"]) + XCTAssertEqual(authorization?.token, "token-a") + } + + /// nil is the defer signal: an unsigned call would be rejected if the app turns out to require auth. + /// Holds even when the rollout flag is off — otherwise production would send legacy unsigned before + /// the first params answer. + func testAuthorizationDefersWhileTheRequirementIsUnknown() { + let auth = makeAuth(enabledKeys: []) + + XCTAssertNil(auth.authorization(onesignalId: "osid-a", externalId: "user-a")) + XCTAssertTrue(jwt.askedFor.isEmpty) + } + + /// Under Identity Verification the server has nothing to serve a device with no identified user. + func testAuthorizationDefersWhileNobodyIsLoggedIn() { + let auth = makeAuth(requiresUserAuth: true) + + XCTAssertNil(auth.authorization(onesignalId: "osid-a", externalId: nil)) + XCTAssertTrue(jwt.askedFor.isEmpty) + } + + func testAuthorizationDefersAndAsksWhenTheUserHasNoToken() { + let auth = makeAuth(requiresUserAuth: true) + + XCTAssertNil(auth.authorization(onesignalId: "osid-a", externalId: "user-a")) + XCTAssertEqual(jwt.askedFor, ["user-a"]) + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift index bfe416b46..1b87cdf31 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift @@ -118,7 +118,7 @@ final class OneSignalUserTests: XCTestCase { OneSignalCoreImpl.setSharedClient(client) // Increase flush interval to allow all the updates to batch - OSOperationRepo.sharedInstance.pollIntervalMilliseconds = 300 + OneSignalUserManagerImpl.sharedInstance.operationRepo.pollIntervalMilliseconds = 300 // Wait to let any pending flushes in the Operation Repo to run OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.1) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/RequestPathEncodingTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/RequestPathEncodingTests.swift new file mode 100644 index 000000000..8ddc3f750 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/RequestPathEncodingTests.swift @@ -0,0 +1,125 @@ +/* + 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 XCTest +import OneSignalCore +import OneSignalOSCore +import OneSignalCoreMocks +import OneSignalOSCoreMocks +import OneSignalUserMocks +@testable import OneSignalUser + +/// Values the app chooses reach a URL path once Identity Verification addresses users by `external_id`, +/// so a path built from one has to survive characters that would otherwise change which endpoint it names. +final class RequestPathEncodingTests: XCTestCase { + private let appId = "test-app-id" + private let onesignalId = "test-onesignal-id" + private let externalId = "us er/a?b#c%d" + private let encodedExternalId = "us%20er%2Fa%3Fb%23c%25d" + + private var newRecordsState = MockNewRecordsState() + + override func setUpWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + OneSignalUserMocks.reset() + OneSignalIdentifiers.currentAppId = appId + newRecordsState = MockNewRecordsState() + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + } + + override func tearDownWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + } + + private var auth: OSRequestAuthorizing { + return OneSignalUserManagerImpl.sharedInstance.requestAuth + } + + /// A user the auth layer can address by `external_id` and sign for. + @discardableResult + private func addIdentifiedUser() -> OSIdentityModel { + let model = OSIdentityModel( + aliases: [OS_ONESIGNAL_ID: onesignalId, OS_EXTERNAL_ID: externalId], + changeNotifier: OSEventProducer() + ) + model.jwtBearerToken = "token-a" + OneSignalUserManagerImpl.sharedInstance.addIdentityModelToRepo(model) + return model + } + + func testFetchUserPercentEncodesTheExternalId() { + let request = OSRequestFetchUser( + identityModel: addIdentifiedUser(), + aliasLabel: OS_ONESIGNAL_ID, + aliasId: onesignalId, + onNewSession: false + ) + + XCTAssertTrue(request.prepareForExecution(newRecordsState: newRecordsState, auth: auth)) + XCTAssertEqual(request.path, "apps/\(appId)/users/by/\(OS_EXTERNAL_ID)/\(encodedExternalId)") + } + + func testUpdatePropertiesPercentEncodesTheExternalId() { + let request = OSRequestUpdateProperties( + params: ["properties": ["language": "en"]], + identityModel: addIdentifiedUser(), + ownerExternalId: externalId + ) + + XCTAssertTrue(request.prepareForExecution(newRecordsState: newRecordsState, auth: auth)) + XCTAssertEqual(request.path, "apps/\(appId)/users/by/\(OS_EXTERNAL_ID)/\(encodedExternalId)") + } + + /// The label the app asks to remove is the other app-chosen value in a path. + func testRemoveAliasPercentEncodesBothTheExternalIdAndTheLabel() { + let request = OSRequestRemoveAlias( + labelToRemove: "my label/x", + identityModel: addIdentifiedUser(), + ownerExternalId: externalId + ) + + XCTAssertTrue(request.prepareForExecution(newRecordsState: newRecordsState, auth: auth)) + XCTAssertEqual( + request.path, + "apps/\(appId)/users/by/\(OS_EXTERNAL_ID)/\(encodedExternalId)/identity/my%20label%2Fx" + ) + } + + /// Server-assigned ids need no escaping, so the path an app without Identity Verification sends is byte + /// for byte what it was. + func testTheOnesignalIdPathIsUnchangedWhileIdentityVerificationIsOff() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: false) + let request = OSRequestUpdateProperties( + params: ["properties": ["language": "en"]], + identityModel: addIdentifiedUser(), + ownerExternalId: externalId + ) + + XCTAssertTrue(request.prepareForExecution(newRecordsState: newRecordsState, auth: auth)) + XCTAssertEqual(request.path, "apps/\(appId)/users/by/\(OS_ONESIGNAL_ID)/\(onesignalId)") + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/SwitchUserIntegrationTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/SwitchUserIntegrationTests.swift index 2c598b6bb..88c14d1b8 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/SwitchUserIntegrationTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/SwitchUserIntegrationTests.swift @@ -319,7 +319,7 @@ final class SwitchUserIntegrationTests: XCTestCase { OneSignalCoreImpl.setSharedClient(client) // Increase flush interval to allow all the updates to batch - OSOperationRepo.sharedInstance.pollIntervalMilliseconds = 300 + OneSignalUserManagerImpl.sharedInstance.operationRepo.pollIntervalMilliseconds = 300 // Wait to let any pending flushes in the Operation Repo to run OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.3) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserConcurrencyTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserConcurrencyTests.swift index ac636e32e..81af4ed71 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserConcurrencyTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserConcurrencyTests.swift @@ -68,7 +68,7 @@ final class UserConcurrencyTests: XCTestCase { for _ in 1...4 { DispatchQueue.global().async { print("🧪 flushDeltaQueue on thread \(Thread.current)") - OSOperationRepo.sharedInstance.addFlushDeltaQueueToDispatchQueue() + OneSignalUserManagerImpl.sharedInstance.operationRepo.addFlushDeltaQueueToDispatchQueue() } } @@ -92,22 +92,22 @@ final class UserConcurrencyTests: XCTestCase { ) OneSignalCoreImpl.setSharedClient(client) - let executor = OSSubscriptionOperationExecutor(newRecordsState: OSNewRecordsState()) - OSOperationRepo.sharedInstance.addExecutor(executor) + let executor = OSSubscriptionOperationExecutor(newRecordsState: OSNewRecordsState(), auth: OneSignalUserManagerImpl.sharedInstance.requestAuth) + OneSignalUserManagerImpl.sharedInstance.operationRepo.addExecutor(executor) /* When */ DispatchQueue.concurrentPerform(iterations: 50) { _ in // 1. Enqueue Remove Subscription Deltas to the Operation Repo - OSOperationRepo.sharedInstance.enqueueDelta(OSDelta(name: OS_REMOVE_SUBSCRIPTION_DELTA, identityModelId: UUID().uuidString, externalId: nil, model: OSSubscriptionModel(type: .email, address: nil, subscriptionId: UUID().uuidString, reachable: true, isDisabled: false, changeNotifier: OSEventProducer()), property: "email", value: "email")) - OSOperationRepo.sharedInstance.enqueueDelta(OSDelta(name: OS_REMOVE_SUBSCRIPTION_DELTA, identityModelId: UUID().uuidString, externalId: nil, model: OSSubscriptionModel(type: .email, address: nil, subscriptionId: UUID().uuidString, reachable: true, isDisabled: false, changeNotifier: OSEventProducer()), property: "email", value: "email")) + OneSignalUserManagerImpl.sharedInstance.operationRepo.enqueueDelta(OSDelta(name: OS_REMOVE_SUBSCRIPTION_DELTA, identityModelId: UUID().uuidString, externalId: nil, model: OSSubscriptionModel(type: .email, address: nil, subscriptionId: UUID().uuidString, reachable: true, isDisabled: false, changeNotifier: OSEventProducer()), property: "email", value: "email")) + OneSignalUserManagerImpl.sharedInstance.operationRepo.enqueueDelta(OSDelta(name: OS_REMOVE_SUBSCRIPTION_DELTA, identityModelId: UUID().uuidString, externalId: nil, model: OSSubscriptionModel(type: .email, address: nil, subscriptionId: UUID().uuidString, reachable: true, isDisabled: false, changeNotifier: OSEventProducer()), property: "email", value: "email")) // 2. Flush Operation Repo - OSOperationRepo.sharedInstance.addFlushDeltaQueueToDispatchQueue() + OneSignalUserManagerImpl.sharedInstance.operationRepo.addFlushDeltaQueueToDispatchQueue() // 3. Simulate updating the executor's request queue from a network response - executor.executeDeleteSubscriptionRequest(OSRequestDeleteSubscription(subscriptionModel: OSSubscriptionModel(type: .email, address: nil, subscriptionId: UUID().uuidString, reachable: true, isDisabled: false, changeNotifier: OSEventProducer())), inBackground: false) - executor.executeDeleteSubscriptionRequest(OSRequestDeleteSubscription(subscriptionModel: OSSubscriptionModel(type: .email, address: nil, subscriptionId: UUID().uuidString, reachable: true, isDisabled: false, changeNotifier: OSEventProducer())), inBackground: false) + executor.executeDeleteSubscriptionRequest(OSRequestDeleteSubscription(subscriptionModel: OSSubscriptionModel(type: .email, address: nil, subscriptionId: UUID().uuidString, reachable: true, isDisabled: false, changeNotifier: OSEventProducer()), ownerExternalId: nil), inBackground: false) + executor.executeDeleteSubscriptionRequest(OSRequestDeleteSubscription(subscriptionModel: OSSubscriptionModel(type: .email, address: nil, subscriptionId: UUID().uuidString, reachable: true, isDisabled: false, changeNotifier: OSEventProducer()), ownerExternalId: nil), inBackground: false) } // 4. Run background threads @@ -131,22 +131,22 @@ final class UserConcurrencyTests: XCTestCase { OneSignalCoreImpl.setSharedClient(client) MockUserRequests.setAddAliasesResponse(with: client, aliases: aliases) - let executor = OSIdentityOperationExecutor(newRecordsState: OSNewRecordsState()) - OSOperationRepo.sharedInstance.addExecutor(executor) + let executor = OSIdentityOperationExecutor(newRecordsState: OSNewRecordsState(), auth: OneSignalUserManagerImpl.sharedInstance.requestAuth) + OneSignalUserManagerImpl.sharedInstance.operationRepo.addExecutor(executor) /* When */ DispatchQueue.concurrentPerform(iterations: 50) { _ in // 1. Enqueue Add Alias Deltas to the Operation Repo - OSOperationRepo.sharedInstance.enqueueDelta(OSDelta(name: OS_ADD_ALIAS_DELTA, identityModelId: UUID().uuidString, externalId: nil, model: OSIdentityModel(aliases: [OS_ONESIGNAL_ID: UUID().uuidString], changeNotifier: OSEventProducer()), property: "aliases", value: aliases)) - OSOperationRepo.sharedInstance.enqueueDelta(OSDelta(name: OS_ADD_ALIAS_DELTA, identityModelId: UUID().uuidString, externalId: nil, model: OSIdentityModel(aliases: [OS_ONESIGNAL_ID: UUID().uuidString], changeNotifier: OSEventProducer()), property: "aliases", value: aliases)) + OneSignalUserManagerImpl.sharedInstance.operationRepo.enqueueDelta(OSDelta(name: OS_ADD_ALIAS_DELTA, identityModelId: UUID().uuidString, externalId: nil, model: OSIdentityModel(aliases: [OS_ONESIGNAL_ID: UUID().uuidString], changeNotifier: OSEventProducer()), property: "aliases", value: aliases)) + OneSignalUserManagerImpl.sharedInstance.operationRepo.enqueueDelta(OSDelta(name: OS_ADD_ALIAS_DELTA, identityModelId: UUID().uuidString, externalId: nil, model: OSIdentityModel(aliases: [OS_ONESIGNAL_ID: UUID().uuidString], changeNotifier: OSEventProducer()), property: "aliases", value: aliases)) // 2. Flush Operation Repo - OSOperationRepo.sharedInstance.addFlushDeltaQueueToDispatchQueue() + OneSignalUserManagerImpl.sharedInstance.operationRepo.addFlushDeltaQueueToDispatchQueue() // 3. Simulate updating the executor's request queue from a network response - executor.executeAddAliasesRequest(OSRequestAddAliases(aliases: aliases, identityModel: OSIdentityModel(aliases: [OS_ONESIGNAL_ID: UUID().uuidString], changeNotifier: OSEventProducer())), inBackground: false) - executor.executeAddAliasesRequest(OSRequestAddAliases(aliases: aliases, identityModel: OSIdentityModel(aliases: [OS_ONESIGNAL_ID: UUID().uuidString], changeNotifier: OSEventProducer())), inBackground: false) + executor.executeAddAliasesRequest(OSRequestAddAliases(aliases: aliases, identityModel: OSIdentityModel(aliases: [OS_ONESIGNAL_ID: UUID().uuidString], changeNotifier: OSEventProducer()), ownerExternalId: nil), inBackground: false) + executor.executeAddAliasesRequest(OSRequestAddAliases(aliases: aliases, identityModel: OSIdentityModel(aliases: [OS_ONESIGNAL_ID: UUID().uuidString], changeNotifier: OSEventProducer()), ownerExternalId: nil), inBackground: false) } // 4. Run background threads @@ -172,21 +172,21 @@ final class UserConcurrencyTests: XCTestCase { let identityModel = OSIdentityModel(aliases: [OS_ONESIGNAL_ID: UUID().uuidString], changeNotifier: OSEventProducer()) OneSignalUserManagerImpl.sharedInstance.addIdentityModelToRepo(identityModel) - let executor = OSPropertyOperationExecutor(newRecordsState: OSNewRecordsState()) - OSOperationRepo.sharedInstance.addExecutor(executor) + let executor = OSPropertyOperationExecutor(newRecordsState: OSNewRecordsState(), auth: OneSignalUserManagerImpl.sharedInstance.requestAuth) + OneSignalUserManagerImpl.sharedInstance.operationRepo.addExecutor(executor) /* When */ DispatchQueue.concurrentPerform(iterations: 50) { _ in // 1. Enqueue Deltas to the Operation Repo - OSOperationRepo.sharedInstance.enqueueDelta(OSDelta(name: OS_UPDATE_PROPERTIES_DELTA, identityModelId: identityModel.modelId, externalId: identityModel.externalId, model: OSPropertiesModel(changeNotifier: OSEventProducer()), property: "language", value: UUID().uuidString)) - OSOperationRepo.sharedInstance.enqueueDelta(OSDelta(name: OS_UPDATE_PROPERTIES_DELTA, identityModelId: identityModel.modelId, externalId: identityModel.externalId, model: OSPropertiesModel(changeNotifier: OSEventProducer()), property: "language", value: UUID().uuidString)) + OneSignalUserManagerImpl.sharedInstance.operationRepo.enqueueDelta(OSDelta(name: OS_UPDATE_PROPERTIES_DELTA, identityModelId: identityModel.modelId, externalId: identityModel.externalId, model: OSPropertiesModel(changeNotifier: OSEventProducer()), property: "language", value: UUID().uuidString)) + OneSignalUserManagerImpl.sharedInstance.operationRepo.enqueueDelta(OSDelta(name: OS_UPDATE_PROPERTIES_DELTA, identityModelId: identityModel.modelId, externalId: identityModel.externalId, model: OSPropertiesModel(changeNotifier: OSEventProducer()), property: "language", value: UUID().uuidString)) // 2. Flush Operation Repo - OSOperationRepo.sharedInstance.addFlushDeltaQueueToDispatchQueue() + OneSignalUserManagerImpl.sharedInstance.operationRepo.addFlushDeltaQueueToDispatchQueue() // 3. Simulate updating the executor's request queue from a network response - executor.executeUpdatePropertiesRequest(OSRequestUpdateProperties(params: ["properties": ["language": UUID().uuidString], "refresh_device_metadata": false], identityModel: identityModel), inBackground: false) + executor.executeUpdatePropertiesRequest(OSRequestUpdateProperties(params: ["properties": ["language": UUID().uuidString], "refresh_device_metadata": false], identityModel: identityModel, ownerExternalId: identityModel.externalId), inBackground: false) } // 4. Run background threads @@ -213,7 +213,7 @@ final class UserConcurrencyTests: XCTestCase { let identityModel1 = OSIdentityModel(aliases: [OS_ONESIGNAL_ID: UUID().uuidString], changeNotifier: OSEventProducer()) let identityModel2 = OSIdentityModel(aliases: [OS_ONESIGNAL_ID: UUID().uuidString], changeNotifier: OSEventProducer()) - let userExecutor = OSUserExecutor(newRecordsState: OSNewRecordsState()) + let userExecutor = OSUserExecutor(newRecordsState: OSNewRecordsState(), identityVerificationService: OneSignalUserManagerImpl.sharedInstance.identityVerificationService, auth: OneSignalUserManagerImpl.sharedInstance.requestAuth) /* When */ diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtLifecycleTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtLifecycleTests.swift new file mode 100644 index 000000000..46e34ab01 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/UserJwtLifecycleTests.swift @@ -0,0 +1,450 @@ +/* + 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 XCTest +import OneSignalCore +import OneSignalCoreMocks +import OneSignalOSCoreMocks +import OneSignalUserMocks +@testable import OneSignalOSCore +@testable import OneSignalUser + +private class MockUserStateObserver: NSObject, OSUserStateObserver { + var states: [OSUserState] = [] + + func onUserStateDidChange(state: OSUserChangedState) { + states.append(state.current) + } +} + +/** + What `login` and `logout` do differently under Identity Verification: no anonymous user is ever sent to + the server, so login creates rather than promotes and logout has to silence the push subscription itself. + */ +final class UserJwtLifecycleTests: XCTestCase { + /// Any opted-in value; the point is that it survives unchanged, or is replaced by -2. + private let optedInNotificationTypes = 7 + + private var client = MockOneSignalClient() + private var observer = MockUserStateObserver() + + override func setUpWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + OneSignalUserMocks.reset() + OneSignalIdentifiers.currentAppId = "test-app-id" + + client = MockOneSignalClient() + MockUserRequests.setDefaultCreateAnonUserResponses(with: client) + MockUserRequests.setDefaultCreateUserResponses(with: client, externalId: userA_EUID) + MockUserRequests.setDefaultIdentifyUserResponses(with: client, externalId: userA_EUID) + OneSignalCoreImpl.setSharedClient(client) + + // Held strongly for the test's lifetime: OSObservable keeps observers weakly. + observer = MockUserStateObserver() + OneSignalUserManagerImpl.sharedInstance.addObserver(observer) + } + + override func tearDownWithError() throws { + OneSignalUserManagerImpl.sharedInstance.removeObserver(observer) + OneSignalUserManagerImpl.sharedInstance.operationRepo.paused = false + OneSignalCoreMocks.clearUserDefaults() + OSFeatureManager.shared.setEnabledFeatureKeys([]) + } + + /// The push subscription as the server would see it right now. + private func pushSubscriptionPayload() -> [String: Any] { + return OneSignalUserManagerImpl.sharedInstance.user.pushSubscriptionModel.jsonRepresentation() + } + + /// Reports a token and notification types, so a silenced payload is distinguishable from the default. + @discardableResult + private func optInPushSubscription() -> OSSubscriptionModel { + let model = OneSignalUserManagerImpl.sharedInstance.user.pushSubscriptionModel + model.address = "push-token" + model.notificationTypes = optedInNotificationTypes + return model + } + + private func queuedUserRequests() -> [OSUserRequest] { + return OneSignalUserManagerImpl.sharedInstance.userExecutor?.userRequestQueue ?? [] + } + + /// The `external_id` of every queued Create User, so a test can tell the anonymous one apart. + private func queuedCreateUserExternalIds() -> [String] { + return queuedUserRequests().compactMap { ($0 as? OSRequestCreateUser)?.identityModel.externalId } + } + + /// The header the Create User went out with, so a test can tell that it was signed. + private func executedCreateUserAuthorization() -> String? { + return client.executedRequests.first { $0 is OSRequestCreateUser }?.additionalHeaders?["Authorization"] + } + + /// The Delta `logout()` produces by silencing the push subscription. + private func silencingDelta() -> OSDelta? { + return OneSignalUserManagerImpl.sharedInstance.operationRepo.deltaQueue.first { + $0.name == OS_UPDATE_SUBSCRIPTION_DELTA && $0.property == "isDisabledInternally" + } + } + + /// `start()` re-reads the cached requirement, so the setup default has to be cleared too. + private func makeRequirementUnknown() { + OneSignalUserDefaults.initShared().removeValue(forKey: OSUD_USE_IDENTITY_VERIFICATION) + OSCoreMocks.resetSharedJwtConfig() + } + + // MARK: - login + + func testLoginFromAnonymousPromotesTheUserWhileIdentityVerificationIsOff() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: false) + _ = OneSignalUserManagerImpl.sharedInstance.user // anonymous user first + + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: nil) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestIdentifyUser.self)) + } + + /// Identify User adds an `external_id` to an anonymous user, which Identity Verification does not allow. + func testLoginFromAnonymousCreatesANewUserWhileIdentityVerificationIsRequired() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + _ = OneSignalUserManagerImpl.sharedInstance.user + + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: "token-a") + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + XCTAssertFalse(client.hasExecutedRequestOfType(OSRequestIdentifyUser.self)) + XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestCreateUser.self)) + } + + /// Promoting before remote params answer is safe because nothing is sent while the requirement is + /// unknown, so the queued promotion can still be reshaped into the Create User auth requires. + func testLoginWhileTheRequirementIsUnknownBecomesACreateUserOnceAuthIsRequired() { + OSFeatureManager.shared.setEnabledFeatureKeys([OSFeatureFlag.identityVerification.rawValue]) + makeRequirementUnknown() + _ = OneSignalUserManagerImpl.sharedInstance.user + + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: "token-a") + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + XCTAssertTrue(queuedUserRequests().contains { $0 is OSRequestIdentifyUser }) + XCTAssertFalse(client.hasExecutedRequestOfType(OSRequestIdentifyUser.self)) + XCTAssertFalse(client.hasExecutedRequestOfType(OSRequestCreateUser.self)) + + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + // The login reaches the server as the Create User it should have been, signed with its own token, + // and the anonymous user it replaced is never created. + XCTAssertFalse(client.hasExecutedRequestOfType(OSRequestIdentifyUser.self)) + XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestCreateUser.self, expectedCount: 1)) + XCTAssertEqual(executedCreateUserAuthorization(), "Bearer token-a") + } + + /// The same promotion when remote params answer the other way is simply sent. + func testLoginWhileTheRequirementIsUnknownIsSentOnceAuthIsKnownToBeOff() { + OSFeatureManager.shared.setEnabledFeatureKeys([OSFeatureFlag.identityVerification.rawValue]) + makeRequirementUnknown() + _ = OneSignalUserManagerImpl.sharedInstance.user + + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: nil) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestIdentifyUser.self)) + } + + /// With the rollout flag off, an unknown requirement has to behave exactly as it did before Identity + /// Verification existed. + func testLoginFromAnonymousPromotesTheUserWhileTheRequirementIsUnknownAndTheFlagIsOff() { + makeRequirementUnknown() + _ = OneSignalUserManagerImpl.sharedInstance.user + + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: nil) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + XCTAssertTrue(queuedUserRequests().contains { $0 is OSRequestIdentifyUser }) + XCTAssertFalse(queuedCreateUserExternalIds().contains(userA_EUID)) + } + + /// Re-logging in is how an app hands over a replacement token. + func testLoggingInAgainAsTheSameUserStoresTheNewToken() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: "token-a") + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: "token-b") + + XCTAssertEqual(OneSignalUserManagerImpl.sharedInstance.userJwtRepo.validJwt(externalId: userA_EUID), "token-b") + } + + /// A token supplied by `login` answers the ask the same way `updateUserJwt` does, so a later rejection + /// can ask again. An ask left standing would silence the app for the rest of the session. + func testLoggingInWithATokenAnswersAPendingAskForThatUser() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + let jwtRepo = OneSignalUserManagerImpl.sharedInstance.userJwtRepo + + // The Create User parks for want of a token, which asks the app once. + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: nil) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + XCTAssertFalse(jwtRepo.askForToken(externalId: userA_EUID)) + + // Log back in as the same user, which builds a new Identity Model rather than reusing the parked one. + OneSignalUserManagerImpl.sharedInstance.logout() + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: "token-a") + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + XCTAssertEqual(jwtRepo.validJwt(externalId: userA_EUID), "token-a") + // `true` means this rejection reached the app, which only happens once the earlier ask was answered. + XCTAssertTrue(jwtRepo.invalidateJwt(externalId: userA_EUID, rejectedToken: "token-a")) + } + + // MARK: - a rejected token on the first Create User + + /** + A 401 on Create User is the likeliest one under Identity Verification, and the token that answers it + arrives through `updateUserJwt`, which resumes work by flushing. A paused Repo drops that flush, so the + app would supply a good token and see nothing happen until the next session. + */ + func testARejectedCreateUserLeavesTheRepoAbleToFlushTheReplacementToken() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + client.setMockFailureResponseForRequest( + request: "", + error: OneSignalClientError(code: 401, message: "unauthorized", responseHeaders: nil, response: nil, underlyingError: nil) + ) + + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: "token-a") + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + XCTAssertFalse(OneSignalUserManagerImpl.sharedInstance.operationRepo.paused) + // Parked, not dropped, and the rejected token is gone so the retry cannot reuse it. + XCTAssertTrue(queuedCreateUserExternalIds().contains(userA_EUID)) + XCTAssertNil(OneSignalUserManagerImpl.sharedInstance.userJwtRepo.validJwt(externalId: userA_EUID)) + } + + /// Nothing else will send the held Create User: it is not a Delta, so the Repo flush does not reach it, + /// and the hold leaves no attempt in flight whose response would drive the queue on. + func testUpdateUserJwtSendsTheCreateUserThatARejectedTokenHeld() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + client.setMockFailureResponseForRequest( + request: "", + error: OneSignalClientError(code: 401, message: "unauthorized", responseHeaders: nil, response: nil, underlyingError: nil) + ) + + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: "token-a") + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestCreateUser.self, expectedCount: 1)) + + // The token the app mints in answer to the invalidated event, which the server accepts. + MockUserRequests.setDefaultCreateUserResponses(with: client, externalId: userA_EUID) + OneSignalUserManagerImpl.sharedInstance.updateUserJwt(externalId: userA_EUID, token: "token-b") + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + // The same Request re-signed and accepted, so it carries the replacement token and leaves the queue. + XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestCreateUser.self, expectedCount: 2)) + XCTAssertEqual(executedCreateUserAuthorization(), "Bearer token-b") + XCTAssertFalse(queuedCreateUserExternalIds().contains(userA_EUID)) + } + + /// A failure the token cannot fix still stops the queue, since the user will never exist this session. + func testACreateUserThatFailsForAnotherReasonStillPausesTheRepo() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + client.setMockFailureResponseForRequest( + request: "", + error: OneSignalClientError(code: 400, message: "bad-request", responseHeaders: nil, response: nil, underlyingError: nil) + ) + + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: "token-a") + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + XCTAssertTrue(OneSignalUserManagerImpl.sharedInstance.operationRepo.paused) + } + + // MARK: - logout + + func testLogoutUnderIdentityVerificationSilencesThePushSubscriptionAndReportsNoUser() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: "token-a") + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + optInPushSubscription() + observer.states.removeAll() + + OneSignalUserManagerImpl.sharedInstance.logout() + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + let payload = pushSubscriptionPayload() + XCTAssertEqual(payload["enabled"] as? Bool, false) + XCTAssertEqual(payload["notification_types"] as? Int, -2) + // The replacement anonymous user is never created on the server, so nothing else would report it. + XCTAssertEqual(observer.states.count, 1) + XCTAssertNil(observer.states.first?.onesignalId) + XCTAssertNil(observer.states.first?.externalId) + } + + /// The silencing has to be stamped with the user being logged out so the unsubscribe is attributed to + /// them rather than the anonymous replacement. + func testLogoutStampsTheSilencedPushSubscriptionWithTheOutgoingUser() throws { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: "token-a") + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + optInPushSubscription() + // Deltas have to stay in the repo queue long enough to be inspected. + OneSignalUserManagerImpl.sharedInstance.operationRepo.paused = true + + OneSignalUserManagerImpl.sharedInstance.logout() + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + XCTAssertEqual(try XCTUnwrap(silencingDelta()).externalId, userA_EUID) + } + + /// Only the reported payload changes, so a later `login` can restore what the app asked for. + func testLogoutUnderIdentityVerificationLeavesTheAppsOptInAlone() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: "token-a") + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + let pushSubscription = optInPushSubscription() + + OneSignalUserManagerImpl.sharedInstance.logout() + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + XCTAssertFalse(pushSubscription._isDisabled) + XCTAssertEqual(pushSubscription.notificationTypes, optedInNotificationTypes) + XCTAssertTrue(pushSubscription.optedIn) + } + + func testLogoutWhileIdentityVerificationIsOffLeavesThePushSubscriptionReporting() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: false) + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: nil) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + optInPushSubscription() + + OneSignalUserManagerImpl.sharedInstance.logout() + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + XCTAssertEqual(pushSubscriptionPayload()["notification_types"] as? Int, optedInNotificationTypes) + } + + /// While the requirement is unknown, silence: the false positive is undone by hydrate-to-off, and the + /// other guess would keep delivering the logged-out user's pushes. + func testLogoutWhileIdentityVerificationIsUnknownSilencesThePushSubscription() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: false) + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: nil) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + optInPushSubscription() + observer.states.removeAll() + makeRequirementUnknown() + + OneSignalUserManagerImpl.sharedInstance.logout() + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + let payload = pushSubscriptionPayload() + XCTAssertEqual(payload["enabled"] as? Bool, false) + XCTAssertEqual(payload["notification_types"] as? Int, -2) + XCTAssertEqual(observer.states.count, 1) + XCTAssertNil(observer.states.first?.onesignalId) + XCTAssertNil(observer.states.first?.externalId) + } + + /// The unknown-logout guess over-silences if the app does not require Identity Verification; hydrate + /// has to undo it the same way it undoes an on→off flip while logged out. + func testLogoutWhileIdentityVerificationIsUnknownThenOffRestoresThePushSubscription() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: false) + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: nil) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + optInPushSubscription() + makeRequirementUnknown() + OneSignalUserManagerImpl.sharedInstance.logout() + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + XCTAssertEqual(pushSubscriptionPayload()["notification_types"] as? Int, optedInNotificationTypes) + } + + /// 404 recovery replaces a user the server no longer has; the device should keep reporting through it. + func testInternalLogoutLeavesThePushSubscriptionReportingUnderIdentityVerification() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: "token-a") + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + optInPushSubscription() + + OneSignalUserManagerImpl.sharedInstance._logout() + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + XCTAssertEqual(pushSubscriptionPayload()["notification_types"] as? Int, optedInNotificationTypes) + } + + func testLoggingBackInRestoresThePushSubscription() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: "token-a") + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + optInPushSubscription() + OneSignalUserManagerImpl.sharedInstance.logout() + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: "token-a") + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + XCTAssertEqual(pushSubscriptionPayload()["notification_types"] as? Int, optedInNotificationTypes) + } + + /// A device left logged out across a restart has to stay silenced, otherwise the next device-property + /// change would re-enable the logged-out user's subscription. + func testAnInternallyDisabledPushSubscriptionSurvivesArchiving() throws { + let model = OSSubscriptionModel(type: .push, address: "push-token", subscriptionId: testPushSubId, reachable: true, isDisabled: false, changeNotifier: OSEventProducer()) + model.notificationTypes = optedInNotificationTypes + model._isDisabledInternally = true + + let data = try NSKeyedArchiver.archivedData(withRootObject: model, requiringSecureCoding: false) + let unarchiver = try NSKeyedUnarchiver(forReadingFrom: data) + unarchiver.requiresSecureCoding = false + defer { unarchiver.finishDecoding() } + let decoded = try XCTUnwrap(unarchiver.decodeObject(forKey: NSKeyedArchiveRootObjectKey) as? OSSubscriptionModel) + + XCTAssertTrue(decoded._isDisabledInternally) + XCTAssertEqual(decoded.jsonRepresentation()["notification_types"] as? Int, -2) + } + + /// `login` is otherwise the only thing that clears the internal disable, which would leave an app that + /// turns Identity Verification off while logged out — or one whose logout guessed on while the + /// requirement was still unknown — silenced until the next login. + func testTurningIdentityVerificationOffRestoresAnInternallyDisabledPushSubscription() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: "token-a") + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + optInPushSubscription() + OneSignalUserManagerImpl.sharedInstance.logout() + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + XCTAssertEqual(pushSubscriptionPayload()["notification_types"] as? Int, optedInNotificationTypes) + } +} From 3197590bb4091a6d5dc24c246653f626a200de69 Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 11 Aug 2026 17:37:56 -0700 Subject: [PATCH 12/21] fix: [PR6] hold new-record IDs in purge tests under TEST delay Under TEST, OP_REPO_POST_CREATE_DELAY_SECONDS is 0, so canAccess released an ID the instant it was added and the Requests left the executor queues before removeOperationsWithoutExternalId could see them. MockNewRecordsState.holdWhilePresent keeps an ID inaccessible for as long as it is present. Purge tests opt in; every other consumer keeps the production timer behavior. Co-authored-by: Cursor --- .../OneSignalOSCoreMocks/MockNewRecordsState.swift | 14 ++++++++++++++ .../Executors/ExecutorAnonymousPurgeTests.swift | 2 ++ 2 files changed, 16 insertions(+) diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/MockNewRecordsState.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/MockNewRecordsState.swift index 25a6444f7..b489cdeac 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/MockNewRecordsState.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/MockNewRecordsState.swift @@ -35,6 +35,13 @@ public class MockNewRecordsState: OSNewRecordsState { public var records: [MockNewRecord] = [] + /** + When true, an ID stays inaccessible for as long as it is present. Under TEST the post-create + delay is zero, so the production timer would otherwise release immediately and a Request would + leave the executor queue before a purge test can see it. + */ + public var holdWhilePresent = false + override public func add(_ key: String, _ overwrite: Bool = false) { let record = MockNewRecord(key: key, overwrite: overwrite) records.append(record) @@ -42,6 +49,13 @@ public class MockNewRecordsState: OSNewRecordsState { super.add(key, overwrite) } + override public func canAccess(_ key: String) -> Bool { + if holdWhilePresent { + return !contains(key) + } + return super.canAccess(key) + } + public func get(_ key: String?) -> [MockNewRecord] { return records.filter { $0.key == key } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/ExecutorAnonymousPurgeTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/ExecutorAnonymousPurgeTests.swift index b23a70d4e..62b1205e2 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/ExecutorAnonymousPurgeTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/ExecutorAnonymousPurgeTests.swift @@ -60,6 +60,8 @@ final class ExecutorAnonymousPurgeTests: XCTestCase { client.fireSuccessForAllRequests = true OneSignalCoreImpl.setSharedClient(client) newRecordsState = MockNewRecordsState() + // Presence is the hold: the production timer is a no-op under TEST. + newRecordsState.holdWhilePresent = true // The purge only ever runs because the requirement came back requiring auth. OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) From a642d1cb785c4e126fcbb68a2948939e7e3c0e98 Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 11 Aug 2026 23:31:33 -0700 Subject: [PATCH 13/21] chore: [PR6] split types to clear SwiftLint length errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No behavior change — move code into extensions / a top-level OSPushSubscriptionImpl so file_length and type_body_length stay under error thresholds. Co-authored-by: Cursor --- .../OneSignal.xcodeproj/project.pbxproj | 4 + .../OSOperationRepoTestSupport.swift | 2 +- .../OSSubscriptionOperationExecutor.swift | 2 + .../Source/OSPushSubscriptionImpl.swift | 103 +++++++++++++++++ .../Source/OneSignalUserManagerImpl+Jwt.swift | 32 ++++++ .../Source/OneSignalUserManagerImpl.swift | 106 +----------------- 6 files changed, 145 insertions(+), 104 deletions(-) create mode 100644 iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSPushSubscriptionImpl.swift diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index 06891b51d..0e4b40081 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -419,6 +419,7 @@ D465D9B81F58B242ADF14874 /* OSIdentityModelRepoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89DE5BB0EDD3964C20C5169F /* OSIdentityModelRepoTests.swift */; }; DAF9C81134248FCDB0C12E5B /* OSUserJwtInvalidatedEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F2FC6C922FF8104F3197DD4 /* OSUserJwtInvalidatedEvent.swift */; }; DD2A89A8052E2D1912B0038B /* OSIamFetchReadyConditionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF4B19D1EC31C0750F13065A /* OSIamFetchReadyConditionTests.swift */; }; + DDE652EF2123473B808F8CCA /* OSPushSubscriptionImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = E78603AF16454A648A96DF7B /* OSPushSubscriptionImpl.swift */; }; DE16C14424D3724700670EFA /* OneSignalLifecycleObserver.m in Sources */ = {isa = PBXBuildFile; fileRef = DE16C14324D3724700670EFA /* OneSignalLifecycleObserver.m */; }; DE16C14524D3724700670EFA /* OneSignalLifecycleObserver.m in Sources */ = {isa = PBXBuildFile; fileRef = DE16C14324D3724700670EFA /* OneSignalLifecycleObserver.m */; }; DE16C14724D3727200670EFA /* OneSignalLifecycleObserver.h in Headers */ = {isa = PBXBuildFile; fileRef = DE16C14624D3727200670EFA /* OneSignalLifecycleObserver.h */; }; @@ -1882,6 +1883,7 @@ DEFB3E622BB731BD00E65DAD /* ActivityKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ActivityKit.framework; path = System/Library/Frameworks/ActivityKit.framework; sourceTree = SDKROOT; }; DEFB3E642BB7346D00E65DAD /* OSLiveActivities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLiveActivities.swift; sourceTree = ""; }; DEFB3E662BB735B500E65DAD /* OSStubLiveActivities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSStubLiveActivities.swift; sourceTree = ""; }; + E78603AF16454A648A96DF7B /* OSPushSubscriptionImpl.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSPushSubscriptionImpl.swift; sourceTree = ""; }; E9376A4957E9090C748BCB18 /* OSUserJwtConfigTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSUserJwtConfigTests.swift; sourceTree = ""; }; F4855B81F170253FB0C1749D /* OSIdentityVerificationServiceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSIdentityVerificationServiceTests.swift; sourceTree = ""; }; F83E7BF2B518EA8B0B51B276 /* OSAliasPair.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSAliasPair.swift; sourceTree = ""; }; @@ -2859,6 +2861,7 @@ 2F2FC6C922FF8104F3197DD4 /* OSUserJwtInvalidatedEvent.swift */, 6A8BBA843AFC81A4940CF7CC /* OSUserJwtRepo.swift */, 4CCE2C93100CAFEE8EB39C77 /* OneSignalUserManagerImpl+Jwt.swift */, + E78603AF16454A648A96DF7B /* OSPushSubscriptionImpl.swift */, 9BF72AAEB5284C97B864A1A8 /* OSRequestAuth.swift */, ); path = Source; @@ -4840,6 +4843,7 @@ DAF9C81134248FCDB0C12E5B /* OSUserJwtInvalidatedEvent.swift in Sources */, FD1F1FCA05D555623DD53B54 /* OSUserJwtRepo.swift in Sources */, 257E219608960B8545199057 /* OneSignalUserManagerImpl+Jwt.swift in Sources */, + DDE652EF2123473B808F8CCA /* OSPushSubscriptionImpl.swift in Sources */, 32601EF1960CD92605D1ABF9 /* OSRequestAuth.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoTestSupport.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoTestSupport.swift index 0c6e4beac..c4f95de6f 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoTestSupport.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoTestSupport.swift @@ -104,4 +104,4 @@ final class MockOperationExecutor: OSOperationExecutor { func removeOperationsWithoutExternalId() { removeOperationsWithoutExternalIdCallCount += 1 } -} \ No newline at end of file +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift index 70b0d6d75..fa91c9096 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift @@ -309,7 +309,9 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_ADD_REQUEST_QUEUE_KEY, withValue: self.addRequestQueue) } } +} +extension OSSubscriptionOperationExecutor { /// This method is called by `processDeltaQueue` only and does not need to be added to the dispatchQueue. private func processRequestQueue(inBackground: Bool) { let requestQueue: [OneSignalRequest] = addRequestQueue + removeRequestQueue + updateRequestQueue diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSPushSubscriptionImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSPushSubscriptionImpl.swift new file mode 100644 index 000000000..61e85e755 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSPushSubscriptionImpl.swift @@ -0,0 +1,103 @@ +/* + Modified MIT License + + Copyright 2023 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 OneSignalCore +import OneSignalOSCore +import OneSignalNotifications + +/** + Implements the push subscription namespace. Lives on `OneSignalUserManagerImpl` so User and Push + Subscription can both expose `addObserver` without colliding on one type. + */ +@objc +public class OSPushSubscriptionImpl: NSObject, OSPushSubscription { + + let pushSubscriptionModelStore: OSModelStore + + private var _pushSubscriptionStateChangesObserver: OSObservable? + var pushSubscriptionStateChangesObserver: OSObservable { + if let observer = _pushSubscriptionStateChangesObserver { + return observer + } + let pushSubscriptionStateChangesObserver = OSObservable(change: #selector(OSPushSubscriptionObserver.onPushSubscriptionDidChange(state:))) + _pushSubscriptionStateChangesObserver = pushSubscriptionStateChangesObserver + + return pushSubscriptionStateChangesObserver + } + + init(pushSubscriptionModelStore: OSModelStore) { + self.pushSubscriptionModelStore = pushSubscriptionModelStore + } + + public func addObserver(_ observer: OSPushSubscriptionObserver) { + // Push Subscription namespace; does not require privacy consent first. + self.pushSubscriptionStateChangesObserver.addObserver(observer) + } + + public func removeObserver(_ observer: OSPushSubscriptionObserver) { + self.pushSubscriptionStateChangesObserver.removeObserver(observer) + } + + public var id: String? { + guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "pushSubscription.id") else { + return nil + } + return pushSubscriptionModelStore.getModel(key: OS_PUSH_SUBSCRIPTION_MODEL_KEY)?.subscriptionId + } + + public var token: String? { + guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "pushSubscription.token") else { + return nil + } + return pushSubscriptionModelStore.getModel(key: OS_PUSH_SUBSCRIPTION_MODEL_KEY)?.address + } + + public var optedIn: Bool { + guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "pushSubscription.optedIn") else { + return false + } + return pushSubscriptionModelStore.getModel(key: OS_PUSH_SUBSCRIPTION_MODEL_KEY)?.optedIn ?? false + } + + /** + Enable the push subscription, and prompts if needed. `optedIn` can still be `false` after `optIn()` is called if permission is not granted. + */ + public func optIn() { + guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "pushSubscription.optIn") else { + return + } + pushSubscriptionModelStore.getModel(key: OS_PUSH_SUBSCRIPTION_MODEL_KEY)?._isDisabled = false + OSNotificationsManager.requestPermission(nil, fallbackToSettings: true) + } + + public func optOut() { + guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "pushSubscription.optOut") else { + return + } + pushSubscriptionModelStore.getModel(key: OS_PUSH_SUBSCRIPTION_MODEL_KEY)?._isDisabled = true + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift index eaf4c7784..859353d90 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift @@ -33,6 +33,38 @@ import OneSignalOSCore tells it when that token stopped being accepted. */ extension OneSignalUserManagerImpl { + /** + Whether `login` may promote the current anonymous user with Identify User instead of creating a new one. + + Identify User adds an `external_id` to a user that has none, and under Identity Verification no such + user is ever sent to the server, so every login has to create its user instead. While the requirement is + unknown this still promotes: the queue is held until it is known, and `OSUserExecutor` then turns the + promotion into the Create User it should have been if the app turns out to require auth. + */ + var canPromoteAnonymousUser: Bool { + return !identityVerificationService.ivBehaviorActive + } + + /** + Stores a token for `externalId` and releases everything held for want of one, so it goes out now: + the Repo's Deltas, the User executor's own queue (which is not Repo-driven), and — over the + notification — the work that travels through neither. + + Every app-supplied token arrives here, from `login` as well as `updateUserJwt`, so that the pending + ask for this user is cleared and a later rejection can ask again. + */ + func storeJwt(externalId: String, token: String) { + guard userJwtRepo.updateJwt(externalId: externalId, token: token) else { + return + } + OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OneSignalUserManager stored a JWT for externalId: \(externalId)") + guard identityVerificationService.newCodePathsRun else { + return + } + operationRepo.addFlushDeltaQueueToDispatchQueue() + userExecutor?.executePendingRequests() + } + /** Replays any ask that already fired this session, so a listener registered after `start` or `login` still hears who currently owes a token. diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift index 308bc1d14..1cf2695f3 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift @@ -393,18 +393,6 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { } - /** - Whether `login` may promote the current anonymous user with Identify User instead of creating a new one. - - Identify User adds an `external_id` to a user that has none, and under Identity Verification no such - user is ever sent to the server, so every login has to create its user instead. While the requirement is - unknown this still promotes: the queue is held until it is known, and `OSUserExecutor` then turns the - promotion into the Create User it should have been if the app turns out to require auth. - */ - private var canPromoteAnonymousUser: Bool { - return !identityVerificationService.ivBehaviorActive - } - /** Converting a 3.x player to a 5.x user. There is a cached legacy player, so we will create the user based on the legacy player ID. */ @@ -566,27 +554,11 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { _user = nil createUserIfNil() } +} - /** - Stores a token for `externalId` and releases everything held for want of one, so it goes out now: - the Repo's Deltas, the User executor's own queue (which is not Repo-driven), and — over the - notification — the work that travels through neither. - - Every app-supplied token arrives here, from `login` as well as `updateUserJwt`, so that the pending - ask for this user is cleared and a later rejection can ask again. - */ - func storeJwt(externalId: String, token: String) { - guard userJwtRepo.updateJwt(externalId: externalId, token: token) else { - return - } - OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OneSignalUserManager stored a JWT for externalId: \(externalId)") - guard identityVerificationService.newCodePathsRun else { - return - } - operationRepo.addFlushDeltaQueueToDispatchQueue() - userExecutor?.executePendingRequests() - } +// MARK: - User setup helpers +extension OneSignalUserManagerImpl { @objc public func clearAllModelsFromStores() { prepareForNewUser() @@ -771,7 +743,6 @@ extension OneSignalUserManagerImpl { operationRepo.addFlushDeltaQueueToDispatchQueue(inBackground: true) } } - extension OneSignalUserManagerImpl: OSUser { public var User: OSUser { start() @@ -974,77 +945,6 @@ extension OneSignalUserManagerImpl: OSUser { } } -extension OneSignalUserManagerImpl { - @objc - public class OSPushSubscriptionImpl: NSObject, OSPushSubscription { - - let pushSubscriptionModelStore: OSModelStore - - private var _pushSubscriptionStateChangesObserver: OSObservable? - var pushSubscriptionStateChangesObserver: OSObservable { - if let observer = _pushSubscriptionStateChangesObserver { - return observer - } - let pushSubscriptionStateChangesObserver = OSObservable(change: #selector(OSPushSubscriptionObserver.onPushSubscriptionDidChange(state:))) - _pushSubscriptionStateChangesObserver = pushSubscriptionStateChangesObserver - - return pushSubscriptionStateChangesObserver - } - - init(pushSubscriptionModelStore: OSModelStore) { - self.pushSubscriptionModelStore = pushSubscriptionModelStore - } - - public func addObserver(_ observer: OSPushSubscriptionObserver) { - // This is a method in the User namespace that doesn't require privacy consent first - self.pushSubscriptionStateChangesObserver.addObserver(observer) - } - - public func removeObserver(_ observer: OSPushSubscriptionObserver) { - self.pushSubscriptionStateChangesObserver.removeObserver(observer) - } - - public var id: String? { - guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "pushSubscription.id") else { - return nil - } - return pushSubscriptionModelStore.getModel(key: OS_PUSH_SUBSCRIPTION_MODEL_KEY)?.subscriptionId - } - - public var token: String? { - guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "pushSubscription.token") else { - return nil - } - return pushSubscriptionModelStore.getModel(key: OS_PUSH_SUBSCRIPTION_MODEL_KEY)?.address - } - - public var optedIn: Bool { - guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "pushSubscription.optedIn") else { - return false - } - return pushSubscriptionModelStore.getModel(key: OS_PUSH_SUBSCRIPTION_MODEL_KEY)?.optedIn ?? false - } - - /** - Enable the push subscription, and prompts if needed. `optedIn` can still be `false` after `optIn()` is called if permission is not granted. - */ - public func optIn() { - guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "pushSubscription.optIn") else { - return - } - pushSubscriptionModelStore.getModel(key: OS_PUSH_SUBSCRIPTION_MODEL_KEY)?._isDisabled = false - OSNotificationsManager.requestPermission(nil, fallbackToSettings: true) - } - - public func optOut() { - guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "pushSubscription.optOut") else { - return - } - pushSubscriptionModelStore.getModel(key: OS_PUSH_SUBSCRIPTION_MODEL_KEY)?._isDisabled = true - } - } -} - extension OneSignalUserManagerImpl: OneSignalNotificationsDelegate { // While we await app_id and privacy consent, these methods are a no-op // Once the UserManager is started in `init`, it calls these to set the state of the pushSubscriptionModel From ebeb006dce3fcc0894430706cec6c08f1c351c10 Mon Sep 17 00:00:00 2001 From: Nan Date: Wed, 12 Aug 2026 17:09:06 -0700 Subject: [PATCH 14/21] fix: [PR6] keep restored Identify under IV so reshape can promote it prepareForExecution is false when Identity Verification is on, which uncache treated as a drop. A cold-start Identify whose ToUpdate is still current never reached reshape, so the login had no Create User. Keep it when IV is on and let reshape convert or drop. Co-authored-by: Cursor --- .../Source/Executors/OSUserExecutor.swift | 39 +++++++++---------- .../Executors/UserExecutorTests.swift | 27 ++++++++++++- 2 files changed, 45 insertions(+), 21 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift index 18b4a1384..66c6450bb 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift @@ -150,29 +150,28 @@ class OSUserExecutor { userRequestQueue.append(req) } else if request.isKind(of: OSRequestIdentifyUser.self), let req = request as? OSRequestIdentifyUser { + let identifyInRepo = getIdentityModel(req.identityModelToIdentify.modelId) + let updateInRepo = getIdentityModel(req.identityModelToUpdate.modelId) + if let identifyInRepo { + req.identityModelToIdentify = identifyInRepo + } + if let updateInRepo { + req.identityModelToUpdate = updateInRepo + } - if let identityModelToIdentify = getIdentityModel(req.identityModelToIdentify.modelId), - let identityModelToUpdate = getIdentityModel(req.identityModelToUpdate.modelId) { - // 1. Both models exist in the repo, set it to be the Request's models - req.identityModelToIdentify = identityModelToIdentify - req.identityModelToUpdate = identityModelToUpdate - } else if let identityModelToIdentify = getIdentityModel(req.identityModelToIdentify.modelId), - getIdentityModel(req.identityModelToUpdate.modelId) == nil { - // 2. A model is in the repo, the other model does not exist - req.identityModelToIdentify = identityModelToIdentify - addIdentityModel(req.identityModelToUpdate) - } else { - // 3. Both models don't exist yet - // Drop the request if the identityModelToIdentify does not already exist AND the request is missing OSID - // Otherwise, this request will forever fail `prepareForExecution` and block pending requests such as recovery calls to `logout` or `login` - guard request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) else { - OneSignalLog.onesignalLog(.LL_ERROR, message: "OSUserExecutor.start() dropped: \(request)") - continue + // `prepareForExecution` is false under IV so `reshapeInvalidRequests` can promote + // this login; do not treat that as a permanent drop. + if auth.ivBehaviorActive || request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) { + if identifyInRepo == nil { + addIdentityModel(req.identityModelToIdentify) } - addIdentityModel(req.identityModelToIdentify) - addIdentityModel(req.identityModelToUpdate) + if updateInRepo == nil { + addIdentityModel(req.identityModelToUpdate) + } + userRequestQueue.append(req) + } else { + OneSignalLog.onesignalLog(.LL_ERROR, message: "OSUserExecutor.start() dropped: \(request)") } - userRequestQueue.append(req) } } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/UserExecutorTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/UserExecutorTests.swift index 570986fe9..b5535a7d3 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/UserExecutorTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/UserExecutorTests.swift @@ -355,7 +355,7 @@ final class UserExecutorTests: XCTestCase { } /// Identify User promotes an anonymous user, which Identity Verification does not allow. A restored one - /// belongs to a user a later `login` has already replaced, so there is no login left to carry over. + /// whose `identityModelToUpdate` is no longer current has no login left to carry over. func testRestoredIdentifyUserIsDroppedWhenIdentityVerificationIsRequired() { /* Setup */ OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) @@ -370,6 +370,31 @@ final class UserExecutorTests: XCTestCase { XCTAssertFalse(mocks.client.hasExecutedRequestOfType(OSRequestCreateUser.self)) } + /// Cold start: the anon `identityModelToIdentify` is gone from the repo, but ToUpdate is still the + /// current user, so reshape must turn the restored Identify into a Create User. + func testRestoredIdentifyUserBecomesACreateUserWhenItIsStillTheCurrentUser() { + /* Setup */ + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + let user = OneSignalUserMocks.setUserManagerInternalUser(externalId: userA_EUID, onesignalId: nil) + user.identityModel.jwtBearerToken = "token-a" + cacheUserRequests([ + OSRequestIdentifyUser( + aliasLabel: OS_EXTERNAL_ID, + aliasId: userA_EUID, + identityModelToIdentify: OSIdentityModel(aliases: [OS_ONESIGNAL_ID: userA_OSID], changeNotifier: OSEventProducer()), + identityModelToUpdate: user.identityModel + ) + ]) + + /* When */ + let mocks = Mocks { MockUserRequests.setDefaultCreateUserResponses(with: $0, externalId: userA_EUID) } + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + /* Then */ + XCTAssertFalse(mocks.client.hasExecutedRequestOfType(OSRequestIdentifyUser.self)) + XCTAssertTrue(mocks.client.hasExecutedRequestOfType(OSRequestCreateUser.self)) + } + /// Same restored Identify User goes out when Identity Verification is off. func testRestoredIdentifyUserIsSentWhenIdentityVerificationIsOff() { /* Setup */ From b39bcff42eaf8847ff7749d9777b5dc4506b230d Mon Sep 17 00:00:00 2001 From: Nan Date: Wed, 12 Aug 2026 17:17:53 -0700 Subject: [PATCH 15/21] fix: [PR6] default missing addsNewRecords to true on Create User decode decodeBool is false when the key is absent, so pre-upgrade Create User caches skipped newRecordsState and follow-ups could 404. A missing key cools down; recovery creates write the field explicitly. Co-authored-by: Cursor --- .../Source/Requests/OSRequestCreateUser.swift | 5 +- .../Executors/UserExecutorTests.swift | 64 +++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestCreateUser.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestCreateUser.swift index c992e6651..d039ce53a 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestCreateUser.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSRequestCreateUser.swift @@ -155,7 +155,10 @@ class OSRequestCreateUser: OneSignalRequest, OSUserRequest { self.identityModel = identityModel self.pushSubscriptionModel = coder.decodeObject(forKey: "pushSubscriptionModel") as? OSSubscriptionModel self.originalPushToken = coder.decodeObject(forKey: "originalPushToken") as? String - self.addsNewRecords = coder.decodeBool(forKey: "addsNewRecords") + // Safe if the key was never written: extra cool-down, not a skipped one. + self.addsNewRecords = coder.containsValue(forKey: "addsNewRecords") + ? coder.decodeBool(forKey: "addsNewRecords") + : true self.stringDescription = "" super.init() self.parameters = parameters diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/UserExecutorTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/UserExecutorTests.swift index b5535a7d3..25b0e1d14 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/UserExecutorTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/UserExecutorTests.swift @@ -474,3 +474,67 @@ final class UserExecutorTests: XCTestCase { ) } } + +/// Upgrade decode of `addsNewRecords` on a cached Create User. +final class OSRequestCreateUserArchiveTests: XCTestCase { + + func testAddsNewRecordsSurvivesAnArchiveRoundTrip() throws { + XCTAssertTrue(try archiveThenUnarchive(makeCreateWithPush()).addsNewRecords) + XCTAssertFalse(try archiveThenUnarchive(makeRecoveryCreate()).addsNewRecords) + } + + /// Omitting the key cools down, even when the body has no push subscription. + func testOmittingAddsNewRecordsDecodesAsTrue() throws { + XCTAssertTrue(try decodeOmittingAddsNewRecords(makeCreateWithPush()).addsNewRecords) + XCTAssertTrue(try decodeOmittingAddsNewRecords(makeRecoveryCreate()).addsNewRecords) + } + + private func makeCreateWithPush() -> OSRequestCreateUser { + OSRequestCreateUser( + identityModel: OSIdentityModel(aliases: [OS_EXTERNAL_ID: userA_EUID], changeNotifier: OSEventProducer()), + propertiesModel: OSPropertiesModel(changeNotifier: OSEventProducer()), + pushSubscriptionModel: OSSubscriptionModel( + type: .push, + address: "", + subscriptionId: "test-subscription-id", + reachable: false, + isDisabled: false, + changeNotifier: OSEventProducer() + ), + originalPushToken: nil + ) + } + + private func makeRecoveryCreate() -> OSRequestCreateUser { + OSRequestCreateUser( + aliasLabel: OS_EXTERNAL_ID, + aliasId: userA_EUID, + identityModel: OSIdentityModel(aliases: [OS_EXTERNAL_ID: userA_EUID], changeNotifier: OSEventProducer()) + ) + } + + private func archiveThenUnarchive(_ request: OSRequestCreateUser) throws -> OSRequestCreateUser { + let data = try NSKeyedArchiver.archivedData(withRootObject: request, requiringSecureCoding: false) + let unarchiver = try NSKeyedUnarchiver(forReadingFrom: data) + unarchiver.requiresSecureCoding = false + defer { unarchiver.finishDecoding() } + return try XCTUnwrap(unarchiver.decodeObject(forKey: NSKeyedArchiveRootObjectKey) as? OSRequestCreateUser) + } + + /// Encodes the same fields as `OSRequestCreateUser.encode`, without `addsNewRecords`. + private func decodeOmittingAddsNewRecords(_ request: OSRequestCreateUser) throws -> OSRequestCreateUser { + let archiver = NSKeyedArchiver(requiringSecureCoding: false) + archiver.encode(request.identityModel, forKey: "identityModel") + archiver.encode(request.pushSubscriptionModel, forKey: "pushSubscriptionModel") + archiver.encode(request.originalPushToken, forKey: "originalPushToken") + archiver.encode(request.parameters, forKey: "parameters") + archiver.encode(request.method.rawValue, forKey: "method") + archiver.encode(request.timestamp, forKey: "timestamp") + archiver.finishEncoding() + + let unarchiver = try NSKeyedUnarchiver(forReadingFrom: archiver.encodedData) + unarchiver.requiresSecureCoding = false + defer { unarchiver.finishDecoding() } + return try XCTUnwrap(OSRequestCreateUser(coder: unarchiver)) + } +} From 1a3a237f719ba9dfd6273c14ae1b6eea9f96bd26 Mon Sep 17 00:00:00 2001 From: Nan Date: Thu, 13 Aug 2026 08:55:17 -0700 Subject: [PATCH 16/21] fix: [PR6] keep uncached owned requests only while Identity Verification is on Without it, an owned Request whose identity model is gone can never become sendable, so uncache must drop it instead of keeping it forever. Co-authored-by: Cursor --- .../Executors/OSCustomEventsExecutor.swift | 4 +- .../OSIdentityOperationExecutor.swift | 8 ++-- .../OSPropertyOperationExecutor.swift | 4 +- .../OSSubscriptionOperationExecutor.swift | 6 +-- .../OneSignalUser/Source/OSRequestAuth.swift | 7 ++++ .../ExecutorAnonymousPurgeTests.swift | 41 +++++++++++++++++++ 6 files changed, 59 insertions(+), 11 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSCustomEventsExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSCustomEventsExecutor.swift index e6c372e61..b6571ac08 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSCustomEventsExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSCustomEventsExecutor.swift @@ -87,8 +87,8 @@ class OSCustomEventsExecutor: OSOperationExecutor { if let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(request.identityModel.modelId) { // 1. The identity model exist in the repo, set it to be the Request's model request.identityModel = identityModel - } else if request.ownerExternalId != nil || request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) { - // 2. The Request is owned, so a token can still arrive for it, or it can be sent as is; add the model to the repo + } else if auth.keepUncachedOwned(request) || request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) { + // 2. Owned while Identity Verification is on, so a token can still arrive; or it can be sent as is OneSignalUserManagerImpl.sharedInstance.addIdentityModelToRepo(request.identityModel) } else { // 3. The identitymodel do not exist AND this request cannot be sent, drop this Request diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSIdentityOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSIdentityOperationExecutor.swift index 46f357854..c3e1825bc 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSIdentityOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSIdentityOperationExecutor.swift @@ -76,8 +76,8 @@ class OSIdentityOperationExecutor: OSOperationExecutor { if let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(request.identityModel.modelId) { // 1. The model exists in the repo, so set it to be the Request's models request.identityModel = identityModel - } else if request.ownerExternalId != nil || request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) { - // 2. The Request is owned, so a token can still arrive for it, or it can be sent as is; add the model to the repo + } else if auth.keepUncachedOwned(request) || request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) { + // 2. Owned while Identity Verification is on, so a token can still arrive; or it can be sent as is OneSignalUserManagerImpl.sharedInstance.addIdentityModelToRepo(request.identityModel) } else { // 3. The model do not exist AND this request cannot be sent, drop this Request @@ -99,8 +99,8 @@ class OSIdentityOperationExecutor: OSOperationExecutor { if let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(request.identityModel.modelId) { // 1. The model exists in the repo, so set it to be the Request's model request.identityModel = identityModel - } else if request.ownerExternalId != nil || request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) { - // 2. The Request is owned, so a token can still arrive for it, or it can be sent as is; add the model to the repo + } else if auth.keepUncachedOwned(request) || request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) { + // 2. Owned while Identity Verification is on, so a token can still arrive; or it can be sent as is OneSignalUserManagerImpl.sharedInstance.addIdentityModelToRepo(request.identityModel) } else { // 3. The model does not exist AND this request cannot be sent, drop this Request diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift index 0ba3b65a4..c91eb50fe 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift @@ -107,8 +107,8 @@ class OSPropertyOperationExecutor: OSOperationExecutor { if let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(request.identityModel.modelId) { // 1. The identity model exist in the repo, set it to be the Request's model request.identityModel = identityModel - } else if request.ownerExternalId != nil || request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) { - // 2. The Request is owned, so a token can still arrive for it, or it can be sent as is; add the model to the repo + } else if auth.keepUncachedOwned(request) || request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) { + // 2. Owned while Identity Verification is on, so a token can still arrive; or it can be sent as is OneSignalUserManagerImpl.sharedInstance.addIdentityModelToRepo(request.identityModel) } else { // 3. The identitymodel do not exist AND this request cannot be sent, drop this Request diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift index fa91c9096..43d3f63b9 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift @@ -93,8 +93,8 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { if let identityModel = OneSignalUserManagerImpl.sharedInstance.getIdentityModel(request.identityModel.modelId) { // a. The model exist in the repo request.identityModel = identityModel - } else if request.ownerExternalId != nil || request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) { - // b. The Request is owned, so a token can still arrive for it, or it can be sent as is; add the model to the repo + } else if auth.keepUncachedOwned(request) || request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) { + // b. Owned while Identity Verification is on, so a token can still arrive; or it can be sent as is OneSignalUserManagerImpl.sharedInstance.addIdentityModelToRepo(request.identityModel) } else { // c. The model do not exist AND this request cannot be sent, drop this Request @@ -120,7 +120,7 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { } else if let subscriptionModel = subscriptionModels[request.subscriptionModel.modelId] { // 2. The model exists in the dict of seen subscription models request.subscriptionModel = subscriptionModel - } else if request.ownerExternalId == nil, + } else if !auth.keepUncachedOwned(request), !request.prepareForExecution(newRecordsState: newRecordsState, auth: auth) { // 3. The model does not exist AND no token can arrive to make this sendable, drop it OneSignalLog.onesignalLog(.LL_ERROR, message: "OSSubscriptionOperationExecutor.init dropped \(request)") diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSRequestAuth.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSRequestAuth.swift index 42e780212..5678d9747 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSRequestAuth.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSRequestAuth.swift @@ -83,6 +83,13 @@ protocol OSRequestAuthorizing: AnyObject { func authorization(onesignalId: String?, externalId: String?) -> OSUserRequestAuthorization? } +extension OSRequestAuthorizing { + /// Returns `true` when `ivBehaviorActive` and the Request has an owner, so a token can still arrive for an uncached Request whose identity model is gone. + func keepUncachedOwned(_ request: OSUserRequest) -> Bool { + ivBehaviorActive && request.ownerExternalId != nil + } +} + /** How another module should address and sign one user-scoped call. diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/ExecutorAnonymousPurgeTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/ExecutorAnonymousPurgeTests.swift index 62b1205e2..defb4c1a6 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/ExecutorAnonymousPurgeTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/ExecutorAnonymousPurgeTests.swift @@ -39,6 +39,9 @@ import OneSignalUserMocks Only ownership is judged: a Delta or Request carries the `external_id` of the user it was built for, and one carrying none can never be signed. The auth layer refuses to send those, so the purge is what keeps them from sitting in the queues unsendable for the rest of the session. + + Uncache also drops an owned Request whose identity model is gone, unless Identity Verification is on: + without it that Request can never become sendable. */ final class ExecutorAnonymousPurgeTests: XCTestCase { private let anonymousOSID = "test-anonymous-onesignal-id" @@ -357,4 +360,42 @@ final class ExecutorAnonymousPurgeTests: XCTestCase { private func subscriptionDelta(_ name: String, for identityModel: OSIdentityModel, subscription: OSSubscriptionModel) -> OSDelta { return delta(name, for: identityModel, model: subscription, property: "optedIn", value: true) } + + // MARK: - Uncache of owned Requests + + /// Without Identity Verification, an owned Request whose identity model is gone can never become sendable. + func testAnOwnedRequestWhoseModelIsGoneIsDroppedWhenIdentityVerificationIsOff() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: false) + let modelId = cacheOwnedPropertyUpdateWithNoIdentityModelInRepo() + + _ = OSPropertyOperationExecutor(newRecordsState: newRecordsState, auth: auth) + + XCTAssertEqual(cachedRequestOwners(OS_PROPERTIES_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, of: OSRequestUpdateProperties.self), []) + XCTAssertNil(OneSignalUserManagerImpl.sharedInstance.getIdentityModel(modelId)) + } + + /// The same Request is kept: a token can still arrive for its owner. + func testAnOwnedRequestWhoseModelIsGoneIsKeptWhenIdentityVerificationIsOn() { + let modelId = cacheOwnedPropertyUpdateWithNoIdentityModelInRepo() + + _ = OSPropertyOperationExecutor(newRecordsState: newRecordsState, auth: auth) + + XCTAssertEqual(cachedRequestOwners(OS_PROPERTIES_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, of: OSRequestUpdateProperties.self), [userA_EUID]) + XCTAssertNotNil(OneSignalUserManagerImpl.sharedInstance.getIdentityModel(modelId)) + } + + /// No `onesignal_id`, so `prepareForExecution` fails. + private func cacheOwnedPropertyUpdateWithNoIdentityModelInRepo() -> String { + let orphan = OSIdentityModel(aliases: [OS_EXTERNAL_ID: userA_EUID], changeNotifier: OSEventProducer()) + let request = OSRequestUpdateProperties( + params: ["properties": ["language": "en"]], + identityModel: orphan, + ownerExternalId: userA_EUID + ) + OneSignalUserDefaults.initShared().saveCodeableData( + forKey: OS_PROPERTIES_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, + withValue: [request] + ) + return orphan.modelId + } } From f74a5734618992723fa922ba1ab7e85db5bf03bf Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 11 Aug 2026 16:56:33 -0700 Subject: [PATCH 17/21] feat: [PR7] in-app messages under Identity Verification The in-app message fetch is user-scoped, so under Identity Verification it has to name the user by external_id and sign the call. It asks the user manager how to address the current user rather than assembling that itself, which keeps the alias and the token consistent with every other user-scoped call. The fetch deliberately does not invalidate a JWT. Getting this request right means getting both the push subscription ID and the user in the URL right, so a 401 here is at least as likely to mean the request was mismatched as it is to mean the token is bad. Treating it as proof would let a malformed fetch invalidate a token that works everywhere else. It handles the rejection and stops; the request pipeline remains the only thing that decides a token is no longer good. This follows Android. A fetch that cannot yet be addressed waits rather than going out unsigned, and is reattempted when the requirement hydrates or the app supplies a token. Co-authored-by: Cursor --- .../OneSignal.xcodeproj/project.pbxproj | 4 + .../Controller/OSMessagingController.m | 166 ++++++++++- .../Requests/OSInAppMessagingRequests.h | 6 +- .../Requests/OSInAppMessagingRequests.m | 16 +- .../IamFetchIdentityVerificationTests.swift | 277 ++++++++++++++++++ .../OSMessagingControllerUserStateTests.swift | 36 ++- ...SignalInAppMessagesTests-Bridging-Header.h | 1 + .../Source/OneSignalUserManagerImpl+Jwt.swift | 20 ++ .../Source/OneSignalUserManagerImpl.swift | 3 + 9 files changed, 492 insertions(+), 37 deletions(-) create mode 100644 iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IamFetchIdentityVerificationTests.swift diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index 0e4b40081..264c2e9cd 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -298,6 +298,7 @@ 5BC1DE602C90B83900CA8807 /* OSConsistencyKeyEnum.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BC1DE5F2C90B83900CA8807 /* OSConsistencyKeyEnum.swift */; }; 5BC1DE622C90B85A00CA8807 /* OSIamFetchOffsetKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BC1DE612C90B85A00CA8807 /* OSIamFetchOffsetKey.swift */; }; 5BC1DE642C90BB9000CA8807 /* OSIamFetchReadyCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BC1DE632C90BB9000CA8807 /* OSIamFetchReadyCondition.swift */; }; + 6F894909CB6D09258DCCA1C9 /* IamFetchIdentityVerificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67ECA2928D863073B785F93F /* IamFetchIdentityVerificationTests.swift */; }; 7A123295235DFE3B002B6CE3 /* OutcomeTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 7A123294235DFE3B002B6CE3 /* OutcomeTests.m */; }; 7A1232A2235E1743002B6CE3 /* OneSignal.m in Sources */ = {isa = PBXBuildFile; fileRef = 912411F11E73342200E41FD7 /* OneSignal.m */; }; 7A2E90622460DA1500B3428C /* OutcomeIntegrationV2Tests.m in Sources */ = {isa = PBXBuildFile; fileRef = 7A2E90612460DA1500B3428C /* OutcomeIntegrationV2Tests.m */; }; @@ -1572,6 +1573,7 @@ 5BC1DE672C90C23E00CA8807 /* OSConsistencyManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSConsistencyManagerTests.swift; sourceTree = ""; }; 5BFE2F960129386AFA6D5F41 /* UserJwtLifecycleTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UserJwtLifecycleTests.swift; sourceTree = ""; }; 6552F2A6DF7776B0582CFAEF /* OSUserJwtConfig.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSUserJwtConfig.swift; sourceTree = ""; }; + 67ECA2928D863073B785F93F /* IamFetchIdentityVerificationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = IamFetchIdentityVerificationTests.swift; sourceTree = ""; }; 6A8BBA843AFC81A4940CF7CC /* OSUserJwtRepo.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSUserJwtRepo.swift; sourceTree = ""; }; 7A123294235DFE3B002B6CE3 /* OutcomeTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OutcomeTests.m; sourceTree = ""; }; 7A12EBD523060A6F005C4FA5 /* OSSessionManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OSSessionManager.m; sourceTree = ""; }; @@ -2292,6 +2294,7 @@ 3C30FE352F21FBE1001B9C25 /* EarlyTriggerTrackingTests.swift */, 3CB35FCA2F0FA20B000E6E0F /* OSMessagingControllerUserStateTests.swift */, 3C7021E72ECF0CF3001768C6 /* OneSignalInAppMessagesTests-Bridging-Header.h */, + 67ECA2928D863073B785F93F /* IamFetchIdentityVerificationTests.swift */, ); path = OneSignalInAppMessagesTests; sourceTree = ""; @@ -4521,6 +4524,7 @@ 3C7021E92ECF0CF4001768C6 /* IAMIntegrationTests.swift in Sources */, 3C01519C2C2E29F90079E076 /* IAMRequestTests.m in Sources */, 3CB35FCB2F0FA20B000E6E0F /* OSMessagingControllerUserStateTests.swift in Sources */, + 6F894909CB6D09258DCCA1C9 /* IamFetchIdentityVerificationTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.m b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.m index 067787d89..eaff70122 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.m +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.m @@ -146,8 +146,16 @@ @interface OSMessagingController () @property (nonatomic) BOOL calledLoadTags; -/// set when we attempt getInAppMessagesFromServer and no onesignal ID is available yet -@property (strong, nonatomic, nullable) NSString *shouldFetchOnUserChangeWithSubscriptionID; +/** + Set when a fetch could not go out: no onesignal ID yet, or Identity Verification has not answered what + the call should be addressed and signed with. Read through `takeDeferredFetchSubscriptionId`, since a + user change, a hydration and a new token can all arrive at once and only one of them should refetch. + */ +@property (strong, nonatomic, nullable) NSString *deferredFetchSubscriptionId; + +/// Bumped on every login and logout. A fetch carries the value it started with, so a response that +/// arrives after the user changed is discarded instead of showing one user's messages to another. +@property (nonatomic) NSUInteger userGeneration; /// Tracks whether the first IAM fetch has completed since this cold start @property (nonatomic) BOOL hasCompletedFirstFetch; @@ -242,6 +250,11 @@ - (instancetype)init { _isInAppMessagingPaused = false; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleIAMPreview:) name:ONESIGNAL_POST_PREVIEW_IAM object:nil]; + // A deferred fetch waits on how to address it and what to sign it with; hydration answers the + // first, a supplied token the second. + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(retryDeferredFetch) name:OS_ON_JWT_CONFIG_HYDRATED object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(retryDeferredFetch) name:OS_ON_USER_JWT_UPDATED object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onUserWillChange) name:OS_ON_USER_WILL_CHANGE object:nil]; } return self; @@ -255,7 +268,15 @@ - (void)initializeTriggerController { dateFromString:timeSinceLastMessage]]; } +/** + Fetches in-app messages for `subscriptionId`, addressed as whoever is the current user. + + Nothing checks that the server has those two paired — the subscription comes from the caller and can + predate a login, while the alias is read here — so a well-formed fetch can still be refused. Pairing + them would mean tracking subscription ownership, which the SDK does not do. + */ - (void)getInAppMessagesFromServer:(NSString *)subscriptionId { + NSUInteger generation = [self currentUserGeneration]; dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:@"getInAppMessagesFromServer"]; @@ -269,7 +290,16 @@ - (void)getInAppMessagesFromServer:(NSString *)subscriptionId { // NOTE: Check for subscription ID above first, before checking for OneSignal ID next if (!onesignalId) { [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:@"Failed to get in app messages due to no OneSignal ID, will reattempt"]; - self.shouldFetchOnUserChangeWithSubscriptionID = subscriptionId; + [self deferFetchWithSubscriptionId:subscriptionId]; + return; + } + + // Resolved before the read-your-write wait, which can hold this thread for as long as it takes + // the user requests to come back. + OSUserRequestAuthorization *authorization = [OneSignalUserManagerImpl.sharedInstance authorizationForCurrentUser]; + if (!authorization) { + [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:@"Failed to get in app messages due to Identity Verification, will reattempt"]; + [self deferFetchWithSubscriptionId:subscriptionId]; return; } @@ -288,24 +318,101 @@ - (void)getInAppMessagesFromServer:(NSString *)subscriptionId { // Initial request [self attemptFetchWithRetries:subscriptionId + authorization:authorization rywData:rywData attempts:@0 // Starting with 0 attempts - retryLimit:nil]; // Retry limit to be set dynamically on first failure + retryLimit:nil // Retry limit to be set dynamically on first failure + userGeneration:generation]; }); }); } +- (NSUInteger)currentUserGeneration { + @synchronized (self) { + return self.userGeneration; + } +} + +/// Whether a fetch that started in `generation` is still for the user it was addressed and signed for. +- (BOOL)isCurrentUserGeneration:(NSUInteger)generation { + @synchronized (self) { + return self.userGeneration == generation; + } +} + +/** + Drops the outgoing user's in-app messages and invalidates any fetch still in flight for them, then + queues one for whoever is signing in. The new user's fetch goes out from `onUserStateDidChange`, once + there is a `onesignal_id` to address it by. + */ +- (void)onUserWillChange { + @synchronized (self) { + self.userGeneration += 1; + } + [self deferFetchWithSubscriptionId:OneSignalUserManagerImpl.sharedInstance.pushSubscriptionId]; + // On main, where every other write to `messages` happens. + dispatch_async(dispatch_get_main_queue(), ^{ + self.messages = @[]; + }); +} + +- (void)deferFetchWithSubscriptionId:(NSString *)subscriptionId { + @synchronized (self) { + self.deferredFetchSubscriptionId = subscriptionId; + } +} + +/// The deferred subscription ID, if there is one, taken so that two signals arriving together refetch once. +- (NSString * _Nullable)takeDeferredFetchSubscriptionId { + @synchronized (self) { + NSString *subscriptionId = self.deferredFetchSubscriptionId; + self.deferredFetchSubscriptionId = nil; + return subscriptionId; + } +} + +- (void)retryDeferredFetch { + NSString *subscriptionId = [self takeDeferredFetchSubscriptionId]; + if (subscriptionId) { + [self getInAppMessagesFromServer:subscriptionId]; + } +} + +/** + Parks the fetch for a later token to reattempt, without reporting the one it was signed with. + + A rejection here is ambiguous: the path names a user and a subscription the server may simply not have + paired, and a replacement token would not change that. Invalidating is left to the user requests, which + address a user alone; the token the app supplies after one of those releases what is parked here. + */ +- (void)handleUnauthorizedFetch:(OSUserRequestAuthorization *)authorization subscriptionId:(NSString *)subscriptionId { + // An unsigned fetch has no token to replace, so there would be nothing different to reattempt with. + if (!authorization.token) { + return; + } + [self deferFetchWithSubscriptionId:subscriptionId]; +} + - (void)attemptFetchWithRetries:(NSString *)subscriptionId + authorization:(OSUserRequestAuthorization *)authorization rywData:(OSReadYourWriteData *)rywData attempts:(NSNumber *)attempts - retryLimit:(NSNumber *)retryLimit { + retryLimit:(NSNumber *)retryLimit + userGeneration:(NSUInteger)generation { + if (![self isCurrentUserGeneration:generation]) { + [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:@"Abandoning an in app message fetch for a previous user"]; + return; + } + NSNumber *sessionDuration = @([OSSessionManager.sharedSessionManager getTimeFocusedElapsed]); NSString *rywToken = rywData.rywToken; NSNumber *rywDelay = rywData.rywDelay; // Create the request with the current attempt count OSRequestGetInAppMessages *request = [OSRequestGetInAppMessages withSubscriptionId:subscriptionId + withAlias:authorization.alias + withUserHeaders:authorization.headers withSessionDuration:sessionDuration withRetryCount:attempts withRywToken:rywToken]; @@ -316,6 +423,10 @@ - (void)attemptFetchWithRetries:(NSString *)subscriptionId onSuccess:^(NSDictionary *result) { dispatch_async(dispatch_get_main_queue(), ^{ [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:@"getInAppMessagesFromServer success"]; + if (![self isCurrentUserGeneration:generation]) { + [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:@"Discarding in app messages fetched for a previous user"]; + return; + } if (result[@"in_app_messages"]) { NSMutableArray *messages = [NSMutableArray new]; @@ -335,7 +446,12 @@ - (void)attemptFetchWithRetries:(NSString *)subscriptionId NSDictionary* responseHeaders = error.responseHeaders; [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:[NSString stringWithFormat:@"getInAppMessagesFromServer failure: %@", error.description]]; - + + // A retry, and the token this was signed with, both belong to the user it started for. + if (![self isCurrentUserGeneration:generation]) { + return; + } + if (error.code == 425 || error.code == 429) { // 425 Too Early or 429 Too Many Requests NSInteger retryAfter = [responseHeaders[@"Retry-After"] integerValue] ?: DEFAULT_RETRY_AFTER_SECONDS; @@ -348,13 +464,17 @@ - (void)attemptFetchWithRetries:(NSString *)subscriptionId NSInteger nextAttempt = [attempts integerValue] + 1; // Increment attempts [self retryAfterDelay:retryAfter subscriptionId:subscriptionId + authorization:authorization rywData:rywData attempts:@(nextAttempt) - retryLimit:blockRetryLimit]; + retryLimit:blockRetryLimit + userGeneration:generation]; } else { // Final attempt without rywToken - [self fetchInAppMessagesWithoutToken:subscriptionId]; + [self fetchInAppMessagesWithoutToken:subscriptionId authorization:authorization userGeneration:generation]; } + } else if ([OSNetworkingUtils getResponseStatusType:error.code] == OSResponseStatusUnauthorized) { + [self handleUnauthorizedFetch:authorization subscriptionId:subscriptionId]; } else if (error.code >= 500 && error.code <= 599) { [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:@"Server error, skipping retries"]; } @@ -363,23 +483,31 @@ - (void)attemptFetchWithRetries:(NSString *)subscriptionId - (void)retryAfterDelay:(NSInteger)retryAfter subscriptionId:(NSString *)subscriptionId + authorization:(OSUserRequestAuthorization *)authorization rywData:(OSReadYourWriteData *)rywData attempts:(NSNumber *)attempts - retryLimit:(NSNumber *)retryLimit { + retryLimit:(NSNumber *)retryLimit + userGeneration:(NSUInteger)generation { dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(retryAfter * NSEC_PER_SEC)), dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ [self attemptFetchWithRetries:subscriptionId + authorization:authorization rywData:rywData attempts:attempts - retryLimit:retryLimit]; + retryLimit:retryLimit + userGeneration:generation]; }); } -- (void)fetchInAppMessagesWithoutToken:(NSString *)subscriptionId { +- (void)fetchInAppMessagesWithoutToken:(NSString *)subscriptionId + authorization:(OSUserRequestAuthorization *)authorization + userGeneration:(NSUInteger)generation { NSNumber *sessionDuration = @([OSSessionManager.sharedSessionManager getTimeFocusedElapsed]); OSRequestGetInAppMessages *request = [OSRequestGetInAppMessages withSubscriptionId:subscriptionId + withAlias:authorization.alias + withUserHeaders:authorization.headers withSessionDuration:sessionDuration withRetryCount:nil withRywToken:nil]; // No retries for the final attempt @@ -388,6 +516,10 @@ - (void)fetchInAppMessagesWithoutToken:(NSString *)subscriptionId { onSuccess:^(NSDictionary *result) { dispatch_async(dispatch_get_main_queue(), ^{ [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:@"Final attempt without token success"]; + if (![self isCurrentUserGeneration:generation]) { + [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:@"Discarding in app messages fetched for a previous user"]; + return; + } if (result[@"in_app_messages"]) { NSMutableArray *messages = [NSMutableArray new]; @@ -404,6 +536,12 @@ - (void)fetchInAppMessagesWithoutToken:(NSString *)subscriptionId { }); } onFailure:^(OneSignalClientError *error) { [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:[NSString stringWithFormat:@"getInAppMessagesFromServer failure: %@", error.description]]; + if (![self isCurrentUserGeneration:generation]) { + return; + } + if ([OSNetworkingUtils getResponseStatusType:error.code] == OSResponseStatusUnauthorized) { + [self handleUnauthorizedFetch:authorization subscriptionId:subscriptionId]; + } }]; } @@ -1237,11 +1375,9 @@ - (void)onPushSubscriptionDidChangeWithState:(OSPushSubscriptionChangedState * _ } - (void)onUserStateDidChangeWithState:(OSUserChangedState * _Nonnull)state { - if (state.current.onesignalId != nil && self.shouldFetchOnUserChangeWithSubscriptionID) { + if (state.current.onesignalId != nil) { [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:@"OSMessagingController onUserStateDidChangeWithState: changed to new valid onesignal id"]; - NSString *subscriptionID = self.shouldFetchOnUserChangeWithSubscriptionID; - self.shouldFetchOnUserChangeWithSubscriptionID = nil; - [self getInAppMessagesFromServer:subscriptionID]; + [self retryDeferredFetch]; } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Requests/OSInAppMessagingRequests.h b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Requests/OSInAppMessagingRequests.h index 047592e63..6be5af702 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Requests/OSInAppMessagingRequests.h +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Requests/OSInAppMessagingRequests.h @@ -28,8 +28,12 @@ #import #import "OSInAppMessageClickResult.h" +@class OSAliasPair; + @interface OSRequestGetInAppMessages : OneSignalRequest -+ (instancetype _Nonnull)withSubscriptionId:(NSString * _Nonnull)subscriptionId withSessionDuration:(NSNumber * _Nonnull)sessionDuration withRetryCount:(NSNumber *)retryCount withRywToken:(NSString *)rywToken; +/// A nil `alias` addresses the subscription on its own, which is how this was addressed before Identity +/// Verification; `userHeaders` carries the Bearer when the call is signed. ++ (instancetype _Nonnull)withSubscriptionId:(NSString * _Nonnull)subscriptionId withAlias:(OSAliasPair * _Nullable)alias withUserHeaders:(NSDictionary * _Nullable)userHeaders withSessionDuration:(NSNumber * _Nonnull)sessionDuration withRetryCount:(NSNumber *)retryCount withRywToken:(NSString *)rywToken; @end @interface OSRequestInAppMessageViewed : OneSignalRequest diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Requests/OSInAppMessagingRequests.m b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Requests/OSInAppMessagingRequests.m index 6cf041dae..574b9854b 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Requests/OSInAppMessagingRequests.m +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Requests/OSInAppMessagingRequests.m @@ -35,13 +35,15 @@ - (NSString *)description { } + (instancetype _Nonnull) withSubscriptionId:(NSString * _Nonnull)subscriptionId + withAlias:(OSAliasPair * _Nullable)alias + withUserHeaders:(NSDictionary * _Nullable)userHeaders withSessionDuration:(NSNumber * _Nonnull)sessionDuration withRetryCount:(NSNumber *)retryCount withRywToken:(NSString *)rywToken { let request = [OSRequestGetInAppMessages new]; request.method = GET; - let headers = [NSMutableDictionary new]; + NSMutableDictionary *headers = userHeaders ? [userHeaders mutableCopy] : [NSMutableDictionary new]; if (sessionDuration != nil) { // convert to ms & round @@ -56,7 +58,17 @@ + (instancetype _Nonnull) withSubscriptionId:(NSString * _Nonnull)subscription request.additionalHeaders = headers; NSString *appId = OneSignalIdentifiers.currentAppId; - request.path = [NSString stringWithFormat:@"apps/%@/subscriptions/%@/iams", appId, subscriptionId]; + if (alias) { + // Encode so an app-chosen external_id cannot change which endpoint the path names. + NSString *encodedAliasId = [OSUrlPath segment:alias.id]; + if (!encodedAliasId) { + [OneSignalLog onesignalLog:ONE_S_LL_ERROR message:@"OSRequestGetInAppMessages: cannot encode alias id for path"]; + return request; + } + request.path = [NSString stringWithFormat:@"apps/%@/users/by/%@/%@/subscriptions/%@/iams", appId, alias.label, encodedAliasId, subscriptionId]; + } else { + request.path = [NSString stringWithFormat:@"apps/%@/subscriptions/%@/iams", appId, subscriptionId]; + } return request; } @end diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IamFetchIdentityVerificationTests.swift b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IamFetchIdentityVerificationTests.swift new file mode 100644 index 000000000..659d90aba --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IamFetchIdentityVerificationTests.swift @@ -0,0 +1,277 @@ +/* + 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 XCTest +import OneSignalCore +import OneSignalOSCore +import OneSignalCoreMocks +import OneSignalOSCoreMocks +import OneSignalUserMocks +import OneSignalInAppMessagesMocks +@testable import OneSignalUser + +/** + How the in-app message fetch is addressed and signed. It does not travel through the Request queues, so + it makes the Identity Verification decision itself and holds the fetch until the answer arrives. + */ +final class IamFetchIdentityVerificationTests: XCTestCase { + private let appId = "test-app-id" + + private var client = MockOneSignalClient() + private var jwtListener = MockUserJwtInvalidatedListener() + + private var legacyPath: String { "apps/\(appId)/subscriptions/\(testPushSubId)/iams" } + private var anonymousUserPath: String { userPath(alias: OS_ONESIGNAL_ID, id: anonUserOSID) } + private var identifiedUserPath: String { userPath(alias: OS_EXTERNAL_ID, id: userA_EUID) } + + override func setUpWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + OneSignalUserMocks.reset() + ConsistencyManagerTestHelpers.reset() + OSMessagingController.removeInstance() + OneSignalIdentifiers.currentAppId = appId + + client = MockOneSignalClient() + MockUserRequests.setDefaultCreateAnonUserResponses(with: client) + MockUserRequests.setDefaultCreateUserResponses(with: client, externalId: userA_EUID) + OneSignalCoreImpl.setSharedClient(client) + for path in [legacyPath, anonymousUserPath, identifiedUserPath] { + respondToFetch(from: path) + } + + // Held strongly for the test's lifetime: the observable keeps listeners weakly. + jwtListener = MockUserJwtInvalidatedListener() + OneSignalUserManagerImpl.sharedInstance.addUserJwtInvalidatedListener(jwtListener) + + OSMessagingController.start() + } + + override func tearDownWithError() throws { + OneSignalUserManagerImpl.sharedInstance.removeUserJwtInvalidatedListener(jwtListener) + OSMessagingController.removeInstance() + OSFeatureManager.shared.setEnabledFeatureKeys([]) + OneSignalCoreMocks.clearUserDefaults() + } + + // MARK: - Setup helpers + + private func userPath(alias: String, id: String) -> String { + return "apps/\(appId)/users/by/\(alias)/\(id)/subscriptions/\(testPushSubId)/iams" + } + + private func respondToFetch(from path: String) { + client.setMockResponseForRequest( + request: "", + response: IAMTestHelpers.testFetchMessagesResponse(messages: [])) + } + + private func rejectFetch(from path: String) { + client.setMockFailureResponseForRequest( + request: "", + error: OneSignalClientError(code: 401, message: "unauthorized", responseHeaders: nil, response: nil, underlyingError: nil)) + } + + private func turnOnTheRolloutFlag() { + OSFeatureManager.shared.setEnabledFeatureKeys([OSFeatureFlag.identityVerification.rawValue]) + } + + /// An anonymous user with an `onesignal_id`, which the fetch needs before it does anything else. + private func startAnonymousUser() { + ConsistencyManagerTestHelpers.setDefaultRywToken(id: anonUserOSID) + OneSignalUserManagerImpl.sharedInstance.start() + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + } + + private func login(token: String?) { + ConsistencyManagerTestHelpers.setDefaultRywToken(id: userA_OSID) + OneSignalUserManagerImpl.sharedInstance.login(externalId: userA_EUID, token: token) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + } + + private func fetch() { + OneSignalInAppMessages.getFromServer(testPushSubId) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + } + + // MARK: - Assertion helpers + + private func executedFetches() -> [OneSignalRequest] { + return client.executedRequests.filter { $0 is OSRequestGetInAppMessages } + } + + private func lastFetchPath() -> String? { + return executedFetches().last?.path + } + + private func lastFetchAuthorization() -> String? { + return executedFetches().last?.additionalHeaders?["Authorization"] + } + + private func deferredSubscriptionId() -> String? { + return OSMessagingController.sharedInstance().deferredFetchSubscriptionId + } + + // MARK: - How the fetch is addressed + + func testTheFetchAddressesTheSubscriptionAloneWhileTheNewCodePathsAreOff() { + startAnonymousUser() + + fetch() + + XCTAssertEqual(lastFetchPath(), legacyPath) + XCTAssertNil(lastFetchAuthorization()) + } + + func testTheFetchAddressesTheOnesignalIdWhileIdentityVerificationIsOff() { + turnOnTheRolloutFlag() + startAnonymousUser() + + fetch() + + XCTAssertEqual(lastFetchPath(), anonymousUserPath) + XCTAssertNil(lastFetchAuthorization()) + } + + func testTheFetchAddressesTheExternalIdAndIsSignedUnderIdentityVerification() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + login(token: "token-a") + + fetch() + + XCTAssertEqual(lastFetchPath(), identifiedUserPath) + XCTAssertEqual(lastFetchAuthorization(), "Bearer token-a") + } + + // MARK: - Holding the fetch until Identity Verification answers + + /// An unsigned fetch on behalf of an app that turns out to require auth would be rejected, so a fetch + /// that runs before remote params answer waits for them — including when the rollout flag is off. + func testAFetchHeldForAnUnknownRequirementGoesOutOnHydration() { + startAnonymousUser() + let fetchesBefore = executedFetches().count + OSCoreMocks.resetSharedJwtConfig() + + fetch() + + XCTAssertEqual(executedFetches().count, fetchesBefore) + XCTAssertEqual(deferredSubscriptionId(), testPushSubId) + + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: false) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + XCTAssertEqual(lastFetchPath(), legacyPath) + XCTAssertNil(deferredSubscriptionId()) + } + + /// A user whose token was rejected has none until the app supplies another, and the fetch has to wait + /// rather than fall back to sending unsigned. + func testAFetchIsHeldRatherThanSentUnsignedWhenTheUserHasNoToken() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + login(token: "token-a") + OneSignalUserManagerImpl.sharedInstance.userJwtRepo.invalidateJwt(externalId: userA_EUID, rejectedToken: "token-a") + let fetchesBefore = executedFetches().count + + fetch() + + XCTAssertEqual(executedFetches().count, fetchesBefore) + XCTAssertEqual(deferredSubscriptionId(), testPushSubId) + + OneSignalUserManagerImpl.sharedInstance.updateUserJwt(externalId: userA_EUID, token: "token-b") + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + XCTAssertEqual(lastFetchPath(), identifiedUserPath) + XCTAssertEqual(lastFetchAuthorization(), "Bearer token-b") + } + + // MARK: - A rejected fetch + + /// The fetch is not a source of truth for whether a token is good, because the server can refuse it + /// over a user and subscription it does not have paired. It parks, and the next token releases it. + func testARejectedFetchParksWithoutReportingTheTokenItUsed() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + login(token: "token-a") + rejectFetch(from: identifiedUserPath) + let fetchesBefore = executedFetches().count + + fetch() + + XCTAssertEqual(executedFetches().count, fetchesBefore + 1) + XCTAssertEqual(jwtListener.invalidatedExternalIds, []) + XCTAssertEqual(deferredSubscriptionId(), testPushSubId) + + // A token the app supplies for its own reasons, since the fetch never asked for one. + respondToFetch(from: identifiedUserPath) + OneSignalUserManagerImpl.sharedInstance.updateUserJwt(externalId: userA_EUID, token: "token-b") + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + XCTAssertEqual(executedFetches().count, fetchesBefore + 2) + XCTAssertEqual(lastFetchAuthorization(), "Bearer token-b") + } + + /// The token the fetch was signed with stays usable, so everything else keeps going out signed. + func testARejectedFetchLeavesTheTokenInPlaceForTheRequests() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + login(token: "token-a") + rejectFetch(from: identifiedUserPath) + + fetch() + + XCTAssertEqual(lastFetchAuthorization(), "Bearer token-a") + XCTAssertEqual(OneSignalUserManagerImpl.sharedInstance.user.identityModel.jwtBearerToken, "token-a") + } + + /// With the gate off a 401 stays as it was: logged and dropped, with no reattempt left pending. + func testARejectedFetchIsLeftAloneWhileTheNewCodePathsAreOff() { + startAnonymousUser() + rejectFetch(from: legacyPath) + let fetchesBefore = executedFetches().count + + fetch() + + XCTAssertEqual(executedFetches().count, fetchesBefore + 1) + XCTAssertNil(deferredSubscriptionId()) + } + + /// Under Identity Verification the alias id is an app-chosen external_id, so it has to be encoded. + func testTheFetchPathPercentEncodesTheAliasId() { + OneSignalIdentifiers.currentAppId = appId + let externalId = "us er/a?b#c%d" + let request = OSRequestGetInAppMessages.withSubscriptionId( + testPushSubId, + withAlias: OSAliasPair(OS_EXTERNAL_ID, externalId), + withUserHeaders: nil, + withSessionDuration: 0, + withRetryCount: 0, + withRywToken: nil + ) + + XCTAssertEqual( + request.path, + "apps/\(appId)/users/by/\(OS_EXTERNAL_ID)/us%20er%2Fa%3Fb%23c%25d/subscriptions/\(testPushSubId)/iams" + ) + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OSMessagingControllerUserStateTests.swift b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OSMessagingControllerUserStateTests.swift index 9c73924d6..3558b21a1 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OSMessagingControllerUserStateTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OSMessagingControllerUserStateTests.swift @@ -71,7 +71,7 @@ final class OSMessagingControllerUserStateTests: XCTestCase { - IAM fetch should be deferred Expected: - - shouldFetchOnUserChangeWithSubscriptionID property is set with the subscription ID + - deferredFetchSubscriptionId property is set with the subscription ID - No IAM fetch actually occurs */ func testStoresSubscriptionIDWhenOneSignalIDUnavailable() throws { @@ -87,15 +87,14 @@ final class OSMessagingControllerUserStateTests: XCTestCase { /* Verify */ // The controller should have stored the subscription ID for retry - let shouldFetchOnUserChangeWithSubscriptionID = OSMessagingController.sharedInstance().value(forKey: "shouldFetchOnUserChangeWithSubscriptionID") - XCTAssertEqual(shouldFetchOnUserChangeWithSubscriptionID as! String, testSubscriptionId) + XCTAssertEqual(OSMessagingController.sharedInstance().deferredFetchSubscriptionId, testSubscriptionId) // Verify no IAM request was actually made (since we don't have OneSignal ID) XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestGetInAppMessages.self, expectedCount: 0)) } /** - Test that when user state changes with a valid OneSignal ID and shouldFetchOnUserChangeWithSubscriptionID is set, it retries the fetch. + Test that when user state changes with a valid OneSignal ID and deferredFetchSubscriptionId is set, it retries the fetch. Scenario: - IAM fetch was previously deferred due to missing OneSignal ID @@ -104,7 +103,7 @@ final class OSMessagingControllerUserStateTests: XCTestCase { Expected: - IAM fetch is retried with the stored subscription ID - - shouldFetchOnUserChangeWithSubscriptionID is cleared + - deferredFetchSubscriptionId is cleared */ func testRetriesFetchWhenUserStateChangesWithValidOneSignalID() throws { /* Setup */ @@ -129,7 +128,7 @@ final class OSMessagingControllerUserStateTests: XCTestCase { OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) // Verify the subscription ID was stored and no IAM fetch occurred - XCTAssertEqual(controller.value(forKey: "shouldFetchOnUserChangeWithSubscriptionID") as! String, testSubscriptionId) + XCTAssertEqual(controller.deferredFetchSubscriptionId, testSubscriptionId) XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestGetInAppMessages.self, expectedCount: 0)) // Now let the login succeed, receive onesignal ID which fires user state observer @@ -143,21 +142,21 @@ final class OSMessagingControllerUserStateTests: XCTestCase { XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestGetInAppMessages.self, expectedCount: 1)) // The stored subscription ID should be cleared after successful retry - XCTAssertNil(controller.value(forKey: "shouldFetchOnUserChangeWithSubscriptionID")) + XCTAssertNil(controller.deferredFetchSubscriptionId) } /** - Test that when user state changes but shouldFetchOnUserChangeWithSubscriptionID is not set, it does nothing. - + Test that logging in refetches in-app messages for the user signing in. + Scenario: - - Normal user state change occurs - - No deferred fetch was pending - + - A user's in-app messages have already been fetched + - The app logs in as someone else + Expected: - - No retry logic is triggered - - Normal operation continues + - A second fetch goes out once the new user has a OneSignal ID, so the previous user's messages are + not what gets evaluated */ - func testDoesNothingWhenNoRetryPending() throws { + func testLoginRefetchesInAppMessagesForTheIncomingUser() throws { /* Setup */ let client = MockOneSignalClient() OneSignalCoreImpl.setSharedClient(client) @@ -177,16 +176,15 @@ final class OSMessagingControllerUserStateTests: XCTestCase { /* Verify */ // IAM is fetched and no retry is pending XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestGetInAppMessages.self, expectedCount: 1)) - XCTAssertNil(controller.value(forKey: "shouldFetchOnUserChangeWithSubscriptionID")) + XCTAssertNil(controller.deferredFetchSubscriptionId) /* Execute */ - // Trigger a normal user state change by login MockUserRequests.setDefaultIdentifyUserResponses(with: client, externalId: testExternalId) OneSignalUserManagerImpl.sharedInstance.login(externalId: testExternalId, token: nil) OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) /* Verify */ - // Does not fetch IAMs again - XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestGetInAppMessages.self, expectedCount: 1)) + XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestGetInAppMessages.self, expectedCount: 2)) + XCTAssertNil(controller.deferredFetchSubscriptionId) } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OneSignalInAppMessagesTests-Bridging-Header.h b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OneSignalInAppMessagesTests-Bridging-Header.h index 813316635..8f671baae 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OneSignalInAppMessagesTests-Bridging-Header.h +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OneSignalInAppMessagesTests-Bridging-Header.h @@ -14,6 +14,7 @@ @property (strong, nonatomic, nonnull) NSMutableDictionary *redisplayedInAppMessages; @property (strong, nonatomic, nonnull) NSMutableArray *messages; @property (strong, nonatomic, nonnull) OSTriggerController *triggerController; +@property (strong, nonatomic, nullable) NSString *deferredFetchSubscriptionId; + (void)start; + (void)removeInstance; - (void)presentInAppPreviewMessage:(OSInAppMessageInternal *)message; diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift index 859353d90..a4fb95fe0 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift @@ -63,6 +63,7 @@ extension OneSignalUserManagerImpl { } operationRepo.addFlushDeltaQueueToDispatchQueue() userExecutor?.executePendingRequests() + NotificationCenter.default.post(name: Notification.Name(OS_ON_USER_JWT_UPDATED), object: nil) } /** @@ -107,4 +108,23 @@ extension OneSignalUserManagerImpl { storeJwt(externalId: externalId, token: token) } + + /** + How another module should address and sign a user-scoped call for the current user, decided in one + read so the alias and the token cannot come from different users. + + Returns nil when the call cannot be sent yet — the requirement is still unknown, nobody is logged in + under Identity Verification, or the app owes a token, which this asks for. Callers reattempt when + `OS_ON_JWT_CONFIG_HYDRATED` or `OS_ON_USER_JWT_UPDATED` is posted. + */ + @objc + public func authorizationForCurrentUser() -> OSUserRequestAuthorization? { + // `_user` rather than `user`, which would create a guest user for a caller that only reads. + guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: nil), + let identityModel = _user?.identityModel + else { + return nil + } + return requestAuth.authorization(onesignalId: identityModel.onesignalId, externalId: identityModel.externalId) + } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift index 1cf2695f3..222137988 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift @@ -296,6 +296,9 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { app's own opt-in, which would make the silencing permanent. */ identityVerificationService.addOnJwtConfigHydratedHandler(for: .userManager) { [weak self] requirement in + // Tells work that does not travel through the Repo, such as the in-app message fetch, + // that how to address a user-scoped call is now decided. + NotificationCenter.default.post(name: Notification.Name(OS_ON_JWT_CONFIG_HYDRATED), object: nil) guard requirement == .off else { return } From 51718e0f5fbe27e71be81d49321e622ebe0a07e4 Mon Sep 17 00:00:00 2001 From: Nan Date: Thu, 13 Aug 2026 09:28:59 -0700 Subject: [PATCH 18/21] fix: [PR7] reset in-app messages on user change only while newCodePathsRun Login/logout must not clear or dismiss IAMs for apps with Identity Verification off. When the code paths are on, also drop queued non-preview messages and dismiss a showing one so they cannot stay up under the next user. Co-authored-by: Cursor --- .../Controller/OSMessagingController.m | 40 +++++++++++++++++-- .../OSMessagingControllerUserStateTests.swift | 29 ++++++++++++++ .../Source/OneSignalUserManagerImpl+Jwt.swift | 5 +++ 3 files changed, 70 insertions(+), 4 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.m b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.m index eaff70122..e456a4570 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.m +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.m @@ -153,8 +153,8 @@ @interface OSMessagingController () */ @property (strong, nonatomic, nullable) NSString *deferredFetchSubscriptionId; -/// Bumped on every login and logout. A fetch carries the value it started with, so a response that -/// arrives after the user changed is discarded instead of showing one user's messages to another. +/// Bumped on login and logout while `newCodePathsRun`. A fetch carries the value it started with, so a +/// response that arrives after the user changed is discarded instead of showing one user's messages to another. @property (nonatomic) NSUInteger userGeneration; /// Tracks whether the first IAM fetch has completed since this cold start @@ -342,10 +342,13 @@ - (BOOL)isCurrentUserGeneration:(NSUInteger)generation { /** Drops the outgoing user's in-app messages and invalidates any fetch still in flight for them, then - queues one for whoever is signing in. The new user's fetch goes out from `onUserStateDidChange`, once - there is a `onesignal_id` to address it by. + queues one for whoever is signing in. Only while `newCodePathsRun`. The new user's fetch goes out + from `onUserStateDidChange`, once there is a `onesignal_id` to address it by. */ - (void)onUserWillChange { + if (!OneSignalUserManagerImpl.sharedInstance.newCodePathsRun) { + return; + } @synchronized (self) { self.userGeneration += 1; } @@ -353,9 +356,38 @@ - (void)onUserWillChange { // On main, where every other write to `messages` happens. dispatch_async(dispatch_get_main_queue(), ^{ self.messages = @[]; + [self dismissOutgoingUserInAppMessages]; }); } +/// Leaves preview IAMs; dismisses a showing non-preview so it cannot stay up under the next user. +- (void)dismissOutgoingUserInAppMessages { + BOOL shouldDismiss = NO; + @synchronized (self.messageDisplayQueue) { + OSInAppMessageInternal *showing = nil; + if (self.isInAppMessageShowing && self.messageDisplayQueue.count > 0) { + OSInAppMessageInternal *first = self.messageDisplayQueue.firstObject; + if (!first.isPreview) { + showing = first; + } + } + NSMutableArray *kept = [NSMutableArray new]; + if (showing) { + [kept addObject:showing]; + } + for (OSInAppMessageInternal *message in self.messageDisplayQueue) { + if (message.isPreview) { + [kept addObject:message]; + } + } + [self.messageDisplayQueue setArray:kept]; + shouldDismiss = showing != nil; + } + if (shouldDismiss) { + [self.viewController dismissCurrentInAppMessage]; + } +} + - (void)deferFetchWithSubscriptionId:(NSString *)subscriptionId { @synchronized (self) { self.deferredFetchSubscriptionId = subscriptionId; diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OSMessagingControllerUserStateTests.swift b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OSMessagingControllerUserStateTests.swift index 3558b21a1..54155d657 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OSMessagingControllerUserStateTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OSMessagingControllerUserStateTests.swift @@ -59,6 +59,7 @@ final class OSMessagingControllerUserStateTests: XCTestCase { } override func tearDownWithError() throws { + OSFeatureManager.shared.setEnabledFeatureKeys([]) OSMessagingController.removeInstance() } @@ -149,6 +150,7 @@ final class OSMessagingControllerUserStateTests: XCTestCase { Test that logging in refetches in-app messages for the user signing in. Scenario: + - Identity Verification code paths are on - A user's in-app messages have already been fetched - The app logs in as someone else @@ -158,6 +160,7 @@ final class OSMessagingControllerUserStateTests: XCTestCase { */ func testLoginRefetchesInAppMessagesForTheIncomingUser() throws { /* Setup */ + OSFeatureManager.shared.setEnabledFeatureKeys([OSFeatureFlag.identityVerification.rawValue]) let client = MockOneSignalClient() OneSignalCoreImpl.setSharedClient(client) OneSignalInAppMessages.start() @@ -187,4 +190,30 @@ final class OSMessagingControllerUserStateTests: XCTestCase { XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestGetInAppMessages.self, expectedCount: 2)) XCTAssertNil(controller.deferredFetchSubscriptionId) } + + /// Without the rollout flag or `jwt_required`, a login leaves the current user's messages as they were. + func testLoginDoesNotRefetchInAppMessagesWhenIdentityVerificationCodePathsAreOff() throws { + let client = MockOneSignalClient() + OneSignalCoreImpl.setSharedClient(client) + OneSignalInAppMessages.start() + let controller = OSMessagingController.sharedInstance() + + MockUserRequests.setDefaultCreateAnonUserResponses( + with: client, + onesignalId: testOneSignalId, + subscriptionId: testSubscriptionId + ) + ConsistencyManagerTestHelpers.setDefaultRywToken(id: testOneSignalId) + OneSignalUserManagerImpl.sharedInstance.start() + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestGetInAppMessages.self, expectedCount: 1)) + + MockUserRequests.setDefaultIdentifyUserResponses(with: client, externalId: testExternalId) + OneSignalUserManagerImpl.sharedInstance.login(externalId: testExternalId, token: nil) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + XCTAssertTrue(client.hasExecutedRequestOfType(OSRequestGetInAppMessages.self, expectedCount: 1)) + XCTAssertNil(controller.deferredFetchSubscriptionId) + } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift index a4fb95fe0..5a424932f 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl+Jwt.swift @@ -109,6 +109,11 @@ extension OneSignalUserManagerImpl { storeJwt(externalId: externalId, token: token) } + /// Rollout flag, or always when the app requires Identity Verification. + @objc public var newCodePathsRun: Bool { + identityVerificationService.newCodePathsRun + } + /** How another module should address and sign a user-scoped call for the current user, decided in one read so the alias and the token cannot come from different users. From e5884a7a4d03d4de99bb900a50cc436b0a41063c Mon Sep 17 00:00:00 2001 From: Nan Date: Thu, 13 Aug 2026 09:37:33 -0700 Subject: [PATCH 19/21] fix: [PR7] refetch IAMs on 401 when a replacement token already landed OS_ON_USER_JWT_UPDATED is a no-op while a fetch is in flight, so parking the 401 would wait for a wakeup that already fired. Co-authored-by: Cursor --- .../Controller/OSMessagingController.m | 53 ++++++++++++++----- .../IamFetchIdentityVerificationTests.swift | 32 +++++++++++ 2 files changed, 72 insertions(+), 13 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.m b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.m index e456a4570..dc7cf17ac 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.m +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.m @@ -294,12 +294,8 @@ - (void)getInAppMessagesFromServer:(NSString *)subscriptionId { return; } - // Resolved before the read-your-write wait, which can hold this thread for as long as it takes - // the user requests to come back. - OSUserRequestAuthorization *authorization = [OneSignalUserManagerImpl.sharedInstance authorizationForCurrentUser]; - if (!authorization) { - [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:@"Failed to get in app messages due to Identity Verification, will reattempt"]; - [self deferFetchWithSubscriptionId:subscriptionId]; + // Park before the wait when the fetch cannot go out; attemptFetchWithRetries reads again after. + if (![self authorizationForFetchOrDefer:subscriptionId]) { return; } @@ -318,7 +314,6 @@ - (void)getInAppMessagesFromServer:(NSString *)subscriptionId { // Initial request [self attemptFetchWithRetries:subscriptionId - authorization:authorization rywData:rywData attempts:@0 // Starting with 0 attempts retryLimit:nil // Retry limit to be set dynamically on first failure @@ -410,6 +405,22 @@ - (void)retryDeferredFetch { } } +/// How to address and sign this fetch. Nil means it is parked until hydration or a token. +- (OSUserRequestAuthorization *)authorizationForFetchOrDefer:(NSString *)subscriptionId { + OSUserRequestAuthorization *authorization = [OneSignalUserManagerImpl.sharedInstance authorizationForCurrentUser]; + if (authorization) { + return authorization; + } + [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:@"Failed to get in app messages due to Identity Verification, will reattempt"]; + [self deferFetchWithSubscriptionId:subscriptionId]; + // OS_ON_USER_JWT_UPDATED can fire before deferredFetchSubscriptionId is set. + authorization = [OneSignalUserManagerImpl.sharedInstance authorizationForCurrentUser]; + if (authorization) { + [self takeDeferredFetchSubscriptionId]; + } + return authorization; +} + /** Parks the fetch for a later token to reattempt, without reporting the one it was signed with. @@ -422,12 +433,17 @@ - (void)handleUnauthorizedFetch:(OSUserRequestAuthorization *)authorization subs if (!authorization.token) { return; } + OSUserRequestAuthorization *current = [OneSignalUserManagerImpl.sharedInstance authorizationForCurrentUser]; + if (current.token.length && ![current.token isEqualToString:authorization.token]) { + // OS_ON_USER_JWT_UPDATED already fired with nothing parked. + [self getInAppMessagesFromServer:subscriptionId]; + return; + } [self deferFetchWithSubscriptionId:subscriptionId]; } - (void)attemptFetchWithRetries:(NSString *)subscriptionId - authorization:(OSUserRequestAuthorization *)authorization rywData:(OSReadYourWriteData *)rywData attempts:(NSNumber *)attempts retryLimit:(NSNumber *)retryLimit @@ -437,6 +453,11 @@ - (void)attemptFetchWithRetries:(NSString *)subscriptionId return; } + OSUserRequestAuthorization *authorization = [self authorizationForFetchOrDefer:subscriptionId]; + if (!authorization) { + return; + } + NSNumber *sessionDuration = @([OSSessionManager.sharedSessionManager getTimeFocusedElapsed]); NSString *rywToken = rywData.rywToken; NSNumber *rywDelay = rywData.rywDelay; @@ -496,14 +517,13 @@ - (void)attemptFetchWithRetries:(NSString *)subscriptionId NSInteger nextAttempt = [attempts integerValue] + 1; // Increment attempts [self retryAfterDelay:retryAfter subscriptionId:subscriptionId - authorization:authorization rywData:rywData attempts:@(nextAttempt) retryLimit:blockRetryLimit userGeneration:generation]; } else { // Final attempt without rywToken - [self fetchInAppMessagesWithoutToken:subscriptionId authorization:authorization userGeneration:generation]; + [self fetchInAppMessagesWithoutToken:subscriptionId userGeneration:generation]; } } else if ([OSNetworkingUtils getResponseStatusType:error.code] == OSResponseStatusUnauthorized) { [self handleUnauthorizedFetch:authorization subscriptionId:subscriptionId]; @@ -515,7 +535,6 @@ - (void)attemptFetchWithRetries:(NSString *)subscriptionId - (void)retryAfterDelay:(NSInteger)retryAfter subscriptionId:(NSString *)subscriptionId - authorization:(OSUserRequestAuthorization *)authorization rywData:(OSReadYourWriteData *)rywData attempts:(NSNumber *)attempts retryLimit:(NSNumber *)retryLimit @@ -524,7 +543,6 @@ - (void)retryAfterDelay:(NSInteger)retryAfter dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(retryAfter * NSEC_PER_SEC)), dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ [self attemptFetchWithRetries:subscriptionId - authorization:authorization rywData:rywData attempts:attempts retryLimit:retryLimit @@ -533,8 +551,17 @@ - (void)retryAfterDelay:(NSInteger)retryAfter } - (void)fetchInAppMessagesWithoutToken:(NSString *)subscriptionId - authorization:(OSUserRequestAuthorization *)authorization userGeneration:(NSUInteger)generation { + if (![self isCurrentUserGeneration:generation]) { + [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:@"Abandoning an in app message fetch for a previous user"]; + return; + } + + OSUserRequestAuthorization *authorization = [self authorizationForFetchOrDefer:subscriptionId]; + if (!authorization) { + return; + } + NSNumber *sessionDuration = @([OSSessionManager.sharedSessionManager getTimeFocusedElapsed]); OSRequestGetInAppMessages *request = [OSRequestGetInAppMessages withSubscriptionId:subscriptionId diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IamFetchIdentityVerificationTests.swift b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IamFetchIdentityVerificationTests.swift index 659d90aba..52031a37a 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IamFetchIdentityVerificationTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IamFetchIdentityVerificationTests.swift @@ -123,6 +123,10 @@ final class IamFetchIdentityVerificationTests: XCTestCase { return client.executedRequests.filter { $0 is OSRequestGetInAppMessages } } + private func startedFetches() -> [OneSignalRequest] { + return client.startedRequests.filter { $0 is OSRequestGetInAppMessages } + } + private func lastFetchPath() -> String? { return executedFetches().last?.path } @@ -232,6 +236,34 @@ final class IamFetchIdentityVerificationTests: XCTestCase { XCTAssertEqual(lastFetchAuthorization(), "Bearer token-b") } + /// A replacement that lands while the fetch is in flight is not parked: `OS_ON_USER_JWT_UPDATED` + /// already fired, and waiting for another would leave the fetch stuck. + func testARejectedFetchRefetchesWhenAReplacementTokenAlreadyLanded() { + OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) + login(token: "token-a") + rejectFetch(from: identifiedUserPath) + let startedBefore = startedFetches().count + let executedBefore = executedFetches().count + + client.holdResponses = true + OneSignalInAppMessages.getFromServer(testPushSubId) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + XCTAssertEqual(startedFetches().count, startedBefore + 1) + XCTAssertEqual(executedFetches().count, executedBefore) + + client.holdResponses = false + OneSignalUserManagerImpl.sharedInstance.updateUserJwt(externalId: userA_EUID, token: "token-b") + client.releaseHeldResponses() + respondToFetch(from: identifiedUserPath) + OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5) + + XCTAssertEqual(executedFetches().count, executedBefore + 2) + XCTAssertEqual(lastFetchAuthorization(), "Bearer token-b") + XCTAssertNil(deferredSubscriptionId()) + XCTAssertEqual(jwtListener.invalidatedExternalIds, []) + } + /// The token the fetch was signed with stays usable, so everything else keeps going out signed. func testARejectedFetchLeavesTheTokenInPlaceForTheRequests() { OSCoreMocks.hydrateSharedJwtConfig(requiresUserAuth: true) From 1fbdef95ca07f45b159cba8e6fa33835c8d58be1 Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 11 Aug 2026 16:57:51 -0700 Subject: [PATCH 20/21] feat: [PR8] demo app Identity Verification UI Exercises the Identity Verification surface end to end: log in with a token, watch a token be rejected and supply a replacement through the invalidated listener, and see how the SDK behaves while the requirement is still unknown. Co-authored-by: Cursor --- examples/demo/App.xcodeproj/project.pbxproj | 22 +++--- examples/demo/App/Models/AppModels.swift | 9 ++- .../demo/App/Services/OneSignalService.swift | 35 +++++++++- .../App/Services/PreferencesService.swift | 28 +++++++- .../demo/App/Services/UserFetchService.swift | 27 +++++-- .../App/ViewModels/OneSignalViewModel.swift | 68 ++++++++++++++++-- .../Views/Components/LoginUserDialog.swift | 70 +++++++++++++++++++ .../demo/App/Views/Sections/UserSection.swift | 38 ++++++++-- 8 files changed, 266 insertions(+), 31 deletions(-) create mode 100644 examples/demo/App/Views/Components/LoginUserDialog.swift diff --git a/examples/demo/App.xcodeproj/project.pbxproj b/examples/demo/App.xcodeproj/project.pbxproj index c2eb9fe65..23fca3d88 100644 --- a/examples/demo/App.xcodeproj/project.pbxproj +++ b/examples/demo/App.xcodeproj/project.pbxproj @@ -41,6 +41,7 @@ 5B959D44AB09CB821C00AFBF /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35F726E64F9B6817F917227C /* ContentView.swift */; }; 5F4B7EC8437D1A8D80DF7674 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20B46F63592FC67B655BEB8 /* NotificationService.swift */; }; 638B81D9DA5FD8636BB038B0 /* OneSignalUser.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EF49509A218369322ECFA3B9 /* OneSignalUser.framework */; }; + 673E0146B4B27EB81FCF50DA /* LoginUserDialog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DD26AED9EA84B0308C1BFE8 /* LoginUserDialog.swift */; }; 674995A7A55C13341317E19B /* OSDialog.swift in Sources */ = {isa = PBXBuildFile; fileRef = D261D46C404E325CBA87A9E0 /* OSDialog.swift */; }; 68BC99D15FDCB26EB35EBB07 /* OneSignalLocation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FE9834773C437CC373607693 /* OneSignalLocation.framework */; }; 6E3E040FD8A750248E70E46F /* AppModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EA9D80191548D49F09D30B3 /* AppModels.swift */; }; @@ -321,6 +322,7 @@ 38138523A8A81A60A77800CA /* NotificationSender.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSender.swift; sourceTree = ""; }; 3A654457BF0A55B54220E669 /* RemoveMultiDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoveMultiDialog.swift; sourceTree = ""; }; 3B81D7E1A7EB9BB4466C768F /* SendPushSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendPushSection.swift; sourceTree = ""; }; + 3DD26AED9EA84B0308C1BFE8 /* LoginUserDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginUserDialog.swift; sourceTree = ""; }; 3FD8258E807E6672642A32E6 /* UserFetchService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserFetchService.swift; sourceTree = ""; }; 4072202943CA64D2CBC38CB5 /* MultiPairInputDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiPairInputDialog.swift; sourceTree = ""; }; 4078B5F096680AFA83D1CB85 /* OutcomesSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutcomesSection.swift; sourceTree = ""; }; @@ -358,7 +360,7 @@ E194A3F19072CB17A8F1A12E /* SmsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmsSection.swift; sourceTree = ""; }; ECAC7EF0B67920F9FEC4F129 /* TagsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagsSection.swift; sourceTree = ""; }; F46DFACB9F304B9374F3C570 /* ToastView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastView.swift; sourceTree = ""; }; - "TEMP_E243B27C-2274-4671-9C94-8B86EB8D4EFA" /* Build.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Build.xcconfig; sourceTree = ""; }; + "TEMP_4292FBC8-5C5B-4FEC-8FC6-E00954AA81D6" /* Build.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Build.xcconfig; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -504,6 +506,7 @@ 2D959B1636916DAEE5FE6278 /* CustomNotificationDialog.swift */, 5984B93007C6B85AFE09045A /* KeyValueRow.swift */, 280B23B41935EAB89C8C6FCB /* ListWidgets.swift */, + 3DD26AED9EA84B0308C1BFE8 /* LoginUserDialog.swift */, 4072202943CA64D2CBC38CB5 /* MultiPairInputDialog.swift */, D261D46C404E325CBA87A9E0 /* OSDialog.swift */, D54B9DAAEDBE67B73893C522 /* OutcomeDialog.swift */, @@ -564,10 +567,10 @@ path = Views; sourceTree = ""; }; - "TEMP_9E448277-C21D-48E5-98E2-992293BCF68A" /* demo */ = { + "TEMP_22A9BEEB-F02D-4CD8-B729-B06597DB45B7" /* demo */ = { isa = PBXGroup; children = ( - "TEMP_E243B27C-2274-4671-9C94-8B86EB8D4EFA" /* Build.xcconfig */, + "TEMP_4292FBC8-5C5B-4FEC-8FC6-E00954AA81D6" /* Build.xcconfig */, ); path = demo; sourceTree = ""; @@ -810,6 +813,7 @@ 7D2BA9022E77B00205453467 /* LiveActivityController.swift in Sources */, DBDA727D6317A3CCC73A1699 /* LiveActivitySection.swift in Sources */, B3AE701075398C6A369DBBE0 /* LocationSection.swift in Sources */, + 673E0146B4B27EB81FCF50DA /* LoginUserDialog.swift in Sources */, E3725231A3FD5F5A88BAA758 /* MultiPairInputDialog.swift in Sources */, 4C18E3D284BB28BD846162F3 /* NotificationSender.swift in Sources */, 674995A7A55C13341317E19B /* OSDialog.swift in Sources */, @@ -936,7 +940,7 @@ /* Begin XCBuildConfiguration section */ 0D2EF3911CA89837C30DB0D1 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = "TEMP_E243B27C-2274-4671-9C94-8B86EB8D4EFA" /* Build.xcconfig */; + baseConfigurationReference = "TEMP_4292FBC8-5C5B-4FEC-8FC6-E00954AA81D6" /* Build.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; @@ -954,7 +958,7 @@ }; 4A0C935808978B5A7673E412 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = "TEMP_E243B27C-2274-4671-9C94-8B86EB8D4EFA" /* Build.xcconfig */; + baseConfigurationReference = "TEMP_4292FBC8-5C5B-4FEC-8FC6-E00954AA81D6" /* Build.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = App.entitlements; @@ -1034,7 +1038,7 @@ }; D0E56A85F1C385808720F94B /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = "TEMP_E243B27C-2274-4671-9C94-8B86EB8D4EFA" /* Build.xcconfig */; + baseConfigurationReference = "TEMP_4292FBC8-5C5B-4FEC-8FC6-E00954AA81D6" /* Build.xcconfig */; buildSettings = { CODE_SIGN_ENTITLEMENTS = OneSignalNotificationServiceExtension/OneSignalNotificationServiceExtension.entitlements; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; @@ -1056,7 +1060,7 @@ }; EB1CC3A930E09FEBECF9195D /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = "TEMP_E243B27C-2274-4671-9C94-8B86EB8D4EFA" /* Build.xcconfig */; + baseConfigurationReference = "TEMP_4292FBC8-5C5B-4FEC-8FC6-E00954AA81D6" /* Build.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = App.entitlements; @@ -1079,7 +1083,7 @@ }; F305A3E63851EE49DA2D190E /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = "TEMP_E243B27C-2274-4671-9C94-8B86EB8D4EFA" /* Build.xcconfig */; + baseConfigurationReference = "TEMP_4292FBC8-5C5B-4FEC-8FC6-E00954AA81D6" /* Build.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; @@ -1102,7 +1106,7 @@ }; F5FD25168D9B32A08A468069 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = "TEMP_E243B27C-2274-4671-9C94-8B86EB8D4EFA" /* Build.xcconfig */; + baseConfigurationReference = "TEMP_4292FBC8-5C5B-4FEC-8FC6-E00954AA81D6" /* Build.xcconfig */; buildSettings = { CODE_SIGN_ENTITLEMENTS = OneSignalNotificationServiceExtension/OneSignalNotificationServiceExtension.entitlements; DEVELOPMENT_TEAM = 99SW8E36CT; diff --git a/examples/demo/App/Models/AppModels.swift b/examples/demo/App/Models/AppModels.swift index 70ccd615a..d98591df0 100644 --- a/examples/demo/App/Models/AppModels.swift +++ b/examples/demo/App/Models/AppModels.swift @@ -80,6 +80,7 @@ enum AddItemType { case tag case trigger case externalUserId + case updateUserJwt var title: String { switch self { @@ -89,12 +90,13 @@ enum AddItemType { case .tag: return "Add Tag" case .trigger: return "Add Trigger" case .externalUserId: return "Login User" + case .updateUserJwt: return "Update User JWT" } } var requiresKeyValue: Bool { switch self { - case .alias, .tag, .trigger: return true + case .alias, .tag, .trigger, .updateUserJwt: return true case .email, .sms, .externalUserId: return false } } @@ -103,6 +105,7 @@ enum AddItemType { switch self { case .alias: return "Label" case .tag, .trigger: return "Key" + case .updateUserJwt: return "External User Id" default: return "Key" } } @@ -114,6 +117,7 @@ enum AddItemType { case .sms: return "Phone Number" case .tag, .trigger: return "Value" case .externalUserId: return "External User Id" + case .updateUserJwt: return "JWT Token" } } @@ -141,6 +145,7 @@ enum AddItemType { case .tag: return "tag" case .trigger: return "trigger" case .externalUserId: return "login_user_id" + case .updateUserJwt: return "update_jwt" } } @@ -152,6 +157,7 @@ enum AddItemType { case .alias: return "alias_label_input" case .tag: return "tag_key_input" case .trigger: return "trigger_key_input" + case .updateUserJwt: return "update_jwt_external_id_input" default: return "\(accessibilityKey)_key_input" } } @@ -165,6 +171,7 @@ enum AddItemType { case .alias: return "alias_id_input" case .tag: return "tag_value_input" case .trigger: return "trigger_value_input" + case .updateUserJwt: return "update_jwt_token_input" default: return "\(accessibilityKey)_input" } } diff --git a/examples/demo/App/Services/OneSignalService.swift b/examples/demo/App/Services/OneSignalService.swift index 598189ea0..7b1b45af3 100644 --- a/examples/demo/App/Services/OneSignalService.swift +++ b/examples/demo/App/Services/OneSignalService.swift @@ -96,16 +96,39 @@ final class OneSignalService { // MARK: - User - func login(externalId: String) { + func login(externalId: String, jwtToken: String? = nil) { prefs.setExternalUserId(externalId) - OneSignal.login(externalId) + // Persist for the demo REST fetch only; cold start does not call login/updateUserJwt with it. + prefs.setSessionJwtToken(jwtToken) + if let jwtToken = jwtToken { + OneSignal.login(externalId: externalId, token: jwtToken) + } else { + OneSignal.login(externalId) + } + } + + func updateUserJwt(externalId: String, token: String) { + prefs.setSessionJwtToken(token) + OneSignal.updateUserJwt(externalId: externalId, token: token) } func logout() { prefs.setExternalUserId(nil) + prefs.setSessionJwtToken(nil) OneSignal.logout() } + // MARK: - Identity Verification (demo REST fetch) + + /// Demo toggle for addressing the REST user fetch by `external_id`. Persisted across launches. + var useIdentityVerification: Bool { + get { prefs.getUseIdentityVerification() } + set { prefs.setUseIdentityVerification(newValue) } + } + + /// JWT from the last login / updateUserJwt. Used by the demo REST fetch only — not auto-fed to the SDK on cold start. + var sessionJwtToken: String? { prefs.getSessionJwtToken() } + // MARK: - Aliases func addAlias(label: String, id: String) { OneSignal.User.addAlias(label: label, id: id) } @@ -222,6 +245,14 @@ final class OneSignalService { OneSignal.Notifications.addPermissionObserver(observer) } + func addUserJwtInvalidatedListener(_ listener: OSUserJwtInvalidatedListener) { + OneSignal.addUserJwtInvalidatedListener(listener) + } + + func removeUserJwtInvalidatedListener(_ listener: OSUserJwtInvalidatedListener) { + OneSignal.removeUserJwtInvalidatedListener(listener) + } + func addNotificationClickListener(_ listener: OSNotificationClickListener) { OneSignal.Notifications.addClickListener(listener) } diff --git a/examples/demo/App/Services/PreferencesService.swift b/examples/demo/App/Services/PreferencesService.swift index b055224f7..f10e31358 100644 --- a/examples/demo/App/Services/PreferencesService.swift +++ b/examples/demo/App/Services/PreferencesService.swift @@ -28,9 +28,9 @@ import Foundation /// `UserDefaults`-backed cache for state the demo restores across cold launches: -/// consent flags, IAM paused, location shared, and the last-logged-in external -/// user id. Mirrors the Capacitor demo's `PreferencesService` so the iOS demo -/// re-feeds these into the SDK during initialization. +/// consent flags, IAM paused, location shared, the last-logged-in external user id, +/// the Identity Verification toggle, and the demo REST JWT. The JWT is only for the +/// demo's `/users` fetch — it is not automatically passed to `login` / `updateUserJwt`. final class PreferencesService { static let shared = PreferencesService() @@ -47,6 +47,8 @@ final class PreferencesService { static let iamPaused = "onesignal.demo.iamPaused" static let locationShared = "onesignal.demo.locationShared" static let externalUserId = "onesignal.demo.externalUserId" + static let useIdentityVerification = "onesignal.demo.useIdentityVerification" + static let sessionJwtToken = "onesignal.demo.sessionJwtToken" } // MARK: - Consent @@ -83,4 +85,24 @@ final class PreferencesService { defaults.removeObject(forKey: Key.externalUserId) } } + + // MARK: - Identity Verification (demo REST fetch) + + func getUseIdentityVerification() -> Bool { defaults.bool(forKey: Key.useIdentityVerification) } + func setUseIdentityVerification(_ value: Bool) { defaults.set(value, forKey: Key.useIdentityVerification) } + + func getSessionJwtToken() -> String? { + guard let value = defaults.string(forKey: Key.sessionJwtToken), !value.isEmpty else { + return nil + } + return value + } + + func setSessionJwtToken(_ value: String?) { + if let value = value, !value.isEmpty { + defaults.set(value, forKey: Key.sessionJwtToken) + } else { + defaults.removeObject(forKey: Key.sessionJwtToken) + } + } } diff --git a/examples/demo/App/Services/UserFetchService.swift b/examples/demo/App/Services/UserFetchService.swift index 836559eaa..8e258884c 100644 --- a/examples/demo/App/Services/UserFetchService.swift +++ b/examples/demo/App/Services/UserFetchService.swift @@ -27,17 +27,36 @@ import Foundation -/// Reads the OneSignal /users API to hydrate aliases / tags / channels in the demo +/// Reads the OneSignal /users API to hydrate aliases / tags / channels in the demo. final class UserFetchService { static let shared = UserFetchService() private init() {} - func fetchUser(appId: String, onesignalId: String) async -> UserData? { - let urlString = "https://api.onesignal.com/apps/\(appId)/users/by/onesignal_id/\(onesignalId)" - guard let url = URL(string: urlString) else { return nil } + /// Fetches by `onesignal_id` (unsigned) or `external_id` (Bearer when `jwt` is set). + func fetchUser( + appId: String, + aliasLabel: String, + aliasValue: String, + jwt: String? = nil + ) async -> UserData? { + guard !aliasValue.isEmpty else { return nil } + + // Path-encode so external_ids with reserved characters don't misroute the GET. + var allowed = CharacterSet.urlPathAllowed + allowed.remove(charactersIn: "/") + guard + let encodedAlias = aliasValue.addingPercentEncoding(withAllowedCharacters: allowed), + let url = URL(string: "https://api.onesignal.com/apps/\(appId)/users/by/\(aliasLabel)/\(encodedAlias)") + else { + return nil + } var request = URLRequest(url: url) request.httpMethod = "GET" + request.setValue("application/json", forHTTPHeaderField: "Accept") + if let jwt = jwt { + request.setValue("Bearer \(jwt)", forHTTPHeaderField: "Authorization") + } do { let (data, response) = try await URLSession.shared.data(for: request) diff --git a/examples/demo/App/ViewModels/OneSignalViewModel.swift b/examples/demo/App/ViewModels/OneSignalViewModel.swift index dfa23553a..a60511018 100644 --- a/examples/demo/App/ViewModels/OneSignalViewModel.swift +++ b/examples/demo/App/ViewModels/OneSignalViewModel.swift @@ -43,6 +43,7 @@ final class OneSignalViewModel: ObservableObject { @Published var externalUserId: String? @Published var aliases: [KeyValueItem] = [] + @Published var useIdentityVerification: Bool = false // MARK: - Push @@ -99,6 +100,7 @@ final class OneSignalViewModel: ObservableObject { self.appId = service.appId self.consentRequired = service.consentRequired self.consentGiven = service.consentGiven + self.useIdentityVerification = service.useIdentityVerification self.externalUserId = service.externalId ?? prefs.getExternalUserId() self.hasNotificationPermission = service.hasNotificationPermission refreshState() @@ -106,7 +108,12 @@ final class OneSignalViewModel: ObservableObject { TooltipService.shared.loadIfNeeded() - if service.onesignalId != nil { + // Demo REST hydrate only — does not call login / updateUserJwt with a cached JWT. + if useIdentityVerification { + if externalUserId != nil || service.externalId != nil { + Task { await fetchUserDataFromApi() } + } + } else if service.onesignalId != nil { Task { await fetchUserDataFromApi() } } } @@ -119,19 +126,43 @@ final class OneSignalViewModel: ObservableObject { isInAppMessagesPaused = service.isInAppMessagesPaused isLocationShared = service.isLocationShared hasNotificationPermission = service.hasNotificationPermission - externalUserId = service.externalId + externalUserId = service.externalId ?? prefs.getExternalUserId() let sdkTags = service.getTags() tags = sdkTags.map { KeyValueItem(key: $0.key, value: $0.value) } } func fetchUserDataFromApi() async { - guard let onesignalId = service.onesignalId else { return } + let aliasLabel: String + let aliasValue: String + let jwt: String? + + if useIdentityVerification { + guard let externalId = externalUserId ?? service.externalId, !externalId.isEmpty else { + return + } + aliasLabel = "external_id" + aliasValue = externalId + jwt = service.sessionJwtToken + } else { + guard let onesignalId = service.onesignalId, !onesignalId.isEmpty else { + return + } + aliasLabel = "onesignal_id" + aliasValue = onesignalId + jwt = nil + } + requestSequence &+= 1 let captured = requestSequence isLoading = true - let userData = await UserFetchService.shared.fetchUser(appId: appId, onesignalId: onesignalId) + let userData = await UserFetchService.shared.fetchUser( + appId: appId, + aliasLabel: aliasLabel, + aliasValue: aliasValue, + jwt: jwt + ) // Drop the result if a newer fetch has started while this one was in flight. guard captured == requestSequence else { return } @@ -166,21 +197,35 @@ final class OneSignalViewModel: ObservableObject { // MARK: - User - func login(externalId: String) { + func login(externalId: String, jwtToken: String? = nil) { let trimmed = externalId.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return } isLoading = true - service.login(externalId: trimmed) + service.login(externalId: trimmed, jwtToken: jwtToken) externalUserId = trimmed clearUserData() } + func updateUserJwt(externalId: String, token: String) { + let trimmedId = externalId.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedToken = token.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedId.isEmpty, !trimmedToken.isEmpty else { return } + service.updateUserJwt(externalId: trimmedId, token: trimmedToken) + print("[OneSignal] Updated JWT for: \(trimmedId)") + } + func logout() { service.logout() externalUserId = nil clearUserData() } + func setUseIdentityVerification(_ enabled: Bool) { + useIdentityVerification = enabled + service.useIdentityVerification = enabled + print("[OneSignal] Identity verification \(enabled ? "enabled" : "disabled")") + } + private func clearUserData() { aliases.removeAll() emails.removeAll() @@ -443,12 +488,17 @@ final class OneSignalViewModel: ObservableObject { service.addPushSubscriptionObserver(observers) service.addUserObserver(observers) service.addPermissionObserver(observers) + service.addUserJwtInvalidatedListener(observers) } } // MARK: - Observer Bridge -private final class Observers: NSObject, OSPushSubscriptionObserver, OSUserStateObserver, OSNotificationPermissionObserver { +private final class Observers: NSObject, + OSPushSubscriptionObserver, + OSUserStateObserver, + OSNotificationPermissionObserver, + OSUserJwtInvalidatedListener { weak var viewModel: OneSignalViewModel? func onPushSubscriptionDidChange(state: OSPushSubscriptionChangedState) { @@ -470,4 +520,8 @@ private final class Observers: NSObject, OSPushSubscriptionObserver, OSUserState viewModel?.isPushEnabled = OneSignal.User.pushSubscription.optedIn } } + + func onUserJwtInvalidated(event: OSUserJwtInvalidatedEvent) { + print("[OneSignal] JWT invalidated for externalId: \(event.externalId)") + } } diff --git a/examples/demo/App/Views/Components/LoginUserDialog.swift b/examples/demo/App/Views/Components/LoginUserDialog.swift new file mode 100644 index 000000000..537470c53 --- /dev/null +++ b/examples/demo/App/Views/Components/LoginUserDialog.swift @@ -0,0 +1,70 @@ +/** + * 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 SwiftUI + +/// Login / switch-user dialog: external id required, JWT optional. +struct LoginUserDialog: View { + let onLogin: (String, String?) -> Void + let onCancel: () -> Void + + @State private var externalId: String = "" + @State private var jwtToken: String = "" + + var body: some View { + OSDialog( + title: "Login User", + confirmLabel: "Login", + isConfirmEnabled: isValid, + confirmAccessibilityID: "singleinput_confirm_button", + cancelAccessibilityID: "singleinput_cancel_button", + onConfirm: { + let id = externalId.trimmingCharacters(in: .whitespacesAndNewlines) + let token = jwtToken.trimmingCharacters(in: .whitespacesAndNewlines) + onLogin(id, token.isEmpty ? nil : token) + }, + onCancel: onCancel + ) { + VStack(spacing: 12) { + OSTextField( + placeholder: "External User Id", + text: $externalId, + accessibilityID: "login_user_id_input" + ) + OSTextField( + placeholder: "JWT Token (optional)", + text: $jwtToken, + accessibilityID: "login_user_jwt_input" + ) + } + } + } + + private var isValid: Bool { + !externalId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } +} diff --git a/examples/demo/App/Views/Sections/UserSection.swift b/examples/demo/App/Views/Sections/UserSection.swift index 0e0bb5067..b6dca78a1 100644 --- a/examples/demo/App/Views/Sections/UserSection.swift +++ b/examples/demo/App/Views/Sections/UserSection.swift @@ -27,13 +27,24 @@ import SwiftUI -/// Login/logout + status display, mirroring the Capacitor UserSection +/// Login/logout + JWT / Identity Verification controls for manual testing. struct UserSection: View { @EnvironmentObject var viewModel: OneSignalViewModel @State private var loginOpen = false + @State private var updateJwtOpen = false var body: some View { SectionCard(title: "USER", sectionKey: "user") { + ToggleRow( + label: "Identity Verification", + description: "Use external_id for API calls", + isOn: Binding( + get: { viewModel.useIdentityVerification }, + set: { viewModel.setUseIdentityVerification($0) } + ), + accessibilityID: "identity_verification_toggle" + ) + ValueCard(rows: [ ValueCard.Row( label: "Status", @@ -64,16 +75,33 @@ struct UserSection: View { viewModel.logout() } } + + ActionButton( + "UPDATE USER JWT", + style: .outline, + accessibilityID: "update_user_jwt_button" + ) { + updateJwtOpen = true + } } .osCenteredDialog(isPresented: $loginOpen) { - AddItemDialog( - itemType: .externalUserId, - onAdd: { _, value in - viewModel.login(externalId: value) + LoginUserDialog( + onLogin: { externalId, jwt in + viewModel.login(externalId: externalId, jwtToken: jwt) loginOpen = false }, onCancel: { loginOpen = false } ) } + .osCenteredDialog(isPresented: $updateJwtOpen) { + AddItemDialog( + itemType: .updateUserJwt, + onAdd: { externalId, token in + viewModel.updateUserJwt(externalId: externalId, token: token) + updateJwtOpen = false + }, + onCancel: { updateJwtOpen = false } + ) + } } } From 3f0ca5edad585e74668c5953cbc8ccc715a99201 Mon Sep 17 00:00:00 2001 From: Nan Date: Thu, 13 Aug 2026 10:01:32 -0700 Subject: [PATCH 21/21] chore: [PR8] (will revert) add local feature-flag override for testing Mirror Android FeatureManager.localFeatureOverrides so testers can force sdk_identity_verification on without waiting on remote config. Co-Authored-By: Cursor --- .../Source/OSFeatureManager.swift | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSFeatureManager.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSFeatureManager.swift index 1283f7fd4..1721f2d8a 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSFeatureManager.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSFeatureManager.swift @@ -48,21 +48,33 @@ public final class OSFeatureManager: OSFeatureManagerProtocol { /// deliver flag keys. Everything below them is handed the feature manager when it is created. public static let shared = OSFeatureManager() + /** + Local-only test hook for forcing features ON without backend config. + Add keys here while testing locally, e.g. `[OSFeatureFlag.identityVerification.rawValue]`. + */ + private static let localFeatureOverrides: Set = [] + // private static let localFeatureOverrides: Set = [ + // OSFeatureFlag.identityVerification.rawValue + // ] + private let lock = NSLock() private var enabledKeys: Set public init() { enabledKeys = OSFeatureManager.cachedKeys() + OSFeatureManager.warnIfLocalOverrides() } /// Bypasses the cache so tests can force flags on. public init(enabledKeys: Set) { self.enabledKeys = Set(enabledKeys.map(OSFeatureManager.canonicalize)) + OSFeatureManager.warnIfLocalOverrides() } public func isEnabled(_ feature: OSFeatureFlag) -> Bool { return lock.withLock { enabledKeys.contains(feature.rawValue) + || OSFeatureManager.localFeatureOverrides.contains(feature.rawValue) } } @@ -99,4 +111,15 @@ public final class OSFeatureManager: OSFeatureManagerProtocol { } return Set(cached.map(canonicalize)) } + + private static func warnIfLocalOverrides() { + let overrides = Set(localFeatureOverrides.map(canonicalize)) + guard !overrides.isEmpty else { + return + } + OneSignalLog.onesignalLog( + .LL_WARN, + message: "OSFeatureManager: local feature override enabled for testing only: \(overrides)" + ) + } }