Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quiet-touch-privacy.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions posthog-android/api/posthog-android.api
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ public final class com/posthog/android/replay/PostHogSessionReplayConfig {
public fun <init> (ZZZLcom/posthog/android/replay/PostHogDrawableConverter;ZJJLjava/lang/Double;)V
public synthetic fun <init> (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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -447,7 +447,7 @@ public class PostHogReplayIntegration(
try {
val state = dispatch(motionEvent)
try {
if (!isActive()) {
if (!config.sessionReplayConfig.captureTouches || !isActive()) {
Comment thread
marandaneto marked this conversation as resolved.
return@TouchEventInterceptor state
}
val timestamp = config.dateProvider.currentTimeMillis()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -2025,13 +2101,17 @@ internal class PostHogReplayIntegrationTest {
.setInt(attachInfo, View.VISIBLE)
}

private fun screenshotFixture(enableMaskAlignmentVerification: Boolean = true): Pair<RealQueueFixture, PostHogFake> {
private fun screenshotFixture(
enableMaskAlignmentVerification: Boolean = true,
captureTouches: Boolean = true,
): Pair<RealQueueFixture, PostHogFake> {
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()
Expand Down Expand Up @@ -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<NextDrawListener>())

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`() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading