From 811035cda012c40d9994cda91ee38280176b732d Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 10 Nov 2026 23:16:03 +0100 Subject: [PATCH] feat(replay): Add pauseTouchTracking and resumeTouchTracking Apps can stop recording touches on sensitive screens, such as a custom PIN pad, while the masked screen recording continues. Masking hides the content, but recorded touch positions could reveal the input. The flag is independent of pause()/resume() and is cleared only by resumeTouchTracking(), also across new replays. It is set directly instead of being queued on the main thread, so it applies to touch events that are already pending. Also mark debugMaskingEnabled as volatile, it is written and read on different threads. Fixes JAVA-658 Fixes #5787 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 + .../api/sentry-android-replay.api | 2 + .../android/replay/ReplayIntegration.kt | 17 +++- .../android/replay/ReplayIntegrationTest.kt | 87 +++++++++++++++++++ sentry/api/sentry.api | 4 + .../src/main/java/io/sentry/IReplayApi.java | 17 ++++ .../java/io/sentry/NoOpReplayController.java | 6 ++ 7 files changed, 135 insertions(+), 2 deletions(-) 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() {}