diff --git a/CHANGELOG.md b/CHANGELOG.md index 814f5af73c9..0ad653695c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,12 +2,39 @@ ## Unreleased +> [!Warning] +> **Session Replay is disabled by default on iOS 26.0+ with Xcode 26.0+ to prevent PII leaks** +> +> Due to potential masking issues introduced by Apple's Liquid Glass rendering changes in iOS 26.0, Session Replay is now **automatically disabled** on apps running iOS 26.0+ when built with Xcode 26.0 or later. This is a defensive measure to protect user privacy and prevent potential PII leaks until masking is reliably supported. +> +> Session replay will work normally if: +> +> - Your app runs on iOS versions older than 26.0, OR +> - Your app is built with Xcode versions older than 26.0, OR +> - Your app explicitly sets `UIDesignRequiresCompatibility` to `YES` in `Info.plist` +> +> **Override (use with caution):** If you understand the PII risks and want to enable session replay anyway, you can set: +> +> ```swift +> options.experimental.enableSessionReplayInUnreliableEnvironment = true +> ``` +> +> This experimental override option will be removed in a future minor version once the masking issues are resolved. + ### Fixes - Fix wrong Frame Delay when becoming active, which lead to false reported app hangs when the app moves to the foreground after being in the background (#6393) +- Session replay is now automatically disabled in environments with unreliable masking to prevent PII leaks (#6389) + - Detects iOS 26.0+ runtime with Xcode 26.0+ builds (DTXcode >= 2600) + - Detects missing or disabled `UIDesignRequiresCompatibility` + - Uses defensive approach: assumes unsafe unless proven safe +- Add `options.experimental.enableSessionReplayInUnreliableEnvironment` to allow overriding the automatic disabling (#6389) ## 8.56.2 +> [!Warning] +> Session Replay in this version does not correctly mask views when built with Xcode 26 and running on iOS 26 with Liquid Glass, which may lead to PII leaks. Please upgrade to 8.57.0 or later, which automatically **disables session replay** in such environments. + ### Fixes - Fix crash from null UIApplication in SwiftUI apps (#6264) @@ -17,6 +44,9 @@ > [!Warning] > This version can cause runtime crashes because the `UIApplication.sharedApplication`/`NSApplication.sharedApplication` is not yet available during SDK initialization, due to the changes in [PR #5900](https://github.com/getsentry/sentry-cocoa/pull/5900), released in [8.56.0](https://github.com/getsentry/sentry-cocoa/releases/tag/8.56.0). +> [!Warning] +> Session Replay in this version does not correctly mask views when built with Xcode 26 and running on iOS 26 with Liquid Glass, which may lead to PII leaks. Please upgrade to 8.57.0 or later, which automatically **disables session replay** in such environments. + ### Fixes - Fix potential app launch hang caused by the SentrySDK (#6181) @@ -28,6 +58,9 @@ > [!Warning] > This version can cause runtime crashes because the `UIApplication.sharedApplication`/`NSApplication.sharedApplication` is not yet available during SDK initialization, due to the changes in [PR #5900](https://github.com/getsentry/sentry-cocoa/pull/5900), released in [8.56.0](https://github.com/getsentry/sentry-cocoa/releases/tag/8.56.0). +> [!Warning] +> Session Replay in this version does not correctly mask views when built with Xcode 26 and running on iOS 26 with Liquid Glass, which may lead to PII leaks. Please upgrade to 8.57.0 or later, which automatically **disables session replay** in such environments. + ### Features - Structured Logs: Flush logs on SDK flush/close (#5834) @@ -110,6 +143,9 @@ ## 8.55.1 +> [!Warning] +> Session Replay in this version does not correctly mask views when built with Xcode 26 and running on iOS 26 with Liquid Glass, which may lead to PII leaks. Please upgrade to 8.57.0 or later, which automatically **disables session replay** in such environments. + ### Features ### Fixes @@ -136,6 +172,9 @@ > If your app does not need arm64e, you don't need to make any changes. > But if your app _needs arm64e_ please use `Sentry-Dynamic-WithARM64e` or `Sentry-WithoutUIKitOrAppKit-WithARM64e` from 8.55.0 so you don't have issues uploading to the App Store. +> [!Warning] +> Session Replay in this version does not correctly mask views when built with Xcode 26 and running on iOS 26 with Liquid Glass, which may lead to PII leaks. Please upgrade to 8.57.0 or later, which automatically **disables session replay** in such environments. + ### Features - Add a new prebuilt framework with arm64e and remove it from the regular one (#5788) @@ -159,6 +198,9 @@ ## 8.54.0 +> [!Warning] +> Session Replay in this version does not correctly mask views when built with Xcode 26 and running on iOS 26 with Liquid Glass, which may lead to PII leaks. Please upgrade to 8.57.0 or later, which automatically **disables session replay** in such environments. + ### Features - Add experimental support for capturing structured logs via `SentrySDK.logger` (#5532, #5593, #5639, #5628, #5637, #5643) diff --git a/Samples/SentrySampleShared/SentrySampleShared/SentrySDKOverrides.swift b/Samples/SentrySampleShared/SentrySampleShared/SentrySDKOverrides.swift index f98701f99c7..9ad8e077cb7 100644 --- a/Samples/SentrySampleShared/SentrySampleShared/SentrySDKOverrides.swift +++ b/Samples/SentrySampleShared/SentrySampleShared/SentrySDKOverrides.swift @@ -110,6 +110,8 @@ public enum SentrySDKOverrides: String, CaseIterable { case disableMaskAllImages = "--io.sentry.session-replay.disable-mask-all-images" case disableMaskAllText = "--io.sentry.session-replay.disable-mask-all-text" + + case enableInUnreliableEnvironment = "--io.sentry.session-replay.enable-in-unreliable-environment" } case sessionReplay = "Session Replay" @@ -324,7 +326,7 @@ extension SentrySDKOverrides.Performance { extension SentrySDKOverrides.SessionReplay { public var overrideType: OverrideType { switch self { - case .disable, .disableViewRendererV2, .enableFastViewRendering, .disableMaskAllText, .disableMaskAllImages: return .boolean + case .disable, .disableViewRendererV2, .enableFastViewRendering, .disableMaskAllText, .disableMaskAllImages, .enableInUnreliableEnvironment: return .boolean case .onErrorSampleRate, .sessionSampleRate: return .float case .quality: return .string } @@ -412,7 +414,7 @@ extension SentrySDKOverrides.SessionReplay { public var ignoresDisableEverything: Bool { switch self { case .disable: return false - case .disableViewRendererV2, .enableFastViewRendering, .disableMaskAllText, .disableMaskAllImages, .onErrorSampleRate, .sessionSampleRate, .quality: return true + case .disableViewRendererV2, .enableFastViewRendering, .disableMaskAllText, .disableMaskAllImages, .onErrorSampleRate, .sessionSampleRate, .quality, .enableInUnreliableEnvironment: return true } } } diff --git a/Samples/SentrySampleShared/SentrySampleShared/SentrySDKWrapper.swift b/Samples/SentrySampleShared/SentrySampleShared/SentrySDKWrapper.swift index bfa70239185..5438e8d9ebb 100644 --- a/Samples/SentrySampleShared/SentrySampleShared/SentrySDKWrapper.swift +++ b/Samples/SentrySampleShared/SentrySampleShared/SentrySDKWrapper.swift @@ -61,6 +61,10 @@ public struct SentrySDKWrapper { ) let defaultReplayQuality = options.sessionReplay.quality options.sessionReplay.quality = SentryReplayOptions.SentryReplayQuality(rawValue: (SentrySDKOverrides.SessionReplay.quality.stringValue as? NSString)?.integerValue ?? defaultReplayQuality.rawValue) ?? defaultReplayQuality + + // Allow configuring unreliable environment protection via SDK override. + // Default to false for the sample app to allow testing on iOS 26+ with Liquid Glass. + options.experimental.enableSessionReplayInUnreliableEnvironment = SentrySDKOverrides.SessionReplay.enableInUnreliableEnvironment.boolValue } #if !os(tvOS) diff --git a/Samples/Shared/feature-flags.yml b/Samples/Shared/feature-flags.yml index 6d0c0951bb3..9d66c7419e6 100644 --- a/Samples/Shared/feature-flags.yml +++ b/Samples/Shared/feature-flags.yml @@ -22,6 +22,7 @@ schemeTemplates: "--io.sentry.session-replay.enable-fast-view-rendering": false "--io.sentry.session-replay.disable-mask-all-images": false "--io.sentry.session-replay.disable-mask-all-text": false + "--io.sentry.session-replay.enable-in-unreliable-environment": false # user feedback "--io.sentry.feedback.use-custom-feedback-button": false diff --git a/Sentry.xcodeproj/project.pbxproj b/Sentry.xcodeproj/project.pbxproj index abd431afb76..96bc6f38a81 100644 --- a/Sentry.xcodeproj/project.pbxproj +++ b/Sentry.xcodeproj/project.pbxproj @@ -787,6 +787,8 @@ D41415A72DEEE532003B14D5 /* SentryRedactViewHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = D41415A62DEEE532003B14D5 /* SentryRedactViewHelper.swift */; }; D4291A692DD61A3F00772088 /* SentryDispatchQueueProviderProtocol.h in Headers */ = {isa = PBXBuildFile; fileRef = D4291A672DD61A3F00772088 /* SentryDispatchQueueProviderProtocol.h */; }; D4291A6D2DD62ACE00772088 /* SentryDispatchFactoryTests.m in Sources */ = {isa = PBXBuildFile; fileRef = D4291A6C2DD62AC800772088 /* SentryDispatchFactoryTests.m */; }; + D42ADEEF2E9CF43200753166 /* SentrySessionReplayEnvironmentChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = D42ADEE92E9CF42800753166 /* SentrySessionReplayEnvironmentChecker.swift */; }; + D42ADF372E9CF95700753166 /* SentrySessionReplayEnvironmentCheckerProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = D42ADF362E9CF95500753166 /* SentrySessionReplayEnvironmentCheckerProvider.swift */; }; D42E48572D48DF1600D251BC /* SentryBuildAppStartSpansTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D42E48562D48DF1600D251BC /* SentryBuildAppStartSpansTests.swift */; }; D434DB092DE09CD000DD6F82 /* TestSentryWatchdogTerminationAttributesProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D452FE722DDC8DB700AFF56F /* TestSentryWatchdogTerminationAttributesProcessor.swift */; }; D434DB0A2DE09CDB00DD6F82 /* TestSentryWatchdogTerminationBreadcrumbProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D452FE742DDC8DC400AFF56F /* TestSentryWatchdogTerminationBreadcrumbProcessor.swift */; }; @@ -814,6 +816,10 @@ D456B4322D706BDF007068CB /* SentrySpanOperation.h in Headers */ = {isa = PBXBuildFile; fileRef = D456B4312D706BDD007068CB /* SentrySpanOperation.h */; }; D456B4362D706BF2007068CB /* SentryTraceOrigin.h in Headers */ = {isa = PBXBuildFile; fileRef = D456B4352D706BEE007068CB /* SentryTraceOrigin.h */; }; D456B4382D706BFE007068CB /* SentrySpanDataKey.h in Headers */ = {isa = PBXBuildFile; fileRef = D456B4372D706BFB007068CB /* SentrySpanDataKey.h */; }; + D4599F892E98F4750045BB95 /* SentryInfoPlistKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4599F832E98F4710045BB95 /* SentryInfoPlistKey.swift */; }; + D4599F8B2E98FE9F0045BB95 /* SentryInfoPlistWrapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4599F8A2E98FE970045BB95 /* SentryInfoPlistWrapperTests.swift */; }; + D4599F8D2E990F960045BB95 /* TestInfoPlistWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4599F8C2E990F920045BB95 /* TestInfoPlistWrapper.swift */; }; + D4599F8F2E99113E0045BB95 /* TestInfoPlistWrapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4599F8E2E9911380045BB95 /* TestInfoPlistWrapperTests.swift */; }; D45B4AF52E019E1A00C31DFB /* TestSentryViewRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D45B4AF42E019E1500C31DFB /* TestSentryViewRenderer.swift */; }; D45B4AF72E01A10100C31DFB /* TestSentryViewPhotographer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D45B4AF62E01A0FA00C31DFB /* TestSentryViewPhotographer.swift */; }; D45CE9752E5F454E00BFEDB2 /* SentryScreenshotSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D45CE9742E5F454300BFEDB2 /* SentryScreenshotSource.swift */; }; @@ -825,13 +831,21 @@ D473ACD72D8090FC000F1CC6 /* FileManager+SentryTracing.swift in Sources */ = {isa = PBXBuildFile; fileRef = D473ACD62D8090FC000F1CC6 /* FileManager+SentryTracing.swift */; }; D480F9D92DE47A50009A0594 /* TestSentryScopePersistentStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D480F9D82DE47A48009A0594 /* TestSentryScopePersistentStore.swift */; }; D480F9DB2DE47AF2009A0594 /* SentryScopePersistentStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D480F9DA2DE47AEB009A0594 /* SentryScopePersistentStoreTests.swift */; }; + D483AFA42E9D555300B43C27 /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D483AFA32E9D555300B43C27 /* XCTest.framework */; }; D48724E02D3549CA005DE483 /* SentrySpanOperationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D48724DF2D3549C6005DE483 /* SentrySpanOperationTests.swift */; }; D48724E22D354D16005DE483 /* SentryTraceOriginTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D48724E12D354D16005DE483 /* SentryTraceOriginTests.swift */; }; + D48891CC2E98F22A00212823 /* SentryInfoPlistWrapperProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = D48891C62E98F21D00212823 /* SentryInfoPlistWrapperProvider.swift */; }; + D48891CE2E98F28E00212823 /* SentryInfoPlistWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = D48891CD2E98F28E00212823 /* SentryInfoPlistWrapper.swift */; }; + D48891D02E98F2E700212823 /* SentryInfoPlistError.swift in Sources */ = {isa = PBXBuildFile; fileRef = D48891CF2E98F2E600212823 /* SentryInfoPlistError.swift */; }; D48E8B8B2D3E79610032E35E /* SentryTraceOrigin.swift in Sources */ = {isa = PBXBuildFile; fileRef = D48E8B8A2D3E79610032E35E /* SentryTraceOrigin.swift */; }; D48E8B9D2D3E82AC0032E35E /* SentrySpanOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = D48E8B9C2D3E82AC0032E35E /* SentrySpanOperation.swift */; }; D490648A2DFAE1F600555785 /* SentryScreenshotOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D49064892DFAE1F600555785 /* SentryScreenshotOptions.swift */; }; D49480D32DC23E9300A3B6E9 /* SentryReplayTypeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D49480D22DC23E8E00A3B6E9 /* SentryReplayTypeTests.swift */; }; D49480D72DC23FE300A3B6E9 /* SentrySessionReplayDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D49480D62DC23FE200A3B6E9 /* SentrySessionReplayDelegate.swift */; }; + D4A0C2312E9E3D0700791353 /* SentryXcodeVersionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4A0C2302E9E3CFF00791353 /* SentryXcodeVersionTests.swift */; }; + D4A0C2332E9E3D1400791353 /* SentryInfoPlistKeyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4A0C2322E9E3D0C00791353 /* SentryInfoPlistKeyTests.swift */; }; + D4A0C2372E9E3F4400791353 /* TestInfoPlist.plist in Resources */ = {isa = PBXBuildFile; fileRef = D4A0C2362E9E3F4400791353 /* TestInfoPlist.plist */; }; + D4A0C2392E9E3F5800791353 /* TestBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4A0C2382E9E3F5800791353 /* TestBundle.swift */; }; D4AF00212D2E92FD00F5F3D7 /* SentryNSFileManagerSwizzling.m in Sources */ = {isa = PBXBuildFile; fileRef = D4AF00202D2E92FD00F5F3D7 /* SentryNSFileManagerSwizzling.m */; }; D4AF00232D2E931000F5F3D7 /* SentryNSFileManagerSwizzling.h in Headers */ = {isa = PBXBuildFile; fileRef = D4AF00222D2E931000F5F3D7 /* SentryNSFileManagerSwizzling.h */; }; D4AF00252D2E93C400F5F3D7 /* SentryNSFileManagerSwizzlingTests.m in Sources */ = {isa = PBXBuildFile; fileRef = D4AF00242D2E93C400F5F3D7 /* SentryNSFileManagerSwizzlingTests.m */; }; @@ -842,15 +856,19 @@ D4CBA2532DE06D1600581618 /* TestConstantTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4CBA2512DE06D1600581618 /* TestConstantTests.swift */; }; D4CD2A802DE9F91900DA9F59 /* SentryRedactRegion.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4CD2A7C2DE9F91900DA9F59 /* SentryRedactRegion.swift */; }; D4CD2A812DE9F91900DA9F59 /* SentryRedactRegionType.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4CD2A7D2DE9F91900DA9F59 /* SentryRedactRegionType.swift */; }; + D4D0E1E82E9D040A00358814 /* SentrySessionReplayEnvironmentCheckerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4D0E1E22E9D040800358814 /* SentrySessionReplayEnvironmentCheckerTests.swift */; }; D4D12E7A2DFC608800DC45C4 /* SentryScreenshotOptionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4D12E792DFC607F00DC45C4 /* SentryScreenshotOptionsTests.swift */; }; D4DEE6592E439B2E00FCA5A9 /* SentryProfileTimeseriesTests.m in Sources */ = {isa = PBXBuildFile; fileRef = D4DEE6582E439B2E00FCA5A9 /* SentryProfileTimeseriesTests.m */; }; D4E3F35D2D4A864600F79E2B /* SentryNSDictionarySanitizeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D42E48582D48FC8F00D251BC /* SentryNSDictionarySanitizeTests.swift */; }; D4E3F35E2D4A877300F79E2B /* SentryNSDictionarySanitize+Tests.m in Sources */ = {isa = PBXBuildFile; fileRef = D41909942D490006002B83D0 /* SentryNSDictionarySanitize+Tests.m */; }; + D4E9420A2E9D1CFB00DB7521 /* TestSessionReplayEnvironmentChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4E942042E9D1CF300DB7521 /* TestSessionReplayEnvironmentChecker.swift */; }; + D4E9420C2E9D1D8000DB7521 /* TestSessionReplayEnvironmentCheckerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4E9420B2E9D1D7600DB7521 /* TestSessionReplayEnvironmentCheckerTests.swift */; }; D4ECA4012E3CBEDE00C757EA /* SentryDummyPublicEmptyClass.m in Sources */ = {isa = PBXBuildFile; fileRef = D4ECA4002E3CBEDE00C757EA /* SentryDummyPublicEmptyClass.m */; }; D4ECA4022E3CBEDE00C757EA /* SentryDummyPrivateEmptyClass.m in Sources */ = {isa = PBXBuildFile; fileRef = D4ECA3FF2E3CBEDE00C757EA /* SentryDummyPrivateEmptyClass.m */; }; D4EDF9842D0B2A210071E7B3 /* Data+SentryTracing.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4EDF9832D0B2A1D0071E7B3 /* Data+SentryTracing.swift */; }; D4EE12D22DE9AC3800385BAF /* TestNSNotificationCenterWrapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4EE12D12DE9AC3300385BAF /* TestNSNotificationCenterWrapperTests.swift */; }; D4F2B5352D0C69D500649E42 /* SentryCrashCTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4F2B5342D0C69D100649E42 /* SentryCrashCTests.swift */; }; + D4F56C5D2E9CF38900D57DAB /* SentryXcodeVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4F56C5C2E9CF38900D57DAB /* SentryXcodeVersion.swift */; }; D4F7BD822E4373BF004A2D77 /* SentryLevelMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4F7BD7C2E4373BB004A2D77 /* SentryLevelMapperTests.swift */; }; D4FC68172DD632E7001B74FF /* SentryDispatchSourceProviderProtocol.h in Headers */ = {isa = PBXBuildFile; fileRef = D4FC68162DD632E7001B74FF /* SentryDispatchSourceProviderProtocol.h */; }; D4FC681A2DD63465001B74FF /* SentryDispatchQueueWrapperTests.m in Sources */ = {isa = PBXBuildFile; fileRef = D4FC68192DD63465001B74FF /* SentryDispatchQueueWrapperTests.m */; }; @@ -2112,6 +2130,8 @@ D41909942D490006002B83D0 /* SentryNSDictionarySanitize+Tests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "SentryNSDictionarySanitize+Tests.m"; sourceTree = ""; }; D4291A672DD61A3F00772088 /* SentryDispatchQueueProviderProtocol.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = SentryDispatchQueueProviderProtocol.h; path = include/SentryDispatchQueueProviderProtocol.h; sourceTree = ""; }; D4291A6C2DD62AC800772088 /* SentryDispatchFactoryTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SentryDispatchFactoryTests.m; sourceTree = ""; }; + D42ADEE92E9CF42800753166 /* SentrySessionReplayEnvironmentChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentrySessionReplayEnvironmentChecker.swift; sourceTree = ""; }; + D42ADF362E9CF95500753166 /* SentrySessionReplayEnvironmentCheckerProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentrySessionReplayEnvironmentCheckerProvider.swift; sourceTree = ""; }; D42E48562D48DF1600D251BC /* SentryBuildAppStartSpansTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryBuildAppStartSpansTests.swift; sourceTree = ""; }; D42E48582D48FC8F00D251BC /* SentryNSDictionarySanitizeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryNSDictionarySanitizeTests.swift; sourceTree = ""; }; D434DB0B2DE09CE700DD6F82 /* TestSentryWatchdogTerminationBreadcrumbProcessorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestSentryWatchdogTerminationBreadcrumbProcessorTests.swift; sourceTree = ""; }; @@ -2141,6 +2161,10 @@ D456B4312D706BDD007068CB /* SentrySpanOperation.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = SentrySpanOperation.h; path = include/SentrySpanOperation.h; sourceTree = ""; }; D456B4352D706BEE007068CB /* SentryTraceOrigin.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = SentryTraceOrigin.h; path = include/SentryTraceOrigin.h; sourceTree = ""; }; D456B4372D706BFB007068CB /* SentrySpanDataKey.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = SentrySpanDataKey.h; path = include/SentrySpanDataKey.h; sourceTree = ""; }; + D4599F832E98F4710045BB95 /* SentryInfoPlistKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryInfoPlistKey.swift; sourceTree = ""; }; + D4599F8A2E98FE970045BB95 /* SentryInfoPlistWrapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryInfoPlistWrapperTests.swift; sourceTree = ""; }; + D4599F8C2E990F920045BB95 /* TestInfoPlistWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestInfoPlistWrapper.swift; sourceTree = ""; }; + D4599F8E2E9911380045BB95 /* TestInfoPlistWrapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestInfoPlistWrapperTests.swift; sourceTree = ""; }; D45B4AF42E019E1500C31DFB /* TestSentryViewRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestSentryViewRenderer.swift; sourceTree = ""; }; D45B4AF62E01A0FA00C31DFB /* TestSentryViewPhotographer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestSentryViewPhotographer.swift; sourceTree = ""; }; D45CE9742E5F454300BFEDB2 /* SentryScreenshotSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryScreenshotSource.swift; sourceTree = ""; }; @@ -2154,13 +2178,21 @@ D473ACD62D8090FC000F1CC6 /* FileManager+SentryTracing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileManager+SentryTracing.swift"; sourceTree = ""; }; D480F9D82DE47A48009A0594 /* TestSentryScopePersistentStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestSentryScopePersistentStore.swift; sourceTree = ""; }; D480F9DA2DE47AEB009A0594 /* SentryScopePersistentStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryScopePersistentStoreTests.swift; sourceTree = ""; }; + D483AFA32E9D555300B43C27 /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = Platforms/WatchOS.platform/Developer/Library/Frameworks/XCTest.framework; sourceTree = DEVELOPER_DIR; }; D48724DF2D3549C6005DE483 /* SentrySpanOperationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentrySpanOperationTests.swift; sourceTree = ""; }; D48724E12D354D16005DE483 /* SentryTraceOriginTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryTraceOriginTests.swift; sourceTree = ""; }; + D48891C62E98F21D00212823 /* SentryInfoPlistWrapperProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryInfoPlistWrapperProvider.swift; sourceTree = ""; }; + D48891CD2E98F28E00212823 /* SentryInfoPlistWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryInfoPlistWrapper.swift; sourceTree = ""; }; + D48891CF2E98F2E600212823 /* SentryInfoPlistError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryInfoPlistError.swift; sourceTree = ""; }; D48E8B8A2D3E79610032E35E /* SentryTraceOrigin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryTraceOrigin.swift; sourceTree = ""; }; D48E8B9C2D3E82AC0032E35E /* SentrySpanOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentrySpanOperation.swift; sourceTree = ""; }; D49064892DFAE1F600555785 /* SentryScreenshotOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryScreenshotOptions.swift; sourceTree = ""; }; D49480D22DC23E8E00A3B6E9 /* SentryReplayTypeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryReplayTypeTests.swift; sourceTree = ""; }; D49480D62DC23FE200A3B6E9 /* SentrySessionReplayDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentrySessionReplayDelegate.swift; sourceTree = ""; }; + D4A0C2302E9E3CFF00791353 /* SentryXcodeVersionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryXcodeVersionTests.swift; sourceTree = ""; }; + D4A0C2322E9E3D0C00791353 /* SentryInfoPlistKeyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryInfoPlistKeyTests.swift; sourceTree = ""; }; + D4A0C2362E9E3F4400791353 /* TestInfoPlist.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = TestInfoPlist.plist; sourceTree = ""; }; + D4A0C2382E9E3F5800791353 /* TestBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestBundle.swift; sourceTree = ""; }; D4A2360A2D5F84FA00D55C58 /* SwiftUITestSample_Base.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = SwiftUITestSample_Base.xctestplan; sourceTree = ""; }; D4AF00202D2E92FD00F5F3D7 /* SentryNSFileManagerSwizzling.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SentryNSFileManagerSwizzling.m; sourceTree = ""; }; D4AF00222D2E931000F5F3D7 /* SentryNSFileManagerSwizzling.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = SentryNSFileManagerSwizzling.h; path = include/SentryNSFileManagerSwizzling.h; sourceTree = ""; }; @@ -2173,13 +2205,17 @@ D4CBA2512DE06D1600581618 /* TestConstantTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestConstantTests.swift; sourceTree = ""; }; D4CD2A7C2DE9F91900DA9F59 /* SentryRedactRegion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryRedactRegion.swift; sourceTree = ""; }; D4CD2A7D2DE9F91900DA9F59 /* SentryRedactRegionType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryRedactRegionType.swift; sourceTree = ""; }; + D4D0E1E22E9D040800358814 /* SentrySessionReplayEnvironmentCheckerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentrySessionReplayEnvironmentCheckerTests.swift; sourceTree = ""; }; D4D12E792DFC607F00DC45C4 /* SentryScreenshotOptionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryScreenshotOptionsTests.swift; sourceTree = ""; }; D4DEE6582E439B2E00FCA5A9 /* SentryProfileTimeseriesTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SentryProfileTimeseriesTests.m; sourceTree = ""; }; + D4E942042E9D1CF300DB7521 /* TestSessionReplayEnvironmentChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestSessionReplayEnvironmentChecker.swift; sourceTree = ""; }; + D4E9420B2E9D1D7600DB7521 /* TestSessionReplayEnvironmentCheckerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestSessionReplayEnvironmentCheckerTests.swift; sourceTree = ""; }; D4ECA3FF2E3CBEDE00C757EA /* SentryDummyPrivateEmptyClass.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SentryDummyPrivateEmptyClass.m; sourceTree = ""; }; D4ECA4002E3CBEDE00C757EA /* SentryDummyPublicEmptyClass.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SentryDummyPublicEmptyClass.m; sourceTree = ""; }; D4EDF9832D0B2A1D0071E7B3 /* Data+SentryTracing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Data+SentryTracing.swift"; sourceTree = ""; }; D4EE12D12DE9AC3300385BAF /* TestNSNotificationCenterWrapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestNSNotificationCenterWrapperTests.swift; sourceTree = ""; }; D4F2B5342D0C69D100649E42 /* SentryCrashCTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryCrashCTests.swift; sourceTree = ""; }; + D4F56C5C2E9CF38900D57DAB /* SentryXcodeVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryXcodeVersion.swift; sourceTree = ""; }; D4F7BD7C2E4373BB004A2D77 /* SentryLevelMapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryLevelMapperTests.swift; sourceTree = ""; }; D4FC68162DD632E7001B74FF /* SentryDispatchSourceProviderProtocol.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = SentryDispatchSourceProviderProtocol.h; path = include/SentryDispatchSourceProviderProtocol.h; sourceTree = ""; }; D4FC68192DD63465001B74FF /* SentryDispatchQueueWrapperTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SentryDispatchQueueWrapperTests.m; sourceTree = ""; }; @@ -2510,6 +2546,7 @@ buildActionMask = 2147483647; files = ( 84B7FA3529B285FC00AD93B1 /* Sentry.framework in Frameworks */, + D483AFA42E9D555300B43C27 /* XCTest.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -2604,6 +2641,7 @@ 621D9F2D2B9B030E003D94DE /* Helper */ = { isa = PBXGroup; children = ( + D4F56C3C2E9CEFFB00D57DAB /* InfoPlist */, FAE579872E7D9D4900B710F9 /* SentrySysctl.swift */, FAE579BC2E7DDDE400B710F9 /* SentryThreadWrapper.swift */, FAE5797E2E7CF21300B710F9 /* SentryMigrateSessionInit.swift */, @@ -2773,6 +2811,7 @@ 6304360C1EC05CEF00C4D3FA /* Frameworks */ = { isa = PBXGroup; children = ( + D483AFA32E9D555300B43C27 /* XCTest.framework */, 84F994E72A6894BD00EC0190 /* SystemConfiguration.framework */, 84F994E52A6894B500EC0190 /* CoreData.framework */, 6387B82F1ED851970045A84C /* libz.tbd */, @@ -2797,7 +2836,6 @@ 6304360C1EC05CEF00C4D3FA /* Frameworks */, 6327C5D41EB8A783004E799B /* Products */, 7D826E3C2390840E00EED93D /* Utils */, - F474CB872E2EC5040001DF41 /* Recovered References */, ); indentWidth = 4; sourceTree = ""; @@ -3568,6 +3606,7 @@ 7BD7299B24654CD500EA3610 /* Helper */ = { isa = PBXGroup; children = ( + D4A0C22A2E9E3CE100791353 /* InfoPlist */, F4A930242E661856006DA6EF /* SentryMobileProvisionParserTests.swift */, D4F7BD7C2E4373BB004A2D77 /* SentryLevelMapperTests.swift */, D8AE48BE2C578D540092A2A6 /* SentrySDKLog.swift */, @@ -4022,6 +4061,8 @@ 84AC61DA29F7654A009EEF61 /* TestDispatchSourceWrapper.swift */, 84A5D75A29D5170700388BFA /* TimeInterval+Sentry.swift */, 7B30B68126527C55006B2752 /* TestDisplayLinkWrapper.swift */, + D4599F8C2E990F920045BB95 /* TestInfoPlistWrapper.swift */, + D4E942042E9D1CF300DB7521 /* TestSessionReplayEnvironmentChecker.swift */, 8E25C97425F8511A00DC215B /* TestRandom.swift */, 7BE3C7762445E50A00A38442 /* TestCurrentDateProvider.swift */, 7BDB03BE25136A7D00BAE198 /* TestSentryDispatchQueueWrapper.swift */, @@ -4306,6 +4347,18 @@ path = Screenshot; sourceTree = ""; }; + D4A0C22A2E9E3CE100791353 /* InfoPlist */ = { + isa = PBXGroup; + children = ( + D4A0C2322E9E3D0C00791353 /* SentryInfoPlistKeyTests.swift */, + D4A0C2302E9E3CFF00791353 /* SentryXcodeVersionTests.swift */, + D4599F8A2E98FE970045BB95 /* SentryInfoPlistWrapperTests.swift */, + D4A0C2382E9E3F5800791353 /* TestBundle.swift */, + D4A0C2362E9E3F4400791353 /* TestInfoPlist.plist */, + ); + path = InfoPlist; + sourceTree = ""; + }; D4CBA2522DE06D1600581618 /* SentryTestUtilsTests */ = { isa = PBXGroup; children = ( @@ -4317,6 +4370,8 @@ D4CBA2512DE06D1600581618 /* TestConstantTests.swift */, D43B0E5F2DE7416600EE3759 /* TestFileManagerTests.swift */, 62E75EB82E152953002EC91B /* InvocationsTests.swift */, + D4599F8E2E9911380045BB95 /* TestInfoPlistWrapperTests.swift */, + D4E9420B2E9D1D7600DB7521 /* TestSessionReplayEnvironmentCheckerTests.swift */, ); path = SentryTestUtilsTests; sourceTree = ""; @@ -4329,6 +4384,18 @@ path = Recording; sourceTree = ""; }; + D4F56C3C2E9CEFFB00D57DAB /* InfoPlist */ = { + isa = PBXGroup; + children = ( + D4F56C5C2E9CF38900D57DAB /* SentryXcodeVersion.swift */, + D4599F832E98F4710045BB95 /* SentryInfoPlistKey.swift */, + D48891CF2E98F2E600212823 /* SentryInfoPlistError.swift */, + D48891CD2E98F28E00212823 /* SentryInfoPlistWrapper.swift */, + D48891C62E98F21D00212823 /* SentryInfoPlistWrapperProvider.swift */, + ); + path = InfoPlist; + sourceTree = ""; + }; D800942328F82E8D005D3943 /* Swift */ = { isa = PBXGroup; children = ( @@ -4351,6 +4418,7 @@ D80694C12B7CC85800B820E6 /* SessionReplay */ = { isa = PBXGroup; children = ( + D4D0E1E22E9D040800358814 /* SentrySessionReplayEnvironmentCheckerTests.swift */, D49480D22DC23E8E00A3B6E9 /* SentryReplayTypeTests.swift */, D80694C22B7CC86E00B820E6 /* SentryReplayEventTests.swift */, D80694C52B7CCFA100B820E6 /* SentryReplayRecordingTests.swift */, @@ -4668,6 +4736,8 @@ D8CAC02C2BA0663E00E38F34 /* SessionReplay */ = { isa = PBXGroup; children = ( + D42ADF362E9CF95500753166 /* SentrySessionReplayEnvironmentCheckerProvider.swift */, + D42ADEE92E9CF42800753166 /* SentrySessionReplayEnvironmentChecker.swift */, D81988C12BEC18710020E36C /* RRWeb */, D88B30A72D48D87F008DE513 /* Preview */, D8CAC02A2BA0663E00E38F34 /* SentryReplayOptions.swift */, @@ -4741,13 +4811,6 @@ path = Tools; sourceTree = ""; }; - F474CB872E2EC5040001DF41 /* Recovered References */ = { - isa = PBXGroup; - children = ( - ); - name = "Recovered References"; - sourceTree = ""; - }; F4FE9E062E6248BB0014FED5 /* SentryCrash */ = { isa = PBXGroup; children = ( @@ -5525,6 +5588,7 @@ buildActionMask = 2147483647; files = ( 630C01961EC341D600C52CEF /* Resources in Resources */, + D4A0C2372E9E3F4400791353 /* TestInfoPlist.plist in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -5619,6 +5683,7 @@ FAEC270E2DF3526000878871 /* SentryUserFeedback.swift in Sources */, FAF1201A2E70C0EE006E1DA3 /* SentryEnvelopeHeaderHelper.m in Sources */, F49D419E2DEA3D0600D9244E /* SentryCrashExceptionApplicationHelper.m in Sources */, + D4F56C5D2E9CF38900D57DAB /* SentryXcodeVersion.swift in Sources */, D8ACE3C82762187200F5A213 /* SentryFileIOTracker.m in Sources */, 7BE3C77D2446112C00A38442 /* SentryRateLimitParser.m in Sources */, D8B088B729C9E3FF00213258 /* SentryTracerConfiguration.m in Sources */, @@ -5628,6 +5693,7 @@ 03BCC38C27E1C01A003232C7 /* SentryTime.mm in Sources */, A8F17B342902870300990B25 /* SentryHttpStatusCodeRange.m in Sources */, 62C97D3A2CC64E6B00DDA204 /* SentryUncaughtNSExceptions.m in Sources */, + D4599F892E98F4750045BB95 /* SentryInfoPlistKey.swift in Sources */, 03F84D3727DD4191008FE43F /* SentrySamplingProfiler.cpp in Sources */, 8453421628BE8A9500C22EEC /* SentrySpanStatus.m in Sources */, 6292585B2DAFA5F70049388F /* SentryCrashCxaThrowSwapper.c in Sources */, @@ -5662,6 +5728,7 @@ D8CB741B2947286500A5F964 /* SentryEnvelopeItemHeader.m in Sources */, 92ECD73C2E05ACE00063EC10 /* SentryLog.swift in Sources */, F458D1152E1869AD0028273E /* SentryScopePersistentStore+String.swift in Sources */, + D48891CC2E98F22A00212823 /* SentryInfoPlistWrapperProvider.swift in Sources */, F458D1172E186DF20028273E /* SentryScopePersistentStore+Fingerprint.swift in Sources */, D8CB7417294724CC00A5F964 /* SentryEnvelopeAttachmentHeader.m in Sources */, D84793262788737D00BE8E99 /* SentryByteCountFormatter.m in Sources */, @@ -5698,6 +5765,7 @@ 63FE70E720DA4C1000CDBAE8 /* SentryCrashMonitor.c in Sources */, 84354E1229BF944900CDBB8B /* SentryProfileTimeseries.m in Sources */, FA94E6912E6B92C100576666 /* SentryClientReport.swift in Sources */, + D42ADEEF2E9CF43200753166 /* SentrySessionReplayEnvironmentChecker.swift in Sources */, 7D5C441C237C2E1F00DAB0A3 /* SentryHub.m in Sources */, 84E13B842CBF1D91003B52EC /* SentryUserFeedbackWidgetButtonMegaphoneIconView.swift in Sources */, 63FE715920DA4C1100CDBAE8 /* SentryCrashCPU_x86_32.c in Sources */, @@ -5729,6 +5797,7 @@ 7BBD188B244841FB00427C76 /* SentryHttpDateParser.m in Sources */, D8AFC03D2BDA79BF00118BE1 /* SentryReplayVideoMaker.swift in Sources */, 840A11122B61E27500650D02 /* SentrySamplerDecision.m in Sources */, + D48891CE2E98F28E00212823 /* SentryInfoPlistWrapper.swift in Sources */, FAE579C22E7DDDE700B710F9 /* SentryThreadWrapper.swift in Sources */, F458D1132E180BB00028273E /* SentryFileManagerProtocol.swift in Sources */, 8E4E7C8225DAB2A5006AB9E2 /* SentryTracer.m in Sources */, @@ -5800,6 +5869,7 @@ FA21F0B42E4A2A80008B4E5A /* SentryAppState.swift in Sources */, FA67DD132DDBD4EA00896B02 /* NumberExtensions.swift in Sources */, FA67DD142DDBD4EA00896B02 /* SentryGraphicsImageRenderer.swift in Sources */, + D42ADF372E9CF95700753166 /* SentrySessionReplayEnvironmentCheckerProvider.swift in Sources */, FA4C32972DF7513F001D7B00 /* SentryExperimentalOptions.swift in Sources */, FA67DD152DDBD4EA00896B02 /* UrlSanitized.swift in Sources */, FA67DD162DDBD4EA00896B02 /* SentryViewPhotographer.swift in Sources */, @@ -5954,6 +6024,7 @@ 63FE710B20DA4C1000CDBAE8 /* SentryCrashMach.c in Sources */, 63FE707720DA4C1000CDBAE8 /* SentryDictionaryDeepSearch.m in Sources */, FACEED132E3179A10007B4AC /* SentyOptionsInternal.m in Sources */, + D48891D02E98F2E700212823 /* SentryInfoPlistError.swift in Sources */, 8482FA9C2DD7C397000E9283 /* SentryFeedbackAPI.m in Sources */, 7BC9A20628F41781001E7C4C /* SentryMeasurementUnit.m in Sources */, 63FE71A020DA4C1100CDBAE8 /* SentryCrashInstallation.m in Sources */, @@ -6056,6 +6127,7 @@ 62CFD9A92C99741100834E1B /* SentryInvalidJSONStringTests.swift in Sources */, 7BB42EF124F3B7B700D7B39A /* SentrySession+Equality.m in Sources */, 7BF9EF8B2722D58700B5BBEF /* SentryInitializeForGettingSubclassesNotCalled.m in Sources */, + D4D0E1E82E9D040A00358814 /* SentrySessionReplayEnvironmentCheckerTests.swift in Sources */, 33042A1729DC2C4300C60085 /* SentryExtraContextProviderTests.swift in Sources */, D8137D54272B53070082656C /* TestSentrySpan.m in Sources */, 7BECF432261463E600D9826E /* SentryMechanismMetaTests.swift in Sources */, @@ -6120,6 +6192,7 @@ D8F6A24E288553A800320515 /* SentryPredicateDescriptorTests.swift in Sources */, 7B0002322477F0520035FEF1 /* SentrySessionTests.m in Sources */, 62CFD9AD2C99770B00834E1B /* SentryInvalidJSONString.m in Sources */, + D4599F8B2E98FE9F0045BB95 /* SentryInfoPlistWrapperTests.swift in Sources */, 62375FB92B47F9F000CC55F1 /* SentryDependencyContainerTests.swift in Sources */, FA8AFDAC2E84FAEE007A0E18 /* TestSentryUIApplication.swift in Sources */, 7BC6EC08255C36DE0059822A /* SentryStacktraceTests.swift in Sources */, @@ -6133,6 +6206,7 @@ D480F9DB2DE47AF2009A0594 /* SentryScopePersistentStoreTests.swift in Sources */, F4DC35582E1FFE1F0077CE89 /* SentryVideoFrameProcessorTests.swift in Sources */, F48F74F32E5F9959009D4E7D /* SentryCrashBinaryImageCacheTests.m in Sources */, + D4A0C2312E9E3D0700791353 /* SentryXcodeVersionTests.swift in Sources */, 7BC6EC18255C44540059822A /* SentryDebugMetaTests.swift in Sources */, A811D867248E2770008A41EA /* SentrySystemEventBreadcrumbsTest.swift in Sources */, 7B82D54924E2A2D400EE670F /* SentryIdTests.swift in Sources */, @@ -6166,6 +6240,7 @@ 7BBD18A2244EE2FD00427C76 /* TestResponseFactory.swift in Sources */, 628B89022D841D7F004B6F2A /* SentryDateUtilsTests.swift in Sources */, D808FB8B281BCE96009A2A33 /* TestSentrySwizzleWrapper.swift in Sources */, + D4A0C2392E9E3F5800791353 /* TestBundle.swift in Sources */, 630C01941EC3402C00C52CEF /* SentryKSCrashReportConverterTests.m in Sources */, 7B59398424AB481B0003AAD2 /* NotificationCenterTestCase.swift in Sources */, 7B0A542E2521C62400A71716 /* SentryFrameRemoverTests.swift in Sources */, @@ -6238,6 +6313,7 @@ D875ED0B276CC84700422FAC /* SentryFileIOTrackerTests.swift in Sources */, D808FB92281BF6EC009A2A33 /* SentryUIEventTrackingIntegrationTests.swift in Sources */, 7BC6EC04255C235F0059822A /* SentryFrameTests.swift in Sources */, + D4A0C2332E9E3D1400791353 /* SentryInfoPlistKeyTests.swift in Sources */, D82DD1CD2BEEB1A0001AB556 /* SentrySRDefaultBreadcrumbConverterTests.swift in Sources */, 0AE455AD28F584D2006680E5 /* SentryReachabilityTests.m in Sources */, 63FE720420DA66EC00CDBAE8 /* SentryCrashString_Tests.m in Sources */, @@ -6333,6 +6409,7 @@ buildActionMask = 2147483647; files = ( 841325DF2BFED0510029228F /* TestFramesTracker.swift in Sources */, + D4E9420A2E9D1CFB00DB7521 /* TestSessionReplayEnvironmentChecker.swift in Sources */, 8431F01629B2851500D8DC56 /* TestSentryNSProcessInfoWrapper.swift in Sources */, 841325BC2BF4184B0029228F /* TestHub.swift in Sources */, 84EB21942BF01C6C00EDDA28 /* TestNSNotificationCenterWrapper.swift in Sources */, @@ -6362,6 +6439,7 @@ 84AC61DB29F7654A009EEF61 /* TestDispatchSourceWrapper.swift in Sources */, D452FE6F2DDC890A00AFF56F /* TestFileManager.swift in Sources */, 8431F01729B2851500D8DC56 /* TestSentrySystemWrapper.swift in Sources */, + D4599F8D2E990F960045BB95 /* TestInfoPlistWrapper.swift in Sources */, 84281C632A579D0700EE88F2 /* SentryProfilerMocks.mm in Sources */, D45B4AF72E01A10100C31DFB /* TestSentryViewPhotographer.swift in Sources */, D8FC98AB2CD0DAB30009824C /* BreadcrumbExtension.swift in Sources */, @@ -6380,6 +6458,8 @@ D44B16722DE464AD006DBDB3 /* TestDispatchFactoryTests.swift in Sources */, D4EE12D22DE9AC3800385BAF /* TestNSNotificationCenterWrapperTests.swift in Sources */, 62E75EB92E152953002EC91B /* InvocationsTests.swift in Sources */, + D4E9420C2E9D1D8000DB7521 /* TestSessionReplayEnvironmentCheckerTests.swift in Sources */, + D4599F8F2E99113E0045BB95 /* TestInfoPlistWrapperTests.swift in Sources */, D4CBA2532DE06D1600581618 /* TestConstantTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/SentryTestUtils/TestInfoPlistWrapper.swift b/SentryTestUtils/TestInfoPlistWrapper.swift new file mode 100644 index 00000000000..0160f2cef94 --- /dev/null +++ b/SentryTestUtils/TestInfoPlistWrapper.swift @@ -0,0 +1,58 @@ +@_spi(Private) @testable import Sentry +import XCTest + +@_spi(Private) public class TestInfoPlistWrapper: SentryInfoPlistWrapperProvider { + + public var getAppValueStringInvocations = Invocations() + private var mockedGetAppValueStringReturnValue: [String: Result] = [:] + + public var getAppValueBooleanInvocations = Invocations<(String, NSErrorPointer)>() + private var mockedGetAppValueBooleanReturnValue: [String: Result] = [:] + + public init() {} + + public func mockGetAppValueStringReturnValue(forKey key: String, value: String) { + mockedGetAppValueStringReturnValue[key] = .success(value) + } + + public func mockGetAppValueStringThrowError(forKey key: String, error: Error) { + mockedGetAppValueStringReturnValue[key] = .failure(error) + } + + public func getAppValueString(for key: String) throws -> String { + getAppValueStringInvocations.record(key) + guard let result = mockedGetAppValueStringReturnValue[key] else { + XCTFail("TestInfoPlistWrapper: No mocked return value set for getAppValueString(for:) for key: \(key)") + return "" + } + switch result { + case .success(let value): + return value + case .failure(let error): + throw error + } + } + + public func mockGetAppValueBooleanReturnValue(forKey key: String, value: Bool) { + mockedGetAppValueBooleanReturnValue[key] = .success(value) + } + + public func mockGetAppValueBooleanThrowError(forKey key: String, error: NSError) { + mockedGetAppValueBooleanReturnValue[key] = .failure(error) + } + + public func getAppValueBoolean(for key: String, errorPtr: NSErrorPointer) -> Bool { + getAppValueBooleanInvocations.record((key, errorPtr)) + guard let result = mockedGetAppValueBooleanReturnValue[key] else { + XCTFail("TestInfoPlistWrapper: No mocked return value set for getAppValueBoolean(for:) for key: \(key)") + return false + } + switch result { + case .success(let value): + return value + case .failure(let error): + errorPtr?.pointee = error + return false + } + } +} diff --git a/SentryTestUtils/TestSessionReplayEnvironmentChecker.swift b/SentryTestUtils/TestSessionReplayEnvironmentChecker.swift new file mode 100644 index 00000000000..95bf23abb32 --- /dev/null +++ b/SentryTestUtils/TestSessionReplayEnvironmentChecker.swift @@ -0,0 +1,22 @@ +@_spi(Private) @testable import Sentry + +@_spi(Private) public class TestSessionReplayEnvironmentChecker: SentrySessionReplayEnvironmentCheckerProvider { + + public var isReliableInvocations = Invocations() + private var mockedIsReliableReturnValue: Bool + + public init( + mockedIsReliableReturnValue: Bool + ) { + self.mockedIsReliableReturnValue = mockedIsReliableReturnValue + } + + public func isReliable() -> Bool { + isReliableInvocations.record(()) + return mockedIsReliableReturnValue + } + + public func mockIsReliableReturnValue(_ returnValue: Bool) { + mockedIsReliableReturnValue = returnValue + } +} diff --git a/SentryTestUtilsTests/TestInfoPlistWrapperTests.swift b/SentryTestUtilsTests/TestInfoPlistWrapperTests.swift new file mode 100644 index 00000000000..de85b628ce5 --- /dev/null +++ b/SentryTestUtilsTests/TestInfoPlistWrapperTests.swift @@ -0,0 +1,282 @@ +@_spi(Private) @testable import Sentry +@_spi(Private) @testable import SentryTestUtils +import XCTest + +class TestInfoPlistWrapperTests: XCTestCase { + + // MARK: - getAppValueString(for:) + + func testGetAppValueString_withoutMockedValue_shouldFail() throws { + // -- Arrange -- + let sut = TestInfoPlistWrapper() + // Don't mock any value for this key + + // -- Act & Assert -- + XCTExpectFailure("We are expecting a failure when accessing an unmocked key, as it indicates the test setup is incomplete") + _ = try sut.getAppValueString(for: "unmockedKey") + } + + func testGetAppValueString_withMockedValue_withSingleInvocations_shouldReturnMockedValue() throws { + // -- Arrange -- + let sut = TestInfoPlistWrapper() + sut.mockGetAppValueStringReturnValue(forKey: "key", value: "value") + + // -- Act -- + let result = try sut.getAppValueString(for: "key") + + // -- Assert -- + XCTAssertEqual(result, "value", "Should return the mocked value") + } + + func testGetAppValueString_withMockedValue_withMultipleInvocations_shouldReturnSameValue() throws { + // -- Arrange -- + let sut = TestInfoPlistWrapper() + sut.mockGetAppValueStringReturnValue(forKey: "key1", value: "value1") + + // -- Act -- + let result1 = try sut.getAppValueString(for: "key1") + let result2 = try sut.getAppValueString(for: "key1") + + // -- Assert -- + XCTAssertEqual(result1, "value1", "First invocation should return mocked value") + XCTAssertEqual(result2, "value1", "Second invocation should return same mocked value") + } + + func testGetAppValueString_shouldRecordInvocations() throws { + // -- Arrange -- + let sut = TestInfoPlistWrapper() + sut.mockGetAppValueStringReturnValue(forKey: "key1", value: "value1") + sut.mockGetAppValueStringReturnValue(forKey: "key2", value: "value2") + sut.mockGetAppValueStringReturnValue(forKey: "key3", value: "value3") + + // -- Act -- + _ = try sut.getAppValueString(for: "key1") + _ = try sut.getAppValueString(for: "key2") + _ = try sut.getAppValueString(for: "key3") + + // -- Assert -- + XCTAssertEqual(sut.getAppValueStringInvocations.count, 3, "Should record all three invocations") + XCTAssertEqual(sut.getAppValueStringInvocations.invocations.element(at: 0), "key1", "First invocation should be for key1") + XCTAssertEqual(sut.getAppValueStringInvocations.invocations.element(at: 1), "key2", "Second invocation should be for key2") + XCTAssertEqual(sut.getAppValueStringInvocations.invocations.element(at: 2), "key3", "Third invocation should be for key3") + } + + func testGetAppValueString_withDifferentKeys_shouldReturnDifferentValues() throws { + // -- Arrange -- + let sut = TestInfoPlistWrapper() + sut.mockGetAppValueStringReturnValue(forKey: "key1", value: "value1") + sut.mockGetAppValueStringReturnValue(forKey: "key2", value: "value2") + + // -- Act -- + let result1 = try sut.getAppValueString(for: "key1") + let result2 = try sut.getAppValueString(for: "key2") + + // -- Assert -- + XCTAssertEqual(result1, "value1", "Should return value1 for key1") + XCTAssertEqual(result2, "value2", "Should return value2 for key2") + XCTAssertEqual(sut.getAppValueStringInvocations.count, 2, "Should record both invocations") + } + + func testGetAppValueString_withFailureResult_shouldThrowError() { + // -- Arrange -- + let sut = TestInfoPlistWrapper() + sut.mockGetAppValueStringThrowError(forKey: "key", error: SentryInfoPlistError.keyNotFound(key: "testKey")) + + // -- Act & Assert -- + XCTAssertThrowsError(try sut.getAppValueString(for: "key")) { error in + guard case SentryInfoPlistError.keyNotFound(let key) = error else { + XCTFail("Expected SentryInfoPlistError.keyNotFound, got \(error)") + return + } + XCTAssertEqual(key, "testKey", "Error should contain the expected key") + } + } + + func testGetAppValueString_withDifferentErrorTypes_shouldThrowCorrectError() { + // -- Arrange -- + let sut = TestInfoPlistWrapper() + + // Test mainInfoPlistNotFound + sut.mockGetAppValueStringThrowError(forKey: "key1", error: SentryInfoPlistError.mainInfoPlistNotFound) + XCTAssertThrowsError(try sut.getAppValueString(for: "key1")) { error in + guard case SentryInfoPlistError.mainInfoPlistNotFound = error else { + XCTFail("Expected SentryInfoPlistError.mainInfoPlistNotFound, got \(error)") + return + } + } + + // Test unableToCastValue + sut.mockGetAppValueStringThrowError(forKey: "key2", error: SentryInfoPlistError.unableToCastValue(key: "castKey", value: 123, type: String.self)) + XCTAssertThrowsError(try sut.getAppValueString(for: "key2")) { error in + guard case SentryInfoPlistError.unableToCastValue(let key, let value, let type) = error else { + XCTFail("Expected SentryInfoPlistError.unableToCastValue, got \(error)") + return + } + XCTAssertEqual(key, "castKey", "Error should contain the correct key") + XCTAssertEqual(value as? Int, 123, "Error should contain the correct value") + XCTAssertTrue(type == String.self, "Error should contain the correct type") + } + } + + func testGetAppValueString_afterThrowingError_shouldRecordInvocation() { + // -- Arrange -- + let sut = TestInfoPlistWrapper() + sut.mockGetAppValueStringThrowError(forKey: "key1", error: SentryInfoPlistError.keyNotFound(key: "testKey")) + + // -- Act -- + _ = try? sut.getAppValueString(for: "key1") + + // -- Assert -- + XCTAssertEqual(sut.getAppValueStringInvocations.count, 1, "Should record invocation even when throwing error") + XCTAssertEqual(sut.getAppValueStringInvocations.invocations.element(at: 0), "key1", "Should record the correct key") + } + + // MARK: - getAppValueBoolean(for:errorPtr:) + + func testGetAppValueBoolean_withoutMockedValue_shouldFail() throws { + // -- Arrange -- + let sut = TestInfoPlistWrapper() + // Don't mock any value for this key + + // -- Act & Assert -- + XCTExpectFailure("We are expecting a failure when accessing an unmocked key, as it indicates the test setup is incomplete") + var error: NSError? + _ = sut.getAppValueBoolean(for: "unmockedKey", errorPtr: &error) + } + + func testGetAppValueBoolean_withMockedValue_withSingleInvocations_shouldReturnMockedValue() throws { + // -- Arrange -- + let sut = TestInfoPlistWrapper() + sut.mockGetAppValueBooleanReturnValue(forKey: "key", value: true) + + // -- Act -- + var error: NSError? + let result = sut.getAppValueBoolean(for: "key", errorPtr: &error) + + // -- Assert -- + XCTAssertTrue(result, "Should return the mocked boolean value") + XCTAssertNil(error, "Should not set error when returning success") + } + + func testGetAppValueBoolean_withMockedValue_withMultipleInvocations_shouldReturnSameValue() { + // -- Arrange -- + let sut = TestInfoPlistWrapper() + sut.mockGetAppValueBooleanReturnValue(forKey: "key1", value: true) + + // -- Act -- + var error1: NSError? + let result1 = sut.getAppValueBoolean(for: "key1", errorPtr: &error1) + + var error2: NSError? + let result2 = sut.getAppValueBoolean(for: "key1", errorPtr: &error2) + + // -- Assert -- + XCTAssertTrue(result1, "First invocation should return mocked value") + XCTAssertNil(error1, "First invocation should not set error") + XCTAssertTrue(result2, "Second invocation should return same mocked value") + XCTAssertNil(error2, "Second invocation should not set error") + } + + func testGetAppValueBoolean_withFalseValue_shouldReturnFalse() { + // -- Arrange -- + let sut = TestInfoPlistWrapper() + sut.mockGetAppValueBooleanReturnValue(forKey: "key", value: false) + + // -- Act -- + var error: NSError? + let result = sut.getAppValueBoolean(for: "key", errorPtr: &error) + + // -- Assert -- + XCTAssertFalse(result, "Should return false when mocked with false") + XCTAssertNil(error, "Should not set error when returning success") + } + + func testGetAppValueBoolean_withFailureResult_shouldReturnFalseAndSetError() { + // -- Arrange -- + let sut = TestInfoPlistWrapper() + let expectedError = NSError(domain: "TestDomain", code: 123, userInfo: [NSLocalizedDescriptionKey: "Test error"]) + sut.mockGetAppValueBooleanThrowError(forKey: "key", error: expectedError) + + // -- Act -- + var error: NSError? + let result = sut.getAppValueBoolean(for: "key", errorPtr: &error) + + // -- Assert -- + XCTAssertFalse(result, "Should return false when mocked to throw error") + XCTAssertNotNil(error, "Should set error pointer when returning failure") + XCTAssertEqual(error?.domain, "TestDomain", "Error should have correct domain") + XCTAssertEqual(error?.code, 123, "Error should have correct code") + XCTAssertEqual(error?.localizedDescription, "Test error", "Error should have correct description") + } + + func testGetAppValueBoolean_withFailureResult_withNilErrorPointer_shouldReturnFalse() { + // -- Arrange -- + let sut = TestInfoPlistWrapper() + let expectedError = NSError(domain: "TestDomain", code: 123) + sut.mockGetAppValueBooleanThrowError(forKey: "key", error: expectedError) + + // -- Act -- + let result = sut.getAppValueBoolean(for: "key", errorPtr: nil) + + // -- Assert -- + XCTAssertFalse(result, "Should return false even when error pointer is nil") + // No crash should occur when error pointer is nil + } + + func testGetAppValueBoolean_shouldRecordInvocations() { + // -- Arrange -- + let sut = TestInfoPlistWrapper() + sut.mockGetAppValueBooleanReturnValue(forKey: "key1", value: true) + sut.mockGetAppValueBooleanReturnValue(forKey: "key2", value: false) + sut.mockGetAppValueBooleanReturnValue(forKey: "key3", value: true) + + // -- Act -- + var error1: NSError? + _ = sut.getAppValueBoolean(for: "key1", errorPtr: &error1) + + var error2: NSError? + _ = sut.getAppValueBoolean(for: "key2", errorPtr: &error2) + + var error3: NSError? + _ = sut.getAppValueBoolean(for: "key3", errorPtr: &error3) + + // -- Assert -- + XCTAssertEqual(sut.getAppValueBooleanInvocations.count, 3, "Should record all three invocations") + XCTAssertEqual(sut.getAppValueBooleanInvocations.invocations.element(at: 0)?.0, "key1", "First invocation should be for key1") + XCTAssertEqual(sut.getAppValueBooleanInvocations.invocations.element(at: 1)?.0, "key2", "Second invocation should be for key2") + XCTAssertEqual(sut.getAppValueBooleanInvocations.invocations.element(at: 2)?.0, "key3", "Third invocation should be for key3") + } + + func testGetAppValueBoolean_withSuccessResult_withNilErrorPointer_shouldReturnTrue() { + // -- Arrange -- + let sut = TestInfoPlistWrapper() + sut.mockGetAppValueBooleanReturnValue(forKey: "key", value: true) + + // -- Act -- + let result = sut.getAppValueBoolean(for: "key", errorPtr: nil) + + // -- Assert -- + XCTAssertTrue(result, "Should return true even when error pointer is nil") + // No crash should occur when error pointer is nil + } + + func testGetAppValueBoolean_withDifferentKeys_shouldReturnDifferentValues() { + // -- Arrange -- + let sut = TestInfoPlistWrapper() + sut.mockGetAppValueBooleanReturnValue(forKey: "key1", value: true) + sut.mockGetAppValueBooleanReturnValue(forKey: "key2", value: false) + + // -- Act -- + var error1: NSError? + let result1 = sut.getAppValueBoolean(for: "key1", errorPtr: &error1) + + var error2: NSError? + let result2 = sut.getAppValueBoolean(for: "key2", errorPtr: &error2) + + // -- Assert -- + XCTAssertTrue(result1, "Should return true for key1") + XCTAssertNil(error1, "Should not set error for key1") + XCTAssertFalse(result2, "Should return false for key2") + XCTAssertNil(error2, "Should not set error for key2") + } +} diff --git a/SentryTestUtilsTests/TestSessionReplayEnvironmentCheckerTests.swift b/SentryTestUtilsTests/TestSessionReplayEnvironmentCheckerTests.swift new file mode 100644 index 00000000000..96949c7ad5f --- /dev/null +++ b/SentryTestUtilsTests/TestSessionReplayEnvironmentCheckerTests.swift @@ -0,0 +1,67 @@ +@_spi(Private) @testable import Sentry +@_spi(Private) @testable import SentryTestUtils +import XCTest + +class TestSessionReplayEnvironmentCheckerTests: XCTestCase { + + // MARK: - isReliable() + + func testIsReliable_withoutMockedValue_shouldReturnDefaultValue() throws { + // -- Arrange -- + let sut = TestSessionReplayEnvironmentChecker( + mockedIsReliableReturnValue: true + ) + + // -- Act -- + let result = sut.isReliable() + + // -- Assert -- + XCTAssertTrue(result, "isReliable() should return the same value as the one mocked") + } + + func testIsReliable_withMockedValue_withSingleInvocations_shouldReturnMockedValue() throws { + // -- Arrange -- + let sut = TestSessionReplayEnvironmentChecker( + mockedIsReliableReturnValue: false + ) + sut.mockIsReliableReturnValue(true) + + // -- Act -- + let result = sut.isReliable() + + // -- Assert -- + XCTAssertTrue(result, "isReliable() should return the same value as the one mocked") + } + + func testIsReliable_withMockedValue_withMultipleInvocations_shouldReturnSameValue() throws { + // -- Arrange -- + let sut = TestSessionReplayEnvironmentChecker( + mockedIsReliableReturnValue: false + ) + sut.mockIsReliableReturnValue(true) + + // -- Act -- + let result1 = sut.isReliable() + let result2 = sut.isReliable() + + // -- Assert -- + XCTAssertTrue(result1) + XCTAssertTrue(result2) + } + + func testIsReliable_shouldRecordInvocations() throws { + // -- Arrange -- + let sut = TestSessionReplayEnvironmentChecker( + mockedIsReliableReturnValue: false + ) + sut.mockIsReliableReturnValue(true) + + // -- Act -- + _ = sut.isReliable() + _ = sut.isReliable() + _ = sut.isReliable() + + // -- Assert -- + XCTAssertEqual(sut.isReliableInvocations.count, 3) + } +} diff --git a/Sources/Sentry/SentryDependencyContainer.m b/Sources/Sentry/SentryDependencyContainer.m index 8cc5a0354d3..3120abc4697 100644 --- a/Sources/Sentry/SentryDependencyContainer.m +++ b/Sources/Sentry/SentryDependencyContainer.m @@ -189,6 +189,9 @@ - (instancetype)init [[SentryDefaultRateLimits alloc] initWithRetryAfterHeaderParser:retryAfterHeaderParser andRateLimitParser:rateLimitParser currentDateProvider:_dateProvider]; + _infoPlistWrapper = [[SentryInfoPlistWrapper alloc] init]; + _sessionReplayEnvironmentChecker = [[SentrySessionReplayEnvironmentChecker alloc] + initWithInfoPlistWrapper:_infoPlistWrapper]; #if SENTRY_HAS_REACHABILITY _reachability = [[SentryReachability alloc] init]; diff --git a/Sources/Sentry/SentrySessionReplayIntegration.m b/Sources/Sentry/SentrySessionReplayIntegration.m index c906ff48974..1f445244621 100644 --- a/Sources/Sentry/SentrySessionReplayIntegration.m +++ b/Sources/Sentry/SentrySessionReplayIntegration.m @@ -52,6 +52,7 @@ - (void)newSceneActivate; @implementation SentrySessionReplayIntegration { BOOL _startedAsFullSession; SentryReplayOptions *_replayOptions; + SentryExperimentalOptions *_experimentalOptions; id _notificationCenter; id _rateLimits; id _currentScreenshotProvider; @@ -63,6 +64,7 @@ @implementation SentrySessionReplayIntegration { // replay absolutely needs segment 0 to make replay work. BOOL _rateLimited; id _dateProvider; + id _environmentChecker; } - (instancetype)init @@ -75,10 +77,13 @@ - (instancetype)initForManualUse:(nonnull SentryOptions *)options { if (self = [super init]) { [self setupWith:options.sessionReplay + experimentalOptions:options.experimental enableTouchTracker:options.enableSwizzling enableViewRendererV2:options.sessionReplay.enableViewRendererV2 enableFastViewRendering:options.sessionReplay.enableFastViewRendering]; - [self startWithOptions:options.sessionReplay fullSession:YES]; + [self startWithOptions:options.sessionReplay + experimentalOptions:options.experimental + fullSession:YES]; } return self; } @@ -90,6 +95,7 @@ - (BOOL)installWithOptions:(nonnull SentryOptions *)options } [self setupWith:options.sessionReplay + experimentalOptions:options.experimental enableTouchTracker:options.enableSwizzling enableViewRendererV2:options.sessionReplay.enableViewRendererV2 enableFastViewRendering:options.sessionReplay.enableFastViewRendering]; @@ -97,11 +103,13 @@ - (BOOL)installWithOptions:(nonnull SentryOptions *)options } - (void)setupWith:(SentryReplayOptions *)replayOptions + experimentalOptions:(SentryExperimentalOptions *)experimentalOptions enableTouchTracker:(BOOL)touchTracker enableViewRendererV2:(BOOL)enableViewRendererV2 enableFastViewRendering:(BOOL)enableFastViewRendering { _replayOptions = replayOptions; + _experimentalOptions = experimentalOptions; _rateLimits = SentryDependencyContainer.sharedInstance.rateLimits; _dateProvider = SentryDependencyContainer.sharedInstance.dateProvider; @@ -132,6 +140,7 @@ - (void)setupWith:(SentryReplayOptions *)replayOptions _notificationCenter = SentryDependencyContainer.sharedInstance.notificationCenterWrapper; _dateProvider = SentryDependencyContainer.sharedInstance.dateProvider; + _environmentChecker = SentryDependencyContainer.sharedInstance.sessionReplayEnvironmentChecker; // We use the dispatch queue provider as a factory to create the queues, but store the queues // directly in this instance, so they get deallocated when the integration is deallocated. @@ -150,8 +159,6 @@ - (void)setupWith:(SentryReplayOptions *)replayOptions [dispatchQueueProvider createUtilityQueue:"io.sentry.session-replay.processing" relativePriority:-2]; - // The asset worker queue is used to work on video and frames data. - [self moveCurrentReplay]; [self cleanUp]; @@ -331,7 +338,9 @@ - (void)runReplayForAvailableWindow if ([SentryDependencyContainer.sharedInstance.application getWindows].count > 0) { SENTRY_LOG_DEBUG(@"[Session Replay] Running replay for available window"); // If a window its already available start replay right away - [self startWithOptions:_replayOptions fullSession:_startedAsFullSession]; + [self startWithOptions:_replayOptions + experimentalOptions:_experimentalOptions + fullSession:_startedAsFullSession]; } else if (@available(iOS 13.0, tvOS 13.0, *)) { SENTRY_LOG_DEBUG( @"[Session Replay] Waiting for a scene to be available to started the replay"); @@ -351,15 +360,19 @@ - (void)newSceneActivate removeObserver:self name:UISceneDidActivateNotification object:nil]; - [self startWithOptions:_replayOptions fullSession:_startedAsFullSession]; + [self startWithOptions:_replayOptions + experimentalOptions:_experimentalOptions + fullSession:_startedAsFullSession]; } } - (void)startWithOptions:(SentryReplayOptions *)replayOptions + experimentalOptions:(SentryExperimentalOptions *)experimentalOptions fullSession:(BOOL)shouldReplayFullSession { SENTRY_LOG_DEBUG(@"[Session Replay] Starting session"); [self startWithOptions:replayOptions + experimentalOptions:experimentalOptions screenshotProvider:_currentScreenshotProvider ?: _viewPhotographer breadcrumbConverter:_currentBreadcrumbConverter ?: [[SentrySRDefaultBreadcrumbConverter alloc] init] @@ -367,6 +380,7 @@ - (void)startWithOptions:(SentryReplayOptions *)replayOptions } - (void)startWithOptions:(SentryReplayOptions *)replayOptions + experimentalOptions:(SentryExperimentalOptions *)experimentalOptions screenshotProvider:(id)screenshotProvider breadcrumbConverter:(id)breadcrumbConverter fullSession:(BOOL)shouldReplayFullSession @@ -401,6 +415,7 @@ - (void)startWithOptions:(SentryReplayOptions *)replayOptions SentryDisplayLinkWrapper *displayLinkWrapper = [[SentryDisplayLinkWrapper alloc] init]; self.sessionReplay = [[SentrySessionReplay alloc] initWithReplayOptions:replayOptions + experimentalOptions:experimentalOptions replayFolderPath:docs screenshotProvider:screenshotProvider replayMaker:replayMaker @@ -408,7 +423,8 @@ - (void)startWithOptions:(SentryReplayOptions *)replayOptions touchTracker:_touchTracker dateProvider:_dateProvider delegate:self - displayLinkWrapper:displayLinkWrapper]; + displayLinkWrapper:displayLinkWrapper + environmentChecker:_environmentChecker]; [self.sessionReplay startWithRootView:[SentryDependencyContainer.sharedInstance.application getWindows] diff --git a/Sources/Sentry/include/HybridPublic/SentryDependencyContainer.h b/Sources/Sentry/include/HybridPublic/SentryDependencyContainer.h index 6e599e57bea..b1b628e6f82 100644 --- a/Sources/Sentry/include/HybridPublic/SentryDependencyContainer.h +++ b/Sources/Sentry/include/HybridPublic/SentryDependencyContainer.h @@ -35,6 +35,8 @@ @protocol SentryDispatchQueueProviderProtocol; @protocol SentryNSNotificationCenterWrapper; @protocol SentryObjCRuntimeWrapper; +@protocol SentryInfoPlistWrapperProvider; +@protocol SentrySessionReplayEnvironmentCheckerProvider; #if SENTRY_HAS_METRIC_KIT @class SentryMXManager; @@ -94,6 +96,9 @@ SENTRY_NO_INIT @property (nonatomic, strong) SentrySysctl *sysctlWrapper; @property (nonatomic, strong) id rateLimits; @property (nonatomic, strong) SentryThreadsafeApplication *threadsafeApplication; +@property (nonatomic, strong) id infoPlistWrapper; +@property (nonatomic, strong) id + sessionReplayEnvironmentChecker; #if SENTRY_HAS_REACHABILITY @property (nonatomic, strong) SentryReachability *reachability; diff --git a/Sources/Swift/Helper/InfoPlist/SentryInfoPlistError.swift b/Sources/Swift/Helper/InfoPlist/SentryInfoPlistError.swift new file mode 100644 index 00000000000..8b7b790741f --- /dev/null +++ b/Sources/Swift/Helper/InfoPlist/SentryInfoPlistError.swift @@ -0,0 +1,5 @@ +enum SentryInfoPlistError: Error { + case mainInfoPlistNotFound + case keyNotFound(key: String) + case unableToCastValue(key: String, value: Any, type: Any.Type) +} diff --git a/Sources/Swift/Helper/InfoPlist/SentryInfoPlistKey.swift b/Sources/Swift/Helper/InfoPlist/SentryInfoPlistKey.swift new file mode 100644 index 00000000000..10cf6607acd --- /dev/null +++ b/Sources/Swift/Helper/InfoPlist/SentryInfoPlistKey.swift @@ -0,0 +1,15 @@ +enum SentryInfoPlistKey: String { + /// Key used to set the Xcode version used to build app + case xcodeVersion = "DTXcode" + + /// A Boolean value that indicates whether the system runs the app using a compatibility mode for UI. + /// + /// If `YES`, the system runs the app using a compatibility mode for UI elements. The compatibility mode displays the app as it looks when built against previous versions of the SDKs. + /// + /// If `NO`, the system uses the UI design of the running OS, with no compatibility mode. Absence of the key, or NO, is the default value for apps linking against the latest SDKs. + /// + /// - Warning: This key is used temporarily while reviewing and refining an app’s UI for the design in the latest SDKs (i.e. Liquid Glass). + /// + /// - SeeAlso: [Apple Documentation](https://developer.apple.com/documentation/BundleResources/Information-Property-List/UIDesignRequiresCompatibility) + case designRequiresCompatibility = "UIDesignRequiresCompatibility" +} diff --git a/Sources/Swift/Helper/InfoPlist/SentryInfoPlistWrapper.swift b/Sources/Swift/Helper/InfoPlist/SentryInfoPlistWrapper.swift new file mode 100644 index 00000000000..be7bb0fa2d0 --- /dev/null +++ b/Sources/Swift/Helper/InfoPlist/SentryInfoPlistWrapper.swift @@ -0,0 +1,54 @@ +@objc @_spi(Private) public class SentryInfoPlistWrapper: NSObject, SentryInfoPlistWrapperProvider { + + private let bundle: Bundle + + public override init() { + // We can not use defaults in the initializer because this class is used from Objective-C + self.bundle = Bundle.main + super.init() + } + + public init(bundle: Bundle) { + self.bundle = bundle + super.init() + } + + // MARK: - Bridge to ObjC + + public func getAppValueBoolean(for key: String, errorPtr errPtr: NSErrorPointer) -> Bool { + do { + guard let value = try getAppValue(for: key, type: Bool.self) else { + throw SentryInfoPlistError.keyNotFound(key: key) + } + return value + } catch { + errPtr?.pointee = error as NSError + return false + } + } + + public func getAppValueString(for key: String) throws -> String { + guard let value = try getAppValue(for: key, type: String.self) else { + throw SentryInfoPlistError.keyNotFound(key: key) + } + return value + } + + // MARK: - Swift Implementation + + private func getAppValue(for key: String, type: T.Type) throws -> T? { + // As soon as this class is not consumed from Objective-C anymore, we can use this method directly to reduce + // unnecessary duplicate code. In addition this method can be adapted to use `SentryInfoPlistKey` as the type + // of the parameter `key` + guard let infoDictionary = bundle.infoDictionary else { + throw SentryInfoPlistError.mainInfoPlistNotFound + } + guard let value = infoDictionary[key] else { + return nil + } + guard let typedValue = value as? T else { + throw SentryInfoPlistError.unableToCastValue(key: key, value: value, type: T.self) + } + return typedValue + } +} diff --git a/Sources/Swift/Helper/InfoPlist/SentryInfoPlistWrapperProvider.swift b/Sources/Swift/Helper/InfoPlist/SentryInfoPlistWrapperProvider.swift new file mode 100644 index 00000000000..7e2d1427664 --- /dev/null +++ b/Sources/Swift/Helper/InfoPlist/SentryInfoPlistWrapperProvider.swift @@ -0,0 +1,27 @@ +@_spi(Private) @objc public protocol SentryInfoPlistWrapperProvider { + /** + * Retrieves a value from the app's `Info.plist` file for the given key and trys to cast it to a ``String``. + * + * - Parameter key: The key for which to retrieve the value from the `Info.plist`. + * - Throws: An error if the value cannot be cast to type ``String`` or ``SentryInfoPlistError.keyNotFound`` if the key was not found or the value is `nil` + * - Returns: The value associated with the specified key cast to type ``String`` + * - Note: The return value can not be nullable, because a throwing function in Objective-C uses `nil` to indicate an error: + * + * Throwing method cannot be a member of an '@objc' protocol because it returns a value of optional type 'String?'; 'nil' indicates failure to Objective-C + */ + func getAppValueString(for key: String) throws -> String + + /** + * Retrieves a value from the app's `Info.plist` file for the given key and trys to cast it to a ``Bool``. + * + * - Parameters + * - key: The key for which to retrieve the value from the `Info.plist`. + * - error: A pointer to a an `NSError` to return an error value. + * - Throws: An error if the value cannot be cast to type ``String`` or ``SentryInfoPlistError.keyNotFound`` if the value is `nil` + * - Returns: The value associated with the specified key cast to type ``String`` + * - Note: This method can not use `throws` because a falsy return value would indicate an error in Objective-C: + * + * Throwing method cannot be a member of an '@objc' protocol because it returns a value of type 'Bool'; return 'Void' or a type that bridges to an Objective-C class + */ + func getAppValueBoolean(for key: String, errorPtr: NSErrorPointer) -> Bool +} diff --git a/Sources/Swift/Helper/InfoPlist/SentryXcodeVersion.swift b/Sources/Swift/Helper/InfoPlist/SentryXcodeVersion.swift new file mode 100644 index 00000000000..f745575eacc --- /dev/null +++ b/Sources/Swift/Helper/InfoPlist/SentryXcodeVersion.swift @@ -0,0 +1,4 @@ +enum SentryXcodeVersion: Int { + case xcode16_4 = 1_640 + case xcode26 = 2_600 +} diff --git a/Sources/Swift/Integrations/SessionReplay/SentrySessionReplay.swift b/Sources/Swift/Integrations/SessionReplay/SentrySessionReplay.swift index 10279ac9f07..3a0800d736d 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentrySessionReplay.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentrySessionReplay.swift @@ -1,3 +1,4 @@ +// swiftlint:disable file_length import Foundation #if (os(iOS) || os(tvOS)) && !SENTRY_NO_UIKIT @_implementationOnly import _SentryPrivate @@ -29,13 +30,15 @@ import UIKit private(set) var isSessionPaused = false private let replayOptions: SentryReplayOptions + private let experimentalOptions: SentryExperimentalOptions private let replayMaker: SentryReplayVideoMaker private let displayLink: SentryReplayDisplayLinkWrapper private let dateProvider: SentryCurrentDateProvider private let touchTracker: SentryTouchTracker? private let lock = NSLock() public var replayTags: [String: Any]? - + private let environmentChecker: SentrySessionReplayEnvironmentCheckerProvider + var isRunning: Bool { displayLink.isRunning() } @@ -45,6 +48,7 @@ import UIKit public init( replayOptions: SentryReplayOptions, + experimentalOptions: SentryExperimentalOptions, replayFolderPath: URL, screenshotProvider: SentryViewScreenshotProvider, replayMaker: SentryReplayVideoMaker, @@ -52,9 +56,11 @@ import UIKit touchTracker: SentryTouchTracker?, dateProvider: SentryCurrentDateProvider, delegate: SentrySessionReplayDelegate, - displayLinkWrapper: SentryReplayDisplayLinkWrapper + displayLinkWrapper: SentryReplayDisplayLinkWrapper, + environmentChecker: SentrySessionReplayEnvironmentCheckerProvider ) { self.replayOptions = replayOptions + self.experimentalOptions = experimentalOptions self.dateProvider = dateProvider self.delegate = delegate self.screenshotProvider = screenshotProvider @@ -63,6 +69,7 @@ import UIKit self.replayMaker = replayMaker self.breadcrumbConverter = breadcrumbConverter self.touchTracker = touchTracker + self.environmentChecker = environmentChecker } deinit { displayLink.invalidate() } @@ -73,6 +80,18 @@ import UIKit SentrySDKLog.debug("[Session Replay] Session replay is already running, not starting again") return } + + // Detect if we are running on iOS 26.0 with Liquid Glass and disable session replay. + // This needs to be done until masking for session replay is properly supported, as it can lead + // to PII leaks otherwise. + if !environmentChecker.isReliable() { + guard experimentalOptions.enableSessionReplayInUnreliableEnvironment else { + SentrySDKLog.fatal("[Session Replay] Detected environment potentially causing PII leaks, disabling Session Replay. To override this mechanism, set `options.experimental.enableSessionReplayInUnreliableEnvironment` to `true`") + return + } + SentrySDKLog.warning("[Session Replay] Detected environment potentially causing PII leaks, but `options.experimental.enableSessionReplayInUnreliableEnvironment` is set to `true`, ignoring and enabling Session Replay.") + } + displayLink.link(withTarget: self, selector: #selector(newFrame(_:))) self.rootView = rootView lastScreenShot = dateProvider.date() @@ -373,3 +392,4 @@ import UIKit // swiftlint:enable type_body_length #endif +// swiftlint:enable file_length diff --git a/Sources/Swift/Integrations/SessionReplay/SentrySessionReplayEnvironmentChecker.swift b/Sources/Swift/Integrations/SessionReplay/SentrySessionReplayEnvironmentChecker.swift new file mode 100644 index 00000000000..d4f099797e4 --- /dev/null +++ b/Sources/Swift/Integrations/SessionReplay/SentrySessionReplayEnvironmentChecker.swift @@ -0,0 +1,113 @@ +@objc @_spi(Private) public class SentrySessionReplayEnvironmentChecker: NSObject, SentrySessionReplayEnvironmentCheckerProvider { + /// Represents the reliability assessment of the environment for Session Replay. + private enum Reliability { + /// The environment is confirmed to be reliable (no Liquid Glass issues). + case reliable + /// The environment is confirmed to be unreliable (Liquid Glass will cause issues). + case unreliable + /// Unable to determine reliability (missing data, errors, etc.). + /// Treated as unreliable defensively. + case unclear + } + + private let infoPlistWrapper: SentryInfoPlistWrapperProvider + + @objc public init(infoPlistWrapper: SentryInfoPlistWrapperProvider) { + self.infoPlistWrapper = infoPlistWrapper + super.init() + } + + // swiftlint:disable:next cyclomatic_complexity function_body_length + public func isReliable() -> Bool { + // Defensive programming: Assume unreliable environment by default on iOS 26.0+ + // and only mark as safe if we have explicit proof it's not using Liquid Glass. + // + // Liquid Glass introduces changes to text rendering that breaks masking in Session Replay. + // It's used on iOS 26.0+ UNLESS one of these conditions is met: + // 1. UIDesignRequiresCompatibility is explicitly set to YES in Info.plist + // 2. The app was built with Xcode < 26.0 (DTXcode < 2600) + + // Run all checks and return true (reliable) if ANY check confirms reliability + if checkIOSVersion() == .reliable { + return true + } + if checkCompatibilityMode() == .reliable { + return true + } + if checkXcodeVersion() == .reliable { + return true + } + + // No proof of reliability found - treat as unreliable (defensively) + SentrySDKLog.warning("[Session Replay] Detected environment as unreliable - no proof of reliability found") + return false + } + + private func checkIOSVersion() -> Reliability { + guard #available(iOS 26.0, *) else { + SentrySDKLog.debug("[Session Replay] Running on iOS version prior to 26.0+ - reliable") + return .reliable + } + SentrySDKLog.debug("[Session Replay] Running on iOS 26.0+") + return .unclear + } + + private func checkCompatibilityMode() -> Reliability { + do { + var error: NSError? + let isRequired = infoPlistWrapper.getAppValueBoolean( + for: SentryInfoPlistKey.designRequiresCompatibility.rawValue, + errorPtr: &error + ) + if let error = error as Error? { + throw error + } + if isRequired { + SentrySDKLog.debug("[Session Replay] UIDesignRequiresCompatibility = YES - reliable") + return .reliable + } + + SentrySDKLog.debug("[Session Replay] UIDesignRequiresCompatibility = NO - unreliable") + return .unreliable + } catch SentryInfoPlistError.mainInfoPlistNotFound { + SentrySDKLog.warning("[Session Replay] Info.plist not found - unclear") + return .unclear + } catch SentryInfoPlistError.keyNotFound { + // Key not found means the default behavior applies (no compatibility mode) + SentrySDKLog.debug("[Session Replay] UIDesignRequiresCompatibility not set - unclear") + return .unclear + } catch { + SentrySDKLog.error("[Session Replay] Failed to read Info.plist: \(error) - unclear") + return .unclear + } + } + + private func checkXcodeVersion() -> Reliability { + do { + // DTXcode format: Xcode 16.4 = "1640", Xcode 26.0 = "2600" + let xcodeVersionString = try infoPlistWrapper.getAppValueString( + for: SentryInfoPlistKey.xcodeVersion.rawValue + ) + guard let xcodeVersion = Int(xcodeVersionString) else { + SentrySDKLog.warning("[Session Replay] DTXcode value '\(xcodeVersionString)' is not a valid integer - unclear") + return .unclear + } + if xcodeVersion >= SentryXcodeVersion.xcode26.rawValue { + SentrySDKLog.debug("[Session Replay] Built with Xcode \(xcodeVersionString) (>= 26.0) - unreliable") + return .unreliable + } + + SentrySDKLog.debug("[Session Replay] Built with Xcode \(xcodeVersionString) (< 26.0) - reliable") + return .reliable + } catch SentryInfoPlistError.mainInfoPlistNotFound { + SentrySDKLog.warning("[Session Replay] Info.plist not found - unclear") + return .unclear + } catch SentryInfoPlistError.keyNotFound { + SentrySDKLog.debug("[Session Replay] DTXcode not found in Info.plist - unclear") + return .unclear + } catch { + SentrySDKLog.error("[Session Replay] Failed to read Info.plist: \(error) - unclear") + return .unclear + } + } +} diff --git a/Sources/Swift/Integrations/SessionReplay/SentrySessionReplayEnvironmentCheckerProvider.swift b/Sources/Swift/Integrations/SessionReplay/SentrySessionReplayEnvironmentCheckerProvider.swift new file mode 100644 index 00000000000..6a07a339337 --- /dev/null +++ b/Sources/Swift/Integrations/SessionReplay/SentrySessionReplayEnvironmentCheckerProvider.swift @@ -0,0 +1,6 @@ +@_spi(Private) @objc public protocol SentrySessionReplayEnvironmentCheckerProvider { + /// Checks if the runtime environment is considered unreliable with regards to Session Replay masking. + /// + /// - Returns: `true` if reliable, otherwise `false` + func isReliable() -> Bool +} diff --git a/Sources/Swift/SentryExperimentalOptions.swift b/Sources/Swift/SentryExperimentalOptions.swift index 926f5782b6f..895303c1f25 100644 --- a/Sources/Swift/SentryExperimentalOptions.swift +++ b/Sources/Swift/SentryExperimentalOptions.swift @@ -30,7 +30,24 @@ public class SentryExperimentalOptions: NSObject { * - Experiment: This is an experimental feature and is therefore disabled by default. We'll enable it by default in a future major release. */ public var enableUnhandledCPPExceptionsV2 = false - + + /** + * Forces enabling of session replay in unreliable environments. + * + * Due to internal changes with the release of Liquid Glass on iOS 26.0, the masking of text and images can not be reliably guaranteed. + * Therefore the SDK uses a defensive programming approach to disable the session replay integration by default, unless the environment is detected as reliable. + * + * Indicators for reliable environments include: + * - Running on an older version of iOS that doesn't have Liquid Glass (iOS 18 or earlier) + * - UIDesignRequiresCompatibility is explicitly set to YES in Info.plist + * - The app was built with Xcode < 26.0 (DTXcode < 2600) + * + * - Important: This flag allows to re-enable the session replay integration on iOS 26.0 and later, but please be aware that text and images may not be masked as expected. + * + * - Note: See [GitHub issues #6389](https://github.com/getsentry/sentry-cocoa/issues/6389) for more information. + */ + public var enableSessionReplayInUnreliableEnvironment = false + /** * Logs are considered beta. */ diff --git a/Tests/SentryTests/Helper/InfoPlist/SentryInfoPlistKeyTests.swift b/Tests/SentryTests/Helper/InfoPlist/SentryInfoPlistKeyTests.swift new file mode 100644 index 00000000000..35c8a3f4b2e --- /dev/null +++ b/Tests/SentryTests/Helper/InfoPlist/SentryInfoPlistKeyTests.swift @@ -0,0 +1,12 @@ +@_spi(Private) @testable import Sentry +import XCTest + +class SentryInfoPlistKeyTests: XCTestCase { + func testXcodeVersion_shouldReturnExpectedConstant() { + XCTAssertEqual(SentryInfoPlistKey.xcodeVersion.rawValue, "DTXcode") + } + + func testDesignRequiresCompatibility_shouldReturnExpectedConstant() { + XCTAssertEqual(SentryInfoPlistKey.designRequiresCompatibility.rawValue, "UIDesignRequiresCompatibility") + } +} diff --git a/Tests/SentryTests/Helper/InfoPlist/SentryInfoPlistWrapperTests.swift b/Tests/SentryTests/Helper/InfoPlist/SentryInfoPlistWrapperTests.swift new file mode 100644 index 00000000000..6619ec25384 --- /dev/null +++ b/Tests/SentryTests/Helper/InfoPlist/SentryInfoPlistWrapperTests.swift @@ -0,0 +1,199 @@ +@_spi(Private) @testable import Sentry +import XCTest + +/// Tests for `SentryInfoPlistWrapper`. +/// +/// This test suite uses a custom test bundle (`TestBundle`) with a predefined Info.plist file +/// (`TestInfoPlist.plist`) to ensure consistent and predictable testing. This approach eliminates +/// the need to rely on the environment's Info.plist, which may vary across different test contexts. +/// +/// ## Test Setup +/// +/// - `TestInfoPlist.plist`: Contains known key-value pairs for testing (strings, booleans, arrays, etc.) +/// - `TestBundle.swift`: Helper class that creates a temporary bundle from the test plist +/// - The bundle is created in `setUp()` and cleaned up in `tearDown()` +class SentryInfoPlistWrapperTests: XCTestCase { + + private var sut: SentryInfoPlistWrapper! + private var testBundle: Bundle! + + override func setUp() { + super.setUp() + + // Create a test bundle with our custom Info.plist + testBundle = TestBundle.createTestBundle() + XCTAssertNotNil(testBundle, "Test bundle should be created successfully") + + sut = SentryInfoPlistWrapper(bundle: testBundle) + } + + override func tearDown() { + TestBundle.cleanup(testBundle) + testBundle = nil + sut = nil + super.tearDown() + } + + // MARK: - getAppValueString Tests + + func testGetAppValueString_whenKeyExists_shouldReturnValue() throws { + // Arrange + let key = "TestStringKey" + + // Act + let value = try sut.getAppValueString(for: key) + + // Assert + XCTAssertEqual(value, "TestStringValue", "Should return the correct string value") + } + + func testGetAppValueString_whenKeyDoesNotExist_shouldThrowKeyNotFoundError() { + // Arrange + let nonExistentKey = "NonExistentKey_12345_XYZ" + + // Act & Assert + XCTAssertThrowsError(try sut.getAppValueString(for: nonExistentKey)) { error in + guard case SentryInfoPlistError.keyNotFound(let key) = error else { + XCTFail("Expected SentryInfoPlistError.keyNotFound, got \(error)") + return + } + XCTAssertEqual(key, nonExistentKey) + } + } + + func testGetAppValueString_whenValueIsNotString_shouldThrowUnableToCastError() { + // Arrange + // TestArrayKey is an array in our test plist, not a string + let key = "TestArrayKey" + + // Act & Assert + XCTAssertThrowsError(try sut.getAppValueString(for: key)) { error in + guard case SentryInfoPlistError.unableToCastValue(let errorKey, _, let type) = error else { + XCTFail("Expected SentryInfoPlistError.unableToCastValue, got \(error)") + return + } + XCTAssertEqual(errorKey, key) + XCTAssertTrue(type == String.self) + } + } + + // MARK: - getAppValueBoolean Tests + + func testGetAppValueBoolean_whenKeyExistsAndIsTrue_shouldReturnTrue() { + // Arrange + let key = "TestBooleanTrue" + var error: NSError? + + // Act + let value = sut.getAppValueBoolean(for: key, errorPtr: &error) + + // Assert + XCTAssertNil(error, "Should not have an error when reading a valid boolean") + XCTAssertTrue(value, "Should return true for TestBooleanTrue key") + } + + func testGetAppValueBoolean_whenKeyExistsAndIsFalse_shouldReturnFalse() { + // Arrange + let key = "TestBooleanFalse" + var error: NSError? + + // Act + let value = sut.getAppValueBoolean(for: key, errorPtr: &error) + + // Assert + XCTAssertNil(error, "Should not have an error when reading a valid boolean") + XCTAssertFalse(value, "Should return false for TestBooleanFalse key") + } + + func testGetAppValueBoolean_whenKeyDoesNotExist_shouldReturnFalseAndSetError() { + // Arrange + let nonExistentKey = "NonExistentBooleanKey_12345_XYZ" + var error: NSError? + + // Act + let value = sut.getAppValueBoolean(for: nonExistentKey, errorPtr: &error) + + // Assert + XCTAssertFalse(value, "Should return false when key is not found") + XCTAssertNotNil(error, "Error should be set when key is not found") + } + + func testGetAppValueBoolean_whenValueIsNotBoolean_shouldReturnFalseAndSetError() { + // Arrange + // TestStringKey is a string, not a boolean + let key = "TestStringKey" + var error: NSError? + + // Act + let value = sut.getAppValueBoolean(for: key, errorPtr: &error) + + // Assert + XCTAssertFalse(value, "Should return false when value cannot be cast to Boolean") + XCTAssertNotNil(error, "Error should be set when type casting fails") + } + + func testGetAppValueBoolean_withNullErrorPointer_shouldNotCrash() { + // Arrange + let key = "TestStringKey" // A key that exists but is not a boolean + + // Act & Assert + // This should not crash even with a null error pointer + let value = sut.getAppValueBoolean(for: key, errorPtr: nil) + XCTAssertFalse(value, "Should return false when casting fails") + } + + // MARK: - Edge Cases + + func testGetAppValueString_withEmptyKey_shouldThrowKeyNotFoundError() { + // Arrange + let emptyKey = "" + + // Act & Assert + XCTAssertThrowsError(try sut.getAppValueString(for: emptyKey)) { error in + guard case SentryInfoPlistError.keyNotFound = error else { + XCTFail("Expected SentryInfoPlistError.keyNotFound, got \(error)") + return + } + } + } + + func testGetAppValueString_withSentryInfoPlistKey_shouldWork() throws { + // Arrange + // Test with the actual enum keys used in production + let xcodeKey = SentryInfoPlistKey.xcodeVersion.rawValue + + // Act + let value = try sut.getAppValueString(for: xcodeKey) + + // Assert + XCTAssertEqual(value, "1610", "Should return the DTXcode value from test bundle") + } + + func testGetAppValueBoolean_withSentryInfoPlistKey_shouldWork() { + // Arrange + let compatibilityKey = SentryInfoPlistKey.designRequiresCompatibility.rawValue + var error: NSError? + + // Act + let value = sut.getAppValueBoolean(for: compatibilityKey, errorPtr: &error) + + // Assert + XCTAssertNil(error, "Should not have an error when reading a valid boolean") + XCTAssertFalse(value, "Should return false for UIDesignRequiresCompatibility key") + } + + // MARK: - Multiple Consecutive Calls + + func testMultipleConsecutiveCalls_shouldReturnConsistentResults() throws { + // Arrange + let key = "TestStringKey" + + // Act + let value1 = try sut.getAppValueString(for: key) + let value2 = try sut.getAppValueString(for: key) + + // Assert + XCTAssertEqual(value1, value2, "Multiple calls should return the same value") + XCTAssertEqual(value1, "TestStringValue") + } +} diff --git a/Tests/SentryTests/Helper/InfoPlist/SentryXcodeVersionTests.swift b/Tests/SentryTests/Helper/InfoPlist/SentryXcodeVersionTests.swift new file mode 100644 index 00000000000..3a27f5801cc --- /dev/null +++ b/Tests/SentryTests/Helper/InfoPlist/SentryXcodeVersionTests.swift @@ -0,0 +1,12 @@ +@_spi(Private) @testable import Sentry +import XCTest + +class SentryXcodeVersionTests: XCTestCase { + func testXcode16_4_shouldReturnExpectedVersion() { + XCTAssertEqual(SentryXcodeVersion.xcode16_4.rawValue, 1_640) + } + + func testXcode26_shouldReturnExpectedVersion() { + XCTAssertEqual(SentryXcodeVersion.xcode26.rawValue, 2_600) + } +} diff --git a/Tests/SentryTests/Helper/InfoPlist/TestBundle.swift b/Tests/SentryTests/Helper/InfoPlist/TestBundle.swift new file mode 100644 index 00000000000..a1e83bc3ceb --- /dev/null +++ b/Tests/SentryTests/Helper/InfoPlist/TestBundle.swift @@ -0,0 +1,149 @@ +import Foundation + +/** + Helper class to create test bundles with custom Info.plist files for testing. + + This class provides utilities to create temporary bundles that can be used in tests, + allowing you to test code that depends on `Bundle.infoDictionary` without relying + on the actual test bundle's Info.plist. + + ## Usage Examples + + ### Using the predefined TestInfoPlist.plist: + ```swift + let testBundle = TestBundle.createTestBundle() + let wrapper = SentryInfoPlistWrapper(bundle: testBundle) + ``` + + ### Creating a custom bundle on-the-fly: + ```swift + let customInfo = [ + "CustomKey": "CustomValue", + "EnableFeature": true + ] as [String: Any] + let testBundle = TestBundle.createBundle(withInfoDictionary: customInfo) + ``` + + ### Cleanup: + ```swift + TestBundle.cleanup(testBundle) + ``` + + ## Note + The bundles created by this class are temporary and stored in the system's + temporary directory. Always call `cleanup(_:)` in your test's `tearDown()` method. + */ +class TestBundle { + + /// Creates a temporary bundle with the test Info.plist + /// - Returns: A Bundle configured with the test Info.plist, or nil if creation fails + static func createTestBundle() -> Bundle? { + guard let plistURL = locateTestInfoPlist() else { + print("⚠️ TestInfoPlist.plist not found in bundle resources") + return nil + } + return createBundleFromPlist(at: plistURL) + } + + // MARK: - Private Helpers + + /// Locates the TestInfoPlist.plist file in the test bundle + /// - Returns: URL to the plist file, or nil if not found + private static func locateTestInfoPlist() -> URL? { + let testBundle = Bundle(for: TestBundle.self) + + // Try with subdirectory first + if let url = testBundle.url(forResource: "TestInfoPlist", withExtension: "plist", subdirectory: "Helper/InfoPlist") { + return url + } + + // Try without subdirectory (Xcode might flatten the structure) + return testBundle.url(forResource: "TestInfoPlist", withExtension: "plist") + } + + /// Creates a temporary bundle directory + /// - Parameter name: The name of the bundle directory + /// - Returns: URL to the created directory, or nil if creation fails + private static func createTempBundleDirectory(name: String) -> URL? { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathComponent(name) + + do { + try FileManager.default.createDirectory( + at: tempDir, + withIntermediateDirectories: true, + attributes: nil + ) + return tempDir + } catch { + print("⚠️ Failed to create bundle directory: \(error)") + return nil + } + } + + /// Creates a Bundle from a URL + /// - Parameter url: The URL of the bundle directory + /// - Returns: A Bundle instance, or nil if creation fails + private static func createBundle(at url: URL) -> Bundle? { + guard let bundle = Bundle(url: url) else { + print("⚠️ Failed to create Bundle from directory at: \(url.path)") + return nil + } + return bundle + } + + /// Creates a bundle by copying an existing Info.plist file + /// - Parameter plistURL: URL to the Info.plist file to copy + /// - Returns: A Bundle configured with the copied Info.plist, or nil if creation fails + private static func createBundleFromPlist(at plistURL: URL) -> Bundle? { + guard let tempDir = createTempBundleDirectory(name: "TestBundle.bundle") else { + return nil + } + + do { + let destURL = tempDir.appendingPathComponent("Info.plist") + try FileManager.default.copyItem(at: plistURL, to: destURL) + return createBundle(at: tempDir) + } catch { + print("⚠️ Failed to copy Info.plist: \(error)") + return nil + } + } + + /// Creates an in-memory bundle with a custom Info.plist dictionary + /// - Parameter infoDictionary: The dictionary to use as Info.plist + /// - Returns: A Bundle with the specified infoDictionary + static func createBundle(withInfoDictionary infoDictionary: [String: Any]) -> Bundle? { + guard let tempDir = createTempBundleDirectory(name: "TestBundle.bundle") else { + return nil + } + + do { + let plistURL = tempDir.appendingPathComponent("Info.plist") + let plistData = try PropertyListSerialization.data( + fromPropertyList: infoDictionary, + format: .xml, + options: 0 + ) + try plistData.write(to: plistURL) + return createBundle(at: tempDir) + } catch { + print("⚠️ Failed to create Info.plist: \(error)") + return nil + } + } + + /// Cleans up a temporary test bundle + /// - Parameter bundle: The bundle to clean up + static func cleanup(_ bundle: Bundle?) { + guard let bundle = bundle else { + return + } + + // Only delete if it's in the temp directory (safety check) + if bundle.bundleURL.path.contains(FileManager.default.temporaryDirectory.path) { + try? FileManager.default.removeItem(at: bundle.bundleURL) + } + } +} diff --git a/Tests/SentryTests/Helper/InfoPlist/TestInfoPlist.plist b/Tests/SentryTests/Helper/InfoPlist/TestInfoPlist.plist new file mode 100644 index 00000000000..724aae47d11 --- /dev/null +++ b/Tests/SentryTests/Helper/InfoPlist/TestInfoPlist.plist @@ -0,0 +1,36 @@ + + + + + CFBundleName + TestBundle + CFBundleIdentifier + io.sentry.test + TestStringKey + TestStringValue + DTXcode + 1610 + DTXcodeBuild + 16B40 + TestBooleanTrue + + TestBooleanFalse + + DTPlatformBuild + 21A5303d + TestNumberKey + 42 + TestArrayKey + + item1 + item2 + + TestDictionaryKey + + nestedKey + nestedValue + + UIDesignRequiresCompatibility + + + diff --git a/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayEnvironmentCheckerTests.swift b/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayEnvironmentCheckerTests.swift new file mode 100644 index 00000000000..dc422a1f82b --- /dev/null +++ b/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayEnvironmentCheckerTests.swift @@ -0,0 +1,407 @@ +@_spi(Private) @testable import Sentry +@_spi(Private) import SentryTestUtils +import XCTest + +final class SentrySessionReplayEnvironmentCheckerTests: XCTestCase { + + private var infoPlistWrapper: TestInfoPlistWrapper! + private var sut: SentrySessionReplayEnvironmentChecker! + + override func setUp() { + super.setUp() + infoPlistWrapper = TestInfoPlistWrapper() + + // Set up default mocks to prevent precondition failures + // Individual tests can override these as needed + + // Default: compatibility mode not set (key not found = unclear) + infoPlistWrapper.mockGetAppValueBooleanThrowError( + forKey: SentryInfoPlistKey.designRequiresCompatibility.rawValue, + error: SentryInfoPlistError.keyNotFound(key: SentryInfoPlistKey.designRequiresCompatibility.rawValue) as NSError + ) + + // Default: Xcode version not set (key not found = unclear) + infoPlistWrapper.mockGetAppValueStringThrowError( + forKey: SentryInfoPlistKey.xcodeVersion.rawValue, + error: SentryInfoPlistError.keyNotFound(key: SentryInfoPlistKey.xcodeVersion.rawValue) + ) + + sut = SentrySessionReplayEnvironmentChecker(infoPlistWrapper: infoPlistWrapper) + } + + override func tearDown() { + sut = nil + infoPlistWrapper = nil + super.tearDown() + } + + // MARK: - iOS Version Check Tests + + func testIsReliable_onIOSOlderThan26_returnsTrue() throws { + // iOS < 26.0 is always reliable (no Liquid Glass) + guard #unavailable(iOS 26.0) else { + throw XCTSkip("Test requires iOS < 26.0") + } + + // Act + let result = sut.isReliable() + + // Assert + XCTAssertTrue(result, "iOS < 26.0 should always be reliable") + } + + // MARK: - Compatibility Mode Tests (iOS 26+) + + func testIsReliable_onIOS26_withCompatibilityModeYES_returnsTrue() throws { + guard #available(iOS 26.0, *) else { + throw XCTSkip("Test requires iOS 26.0+") + } + + // Arrange + infoPlistWrapper.mockGetAppValueBooleanReturnValue( + forKey: SentryInfoPlistKey.designRequiresCompatibility.rawValue, + value: true + ) + + // Act + let result = sut.isReliable() + + // Assert + XCTAssertTrue(result, "UIDesignRequiresCompatibility = YES should make environment reliable") + } + + func testIsReliable_onIOS26_withCompatibilityModeNO_withOldXcode_returnsTrue() throws { + guard #available(iOS 26.0, *) else { + throw XCTSkip("Test requires iOS 26.0+") + } + + // Arrange + infoPlistWrapper.mockGetAppValueBooleanReturnValue( + forKey: SentryInfoPlistKey.designRequiresCompatibility.rawValue, + value: false + ) + infoPlistWrapper.mockGetAppValueStringReturnValue( + forKey: SentryInfoPlistKey.xcodeVersion.rawValue, + value: "\(SentryXcodeVersion.xcode16_4.rawValue)" // Xcode 16.4 < 26.0 + ) + + // Act + let result = sut.isReliable() + + // Assert + XCTAssertTrue(result, "Xcode < 26.0 should make environment reliable even with compatibility mode NO") + } + + func testIsReliable_onIOS26_withCompatibilityModeNO_withNewXcode_returnsFalse() throws { + guard #available(iOS 26.0, *) else { + throw XCTSkip("Test requires iOS 26.0+") + } + + // Arrange + infoPlistWrapper.mockGetAppValueBooleanReturnValue( + forKey: SentryInfoPlistKey.designRequiresCompatibility.rawValue, + value: false + ) + infoPlistWrapper.mockGetAppValueStringReturnValue( + forKey: SentryInfoPlistKey.xcodeVersion.rawValue, + value: "\(SentryXcodeVersion.xcode26.rawValue)" // Xcode 26.0 + ) + + // Act + let result = sut.isReliable() + + // Assert + XCTAssertFalse(result, "iOS 26+ with compatibility mode NO and Xcode >= 26.0 should be unreliable") + } + + // MARK: - Xcode Version Tests (iOS 26+) + + func testIsReliable_onIOS26_withXcodeOlderThan26_returnsTrue() throws { + guard #available(iOS 26.0, *) else { + throw XCTSkip("Test requires iOS 26.0+") + } + + // Arrange + infoPlistWrapper.mockGetAppValueStringReturnValue( + forKey: SentryInfoPlistKey.xcodeVersion.rawValue, + value: "\(SentryXcodeVersion.xcode16_4.rawValue)" // Xcode 16.4 + ) + + // Act + let result = sut.isReliable() + + // Assert + XCTAssertTrue(result, "Xcode < 26.0 should make environment reliable") + } + + func testIsReliable_onIOS26_withXcode26OrNewer_returnsFalse() throws { + guard #available(iOS 26.0, *) else { + throw XCTSkip("Test requires iOS 26.0+") + } + + // Arrange + infoPlistWrapper.mockGetAppValueStringReturnValue( + forKey: SentryInfoPlistKey.xcodeVersion.rawValue, + value: "\(SentryXcodeVersion.xcode26.rawValue)" // Xcode 26.0 + ) + + // Act + let result = sut.isReliable() + + // Assert + XCTAssertFalse(result, "Xcode >= 26.0 on iOS 26+ should be unreliable") + } + + func testIsReliable_onIOS26_withInvalidXcodeVersion_returnsFalse() throws { + guard #available(iOS 26.0, *) else { + throw XCTSkip("Test requires iOS 26.0+") + } + + // Arrange + infoPlistWrapper.mockGetAppValueStringReturnValue( + forKey: SentryInfoPlistKey.xcodeVersion.rawValue, + value: "invalid_version" + ) + + // Act + let result = sut.isReliable() + + // Assert + XCTAssertFalse(result, "Invalid Xcode version should be treated as unreliable (defensive)") + } + + func testIsReliable_onIOS26_withMissingXcodeVersion_returnsFalse() throws { + guard #available(iOS 26.0, *) else { + throw XCTSkip("Test requires iOS 26.0+") + } + + // Arrange + infoPlistWrapper.mockGetAppValueStringThrowError( + forKey: SentryInfoPlistKey.xcodeVersion.rawValue, + error: SentryInfoPlistError.keyNotFound(key: SentryInfoPlistKey.xcodeVersion.rawValue) + ) + + // Act + let result = sut.isReliable() + + // Assert + XCTAssertFalse(result, "Missing Xcode version should be treated as unreliable (defensive)") + } + + // MARK: - Compatibility Mode Error Handling Tests (iOS 26+) + + func testIsReliable_onIOS26_withMissingCompatibilityKey_withOldXcode_returnsTrue() throws { + guard #available(iOS 26.0, *) else { + throw XCTSkip("Test requires iOS 26.0+") + } + + // Arrange + infoPlistWrapper.mockGetAppValueBooleanThrowError( + forKey: SentryInfoPlistKey.designRequiresCompatibility.rawValue, + error: SentryInfoPlistError.keyNotFound(key: SentryInfoPlistKey.designRequiresCompatibility.rawValue) as NSError + ) + infoPlistWrapper.mockGetAppValueStringReturnValue( + forKey: SentryInfoPlistKey.xcodeVersion.rawValue, + value: "1640" // Xcode 16.4 + ) + + // Act + let result = sut.isReliable() + + // Assert + XCTAssertTrue(result, "Old Xcode version should make it reliable even without compatibility key") + } + + func testIsReliable_onIOS26_withMissingCompatibilityKey_withNewXcode_returnsFalse() throws { + guard #available(iOS 26.0, *) else { + throw XCTSkip("Test requires iOS 26.0+") + } + + // Arrange + infoPlistWrapper.mockGetAppValueBooleanThrowError( + forKey: SentryInfoPlistKey.designRequiresCompatibility.rawValue, + error: SentryInfoPlistError.keyNotFound(key: SentryInfoPlistKey.designRequiresCompatibility.rawValue) as NSError + ) + infoPlistWrapper.mockGetAppValueStringReturnValue( + forKey: SentryInfoPlistKey.xcodeVersion.rawValue, + value: "2600" // Xcode 26.0 + ) + + // Act + let result = sut.isReliable() + + // Assert + XCTAssertFalse(result, "New Xcode with missing compatibility key should be unreliable") + } + + func testIsReliable_onIOS26_withInfoPlistNotFound_returnsFalse() throws { + guard #available(iOS 26.0, *) else { + throw XCTSkip("Test requires iOS 26.0+") + } + + // Arrange + infoPlistWrapper.mockGetAppValueBooleanThrowError( + forKey: SentryInfoPlistKey.designRequiresCompatibility.rawValue, + error: SentryInfoPlistError.mainInfoPlistNotFound as NSError + ) + infoPlistWrapper.mockGetAppValueStringThrowError( + forKey: SentryInfoPlistKey.xcodeVersion.rawValue, + error: SentryInfoPlistError.mainInfoPlistNotFound as Error + ) + + // Act + let result = sut.isReliable() + + // Assert + XCTAssertFalse(result, "Missing Info.plist should be treated as unreliable (defensive)") + } + + // MARK: - Edge Cases and Error Handling (iOS 26+) + + func testIsReliable_onIOS26_withAllChecksUnclear_returnsFalse() throws { + guard #available(iOS 26.0, *) else { + throw XCTSkip("Test requires iOS 26.0+") + } + + // Arrange - all checks return unclear + infoPlistWrapper.mockGetAppValueBooleanThrowError( + forKey: SentryInfoPlistKey.designRequiresCompatibility.rawValue, + error: SentryInfoPlistError.keyNotFound(key: SentryInfoPlistKey.designRequiresCompatibility.rawValue) as NSError + ) + infoPlistWrapper.mockGetAppValueStringThrowError( + forKey: SentryInfoPlistKey.xcodeVersion.rawValue, + error: SentryInfoPlistError.keyNotFound(key: SentryInfoPlistKey.xcodeVersion.rawValue) + ) + + // Act + let result = sut.isReliable() + + // Assert + XCTAssertFalse(result, "When all checks are unclear, should be treated as unreliable (defensive)") + } + + func testIsReliable_onIOS26_withXcodeExactly2600_returnsFalse() throws { + guard #available(iOS 26.0, *) else { + throw XCTSkip("Test requires iOS 26.0+") + } + + // Arrange + infoPlistWrapper.mockGetAppValueStringReturnValue( + forKey: SentryInfoPlistKey.xcodeVersion.rawValue, + value: "2600" // Exactly Xcode 26.0 + ) + + // Act + let result = sut.isReliable() + + // Assert + XCTAssertFalse(result, "Xcode 26.0 (exactly) should be unreliable") + } + + func testIsReliable_onIOS26_withXcodeExactly2599_returnsTrue() throws { + guard #available(iOS 26.0, *) else { + throw XCTSkip("Test requires iOS 26.0+") + } + + // Arrange + infoPlistWrapper.mockGetAppValueStringReturnValue( + forKey: SentryInfoPlistKey.xcodeVersion.rawValue, + value: "2599" // Just before Xcode 26.0 + ) + + // Act + let result = sut.isReliable() + + // Assert + XCTAssertTrue(result, "Xcode 25.9.9 (< 26.0) should be reliable") + } + + // MARK: - Multiple Reliable Conditions Tests (iOS 26+) + + func testIsReliable_onIOS26_withMultipleReliableConditions_returnsTrue() throws { + guard #available(iOS 26.0, *) else { + throw XCTSkip("Test requires iOS 26.0+") + } + + // Arrange - both compatibility mode AND old Xcode + infoPlistWrapper.mockGetAppValueBooleanReturnValue( + forKey: SentryInfoPlistKey.designRequiresCompatibility.rawValue, + value: true + ) + infoPlistWrapper.mockGetAppValueStringReturnValue( + forKey: SentryInfoPlistKey.xcodeVersion.rawValue, + value: "1640" // Xcode 16.4 + ) + + // Act + let result = sut.isReliable() + + // Assert + XCTAssertTrue(result, "Multiple reliable conditions should make environment reliable") + } + + // MARK: - Real-World Scenario Tests (iOS 26+) + + func testIsReliable_typicalNewApp_onIOS26_withXcode26_returnsFalse() throws { + // Typical scenario: New app built with Xcode 26 running on iOS 26 + // Should be detected as unreliable (Liquid Glass will be used) + guard #available(iOS 26.0, *) else { + throw XCTSkip("Test requires iOS 26.0+") + } + + // Arrange + infoPlistWrapper.mockGetAppValueStringReturnValue( + forKey: SentryInfoPlistKey.xcodeVersion.rawValue, + value: "2600" + ) + + // Act + let result = sut.isReliable() + + // Assert + XCTAssertFalse(result, "Typical new app on iOS 26 with Xcode 26 should be unreliable") + } + + func testIsReliable_legacyApp_onIOS26_withXcode16_returnsTrue() throws { + // Legacy scenario: Old app built with Xcode 16 running on iOS 26 + // Should be detected as reliable (Liquid Glass won't be used) + guard #available(iOS 26.0, *) else { + throw XCTSkip("Test requires iOS 26.0+") + } + + // Arrange + infoPlistWrapper.mockGetAppValueStringReturnValue( + forKey: SentryInfoPlistKey.xcodeVersion.rawValue, + value: "1600" + ) + + // Act + let result = sut.isReliable() + + // Assert + XCTAssertTrue(result, "Legacy app built with Xcode 16 on iOS 26 should be reliable") + } + + func testIsReliable_appOptingIntoCompatibility_onIOS26_withXcode26_returnsTrue() throws { + // Scenario: New app with Xcode 26 but explicitly opts into compatibility mode + guard #available(iOS 26.0, *) else { + throw XCTSkip("Test requires iOS 26.0+") + } + + // Arrange + infoPlistWrapper.mockGetAppValueBooleanReturnValue( + forKey: SentryInfoPlistKey.designRequiresCompatibility.rawValue, + value: true + ) + infoPlistWrapper.mockGetAppValueStringReturnValue( + forKey: SentryInfoPlistKey.xcodeVersion.rawValue, + value: "2600" + ) + + // Act + let result = sut.isReliable() + + // Assert + XCTAssertTrue(result, "App with compatibility mode enabled should be reliable") + } +} diff --git a/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayIntegrationTests.swift b/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayIntegrationTests.swift index 0700833769d..21f63826ba0 100644 --- a/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayIntegrationTests.swift +++ b/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayIntegrationTests.swift @@ -30,7 +30,7 @@ class SentrySessionReplayIntegrationTests: XCTestCase { } if #available(iOS 26.0, tvOS 26.0, macCatalyst 26.0, *) { - throw XCTSkip("When running the unit tests on iOS 26.0, tvOS 26 or macCatalyst 26.0 with Xcode 26.0, we get warning log messages on the console: 'nw_socket_set_connection_idle [C1.1.1.1:3] setsockopt SO_CONNECTION_IDLE failed [42: Protocol not available]'. This leads to test failures in CI. Therefore, we skip these for now. We are going to fix this with https://github.com/getsentry/sentry-cocoa/issues/6165.") + throw XCTSkip("When running the unit tests on iOS 26.0, tvOS 26 or macCatalyst 26.0 with Xcode 26.0, we get warning log messages on the console: 'nw_socket_set_connection_idle [C1.1.1.1:3] setsockopt SO_CONNECTION_IDLE failed [42: Protocol not available]'. This leads to test failures in CI. Therefore, we skip these for now. We are going to fix this with https://github.com/getsentry/sentry-cocoa/issues/6165. Note: Session Replay is also disabled by default on iOS 26 due to Liquid Glass rendering changes.") } uiApplication = TestSentryUIApplication() diff --git a/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayTests.swift b/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayTests.swift index 0546163b446..38043b76206 100644 --- a/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayTests.swift +++ b/Tests/SentryTests/Integrations/SessionReplay/SentrySessionReplayTests.swift @@ -80,7 +80,10 @@ class SentrySessionReplayTests: XCTestCase { let rootView = UIView() let replayMaker = TestReplayMaker() let cacheFolder = FileManager.default.temporaryDirectory - + let environmentChecker = TestSessionReplayEnvironmentChecker( + mockedIsReliableReturnValue: true + ) + var breadcrumbs: [Breadcrumb]? var isFullSession = true var lastReplayEvent: SentryReplayEvent? @@ -88,17 +91,32 @@ class SentrySessionReplayTests: XCTestCase { var lastVideoUrl: URL? var lastReplayId: SentryId? var currentScreen: String? - - func getSut(options: SentryReplayOptions = .init(sessionSampleRate: 0, onErrorSampleRate: 0), touchTracker: SentryTouchTracker? = nil) -> SentrySessionReplay { - return SentrySessionReplay(replayOptions: options, - replayFolderPath: cacheFolder, - screenshotProvider: screenshotProvider, - replayMaker: replayMaker, - breadcrumbConverter: SentrySRDefaultBreadcrumbConverter(), - touchTracker: touchTracker ?? SentryTouchTracker(dateProvider: dateProvider, scale: 0), - dateProvider: dateProvider, - delegate: self, - displayLinkWrapper: displayLink) + + override init() { + super.init() + + // By default we are testing a reliable environment so all of the functionality is enabled + environmentChecker.mockIsReliableReturnValue(true) + } + + func getSut( + options: SentryReplayOptions = .init(sessionSampleRate: 0, onErrorSampleRate: 0), + experimentalOptions: SentryExperimentalOptions = .init(), + touchTracker: SentryTouchTracker? = nil + ) -> SentrySessionReplay { + return SentrySessionReplay( + replayOptions: options, + experimentalOptions: experimentalOptions, + replayFolderPath: cacheFolder, + screenshotProvider: screenshotProvider, + replayMaker: replayMaker, + breadcrumbConverter: SentrySRDefaultBreadcrumbConverter(), + touchTracker: touchTracker ?? SentryTouchTracker(dateProvider: dateProvider, scale: 0), + dateProvider: dateProvider, + delegate: self, + displayLinkWrapper: displayLink, + environmentChecker: environmentChecker + ) } func sessionReplayShouldCaptureReplayForError() -> Bool { @@ -397,7 +415,7 @@ class SentrySessionReplayTests: XCTestCase { let touchTracker = TestTouchTracker(dateProvider: fixture.dateProvider, scale: 1) let sut = fixture.getSut(options: SentryReplayOptions(sessionSampleRate: 1, onErrorSampleRate: 1), touchTracker: touchTracker) sut.start(rootView: fixture.rootView, fullSession: true) - + //Starting session replay at time 0 Dynamic(sut).newFrame(nil) @@ -548,6 +566,50 @@ class SentrySessionReplayTests: XCTestCase { XCTAssertEqual(fixture.displayLink.invalidateInvocations.count, 1) } + func testStart_withUnreliableEnvironment_withoutOverrideOptionEnabled_shouldNotStart() { + // -- Arrange -- + let fixture = Fixture() + fixture.environmentChecker.mockIsReliableReturnValue(false) + + let options = SentryReplayOptions(sessionSampleRate: 1.0, onErrorSampleRate: 1.0) + let experimentalOptions = SentryExperimentalOptions() + experimentalOptions.enableSessionReplayInUnreliableEnvironment = false + + let sut = fixture.getSut(options: options, experimentalOptions: experimentalOptions) + + // -- Act -- + // Attempt to start session replay + sut.start(rootView: fixture.rootView, fullSession: true) + + // -- Assert -- + // Verify that session replay did not actually start + // (it should have been blocked by isInUnreliableEnvironment) + XCTAssertFalse(fixture.displayLink.isRunning()) + } + + func testStart_withUnreliableEnvironment_withOverrideOptionEnabled_shouldStart() { + // -- Arrange -- + let fixture = Fixture() + fixture.environmentChecker.mockIsReliableReturnValue(false) + + let options = SentryReplayOptions(sessionSampleRate: 1.0, onErrorSampleRate: 1.0) + let experimentalOptions = SentryExperimentalOptions() + experimentalOptions.enableSessionReplayInUnreliableEnvironment = true + + let sut = fixture.getSut(options: options, experimentalOptions: experimentalOptions) + + // -- Act -- + // Attempt to start session replay + sut.start(rootView: fixture.rootView, fullSession: true) + + // -- Assert -- + // Verify that session replay started despite unreliable environment + // (override option is enabled) + XCTAssertTrue(fixture.displayLink.isRunning(), "Session replay should start when override option is enabled") + } + + // MARK: - Helpers + private func assertFullSession(_ sessionReplay: SentrySessionReplay, expected: Bool) { XCTAssertEqual(sessionReplay.isFullSession, expected) } diff --git a/scripts/.clang-format-version b/scripts/.clang-format-version index 9fcf356b68f..0ecacc9d29a 100644 --- a/scripts/.clang-format-version +++ b/scripts/.clang-format-version @@ -1 +1 @@ -21.1.2 +21.1.3 diff --git a/sdk_api.json b/sdk_api.json index 8a3d480ccdd..b049e90f73e 100644 --- a/sdk_api.json +++ b/sdk_api.json @@ -56890,6 +56890,79 @@ } ] }, + { + "kind": "Var", + "name": "enableSessionReplayInUnreliableEnvironment", + "printedName": "enableSessionReplayInUnreliableEnvironment", + "children": [ + { + "kind": "TypeNominal", + "name": "Bool", + "printedName": "Swift.Bool", + "usr": "s:Sb" + } + ], + "declKind": "Var", + "usr": "c:@M@Sentry@objc(cs)SentryExperimentalOptions(py)enableSessionReplayInUnreliableEnvironment", + "mangledName": "$s6Sentry0A19ExperimentalOptionsC42enableSessionReplayInUnreliableEnvironmentSbvp", + "moduleName": "Sentry", + "declAttributes": [ + "ObjC", + "HasStorage" + ], + "hasStorage": true, + "accessors": [ + { + "kind": "Accessor", + "name": "Get", + "printedName": "Get()", + "children": [ + { + "kind": "TypeNominal", + "name": "Bool", + "printedName": "Swift.Bool", + "usr": "s:Sb" + } + ], + "declKind": "Accessor", + "usr": "c:@M@Sentry@objc(cs)SentryExperimentalOptions(im)enableSessionReplayInUnreliableEnvironment", + "mangledName": "$s6Sentry0A19ExperimentalOptionsC42enableSessionReplayInUnreliableEnvironmentSbvg", + "moduleName": "Sentry", + "implicit": true, + "declAttributes": [ + "ObjC" + ], + "accessorKind": "get" + }, + { + "kind": "Accessor", + "name": "Set", + "printedName": "Set()", + "children": [ + { + "kind": "TypeNominal", + "name": "Void", + "printedName": "()" + }, + { + "kind": "TypeNominal", + "name": "Bool", + "printedName": "Swift.Bool", + "usr": "s:Sb" + } + ], + "declKind": "Accessor", + "usr": "c:@M@Sentry@objc(cs)SentryExperimentalOptions(im)setEnableSessionReplayInUnreliableEnvironment:", + "mangledName": "$s6Sentry0A19ExperimentalOptionsC42enableSessionReplayInUnreliableEnvironmentSbvs", + "moduleName": "Sentry", + "implicit": true, + "declAttributes": [ + "ObjC" + ], + "accessorKind": "set" + } + ] + }, { "kind": "Var", "name": "enableLogs", diff --git a/sdk_api_V9.json b/sdk_api_V9.json index 9869b162c09..9b27b6759b6 100644 --- a/sdk_api_V9.json +++ b/sdk_api_V9.json @@ -53335,6 +53335,79 @@ } ] }, + { + "kind": "Var", + "name": "enableSessionReplayInUnreliableEnvironment", + "printedName": "enableSessionReplayInUnreliableEnvironment", + "children": [ + { + "kind": "TypeNominal", + "name": "Bool", + "printedName": "Swift.Bool", + "usr": "s:Sb" + } + ], + "declKind": "Var", + "usr": "c:@M@Sentry@objc(cs)SentryExperimentalOptions(py)enableSessionReplayInUnreliableEnvironment", + "mangledName": "$s6Sentry0A19ExperimentalOptionsC42enableSessionReplayInUnreliableEnvironmentSbvp", + "moduleName": "Sentry", + "declAttributes": [ + "ObjC", + "HasStorage" + ], + "hasStorage": true, + "accessors": [ + { + "kind": "Accessor", + "name": "Get", + "printedName": "Get()", + "children": [ + { + "kind": "TypeNominal", + "name": "Bool", + "printedName": "Swift.Bool", + "usr": "s:Sb" + } + ], + "declKind": "Accessor", + "usr": "c:@M@Sentry@objc(cs)SentryExperimentalOptions(im)enableSessionReplayInUnreliableEnvironment", + "mangledName": "$s6Sentry0A19ExperimentalOptionsC42enableSessionReplayInUnreliableEnvironmentSbvg", + "moduleName": "Sentry", + "implicit": true, + "declAttributes": [ + "ObjC" + ], + "accessorKind": "get" + }, + { + "kind": "Accessor", + "name": "Set", + "printedName": "Set()", + "children": [ + { + "kind": "TypeNominal", + "name": "Void", + "printedName": "()" + }, + { + "kind": "TypeNominal", + "name": "Bool", + "printedName": "Swift.Bool", + "usr": "s:Sb" + } + ], + "declKind": "Accessor", + "usr": "c:@M@Sentry@objc(cs)SentryExperimentalOptions(im)setEnableSessionReplayInUnreliableEnvironment:", + "mangledName": "$s6Sentry0A19ExperimentalOptionsC42enableSessionReplayInUnreliableEnvironmentSbvs", + "moduleName": "Sentry", + "implicit": true, + "declAttributes": [ + "ObjC" + ], + "accessorKind": "set" + } + ] + }, { "kind": "Var", "name": "enableLogs",