Skip to content
Closed
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
6 changes: 6 additions & 0 deletions .changeset/brave-windows-replay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"posthog": patch
"posthog-android": patch
---

Assign stable session replay window IDs to Android decor views so dialogs do not replace activity wireframes during playback.
1 change: 1 addition & 0 deletions posthog-android/api/posthog-android.api
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ public final class com/posthog/android/replay/PostHogReplayIntegration : com/pos
public static final field PH_NO_MASK_LABEL Ljava/lang/String;
public fun <init> (Landroid/content/Context;Lcom/posthog/android/PostHogAndroidConfig;Lcom/posthog/android/internal/MainHandler;)V
public final fun captureSessionReplaySnapshot (Landroid/view/View;ZLkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;)Z
public fun getCurrentWindowId ()Ljava/lang/String;
public fun install (Lcom/posthog/PostHogInterface;)V
public fun isActive ()Z
public fun onEvent (Ljava/lang/String;Ljava/util/Map;)V
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import com.posthog.PostHogIntegration
import com.posthog.PostHogInterface
import com.posthog.PostHogInternal
import com.posthog.android.PostHogAndroidConfig
import com.posthog.android.internal.MainHandler
import com.posthog.android.internal.densityValue
Expand Down Expand Up @@ -92,6 +93,8 @@ import com.posthog.internal.replay.RRRemovedNode
import com.posthog.internal.replay.RRStyle
import com.posthog.internal.replay.RRWireframe
import com.posthog.internal.replay.capture
import com.posthog.internal.replay.captureInWindow
import com.posthog.vendor.uuid.TimeBasedEpochGenerator
import curtains.Curtains
import curtains.OnRootViewsChangedListener
import curtains.TouchEventInterceptor
Expand Down Expand Up @@ -203,6 +206,13 @@ public class PostHogReplayIntegration(
@Volatile
private var replaySessionId: String? = null

// Acquire decorViews before this lock when an operation needs both.
private val foregroundWindowsLock = Any()
private val foregroundWindowIds = mutableListOf<String>()

@Volatile
private var foregroundWindowId: String? = null

// Minimum duration buffering state
private val bufferingLock = Any()

Expand Down Expand Up @@ -303,69 +313,139 @@ public class PostHogReplayIntegration(
}
}

private fun markWindowForeground(windowId: String) {
synchronized(foregroundWindowsLock) {
foregroundWindowIds.remove(windowId)
foregroundWindowIds.add(windowId)
foregroundWindowId = windowId
}
}

private fun forgetWindow(windowId: String) {
synchronized(foregroundWindowsLock) {
foregroundWindowIds.remove(windowId)
if (foregroundWindowId == windowId) {
foregroundWindowId = foregroundWindowIds.lastOrNull()
}
}
}

private fun clearForegroundWindows() {
synchronized(foregroundWindowsLock) {
foregroundWindowIds.clear()
foregroundWindowId = null
}
}

private fun updateWindowFocus(
decorView: View,
windowId: String,
hasFocus: Boolean,
) {
synchronized(decorViews) {
if (decorViews[decorView]?.windowId != windowId) {
return
}
if (hasFocus) {
markWindowForeground(windowId)
} else {
forgetWindow(windowId)
}
}
}

private fun findTrackedDecorView(view: View): Pair<View, ViewTreeSnapshotStatus>? {
val window = view.phoneWindow
val candidates = listOfNotNull(view, view.rootView, window?.peekDecorView())
return synchronized(decorViews) {
candidates.firstNotNullOfOrNull { decorView ->
decorViews[decorView]?.let { decorView to it }
} ?: window?.let { target ->
decorViews.entries.firstOrNull { it.value.windowRef?.get() === target }?.toPair()
}
}
}

