diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a187354b76..76789a90411 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ ## Unreleased +### Features + +- Session Replay: Add `Sentry.replay().pauseTouchTracking()` and `resumeTouchTracking()` to stop recording touches on sensitive screens, such as a custom PIN pad, while the screen recording continues. Touch tracking stays paused until `resumeTouchTracking()` is called, also for new replays and after `resume()` ([#6546](https://github.com/getsentry/sentry-java/pull/6546)) + ### Performance - Most SDK log calls no longer allocate when `debug` is off ([#6600](https://github.com/getsentry/sentry-java/pull/6600)) diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index 91c91778f05..1abe170ef99 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -79,10 +79,12 @@ public final class io/sentry/android/replay/ReplayIntegration : io/sentry/IConne public fun onTouchEvent (Landroid/view/MotionEvent;)V public fun onWindowSizeChanged (II)V public fun pause ()V + public fun pauseTouchTracking ()V public fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V public fun registerSegmentName (Ljava/lang/String;)V public fun registerTraceId (Lio/sentry/protocol/SentryId;)V public fun resume ()V + public fun resumeTouchTracking ()V public fun setBreadcrumbConverter (Lio/sentry/ReplayBreadcrumbConverter;)V public fun start ()V public fun startBuffering ()V diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index 4d6614a7832..bebaf95d396 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -114,7 +114,7 @@ public class ReplayIntegration( } private var lastKnownConnectionStatus: ConnectionStatus = ConnectionStatus.UNKNOWN - private var debugMaskingEnabled: Boolean = false + @Volatile private var debugMaskingEnabled: Boolean = false private lateinit var options: SentryOptions private var scopes: IScopes? = null private var recorder: Recorder? = null @@ -135,6 +135,11 @@ public class ReplayIntegration( internal val isEnabled = AtomicBoolean(false) internal var isManualPause = false + // Not reset by start()/stop(), only resumeTouchTracking() clears it, so touches cannot leak + // by accident. Set directly instead of enqueueOnMainThread like lifecycle commands: it does not + // touch ReplayState, so ordering does not matter, and a queued set could let already-pending + // touch events through. + @Volatile private var isTouchTrackingPaused = false public val replayCacheDir: File? get() = state.get().captureStrategy?.replayCacheDir @@ -397,6 +402,14 @@ public class ReplayIntegration( } } + override fun pauseTouchTracking() { + isTouchTrackingPaused = true + } + + override fun resumeTouchTracking() { + isTouchTrackingPaused = false + } + override fun enableDebugMaskingOverlay() { debugMaskingEnabled = true } @@ -606,7 +619,7 @@ public class ReplayIntegration( override fun onTouchEvent(event: MotionEvent) { val current = state.get() - if (!isEnabled.get() || !current.isTouchRecordingAllowed) { + if (!isEnabled.get() || isTouchTrackingPaused || !current.isTouchRecordingAllowed) { return } current.captureStrategy?.onTouchEvent(event) diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt index 1dad10238f5..49ec8565c1b 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt @@ -1598,6 +1598,93 @@ class ReplayIntegrationTest { verify(captureStrategy, never()).onTouchEvent(any()) } + @Test + fun `pauseTouchTracking drops touches but keeps recording screenshots`() { + val captureStrategy = mock() + val recorder = mock() + val replay = + fixture.getSut( + context, + recorderProvider = { recorder }, + replayCaptureStrategyProvider = { captureStrategy }, + ) + + replay.register(fixture.scopes, fixture.options) + replay.start() + replay.pauseTouchTracking() + replay.onTouchEvent(mock()) + + verify(captureStrategy, never()).onTouchEvent(any()) + verify(recorder, never()).pause() + verify(captureStrategy, never()).pause() + assertTrue(replay.isRecording) + + replay.resumeTouchTracking() + replay.onTouchEvent(mock()) + + verify(captureStrategy).onTouchEvent(any()) + } + + @Test + fun `resume does not resume paused touch tracking`() { + val captureStrategy = mock() + val replay = fixture.getSut(context, replayCaptureStrategyProvider = { captureStrategy }) + + replay.register(fixture.scopes, fixture.options) + replay.start() + replay.pauseTouchTracking() + replay.pause() + replay.resume() + replay.onTouchEvent(mock()) + + verify(captureStrategy, never()).onTouchEvent(any()) + } + + @Test + fun `resumeTouchTracking does not record touches while replay is paused`() { + val captureStrategy = mock() + val replay = fixture.getSut(context, replayCaptureStrategyProvider = { captureStrategy }) + + replay.register(fixture.scopes, fixture.options) + replay.start() + replay.pauseTouchTracking() + replay.pause() + replay.resumeTouchTracking() + replay.onTouchEvent(mock()) + + verify(captureStrategy, never()).onTouchEvent(any()) + + replay.resume() + replay.onTouchEvent(mock()) + + verify(captureStrategy).onTouchEvent(any()) + } + + @Test + fun `paused touch tracking stays paused for new replays`() { + val firstStrategy = mock() + val secondStrategy = mock() + val thirdStrategy = mock() + val strategies = ArrayDeque(listOf(firstStrategy, secondStrategy, thirdStrategy)) + val replay = + fixture.getSut(context, replayCaptureStrategyProvider = { strategies.removeFirst() }) + + replay.register(fixture.scopes, fixture.options) + // called before any replay is started + replay.pauseTouchTracking() + replay.onAppForegrounded(true) + replay.onTouchEvent(mock()) + replay.onAppForegrounded(true) + replay.onTouchEvent(mock()) + replay.stop() + replay.start() + replay.onTouchEvent(mock()) + + verify(firstStrategy, never()).onTouchEvent(any()) + verify(secondStrategy, never()).onTouchEvent(any()) + verify(thirdStrategy, never()).onTouchEvent(any()) + } + @Test fun `when paused manually onConnectionStatusChanged does not resume`() { val captureStrategy = getSessionCaptureStrategy(fixture.options) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 240a52a43d0..17ca4e62279 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -988,7 +988,9 @@ public abstract interface class io/sentry/IReplayApi { public abstract fun enableDebugMaskingOverlay ()V public abstract fun flush ()V public abstract fun pause ()V + public abstract fun pauseTouchTracking ()V public abstract fun resume ()V + public abstract fun resumeTouchTracking ()V public abstract fun start ()V public abstract fun startBuffering ()V public abstract fun stop ()V @@ -1842,9 +1844,11 @@ public final class io/sentry/NoOpReplayController : io/sentry/ReplayController { public fun onAppForegrounded (Z)V public fun onAppSessionEnded ()V public fun pause ()V + public fun pauseTouchTracking ()V public fun registerSegmentName (Ljava/lang/String;)V public fun registerTraceId (Lio/sentry/protocol/SentryId;)V public fun resume ()V + public fun resumeTouchTracking ()V public fun setBreadcrumbConverter (Lio/sentry/ReplayBreadcrumbConverter;)V public fun start ()V public fun startBuffering ()V diff --git a/sentry/src/main/java/io/sentry/IReplayApi.java b/sentry/src/main/java/io/sentry/IReplayApi.java index e15bcdaa542..aa076b72df1 100644 --- a/sentry/src/main/java/io/sentry/IReplayApi.java +++ b/sentry/src/main/java/io/sentry/IReplayApi.java @@ -32,6 +32,23 @@ public interface IReplayApi { /** Resumes a replay paused with {@link #pause()}. */ void resume(); + /** + * Stops recording touch events until {@link #resumeTouchTracking()} is called. Screenshots are + * still recorded. This can be used on sensitive screens, such as a custom PIN pad, where the + * screen is masked but the touch positions could reveal the input. + * + *

Touch tracking stays paused for all replays in the same process, also if the replay is + * stopped and started again. {@link #resume()} does not resume touch tracking. Has effect + * immediately, also before a replay is started. + */ + void pauseTouchTracking(); + + /** + * Resumes touch tracking paused with {@link #pauseTouchTracking()}. Touch events are recorded + * again only while the replay is not paused with {@link #pause()}. + */ + void resumeTouchTracking(); + /** * Immediately sends the current replay data to Sentry in either session or buffer mode. A * buffering replay continues in session mode after the buffer is sent. If replay is not diff --git a/sentry/src/main/java/io/sentry/NoOpReplayController.java b/sentry/src/main/java/io/sentry/NoOpReplayController.java index 669e42f3116..a537ca064ff 100644 --- a/sentry/src/main/java/io/sentry/NoOpReplayController.java +++ b/sentry/src/main/java/io/sentry/NoOpReplayController.java @@ -31,6 +31,12 @@ public void pause() {} @Override public void resume() {} + @Override + public void pauseTouchTracking() {} + + @Override + public void resumeTouchTracking() {} + @Override public void flush() {}