From f864e19c0f6db29820bceef76538f57d7fc5bf05 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Fri, 11 Sep 2026 12:41:01 +0200 Subject: [PATCH 1/2] feat(replay): allow disabling touch capture --- .changeset/quiet-touch-privacy.md | 5 + posthog-android/api/posthog-android.api | 2 + .../replay/PostHogReplayIntegration.kt | 4 +- .../replay/PostHogSessionReplayConfig.kt | 8 + .../replay/PostHogReplayIntegrationTest.kt | 151 ++++++++++++++++++ .../replay/PostHogSessionReplayConfigTest.kt | 10 ++ 6 files changed, 178 insertions(+), 2 deletions(-) create mode 100644 .changeset/quiet-touch-privacy.md diff --git a/.changeset/quiet-touch-privacy.md b/.changeset/quiet-touch-privacy.md new file mode 100644 index 000000000..837868f83 --- /dev/null +++ b/.changeset/quiet-touch-privacy.md @@ -0,0 +1,5 @@ +--- +"posthog-android": minor +--- + +Add `PostHogSessionReplayConfig.captureTouches` (default `true`) to disable touch coordinate recording independently of screenshots and view capture. The setting can be changed at runtime, and queued touch capture is skipped while disabled. diff --git a/posthog-android/api/posthog-android.api b/posthog-android/api/posthog-android.api index 5bb4993a6..bae161d83 100644 --- a/posthog-android/api/posthog-android.api +++ b/posthog-android/api/posthog-android.api @@ -116,6 +116,7 @@ public final class com/posthog/android/replay/PostHogSessionReplayConfig { public fun (ZZZLcom/posthog/android/replay/PostHogDrawableConverter;ZJJLjava/lang/Double;)V public synthetic fun (ZZZLcom/posthog/android/replay/PostHogDrawableConverter;ZJJLjava/lang/Double;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun getCaptureLogcat ()Z + public final fun getCaptureTouches ()Z public final fun getDebouncerDelayMs ()J public final fun getDrawableConverter ()Lcom/posthog/android/replay/PostHogDrawableConverter; public final fun getMaskAllImages ()Z @@ -128,6 +129,7 @@ public final class com/posthog/android/replay/PostHogSessionReplayConfig { public final fun getThrottleDelayMs ()J public final fun getVerifyScreenshotMaskAlignment ()Z public final fun setCaptureLogcat (Z)V + public final fun setCaptureTouches (Z)V public final fun setDebouncerDelayMs (J)V public final fun setDrawableConverter (Lcom/posthog/android/replay/PostHogDrawableConverter;)V public final fun setMaskAllImages (Z)V diff --git a/posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.kt b/posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.kt index 310007b19..855cfa2f5 100644 --- a/posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.kt +++ b/posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.kt @@ -447,7 +447,7 @@ public class PostHogReplayIntegration( try { val state = dispatch(motionEvent) try { - if (!isActive()) { + if (!config.sessionReplayConfig.captureTouches || !isActive()) { return@TouchEventInterceptor state } val timestamp = config.dateProvider.currentTimeMillis() @@ -457,7 +457,7 @@ public class PostHogReplayIntegration( executor.submit { try { - if (!isActive()) { + if (!config.sessionReplayConfig.captureTouches || !isActive()) { return@submit } when (safeMotionEvent.action.and(MotionEvent.ACTION_MASK)) { diff --git a/posthog-android/src/main/java/com/posthog/android/replay/PostHogSessionReplayConfig.kt b/posthog-android/src/main/java/com/posthog/android/replay/PostHogSessionReplayConfig.kt index 22a4a77ad..3961575c2 100644 --- a/posthog-android/src/main/java/com/posthog/android/replay/PostHogSessionReplayConfig.kt +++ b/posthog-android/src/main/java/com/posthog/android/replay/PostHogSessionReplayConfig.kt @@ -62,6 +62,14 @@ public class PostHogSessionReplayConfig */ public var sampleRate: Double? = null, ) { + /** + * Capture touch coordinates in session replay. Defaults to true. + * Can be changed at runtime without stopping screenshots or view capture. + * Disable this when touch positions could reveal sensitive input, even if the views are masked. + */ + @Volatile + public var captureTouches: Boolean = true + /** * Verifies mask alignment for session replay screenshots. * This can preserve screenshots during pixel-only redraws, including continuously animated diff --git a/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayIntegrationTest.kt b/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayIntegrationTest.kt index eaf1e2212..4b7a6c6bd 100644 --- a/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayIntegrationTest.kt +++ b/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayIntegrationTest.kt @@ -50,7 +50,10 @@ import com.posthog.internal.PostHogSessionManager import com.posthog.internal.replay.RREvent import com.posthog.internal.replay.RREventType import com.posthog.internal.replay.RRFullSnapshotEvent +import com.posthog.internal.replay.RRIncrementalMouseInteractionData +import com.posthog.internal.replay.RRIncrementalMouseInteractionEvent import com.posthog.internal.replay.RRMetaEvent +import com.posthog.internal.replay.RRMouseInteraction import com.posthog.internal.replay.RRWireframe import curtains.Curtains import curtains.DispatchState @@ -463,6 +466,129 @@ internal class PostHogReplayIntegrationTest { } } + private fun dispatchTouch( + sut: PostHogReplayIntegration, + action: Int = MotionEvent.ACTION_DOWN, + ) { + val event = MotionEvent.obtain(0L, 0L, action, 42f, 73f, 0) + var dispatched = false + try { + val state = + sut.onTouchEventListener.intercept(event) { + assertTrue(it === event) + dispatched = true + DispatchState.Consumed + } + assertTrue(dispatched, "Replay must dispatch the original touch to the app") + assertEquals(DispatchState.Consumed, state) + } finally { + event.recycle() + } + } + + @Test + fun `captureTouches enabled by default records touch start and end coordinates`() { + val config = configWithSampling(flagActive = true, samplingPasses = true) + val executor = QueuedReplayExecutor(createReplayExecutor()) + val sut = PostHogReplayIntegration(ApplicationProvider.getApplicationContext(), config, MainHandler(), executor) + val fake = createPostHogFake() + sut.install(fake) + try { + sut.start(resumeCurrent = true) + assertTrue(sut.isActive()) + assertTrue(config.sessionReplayConfig.captureTouches) + listOf( + MotionEvent.ACTION_DOWN to RRMouseInteraction.TouchStart, + MotionEvent.ACTION_UP to RRMouseInteraction.TouchEnd, + ).forEach { (action, type) -> + dispatchTouch(sut, action) + executor.tasks.removeAt(0).run() + val events = fake.properties!!["\$snapshot_data"] as List<*> + val event = events.single() as RRIncrementalMouseInteractionEvent + val data = event.data as RRIncrementalMouseInteractionData + assertEquals(type, data.type) + assertEquals(42, data.x) + assertEquals(73, data.y) + } + assertEquals(2, fake.captures) + } finally { + sut.uninstall() + } + } + + @Test + fun `captureTouches initially false skips collection without stopping dispatch or replay`() { + val config = configWithSampling(flagActive = true, samplingPasses = true) + config.sessionReplayConfig.captureTouches = false + val executor = QueuedReplayExecutor(createReplayExecutor()) + val dateCalls = AtomicInteger(0) + val sut = PostHogReplayIntegration(ApplicationProvider.getApplicationContext(), config, MainHandler(), executor) + val fake = createPostHogFake() + sut.install(fake) + try { + sut.start(resumeCurrent = true) + config.dateProvider = CountingDateProvider(dateCalls) + dispatchTouch(sut) + dispatchTouch(sut, MotionEvent.ACTION_UP) + assertTrue(sut.isActive()) + assertEquals(0, executor.tasks.size, "Disabled touches must not queue coordinate capture") + assertEquals(0, dateCalls.get()) + assertEquals(0, fake.captures) + } finally { + sut.uninstall() + } + } + + @Test + fun `captureTouches runtime false then true suppresses only disabled touches`() { + val config = configWithSampling(flagActive = true, samplingPasses = true) + val executor = QueuedReplayExecutor(createReplayExecutor()) + val sut = PostHogReplayIntegration(ApplicationProvider.getApplicationContext(), config, MainHandler(), executor) + val fake = createPostHogFake() + sut.install(fake) + try { + sut.start(resumeCurrent = true) + dispatchTouch(sut) + executor.tasks.removeAt(0).run() + assertEquals(1, fake.captures) + + config.sessionReplayConfig.captureTouches = false + dispatchTouch(sut) + executor.tasks.forEach { it.run() } + executor.tasks.clear() + assertEquals(1, fake.captures, "Disabled touches must not emit coordinates") + + config.sessionReplayConfig.captureTouches = true + dispatchTouch(sut) + executor.tasks.removeAt(0).run() + assertEquals(2, fake.captures) + assertTrue(sut.isActive()) + } finally { + sut.uninstall() + } + } + + @Test + fun `captureTouches disabled before queued work runs drops coordinates`() { + val config = configWithSampling(flagActive = true, samplingPasses = true) + val executor = QueuedReplayExecutor(createReplayExecutor()) + val sut = PostHogReplayIntegration(ApplicationProvider.getApplicationContext(), config, MainHandler(), executor) + val fake = createPostHogFake() + sut.install(fake) + try { + sut.start(resumeCurrent = true) + dispatchTouch(sut) + dispatchTouch(sut, MotionEvent.ACTION_UP) + assertEquals(2, executor.tasks.size) + config.sessionReplayConfig.captureTouches = false + executor.tasks.forEach { it.run() } + assertEquals(0, fake.captures, "Already queued touches must be dropped when disabled") + assertTrue(sut.isActive()) + } finally { + sut.uninstall() + } + } + @Test fun `onSessionIdChanged starts replay when previously inactive and sampling passes`() { // The prior session may have been sampled out; rotation must re-evaluate sampling and @@ -2309,6 +2435,31 @@ internal class PostHogReplayIntegrationTest { } } + @Test + @Config(sdk = [26], shadows = [ShadowPixelCopy::class]) + fun `captureTouches disabled leaves screenshot capture active`() { + val (fx, fake) = screenshotFixture() + val controller = Robolectric.buildActivity(Activity::class.java).setup() + try { + fx.config.sessionReplayConfig.captureTouches = false + shadowOf(Looper.getMainLooper()).idle() + val window = controller.get().window + val view = window.decorView + makeWindowVisible(view) + fx.sut.decorViews[view] = ViewTreeSnapshotStatus(mock()) + + assertTrue(fx.sut.isActive()) + assertTrue(fx.sut.generateSnapshot(WeakReference(view), WeakReference(window))) + assertEquals(1, fake.captures) + val events = fake.properties!!["\$snapshot_data"] as List<*> + assertTrue(events[0] is RRMetaEvent) + assertTrue(events[1] is RRFullSnapshotEvent) + } finally { + fx.sut.uninstall() + controller.pause().stop().destroy() + } + } + @Test @Config(sdk = [26], shadows = [RecordingShadowPixelCopy::class]) fun `screenshot capture reuses a full resolution ARGB8888 destination by default`() { diff --git a/posthog-android/src/test/java/com/posthog/android/replay/PostHogSessionReplayConfigTest.kt b/posthog-android/src/test/java/com/posthog/android/replay/PostHogSessionReplayConfigTest.kt index addc32d98..dda67cbad 100644 --- a/posthog-android/src/test/java/com/posthog/android/replay/PostHogSessionReplayConfigTest.kt +++ b/posthog-android/src/test/java/com/posthog/android/replay/PostHogSessionReplayConfigTest.kt @@ -7,6 +7,16 @@ import kotlin.test.Test import kotlin.test.assertEquals internal class PostHogSessionReplayConfigTest { + @Test + fun `captureTouches defaults to true and can change at runtime`() { + val config = PostHogSessionReplayConfig() + assertEquals(true, config.captureTouches) + config.captureTouches = false + assertEquals(false, config.captureTouches) + config.captureTouches = true + assertEquals(true, config.captureTouches) + } + @RunWith(Parameterized::class) class ScreenshotScaleTest(private val input: Float, private val expected: Float) { companion object { From ca42266974bef198db39c7101e225f97ed3ced79 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Fri, 11 Sep 2026 13:08:36 +0200 Subject: [PATCH 2/2] refactor: configure replay touch capture only during setup --- .changeset/quiet-touch-privacy.md | 2 +- .../replay/PostHogReplayIntegration.kt | 2 +- .../replay/PostHogSessionReplayConfig.kt | 4 +- .../replay/PostHogReplayIntegrationTest.kt | 59 ++----------------- .../replay/PostHogSessionReplayConfigTest.kt | 4 +- 5 files changed, 11 insertions(+), 60 deletions(-) diff --git a/.changeset/quiet-touch-privacy.md b/.changeset/quiet-touch-privacy.md index 837868f83..5e9b97dda 100644 --- a/.changeset/quiet-touch-privacy.md +++ b/.changeset/quiet-touch-privacy.md @@ -2,4 +2,4 @@ "posthog-android": minor --- -Add `PostHogSessionReplayConfig.captureTouches` (default `true`) to disable touch coordinate recording independently of screenshots and view capture. The setting can be changed at runtime, and queued touch capture is skipped while disabled. +Add `PostHogSessionReplayConfig.captureTouches` (default `true`) to disable touch coordinate recording during SDK initialization independently of screenshots and view capture. Runtime changes are not supported. diff --git a/posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.kt b/posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.kt index 855cfa2f5..c37b25be5 100644 --- a/posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.kt +++ b/posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.kt @@ -457,7 +457,7 @@ public class PostHogReplayIntegration( executor.submit { try { - if (!config.sessionReplayConfig.captureTouches || !isActive()) { + if (!isActive()) { return@submit } when (safeMotionEvent.action.and(MotionEvent.ACTION_MASK)) { diff --git a/posthog-android/src/main/java/com/posthog/android/replay/PostHogSessionReplayConfig.kt b/posthog-android/src/main/java/com/posthog/android/replay/PostHogSessionReplayConfig.kt index 3961575c2..bc3c492a8 100644 --- a/posthog-android/src/main/java/com/posthog/android/replay/PostHogSessionReplayConfig.kt +++ b/posthog-android/src/main/java/com/posthog/android/replay/PostHogSessionReplayConfig.kt @@ -64,10 +64,10 @@ public class PostHogSessionReplayConfig ) { /** * Capture touch coordinates in session replay. Defaults to true. - * Can be changed at runtime without stopping screenshots or view capture. + * Set before SDK setup. Runtime changes are not supported. + * Screenshot and view capture are unaffected. * Disable this when touch positions could reveal sensitive input, even if the views are masked. */ - @Volatile public var captureTouches: Boolean = true /** diff --git a/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayIntegrationTest.kt b/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayIntegrationTest.kt index 4b7a6c6bd..d4599d8a6 100644 --- a/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayIntegrationTest.kt +++ b/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayIntegrationTest.kt @@ -539,56 +539,6 @@ internal class PostHogReplayIntegrationTest { } } - @Test - fun `captureTouches runtime false then true suppresses only disabled touches`() { - val config = configWithSampling(flagActive = true, samplingPasses = true) - val executor = QueuedReplayExecutor(createReplayExecutor()) - val sut = PostHogReplayIntegration(ApplicationProvider.getApplicationContext(), config, MainHandler(), executor) - val fake = createPostHogFake() - sut.install(fake) - try { - sut.start(resumeCurrent = true) - dispatchTouch(sut) - executor.tasks.removeAt(0).run() - assertEquals(1, fake.captures) - - config.sessionReplayConfig.captureTouches = false - dispatchTouch(sut) - executor.tasks.forEach { it.run() } - executor.tasks.clear() - assertEquals(1, fake.captures, "Disabled touches must not emit coordinates") - - config.sessionReplayConfig.captureTouches = true - dispatchTouch(sut) - executor.tasks.removeAt(0).run() - assertEquals(2, fake.captures) - assertTrue(sut.isActive()) - } finally { - sut.uninstall() - } - } - - @Test - fun `captureTouches disabled before queued work runs drops coordinates`() { - val config = configWithSampling(flagActive = true, samplingPasses = true) - val executor = QueuedReplayExecutor(createReplayExecutor()) - val sut = PostHogReplayIntegration(ApplicationProvider.getApplicationContext(), config, MainHandler(), executor) - val fake = createPostHogFake() - sut.install(fake) - try { - sut.start(resumeCurrent = true) - dispatchTouch(sut) - dispatchTouch(sut, MotionEvent.ACTION_UP) - assertEquals(2, executor.tasks.size) - config.sessionReplayConfig.captureTouches = false - executor.tasks.forEach { it.run() } - assertEquals(0, fake.captures, "Already queued touches must be dropped when disabled") - assertTrue(sut.isActive()) - } finally { - sut.uninstall() - } - } - @Test fun `onSessionIdChanged starts replay when previously inactive and sampling passes`() { // The prior session may have been sampled out; rotation must re-evaluate sampling and @@ -2151,13 +2101,17 @@ internal class PostHogReplayIntegrationTest { .setInt(attachInfo, View.VISIBLE) } - private fun screenshotFixture(enableMaskAlignmentVerification: Boolean = true): Pair { + private fun screenshotFixture( + enableMaskAlignmentVerification: Boolean = true, + captureTouches: Boolean = true, + ): Pair { val fx = createIntegrationWithRealQueue( flagActive = true, hasFetched = true, integrationContext = ApplicationProvider.getApplicationContext(), ) + fx.config.sessionReplayConfig.captureTouches = captureTouches fx.config.sessionReplayConfig.screenshot = true fx.config.sessionReplayConfig.verifyScreenshotMaskAlignment = enableMaskAlignmentVerification val fake = PostHogFake() @@ -2438,10 +2392,9 @@ internal class PostHogReplayIntegrationTest { @Test @Config(sdk = [26], shadows = [ShadowPixelCopy::class]) fun `captureTouches disabled leaves screenshot capture active`() { - val (fx, fake) = screenshotFixture() + val (fx, fake) = screenshotFixture(captureTouches = false) val controller = Robolectric.buildActivity(Activity::class.java).setup() try { - fx.config.sessionReplayConfig.captureTouches = false shadowOf(Looper.getMainLooper()).idle() val window = controller.get().window val view = window.decorView diff --git a/posthog-android/src/test/java/com/posthog/android/replay/PostHogSessionReplayConfigTest.kt b/posthog-android/src/test/java/com/posthog/android/replay/PostHogSessionReplayConfigTest.kt index dda67cbad..a5d7f35f7 100644 --- a/posthog-android/src/test/java/com/posthog/android/replay/PostHogSessionReplayConfigTest.kt +++ b/posthog-android/src/test/java/com/posthog/android/replay/PostHogSessionReplayConfigTest.kt @@ -8,13 +8,11 @@ import kotlin.test.assertEquals internal class PostHogSessionReplayConfigTest { @Test - fun `captureTouches defaults to true and can change at runtime`() { + fun `captureTouches defaults to true and can be disabled before setup`() { val config = PostHogSessionReplayConfig() assertEquals(true, config.captureTouches) config.captureTouches = false assertEquals(false, config.captureTouches) - config.captureTouches = true - assertEquals(true, config.captureTouches) } @RunWith(Parameterized::class)