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/native-replay-snapshot-hook.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"posthog-android": minor
---

Add `PostHogReplayIntegration.captureSessionReplaySnapshot(...)`, an internal opt-in session-replay hook (`@PostHogInternalReplayApi`) that lets first-party PostHog wrapper SDKs (e.g. posthog-flutter) capture the current native window on their own cadence β€” used to record native screens that cover an out-of-engine UI. Not a public API: it requires an explicit opt-in and carries no stability guarantees.
4 changes: 4 additions & 0 deletions posthog-android/api/posthog-android.api
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ public abstract interface class com/posthog/android/replay/PostHogDrawableConver
public abstract fun convert (Landroid/graphics/drawable/Drawable;)Landroid/graphics/Bitmap;
}

public abstract interface annotation class com/posthog/android/replay/PostHogInternalReplayApi : java/lang/annotation/Annotation {
}

public final class com/posthog/android/replay/PostHogMaskModifier {
public static final field INSTANCE Lcom/posthog/android/replay/PostHogMaskModifier;
public final fun postHogMask (Landroidx/compose/ui/Modifier;Z)Landroidx/compose/ui/Modifier;
Expand All @@ -68,6 +71,7 @@ public final class com/posthog/android/replay/PostHogReplayIntegration : com/pos
public static final field PH_NO_CAPTURE_LABEL Ljava/lang/String;
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 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
@@ -0,0 +1,17 @@
package com.posthog.android.replay

/**
* Marks a session-replay API that exists only for first-party PostHog wrapper
* SDKs (e.g. posthog-flutter) to drive out-of-engine capture. It is not a
* public API: it has no source/binary stability guarantees and can change or
* be removed without a major-version bump. App code should never opt in.
*/
@RequiresOptIn(
message =
"Internal PostHog session-replay API for first-party SDK integrations only. " +
"Not covered by semantic versioning; do not use from app code.",
level = RequiresOptIn.Level.ERROR,
)
@Retention(AnnotationRetention.BINARY)
@Target(AnnotationTarget.FUNCTION, AnnotationTarget.CLASS, AnnotationTarget.PROPERTY)
public annotation class PostHogInternalReplayApi
Comment thread
turnipdabeets marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import android.view.PixelCopy
import android.view.View
import android.view.ViewGroup
import android.view.Window
import android.view.WindowManager
import android.webkit.WebView
import android.widget.Button
import android.widget.CheckBox
Expand Down Expand Up @@ -502,6 +503,103 @@ public class PostHogReplayIntegration(
}
}

