diff --git a/Sentry.xcodeproj/project.pbxproj b/Sentry.xcodeproj/project.pbxproj index 1dc5fb2d1a3..969895b0c54 100644 --- a/Sentry.xcodeproj/project.pbxproj +++ b/Sentry.xcodeproj/project.pbxproj @@ -842,6 +842,7 @@ D4CD2A802DE9F91900DA9F59 /* SentryRedactRegion.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4CD2A7C2DE9F91900DA9F59 /* SentryRedactRegion.swift */; }; D4CD2A812DE9F91900DA9F59 /* SentryRedactRegionType.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4CD2A7D2DE9F91900DA9F59 /* SentryRedactRegionType.swift */; }; D4D12E7A2DFC608800DC45C4 /* SentryScreenshotOptionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4D12E792DFC607F00DC45C4 /* SentryScreenshotOptionsTests.swift */; }; + D4D849792E82E2240086BF67 /* SentryReplayApiTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4D849782E82E21F0086BF67 /* SentryReplayApiTests.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 */; }; @@ -1110,16 +1111,16 @@ FACEED132E3179A10007B4AC /* SentyOptionsInternal.m in Sources */ = {isa = PBXBuildFile; fileRef = FACEED122E3179A10007B4AC /* SentyOptionsInternal.m */; }; FAE2DAB82E1F317900262307 /* SentryProfilingSwiftHelpers.m in Sources */ = {isa = PBXBuildFile; fileRef = FAE2DAB72E1F317900262307 /* SentryProfilingSwiftHelpers.m */; }; FAE2DABA2E1F318900262307 /* SentryProfilingSwiftHelpers.h in Headers */ = {isa = PBXBuildFile; fileRef = FAE2DAB92E1F318900262307 /* SentryProfilingSwiftHelpers.h */; }; + FAE579842E7CF21800B710F9 /* SentryMigrateSessionInit.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE5797E2E7CF21300B710F9 /* SentryMigrateSessionInit.swift */; }; FAE5798D2E7D9D4C00B710F9 /* SentrySysctl.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE579872E7D9D4900B710F9 /* SentrySysctl.swift */; }; FAE579BA2E7DBE9900B710F9 /* SentryGlobalEventProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE579B42E7DBE9400B710F9 /* SentryGlobalEventProcessor.swift */; }; FAE579C22E7DDDE700B710F9 /* SentryThreadWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE579BC2E7DDDE400B710F9 /* SentryThreadWrapper.swift */; }; - FAE579842E7CF21800B710F9 /* SentryMigrateSessionInit.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE5797E2E7CF21300B710F9 /* SentryMigrateSessionInit.swift */; }; FAE579CC2E7DE14900B710F9 /* SentryFrameRemover.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAE579C62E7DE14400B710F9 /* SentryFrameRemover.swift */; }; FAE80C242E4695B40010A595 /* SentryEvent+Serialize.h in Headers */ = {isa = PBXBuildFile; fileRef = FAE80C232E4695AE0010A595 /* SentryEvent+Serialize.h */; }; FAEC270E2DF3526000878871 /* SentryUserFeedback.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAEC270D2DF3526000878871 /* SentryUserFeedback.swift */; }; FAEC273D2DF3933A00878871 /* NSData+Unzip.m in Sources */ = {isa = PBXBuildFile; fileRef = FAEC273C2DF3933200878871 /* NSData+Unzip.m */; }; - FAEEC0522E75E55F00E79CA9 /* SentrySerializationSwift.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAEEC04C2E75E55A00E79CA9 /* SentrySerializationSwift.swift */; }; FAEEBFE22E736D4B00E79CA9 /* SentryViewHierarchyProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAEEBFDC2E736D4100E79CA9 /* SentryViewHierarchyProvider.swift */; }; + FAEEC0522E75E55F00E79CA9 /* SentrySerializationSwift.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAEEC04C2E75E55A00E79CA9 /* SentrySerializationSwift.swift */; }; FAEFA12F2E4FAE1900C431D9 /* SentrySDKSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAEFA1292E4FAE1700C431D9 /* SentrySDKSettings.swift */; }; FAF120182E70C08F006E1DA3 /* SentryEnvelopeHeaderHelper.h in Headers */ = {isa = PBXBuildFile; fileRef = FAF120122E70C088006E1DA3 /* SentryEnvelopeHeaderHelper.h */; }; FAF1201A2E70C0EE006E1DA3 /* SentryEnvelopeHeaderHelper.m in Sources */ = {isa = PBXBuildFile; fileRef = FAF120192E70C0EA006E1DA3 /* SentryEnvelopeHeaderHelper.m */; }; @@ -2170,6 +2171,7 @@ D4CD2A7C2DE9F91900DA9F59 /* SentryRedactRegion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryRedactRegion.swift; sourceTree = ""; }; D4CD2A7D2DE9F91900DA9F59 /* SentryRedactRegionType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryRedactRegionType.swift; sourceTree = ""; }; D4D12E792DFC607F00DC45C4 /* SentryScreenshotOptionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryScreenshotOptionsTests.swift; sourceTree = ""; }; + D4D849782E82E21F0086BF67 /* SentryReplayApiTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryReplayApiTests.swift; sourceTree = ""; }; D4DEE6582E439B2E00FCA5A9 /* SentryProfileTimeseriesTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SentryProfileTimeseriesTests.m; 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 = ""; }; @@ -2463,8 +2465,8 @@ FAEC270D2DF3526000878871 /* SentryUserFeedback.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryUserFeedback.swift; sourceTree = ""; }; FAEC273C2DF3933200878871 /* NSData+Unzip.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "NSData+Unzip.m"; sourceTree = ""; }; FAEC273E2DF393E000878871 /* NSData+Unzip.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSData+Unzip.h"; sourceTree = ""; }; - FAEEC04C2E75E55A00E79CA9 /* SentrySerializationSwift.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentrySerializationSwift.swift; sourceTree = ""; }; FAEEBFDC2E736D4100E79CA9 /* SentryViewHierarchyProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryViewHierarchyProvider.swift; sourceTree = ""; }; + FAEEC04C2E75E55A00E79CA9 /* SentrySerializationSwift.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentrySerializationSwift.swift; sourceTree = ""; }; FAEFA1292E4FAE1700C431D9 /* SentrySDKSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentrySDKSettings.swift; sourceTree = ""; }; FAF120122E70C088006E1DA3 /* SentryEnvelopeHeaderHelper.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = SentryEnvelopeHeaderHelper.h; path = include/SentryEnvelopeHeaderHelper.h; sourceTree = ""; }; FAF120192E70C0EA006E1DA3 /* SentryEnvelopeHeaderHelper.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SentryEnvelopeHeaderHelper.m; sourceTree = ""; }; @@ -4343,6 +4345,7 @@ D80694C12B7CC85800B820E6 /* SessionReplay */ = { isa = PBXGroup; children = ( + D4D849782E82E21F0086BF67 /* SentryReplayApiTests.swift */, D49480D22DC23E8E00A3B6E9 /* SentryReplayTypeTests.swift */, D80694C22B7CC86E00B820E6 /* SentryReplayEventTests.swift */, D80694C52B7CCFA100B820E6 /* SentryReplayRecordingTests.swift */, @@ -6130,6 +6133,7 @@ 623FD9062D3FA9C800803EDA /* NSNumberDecodableWrapperTests.swift in Sources */, 7B87C916295ECFD700510C52 /* SentryMetricKitEventTests.swift in Sources */, 7B6D98ED24C703F8005502FA /* Async.swift in Sources */, + D4D849792E82E2240086BF67 /* SentryReplayApiTests.swift in Sources */, 7BA0C04C28056556003E0326 /* SentryTransportAdapterTests.swift in Sources */, 7BE0DC29272A9E1C004FA8B7 /* SentryBreadcrumbTrackerTests.swift in Sources */, D4FC681A2DD63465001B74FF /* SentryDispatchQueueWrapperTests.m in Sources */, diff --git a/Sources/Sentry/Public/SentryReplayApi.h b/Sources/Sentry/Public/SentryReplayApi.h index 5872c92ae04..c7febab9169 100644 --- a/Sources/Sentry/Public/SentryReplayApi.h +++ b/Sources/Sentry/Public/SentryReplayApi.h @@ -13,34 +13,74 @@ NS_ASSUME_NONNULL_BEGIN @interface SentryReplayApi : NSObject +SENTRY_NO_INIT /** * Marks this view to be masked during replays. + * + * When this method is called, the specified view will be masked (redacted) in session replays + * to protect sensitive information. The view's content will appear as a solid rectangle in + * the replay instead of showing the actual content. + * + * @param view The UIView to mask in session replays. + * + * @note This method is thread-safe and can be called from any thread. */ - (void)maskView:(UIView *)view NS_SWIFT_NAME(maskView(_:)); /** * Marks this view to not be masked during redact step of session replay. + * + * When this method is called, the specified view will be excluded from masking during session + * replays, even if it would normally be masked based on the replay configuration. This allows + * specific views to remain visible in replays when they contain non-sensitive information. + * + * @param view The UIView to exclude from masking in session replays. + * + * @note This method is thread-safe and can be called from any thread. */ - (void)unmaskView:(UIView *)view NS_SWIFT_NAME(unmaskView(_:)); /** * Pauses the replay. + * + * Temporarily stops recording frames for the session replay. The replay can be resumed + * later by calling resume. This is useful for temporarily suspending replay recording + * during sensitive operations or when the app goes into the background. + * + * @note This method is thread-safe and can be called from any thread. */ - (void)pause; /** * Resumes the ongoing replay. + * + * Resumes recording frames for the session replay after it was paused. If no replay + * session is currently active, this method has no effect. + * + * @note This method is thread-safe and can be called from any thread. */ - (void)resume; /** * Start recording a session replay if not started. + * + * Manually starts a session replay recording. If session replay is already running, + * this method has no effect. This is useful for manually controlling when replay + * recording begins, independent of the automatic session management. + * + * @note This method is thread-safe and can be called from any thread. */ - (void)start; /** * Stop the current session replay recording. + * + * Permanently stops the current session replay recording and clears any cached frames. + * To restart recording, you must call start again. This is useful for completely + * terminating replay functionality. + * + * @note This method is thread-safe and can be called from any thread. */ - (void)stop; @@ -49,13 +89,13 @@ NS_ASSUME_NONNULL_BEGIN * * By calling this function an overlay will appear covering the parts * of the app that will be masked for the session replay. - * This will only work if the debbuger is attached and it will + * This will only work if the debugger is attached and it will * cause some slow frames. * - * @note This method must be called from the main thread. + * @note This method is thread-safe and can be called from any thread. * * @warning This is an experimental feature and may still have bugs. - * Do not use this is production. + * Do not use this in production. */ - (void)showMaskPreview; @@ -64,25 +104,28 @@ NS_ASSUME_NONNULL_BEGIN * * By calling this function an overlay will appear covering the parts * of the app that will be masked for the session replay. - * This will only work if the debbuger is attached and it will + * This will only work if the debugger is attached and it will * cause some slow frames. * - * @param opacity The opacity of the overlay. + * @param opacity The opacity of the overlay (0.0 to 1.0). * - * @note This method must be called from the main thread. + * @note This method is thread-safe and can be called from any thread. * * @warning This is an experimental feature and may still have bugs. - * Do not use this is production. + * Do not use this in production. */ - (void)showMaskPreview:(CGFloat)opacity; /** * Removes the overlay that shows replay masking. * - * @note This method must be called from the main thread. + * Hides the mask preview overlay that was previously shown by calling showMaskPreview. + * If no overlay is currently displayed, this method has no effect. + * + * @note This method is thread-safe and can be called from any thread. * * @warning This is an experimental feature and may still have bugs. - * Do not use this is production. + * Do not use this in production. */ - (void)hideMaskPreview; diff --git a/Sources/Sentry/SentryReplayApi.m b/Sources/Sentry/SentryReplayApi.m index 7472e31f687..18e09c4fb51 100644 --- a/Sources/Sentry/SentryReplayApi.m +++ b/Sources/Sentry/SentryReplayApi.m @@ -1,7 +1,9 @@ #import "SentryReplayApi.h" +#import "SentryReplayApi+Private.h" #if SENTRY_TARGET_REPLAY_SUPPORTED +# import "SentryDependencyContainer.h" # import "SentryHub+Private.h" # import "SentryInternalCDefines.h" # import "SentryLogC.h" @@ -9,101 +11,157 @@ # import "SentrySDK+Private.h" # import "SentrySessionReplayIntegration+Private.h" # import "SentrySwift.h" +# import "_SentryDispatchQueueWrapperInternal.h" # import +@interface SentryReplayApi () + +@property (nonatomic, strong) _SentryDispatchQueueWrapperInternal *dispatchQueueWrapper; + +- (nullable SentrySessionReplayIntegration *)installedIntegration; + +@end + @implementation SentryReplayApi +- (instancetype)initPrivateWithDispatchQueueWrapper: + (_SentryDispatchQueueWrapperInternal *)dispatchQueueWrapper +{ + if (self = [super init]) { + _dispatchQueueWrapper = dispatchQueueWrapper; + } + return self; +} + +# if SENTRY_TEST || SENTRY_TEST_CI +- (instancetype)initWithDispatchQueueWrapper: + (_SentryDispatchQueueWrapperInternal *)dispatchQueueWrapper +{ + return [self initPrivateWithDispatchQueueWrapper:dispatchQueueWrapper]; +} +# endif + +- (nullable SentrySessionReplayIntegration *)installedIntegration +{ + return (SentrySessionReplayIntegration *)[SentrySDKInternal.currentHub + getInstalledIntegration:SentrySessionReplayIntegration.class]; +} + - (void)maskView:(UIView *)view { - [SentryRedactViewHelper maskView:view]; + // UIView operations must be performed on the main thread + [self.dispatchQueueWrapper + dispatchSyncOnMainQueue:^{ [SentryRedactViewHelper maskView:view]; }]; } - (void)unmaskView:(UIView *)view { - [SentryRedactViewHelper unmaskView:view]; + // UIView operations must be performed on the main thread + [self.dispatchQueueWrapper + dispatchSyncOnMainQueue:^{ [SentryRedactViewHelper unmaskView:view]; }]; } - (void)pause { SENTRY_LOG_INFO(@"[Session Replay] Pausing session"); - SentrySessionReplayIntegration *replayIntegration - = (SentrySessionReplayIntegration *)[SentrySDKInternal.currentHub - getInstalledIntegration:SentrySessionReplayIntegration.class]; - [replayIntegration pause]; + // Session replay operations may involve UIKit operations that must be performed on the main + // thread + __weak typeof(self) weakSelf = self; + [self.dispatchQueueWrapper + dispatchSyncOnMainQueue:^{ [[weakSelf installedIntegration] pause]; }]; } - (void)resume { SENTRY_LOG_INFO(@"[Session Replay] Resuming session"); - SentrySessionReplayIntegration *replayIntegration - = (SentrySessionReplayIntegration *)[SentrySDKInternal.currentHub - getInstalledIntegration:SentrySessionReplayIntegration.class]; - [replayIntegration resume]; + // Session replay operations may involve UIKit operations that must be performed on the main + // thread + __weak typeof(self) weakSelf = self; + [self.dispatchQueueWrapper + dispatchSyncOnMainQueue:^{ [[weakSelf installedIntegration] resume]; }]; } - (void)start SENTRY_DISABLE_THREAD_SANITIZER("double-checked lock produce false alarms") { SENTRY_LOG_INFO(@"[Session Replay] Starting session"); - SentrySessionReplayIntegration *replayIntegration - = (SentrySessionReplayIntegration *)[SentrySDKInternal.currentHub - getInstalledIntegration:SentrySessionReplayIntegration.class]; - - // Start could be misused and called multiple times, causing it to - // be initialized more than once before being installed. - // Synchronizing it will prevent this problem. - if (replayIntegration == nil) { - @synchronized(self) { - replayIntegration = (SentrySessionReplayIntegration *)[SentrySDKInternal.currentHub - getInstalledIntegration:SentrySessionReplayIntegration.class]; - if (replayIntegration == nil) { - SENTRY_LOG_DEBUG(@"[Session Replay] Initializing replay integration"); - SentryOptions *currentOptions = SentrySDKInternal.currentHub.client.options; - replayIntegration = - [[SentrySessionReplayIntegration alloc] initForManualUse:currentOptions]; - - [SentrySDKInternal.currentHub - addInstalledIntegration:replayIntegration - name:NSStringFromClass(SentrySessionReplay.class)]; + // Session replay operations may involve UIKit operations that must be performed on the main + // thread + __weak typeof(self) weakSelf = self; + [self.dispatchQueueWrapper dispatchSyncOnMainQueue:^{ + __strong typeof(weakSelf) strongSelf = weakSelf; + if (!strongSelf) + return; + + SentrySessionReplayIntegration *replayIntegration = [strongSelf installedIntegration]; + + // Start could be misused and called multiple times, causing it to + // be initialized more than once before being installed. + // Synchronizing it will prevent this problem. + if (replayIntegration == nil) { + @synchronized(strongSelf) { + replayIntegration = [strongSelf installedIntegration]; + if (replayIntegration == nil) { + SENTRY_LOG_DEBUG(@"[Session Replay] Initializing replay integration"); + SentryOptions *currentOptions = SentrySDKInternal.currentHub.client.options; + replayIntegration = + [[SentrySessionReplayIntegration alloc] initForManualUse:currentOptions]; + + [SentrySDKInternal.currentHub + addInstalledIntegration:replayIntegration + name:NSStringFromClass(SentrySessionReplay.class)]; + } } } - } - [replayIntegration start]; + [replayIntegration start]; + }]; } - (void)stop { SENTRY_LOG_INFO(@"[Session Replay] Stopping session"); - SentrySessionReplayIntegration *replayIntegration - = (SentrySessionReplayIntegration *)[SentrySDKInternal.currentHub - getInstalledIntegration:SentrySessionReplayIntegration.class]; - [replayIntegration stop]; + // Session replay operations may involve UIKit operations that must be performed on the main + // thread + __weak typeof(self) weakSelf = self; + [self.dispatchQueueWrapper + dispatchSyncOnMainQueue:^{ [[weakSelf installedIntegration] stop]; }]; } - (void)showMaskPreview { SENTRY_LOG_DEBUG(@"[Session Replay] Showing mask preview"); - [self showMaskPreview:1]; + // Session replay operations may involve UIKit operations that must be performed on the main + // thread + __weak typeof(self) weakSelf = self; + [self.dispatchQueueWrapper dispatchSyncOnMainQueue:^{ [weakSelf showMaskPreview:1]; }]; } - (void)showMaskPreview:(CGFloat)opacity { SENTRY_LOG_DEBUG(@"[Session Replay] Showing mask preview with opacity: %f", opacity); - SentrySessionReplayIntegration *replayIntegration - = (SentrySessionReplayIntegration *)[SentrySDKInternal.currentHub - getInstalledIntegration:SentrySessionReplayIntegration.class]; - - [replayIntegration showMaskPreview:opacity]; + // Session replay operations may involve UIKit operations that must be performed on the main + // thread + __weak typeof(self) weakSelf = self; + [self.dispatchQueueWrapper + dispatchSyncOnMainQueue:^{ [[weakSelf installedIntegration] showMaskPreview:opacity]; }]; } - (void)hideMaskPreview { SENTRY_LOG_DEBUG(@"[Session Replay] Hiding mask preview"); - SentrySessionReplayIntegration *replayIntegration - = (SentrySessionReplayIntegration *)[SentrySDKInternal.currentHub - getInstalledIntegration:SentrySessionReplayIntegration.class]; + // Session replay operations may involve UIKit operations that must be performed on the main + // thread + __weak typeof(self) weakSelf = self; + [self.dispatchQueueWrapper + dispatchSyncOnMainQueue:^{ [[weakSelf installedIntegration] hideMaskPreview]; }]; +} - [replayIntegration hideMaskPreview]; +# if SENTRY_TEST || SENTRY_TEST_CI +// Test-only method to access the dispatch queue wrapper for verification +- (_SentryDispatchQueueWrapperInternal *)getDispatchQueueWrapper +{ + return _dispatchQueueWrapper; } +# endif @end diff --git a/Sources/Sentry/SentrySDKInternal.m b/Sources/Sentry/SentrySDKInternal.m index 1aa33ec6d20..49c03c12267 100644 --- a/Sources/Sentry/SentrySDKInternal.m +++ b/Sources/Sentry/SentrySDKInternal.m @@ -15,6 +15,7 @@ #import "SentryOptions+Private.h" #import "SentryOptionsInternal.h" #import "SentryProfilingConditionals.h" +#import "SentryReplayApi+Private.h" #import "SentryReplayApi.h" #import "SentrySamplerDecision.h" #import "SentrySamplingContext.h" @@ -131,7 +132,11 @@ + (SentryReplayApi *)replay { static SentryReplayApi *replay; static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ replay = [[SentryReplayApi alloc] init]; }); + dispatch_once(&onceToken, ^{ + replay = [[SentryReplayApi alloc] + initPrivateWithDispatchQueueWrapper:SentryDependencyContainer.sharedInstance + .dispatchQueueWrapper]; + }); return replay; } #endif diff --git a/Sources/Sentry/SentrySessionReplayIntegration.m b/Sources/Sentry/SentrySessionReplayIntegration.m index c906ff48974..0e5ea22ebfc 100644 --- a/Sources/Sentry/SentrySessionReplayIntegration.m +++ b/Sources/Sentry/SentrySessionReplayIntegration.m @@ -328,31 +328,48 @@ - (void)startSession - (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]; - } 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"); - // Wait for a scene to be available to started the replay - [_notificationCenter addObserver:self - selector:@selector(newSceneActivate) - name:UISceneDidActivateNotification - object:nil]; - } + // UIKit operations must be performed on the main thread + __weak typeof(self) weakSelf = self; + [SentryDependencyContainer.sharedInstance.dispatchQueueWrapper dispatchSyncOnMainQueue:^{ + __strong typeof(weakSelf) strongSelf = weakSelf; + if (!strongSelf) + return; + + 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 + [strongSelf startWithOptions:strongSelf->_replayOptions + fullSession:strongSelf->_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"); + // Wait for a scene to be available to started the replay + [strongSelf->_notificationCenter addObserver:strongSelf + selector:@selector(newSceneActivate) + name:UISceneDidActivateNotification + object:nil]; + } + }]; } - (void)newSceneActivate { - if (@available(iOS 13.0, tvOS 13.0, *)) { - SENTRY_LOG_DEBUG(@"[Session Replay] Scene is available, starting replay"); - [SentryDependencyContainer.sharedInstance.notificationCenterWrapper - removeObserver:self - name:UISceneDidActivateNotification - object:nil]; - [self startWithOptions:_replayOptions fullSession:_startedAsFullSession]; - } + __weak typeof(self) weakSelf = self; + [SentryDependencyContainer.sharedInstance.dispatchQueueWrapper dispatchSyncOnMainQueue:^{ + __strong typeof(weakSelf) strongSelf = weakSelf; + if (!strongSelf) + return; + + if (@available(iOS 13.0, tvOS 13.0, *)) { + SENTRY_LOG_DEBUG(@"[Session Replay] Scene is available, starting replay"); + [SentryDependencyContainer.sharedInstance.notificationCenterWrapper + removeObserver:strongSelf + name:UISceneDidActivateNotification + object:nil]; + [strongSelf startWithOptions:strongSelf->_replayOptions + fullSession:strongSelf->_startedAsFullSession]; + } + }]; } - (void)startWithOptions:(SentryReplayOptions *)replayOptions @@ -797,11 +814,21 @@ - (void)connectivityChanged:(BOOL)connected typeDescription:(nonnull NSString *) { SENTRY_LOG_DEBUG(@"[Session Replay] Connectivity changed to: %@, type: %@", connected ? @"connected" : @"disconnected", typeDescription); - if (connected) { - [_sessionReplay resume]; - } else { - [_sessionReplay pauseSessionMode]; - } + // The connectivity handler is called from a background thread, but session replay operations + // need to be performed on the main thread to avoid race conditions with the session tracker + // being set up on the main thread. + __weak typeof(self) weakSelf = self; + [SentryDependencyContainer.sharedInstance.dispatchQueueWrapper dispatchAsyncOnMainQueue:^{ + __strong typeof(weakSelf) strongSelf = weakSelf; + if (!strongSelf) + return; + + if (connected) { + [strongSelf->_sessionReplay resume]; + } else { + [strongSelf->_sessionReplay pauseSessionMode]; + } + }]; } @end diff --git a/Sources/Sentry/include/SentryReplayApi+Private.h b/Sources/Sentry/include/SentryReplayApi+Private.h new file mode 100644 index 00000000000..c6de5855a98 --- /dev/null +++ b/Sources/Sentry/include/SentryReplayApi+Private.h @@ -0,0 +1,22 @@ +#import "SentryReplayApi.h" + +#if SENTRY_TARGET_REPLAY_SUPPORTED + +@class _SentryDispatchQueueWrapperInternal; + +NS_ASSUME_NONNULL_BEGIN + +@interface SentryReplayApi (Private) + +/** + * Internal initializer for dependency injection. + * This method is only available for internal SDK use. + */ +- (instancetype)initPrivateWithDispatchQueueWrapper: + (_SentryDispatchQueueWrapperInternal *)dispatchQueueWrapper; + +@end + +NS_ASSUME_NONNULL_END + +#endif diff --git a/Tests/SentryTests/Integrations/SessionReplay/SentryReplayApiTests.swift b/Tests/SentryTests/Integrations/SessionReplay/SentryReplayApiTests.swift new file mode 100644 index 00000000000..a42be0817e2 --- /dev/null +++ b/Tests/SentryTests/Integrations/SessionReplay/SentryReplayApiTests.swift @@ -0,0 +1,347 @@ +@_spi(Private) @testable import Sentry +@_spi(Private) import SentryTestUtils +import XCTest + +#if os(iOS) || os(tvOS) + +class SentryReplayApiTests: XCTestCase { + + private class MockSentrySessionReplayIntegration: SentrySessionReplayIntegration { + var pauseCalled = false + var resumeCalled = false + var startCalled = false + var stopCalled = false + var showMaskPreviewCalled = false + var showMaskPreviewOpacity: CGFloat = 0 + var hideMaskPreviewCalled = false + + override func pause() { + pauseCalled = true + } + + override func resume() { + resumeCalled = true + } + + override func start() { + startCalled = true + } + + override func stop() { + stopCalled = true + } + + override func showMaskPreview(_ opacity: CGFloat) { + showMaskPreviewCalled = true + showMaskPreviewOpacity = opacity + } + + override func hideMaskPreview() { + hideMaskPreviewCalled = true + } + } + + private class Fixture { + let dispatchQueueWrapper = TestSentryDispatchQueueWrapper() + let mockIntegration = MockSentrySessionReplayIntegration() + let testHub = TestHub(client: nil, andScope: nil) + let mockView = UIView() + + init() { + // Setup test hub to return our mock integration + testHub.installedIntegrations = [mockIntegration] + } + + func getSut() -> SentryReplayApi { + return SentryReplayApi(dispatchQueueWrapper: dispatchQueueWrapper.internalWrapper) + } + } + + private var fixture: Fixture! + private var sut: SentryReplayApi! + + override func setUpWithError() throws { + try super.setUpWithError() + fixture = Fixture() + sut = fixture.getSut() + + // Set the test hub as current hub + SentrySDKInternal.setCurrentHub(fixture.testHub) + } + + override func tearDown() { + super.tearDown() + clearTestState() + } + + // MARK: - maskView Tests + + func testMaskView_CallsRedactViewHelperOnMainThread() { + // Arrange + let view = fixture.mockView + + // Act + sut.maskView(view) + + // Assert + XCTAssertEqual(1, fixture.dispatchQueueWrapper.blockOnMainInvocations.count) + } + + func testMaskView_CallsRedactViewHelperImmediately() { + // Arrange + let view = fixture.mockView + + // Act + sut.maskView(view) + + // Assert + // Verify the dispatch was called and executed + XCTAssertEqual(1, fixture.dispatchQueueWrapper.blockOnMainInvocations.count) + // The TestSentryDispatchQueueWrapper executes blocks immediately by default + } + + // MARK: - unmaskView Tests + + func testUnmaskView_CallsRedactViewHelperOnMainThread() { + // Arrange + let view = fixture.mockView + + // Act + sut.unmaskView(view) + + // Assert + XCTAssertEqual(1, fixture.dispatchQueueWrapper.blockOnMainInvocations.count) + } + + // MARK: - pause Tests + + func testPause_CallsIntegrationPauseOnMainThread() { + // Arrange + // (No additional setup needed) + + // Act + sut.pause() + + // Assert + XCTAssertEqual(1, fixture.dispatchQueueWrapper.blockOnMainInvocations.count) + XCTAssertTrue(fixture.mockIntegration.pauseCalled) + } + + func testPause_WithNilIntegration_DoesNotCrash() { + // Arrange + fixture.testHub.installedIntegrations = [] + + // Act + sut.pause() + + // Assert + XCTAssertEqual(1, fixture.dispatchQueueWrapper.blockOnMainInvocations.count) + // Should not crash when integration is nil + } + + // MARK: - resume Tests + + func testResume_CallsIntegrationResumeOnMainThread() { + // Arrange + // (No additional setup needed) + + // Act + sut.resume() + + // Assert + XCTAssertEqual(1, fixture.dispatchQueueWrapper.blockOnMainInvocations.count) + XCTAssertTrue(fixture.mockIntegration.resumeCalled) + } + + func testResume_WithNilIntegration_DoesNotCrash() { + // Arrange + fixture.testHub.installedIntegrations = [] + + // Act + sut.resume() + + // Assert + XCTAssertEqual(1, fixture.dispatchQueueWrapper.blockOnMainInvocations.count) + // Should not crash when integration is nil + } + + // MARK: - start Tests + + func testStart_WithExistingIntegration_CallsIntegrationStartOnMainThread() { + // Arrange + // (No additional setup needed) + + // Act + sut.start() + + // Assert + XCTAssertEqual(1, fixture.dispatchQueueWrapper.blockOnMainInvocations.count) + XCTAssertTrue(fixture.mockIntegration.startCalled) + } + + func testStart_WithNilIntegration_DoesNotCrash() { + // Arrange + fixture.testHub.installedIntegrations = [] + + // Act + sut.start() + + // Assert + XCTAssertEqual(1, fixture.dispatchQueueWrapper.blockOnMainInvocations.count) + // Should not crash when integration is nil + } + + // MARK: - stop Tests + + func testStop_CallsIntegrationStopOnMainThread() { + // Arrange + // (No additional setup needed) + + // Act + sut.stop() + + // Assert + XCTAssertEqual(1, fixture.dispatchQueueWrapper.blockOnMainInvocations.count) + XCTAssertTrue(fixture.mockIntegration.stopCalled) + } + + func testStop_WithNilIntegration_DoesNotCrash() { + // Arrange + fixture.testHub.installedIntegrations = [] + + // Act + sut.stop() + + // Assert + XCTAssertEqual(1, fixture.dispatchQueueWrapper.blockOnMainInvocations.count) + // Should not crash when integration is nil + } + + // MARK: - showMaskPreview Tests + + func testShowMaskPreview_CallsIntegrationWithDefaultOpacityOnMainThread() { + // Arrange + // (No additional setup needed) + + // Act + sut.showMaskPreview() + + // Assert + XCTAssertEqual(2, fixture.dispatchQueueWrapper.blockOnMainInvocations.count) // One for showMaskPreview(), one for showMaskPreview(_:) + XCTAssertTrue(fixture.mockIntegration.showMaskPreviewCalled) + XCTAssertEqual(1.0, fixture.mockIntegration.showMaskPreviewOpacity) + } + + func testShowMaskPreviewWithOpacity_CallsIntegrationWithSpecifiedOpacityOnMainThread() { + // Arrange + let testOpacity: CGFloat = 0.5 + + // Act + sut.showMaskPreview(testOpacity) + + // Assert + XCTAssertEqual(1, fixture.dispatchQueueWrapper.blockOnMainInvocations.count) + XCTAssertTrue(fixture.mockIntegration.showMaskPreviewCalled) + XCTAssertEqual(testOpacity, fixture.mockIntegration.showMaskPreviewOpacity) + } + + func testShowMaskPreview_WithNilIntegration_DoesNotCrash() { + // Arrange + fixture.testHub.installedIntegrations = [] + + // Act + sut.showMaskPreview() + + // Assert + XCTAssertEqual(2, fixture.dispatchQueueWrapper.blockOnMainInvocations.count) + // Should not crash when integration is nil + } + + // MARK: - hideMaskPreview Tests + + func testHideMaskPreview_CallsIntegrationOnMainThread() { + // Arrange + // (No additional setup needed) + + // Act + sut.hideMaskPreview() + + // Assert + XCTAssertEqual(1, fixture.dispatchQueueWrapper.blockOnMainInvocations.count) + XCTAssertTrue(fixture.mockIntegration.hideMaskPreviewCalled) + } + + func testHideMaskPreview_WithNilIntegration_DoesNotCrash() { + // Arrange + fixture.testHub.installedIntegrations = [] + + // Act + sut.hideMaskPreview() + + // Assert + XCTAssertEqual(1, fixture.dispatchQueueWrapper.blockOnMainInvocations.count) + // Should not crash when integration is nil + } + + // MARK: - Thread Safety Tests + + func testAllMethods_DispatchToMainQueue() { + // Arrange + let view = fixture.mockView + + // Act + sut.maskView(view) + sut.unmaskView(view) + sut.pause() + sut.resume() + sut.start() + sut.stop() + sut.showMaskPreview() + sut.showMaskPreview(0.8) + sut.hideMaskPreview() + + // Assert + // 10 total calls: maskView, unmaskView, pause, resume, start, stop, showMaskPreview(), showMaskPreview(_:), showMaskPreview() -> showMaskPreview(_:), hideMaskPreview + XCTAssertEqual(10, fixture.dispatchQueueWrapper.blockOnMainInvocations.count) + } + + func testDispatchQueueWrapper_NotRetained() { + // Arrange + weak var weakDispatchWrapper = fixture.dispatchQueueWrapper + + // Act + // Create a new SentryReplayApi instance with the dispatch wrapper + _ = SentryReplayApi(dispatchQueueWrapper: fixture.dispatchQueueWrapper) + + // Assert + // The dispatch wrapper should not be strongly retained beyond the scope + XCTAssertNotNil(weakDispatchWrapper) // Still exists because fixture holds it + } + + // MARK: - Integration Helper Tests + + func testInstalledIntegration_ReturnsCorrectIntegration() { + // Arrange + // (Mock integration is already set up in fixture) + + // Act + sut.pause() // This will call installedIntegration internally + + // Assert + XCTAssertTrue(fixture.mockIntegration.pauseCalled) + } + + func testInstalledIntegration_WithMultipleIntegrations_ReturnsCorrectType() { + // Arrange + let otherIntegration = SentryIntegration() // Different type + fixture.testHub.installedIntegrations = [otherIntegration, fixture.mockIntegration] + + // Act + sut.pause() + + // Assert + XCTAssertTrue(fixture.mockIntegration.pauseCalled) // Should find the correct type + } +} + +#endif