diff --git a/.changeset/quiet-touch-privacy.md b/.changeset/quiet-touch-privacy.md new file mode 100644 index 000000000..5e9b97dda --- /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 during SDK initialization independently of screenshots and view capture. Runtime changes are not supported. 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..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 @@ -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() 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..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 @@ -62,6 +62,14 @@ public class PostHogSessionReplayConfig */ public var sampleRate: Double? = null, ) { + /** + * Capture touch coordinates in session replay. Defaults to true. + * 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. + */ + 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..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 @@ -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,79 @@ 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 `onSessionIdChanged starts replay when previously inactive and sampling passes`() { // The prior session may have been sampled out; rotation must re-evaluate sampling and @@ -2025,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() @@ -2309,6 +2389,30 @@ internal class PostHogReplayIntegrationTest { } } + @Test + @Config(sdk = [26], shadows = [ShadowPixelCopy::class]) + fun `captureTouches disabled leaves screenshot capture active`() { + val (fx, fake) = screenshotFixture(captureTouches = false) + val controller = Robolectric.buildActivity(Activity::class.java).setup() + try { + 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..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 @@ -7,6 +7,14 @@ import kotlin.test.Test import kotlin.test.assertEquals internal class PostHogSessionReplayConfigTest { + @Test + 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) + } + @RunWith(Parameterized::class) class ScreenshotScaleTest(private val input: Float, private val expected: Float) { companion object {