/**
* One-shot capture of the top-most native activity window, for first-party
* PostHog wrapper SDKs (e.g. posthog-flutter) driving out-of-engine capture
* on their own cadence. Not for app use: it shares snapshot state with the
* normal timer-driven capture. Must be called on the main thread.
*
* [excludeView] (the caller's own decor view) is never captured.
* [forceFullSnapshot] resets the decor view's snapshot state so an episode's
* first capture is a full snapshot, not incremental mutations against a
* player mirror that interleaved frames have invalidated. [isStillValid] is
* re-checked on the capture thread, so work queued on an episode's last tick
* self-drops instead of emitting a late frame.
*
* The return value only means the capture was scheduled; [onResult] fires on
* the capture thread with whether a frame was actually delivered β€” callers
* must treat that, not the return value, as the retry signal.
*/
@PostHogInternalReplayApi
public fun captureSessionReplaySnapshot(
excludeView: View?,
forceFullSnapshot: Boolean,
isStillValid: () -> Boolean,
onResult: (delivered: Boolean) -> Unit,
): Boolean {
if (!isActive()) {
return false
}
try {
// Topmost ACTIVITY window only: a Dialog's decor on top would
// collapse the replay viewport to the dialog, and a PopupWindow
// has no phoneWindow at all β€” both are treated as overlays of the
// activity window beneath them. Identified by window type
// (callbacks are wrapped by Curtains, so `callback is Activity`
// does not hold).
val decorView =
Curtains.rootViews.lastOrNull {
it.isAliveAndAttachedToWindow() &&
it.phoneWindow != null &&
(it.layoutParams as? WindowManager.LayoutParams)?.type ==
WindowManager.LayoutParams.TYPE_BASE_APPLICATION
} ?: return false
if (decorView === excludeView) {
return false
}
val window = decorView.phoneWindow ?: return false
if (decorViews[decorView] == null) {
// Not tracked yet (onDecorViewReady pending): generateSnapshot
// would bail silently β€” report failure so the caller retries
// and the first-of-episode reset is not consumed.
return false
}
executor.submit {
// A throwing onResult would land in the catch below and fire a
// second time β€” report exactly once per scheduled capture.
var resultReported = false

fun report(delivered: Boolean) {
if (resultReported) return
resultReported = true
onResult(delivered)
}
try {
// Validity and the first-of-episode reset both happen on
// the capture thread: the reset mutates snapshot status
// fields that are otherwise only touched here, and a
// stale queued capture must not emit after the episode.
if (!isStillValid()) {
// The contract promises onResult for every scheduled
// capture; a silent self-drop would leave the caller's
// in-flight tracking latched forever.
report(false)
return@submit
}
Comment thread
turnipdabeets marked this conversation as resolved.
if (forceFullSnapshot) {
decorViews[decorView]?.let { status ->
status.sentFullSnapshot = false
status.sentMetaEvent = false
status.lastSnapshot = null
}
}
val delivered = generateSnapshot(WeakReference(decorView), WeakReference(window), forceScreenshot = true)
if (!delivered) {
config.logger.log("Session Replay bridge capture produced no frame (will retry next tick).")
}
report(delivered)
} catch (e: Throwable) {
config.logger.log("Session Replay bridge capture failed: $e.")
report(false)
}
}
return true
} catch (e: Throwable) {
config.logger.log("Session Replay bridge capture failed: $e.")
return false
}
}

private fun Resources.Theme.toRGBColor(): String? {
val value = TypedValue()
resolveAttribute(android.R.attr.windowBackground, value, true)
Expand All @@ -515,34 +613,38 @@ public class PostHogReplayIntegration(
}

// internal (not private) so tests can drive a snapshot pass directly.
// Returns whether a frame was actually produced (false on every early bail),
// so the bridge caller can tell "captured" from "silently skipped".
internal fun generateSnapshot(
viewRef: WeakReference<View>,
windowRef: WeakReference<Window>,
) {
forceScreenshot: Boolean = false,
): Boolean {
// Early bail if stopped and this is processing previous generateSnapshot() from executor.submit
if (!isActive()) return
if (!isActive()) return false

val view = viewRef.get() ?: return
val status = decorViews[view] ?: return
val window = windowRef.get() ?: return
val view = viewRef.get() ?: return false
val status = decorViews[view] ?: return false
val window = windowRef.get() ?: return false

// Check view is still alive to avoid native crashes
if (!view.isAlive()) return
if (!view.isAlive()) return false

val timestamp = config.dateProvider.currentTimeMillis()

val useScreenshot = config.sessionReplayConfig.screenshot || forceScreenshot
val wireframe =
if (config.sessionReplayConfig.screenshot) {
if (useScreenshot) {
view.toScreenshotWireframe(
window,
) ?: return
) ?: return false
} else {
view.toWireframe() ?: return
view.toWireframe() ?: return false
}

// if the decorView has no backgroundColor, we use the theme color
// no need to do this if we are capturing a screenshot
if (wireframe.style?.backgroundColor == null && !config.sessionReplayConfig.screenshot) {
if (wireframe.style?.backgroundColor == null && !useScreenshot) {
context.theme?.toRGBColor()?.let {
wireframe.style?.backgroundColor = it
}
Expand All @@ -554,7 +656,7 @@ public class PostHogReplayIntegration(
val title = view.phoneWindow?.attributes?.title?.toString()?.substringAfter("/") ?: ""
// TODO: cache and compare, if size changes, we send a ViewportResize event

val screenSizeInfo = view.context.screenSize() ?: return
val screenSizeInfo = view.context.screenSize() ?: return false

val metaEvent =
RRMetaEvent(
Expand Down Expand Up @@ -633,6 +735,7 @@ public class PostHogReplayIntegration(
}

status.lastSnapshot = wireframe
return true
}

/**
Expand Down
Loading