private fun addView(
view: View,
added: Boolean = true,
) {
try {
view.phoneWindow?.let { window ->
var hasDecorView = false

// react native already has the window attached
// so we check if the decor view exists otherwise we need the onDecorViewReady anyways
window.peekDecorView()?.let { decorView ->
hasDecorView = decorViews[decorView] != null
if (!added) {
findTrackedDecorView(view)?.let { (decorView, status) ->
clearViewListeners(decorView, status)
}
if (added) {
if (view.windowAttachCount == 0 || !hasDecorView) {
window.onDecorViewReady { decorView ->
try {
// Captured by the listeners directly so no draw can be missed
// before the decorViews map insertion.
val drawState = WindowDrawState()
val listener =
decorView.onNextDraw(
mainHandler,
config.dateProvider,
config.sessionReplayConfig.throttleDelayMs,
{ onDrawCallback(decorView, drawState) },
) {
if (!isActive() || !isNativeSdk) {
return@onNextDraw
}

executor.submit {
try {
generateSnapshot(WeakReference(decorView), WeakReference(window))
} catch (e: Throwable) {
config.logger.log("Session Replay generateSnapshot failed: $e.")
}
}
}
return
}

val window = view.phoneWindow ?: return
val hasDecorView = window.peekDecorView()?.let { decorViews[it] != null } == true

// React Native can attach the window before replay is installed. In that case we still
// need onDecorViewReady when its decor view has not been registered yet.
if (view.windowAttachCount != 0 && hasDecorView) {
config.logger.log("Session Replay already has onDecorViewReady.")
return
}

val layoutListener =
ViewTreeObserver.OnGlobalLayoutListener { drawState.recordLayout() }
decorView.viewTreeObserver?.addOnGlobalLayoutListener(layoutListener)
window.onDecorViewReady { decorView ->
try {
if (decorViews[decorView] != null) {
return@onDecorViewReady
}

val status = ViewTreeSnapshotStatus(listener, layoutListener, drawState = drawState)
decorViews[decorView] = status
} catch (e: Throwable) {
config.logger.log("Session Replay onDecorViewReady failed: $e.")
val windowId = TimeBasedEpochGenerator.generate().toString()
val touchEventInterceptor = createTouchEventListener(windowId)
// Captured by the listeners directly so no draw can be missed before the
// decorViews map insertion.
val drawState = WindowDrawState()
val listener =
decorView.onNextDraw(
mainHandler,
config.dateProvider,
config.sessionReplayConfig.throttleDelayMs,
{ onDrawCallback(decorView, drawState) },
) {
if (!isActive() || !isNativeSdk) {
return@onNextDraw
}

executor.submit {
try {
generateSnapshot(WeakReference(decorView), WeakReference(window))
} catch (e: Throwable) {
config.logger.log("Session Replay generateSnapshot failed: $e.")
}
}
}

window.touchEventInterceptors += onTouchEventListener
// TODO: can check if user pressed hardware back button (KEYCODE_BACK)
// window.keyEventInterceptors
} else {
config.logger.log("Session Replay already has onDecorViewReady.")
}
} else {
window.peekDecorView()?.let { decorView ->
decorViews[decorView]?.let { status ->
clearViewListeners(decorView, status)
val layoutListener = ViewTreeObserver.OnGlobalLayoutListener { drawState.recordLayout() }
val windowFocusListener =
ViewTreeObserver.OnWindowFocusChangeListener { hasFocus ->
updateWindowFocus(decorView, windowId, hasFocus)
}
decorView.viewTreeObserver?.apply {
addOnGlobalLayoutListener(layoutListener)
addOnWindowFocusChangeListener(windowFocusListener)
}

val status =
ViewTreeSnapshotStatus(
listener,
layoutListener,
drawState = drawState,
windowId = windowId,
touchEventInterceptor = touchEventInterceptor,
windowFocusListener = windowFocusListener,
windowRef = WeakReference(window),
)
decorViews[decorView] = status
window.touchEventInterceptors += touchEventInterceptor
if (decorView.hasWindowFocus()) {
updateWindowFocus(decorView, windowId, hasFocus = true)
}
} catch (e: Throwable) {
config.logger.log("Session Replay onDecorViewReady failed: $e.")
}
}
} catch (e: Throwable) {
Expand Down Expand Up @@ -407,9 +487,12 @@ public class PostHogReplayIntegration(
return Pair(imeVisible, event)
}

internal val onTouchEventListener =
internal fun createTouchEventListener(windowId: String): TouchEventInterceptor =
TouchEventInterceptor { motionEvent, dispatch ->
try {
if (isActive()) {
markWindowForeground(windowId)
}
val state = dispatch(motionEvent)
try {
if (!isActive()) {
Expand All @@ -425,12 +508,23 @@ public class PostHogReplayIntegration(
if (!isActive()) {
return@submit
}
val replayWindowId = windowId.takeIf { isNativeSdk }
when (safeMotionEvent.action.and(MotionEvent.ACTION_MASK)) {
MotionEvent.ACTION_DOWN -> {
generateMouseInteractions(timestamp, safeMotionEvent, RRMouseInteraction.TouchStart)
generateMouseInteractions(
timestamp,
safeMotionEvent,
RRMouseInteraction.TouchStart,
replayWindowId,
)
}
MotionEvent.ACTION_UP -> {
generateMouseInteractions(timestamp, safeMotionEvent, RRMouseInteraction.TouchEnd)
generateMouseInteractions(
timestamp,
safeMotionEvent,
RRMouseInteraction.TouchEnd,
replayWindowId,
)
}
}
} catch (e: Throwable) {
Expand All @@ -453,6 +547,7 @@ public class PostHogReplayIntegration(
timestamp: Long,
motionEvent: MotionEvent,
type: RRMouseInteraction,
windowId: String?,
) {
val mouseInteractions = mutableListOf<RRIncrementalMouseInteractionEvent>()
for (index in 0 until motionEvent.pointerCount) {
Expand Down Expand Up @@ -481,7 +576,8 @@ public class PostHogReplayIntegration(
// if we batch them, we need to be aware that the order of the events matters
// also because if we send a mouse interaction later, it might be attached to the wrong
// screen
mouseInteractions.capture(postHog)
windowId?.let { mouseInteractions.captureInWindow(it, postHog) }
?: mouseInteractions.capture(postHog)
}
}

Expand All @@ -497,6 +593,11 @@ public class PostHogReplayIntegration(
view: View,
status: ViewTreeSnapshotStatus,
) {
synchronized(decorViews) {
decorViews.remove(view)
forgetWindow(status.windowId)
}

if (view.isAliveAndAttachedToWindow()) {
mainHandler.handler.post {
// 2nd check to avoid:
Expand All @@ -505,20 +606,21 @@ public class PostHogReplayIntegration(
if (view.isAliveAndAttachedToWindow()) {
try {
// swallow the exception because we still wanna remove it from the decorViews
view.viewTreeObserver?.removeOnDrawListener(status.listener)
status.layoutListener?.let { view.viewTreeObserver?.removeOnGlobalLayoutListener(it) }
view.viewTreeObserver?.apply {
removeOnDrawListener(status.listener)
status.layoutListener?.let { removeOnGlobalLayoutListener(it) }
status.windowFocusListener?.let { removeOnWindowFocusChangeListener(it) }
}
} catch (e: Throwable) {
config.logger.log("Removing the viewTreeObserver failed: $e.")
}
}
}
}

view.phoneWindow?.let { window ->
window.touchEventInterceptors -= onTouchEventListener
status.windowRef?.get()?.let { window ->
status.touchEventInterceptor?.let { window.touchEventInterceptors -= it }
}

decorViews.remove(view)
}

@Synchronized
Expand Down Expand Up @@ -592,6 +694,7 @@ public class PostHogReplayIntegration(
} catch (e: Throwable) {
config.logger.log("Session Replay uninstall failed: $e.")
} finally {
clearForegroundWindows()
ownsInstallation = false
integrationInstalled.set(false)
}
Expand Down Expand Up @@ -826,7 +929,7 @@ public class PostHogReplayIntegration(
}

if (events.isNotEmpty()) {
events.capture(postHog)
events.captureInWindow(status.windowId, postHog)
}

status.lastSnapshot = wireframe
Expand Down Expand Up @@ -2304,6 +2407,11 @@ public class PostHogReplayIntegration(
return isSessionReplayActive
}

@PostHogInternal
override fun getCurrentWindowId(): String? {
return foregroundWindowId.takeIf { isNativeSdk }
}

/**
* Called when an event is captured. Checks if the event matches any configured triggers
* and starts session recording if so.
Expand Down
Loading
Loading