Skip to content
Draft
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/sixty-chefs-hug.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"posthog-android": minor
---

Compose Android activity and dialog layers in screenshot-mode session replay, preserving dialog window opacity and pairing complete scenes with current activity metadata. Activity captures honor the configured screenshot pixel format; dialogs use RGB565 only when configured and cropped to proven-opaque bounds with full window opacity, otherwise preserving transparency with ARGB8888. Crop compatible Material dialog captures and reuse capture bitmap capacity across windows. On Android 8.0–8.1, replace dialogs containing masked content, or whose masks cannot be verified, with an opaque mask covering the whole dialog.
1 change: 1 addition & 0 deletions posthog-android/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ dependencies {
testImplementation("androidx.test:rules:${PosthogBuildConfig.Dependencies.ANDROIDX_CORE}")
testImplementation("org.robolectric:robolectric:${PosthogBuildConfig.Dependencies.ROBOLECTRIC}")
testImplementation("androidx.activity:activity-compose:1.13.0")
testImplementation("com.google.android.material:material:1.12.0")
testImplementation(platform("androidx.compose:compose-bom:2026.06.01"))
testImplementation("androidx.compose.material3:material3")
testImplementation("androidx.compose.ui:ui-test-junit4")
Expand Down
13 changes: 13 additions & 0 deletions posthog-android/consumer-rules.pro
Original file line number Diff line number Diff line change
Expand Up @@ -132,3 +132,16 @@
-keepnames class androidx.compose.ui.viewinterop.* extends android.view.View
-keepnames class androidx.compose.material.ripple.RippleContainer
-keepnames class androidx.compose.material.ripple.RippleHostView

# Screenshot cropping recognizes Material's optional dialog shell and public shape APIs.
-keepnames class androidx.coordinatorlayout.widget.CoordinatorLayout
-keepnames class com.google.android.material.bottomsheet.BottomSheetBehavior
-keepclassmembers class androidx.coordinatorlayout.widget.CoordinatorLayout$LayoutParams {
public *** getBehavior();
}
-keepnames class com.google.android.material.shape.MaterialShapeDrawable
-keepclassmembers class com.google.android.material.shape.MaterialShapeDrawable {
public android.content.res.ColorStateList getFillColor();
public android.content.res.ColorStateList getTintList();
public android.graphics.Paint$Style getPaintStyle();
}
36 changes: 27 additions & 9 deletions posthog-android/gradle.lockfile

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,10 @@ public class PostHogSessionReplayConfig
* Pixel format used for screenshot captures. Defaults to [PostHogScreenshotColorMode.ARGB_8888]
* to preserve alpha and color precision before lossy WebP compression.
* [PostHogScreenshotColorMode.RGB_565] uses less bitmap memory but reduces color precision
* and removes alpha, making transparent window regions appear black. Devices that reject
* RGB_565 fall back to ARGB_8888. Does not enable screenshot capture.
* and removes alpha, making transparent activity window regions appear black. In composed
* scenes, dialogs use RGB_565 only for proven-opaque crops with window opacity equal to 1;
* other dialogs use ARGB_8888 to preserve transparency. Devices that reject RGB_565 fall
* back to ARGB_8888. Does not enable screenshot capture.
*/
@PostHogExperimental
@Volatile
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
package com.posthog.android.replay.internal

import android.content.res.ColorStateList
import android.graphics.Outline
import android.graphics.Paint
import android.graphics.PixelFormat
import android.graphics.Rect
import android.graphics.RectF
import android.os.Build
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import kotlin.math.ceil
import kotlin.math.floor

// Physical pixels in the decor's coordinate space. Keep this immutable: PixelCopy can mutate
// its source Rect, and the same geometry is used by draw verification and the scene cache.
internal data class DialogScreenshotCrop(
val left: Int,
val top: Int,
val right: Int,
val bottom: Int,
val opaque: Boolean,
) {
val width: Int get() = right - left
val height: Int get() = bottom - top

fun toRect(): Rect = Rect(left, top, right, bottom)
}

private const val BOTTOM_SHEET_BEHAVIOR = "com.google.android.material.bottomsheet.BottomSheetBehavior"
private const val MATERIAL_SHAPE = "com.google.android.material.shape.MaterialShapeDrawable"
private const val COORDINATOR = "androidx.coordinatorlayout.widget.CoordinatorLayout"

private val transparentContainers =
setOf(
"com.android.internal.policy.DecorView",
"android.widget.FrameLayout",
"android.widget.LinearLayout",
"androidx.appcompat.widget.FitWindowsFrameLayout",
"androidx.appcompat.widget.ContentFrameLayout",
COORDINATOR,
)

/**
* Recognizes the stock Material dialog shell, not arbitrary custom dialog drawing. Only the
* shell is visited: the sheet clips its children, so its subtree need not be walked for bounds.
* Native shadows outside the content rectangle are omitted.
*/
@Suppress("DEPRECATION")
internal fun findDialogScreenshotCrop(decor: View): DialogScreenshotCrop? {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P || decor.width <= 0 || decor.height <= 0) return null
return try {
var remaining = 32
var sheet: ViewGroup? = null

fun visit(view: View): Boolean {
if (view.visibility != View.VISIBLE || view.alpha <= 0f) return true
if (--remaining < 0 || view.width <= 0 || view.height <= 0 || !view.hasCropCompatibleTransform()) return false
if (view is FrameLayout && view.javaClass == FrameLayout::class.java && view.resourceEntryName() == "design_bottom_sheet") {
if (sheet != null || (view.parent as? View)?.javaClass?.name != COORDINATOR || !view.clipChildren) return false
val behavior = view.layoutParams.javaClass.getMethod("getBehavior").invoke(view.layoutParams)
if (behavior?.javaClass?.name != BOTTOM_SHEET_BEHAVIOR) return false
sheet = view
return true
}
if (view.foreground != null || (view.background != null && view.background.opacity != PixelFormat.TRANSPARENT)) return false
// Material's full-window outside-touch target draws nothing, but willNotDraw is false.
if (view.javaClass == View::class.java && view.resourceEntryName() == "touch_outside") return true
if (view !is ViewGroup || view.javaClass.name !in transparentContainers ||
!view.willNotDraw() || !view.clipChildren
) {
return false
}
for (index in 0 until view.childCount) {
if (!visit(view.getChildAt(index))) return false
}
return true
}
if (!visit(decor)) return null
val content = sheet ?: return null
val bounds = RectF(0f, 0f, content.width.toFloat(), content.height.toFloat())
var opaque = content.hasOpaqueCropBackground()
var current: View = content
while (true) {
opaque = opaque && current.alpha == 1f &&
(Build.VERSION.SDK_INT < Build.VERSION_CODES.Q || current.transitionAlpha == 1f) &&
current.hasRectangularCropClip(bounds)
current.clipBounds?.let { if (!bounds.intersect(RectF(it))) return null }
if (current === decor) break
val parent = current.parent as? ViewGroup ?: return null
bounds.offset(current.left - parent.scrollX + current.translationX, current.top - parent.scrollY + current.translationY)
if (!bounds.intersect(0f, 0f, parent.width.toFloat(), parent.height.toFloat())) return null
if (parent.clipToPadding &&
!bounds.intersect(
parent.paddingLeft.toFloat(),
parent.paddingTop.toFloat(),
(parent.width - parent.paddingRight).toFloat(),
(parent.height - parent.paddingBottom).toFloat(),
)
) {
return null
}
current = parent
}
val left = floor(bounds.left).toInt()
val top = floor(bounds.top).toInt()
val right = ceil(bounds.right).toInt()
val bottom = ceil(bounds.bottom).toInt()
// Fractional translation leaves antialiased edge pixels in the outward-rounded crop.
opaque = opaque && bounds.left == left.toFloat() && bounds.top == top.toFloat() &&
bounds.right == right.toFloat() && bounds.bottom == bottom.toFloat()
DialogScreenshotCrop(left, top, right, bottom, opaque)
} catch (_: Throwable) {
// Optional library APIs and custom View implementations can vary independently of us.
null
}
}

private fun View.hasRectangularCropClip(bounds: RectF): Boolean {
if (!clipToOutline) return true
val outline = Outline()
outlineProvider?.getOutline(this, outline)
val rect = Rect()
return outline.getRect(rect) && outline.radius == 0f && RectF(rect).contains(bounds)
}

private fun View.resourceEntryName(): String? = if (id == View.NO_ID) null else resources.getResourceEntryName(id)

private fun View.hasCropCompatibleTransform(): Boolean =
scaleX == 1f && scaleY == 1f && rotation == 0f && rotationX == 0f && rotationY == 0f &&
translationX.isFinite() && translationY.isFinite() &&
(animation == null || animation?.hasEnded() == true)

@Suppress("DEPRECATION")
private fun View.hasOpaqueCropBackground(): Boolean {
val drawable = background ?: return false
if (foreground != null || drawable.alpha != 255 || drawable.colorFilter != null) return false
if (drawable.opacity == PixelFormat.OPAQUE) return true
if (drawable.javaClass.name != MATERIAL_SHAPE || drawable.bounds != Rect(0, 0, width, height)) return false
return try {
// MaterialShapeDrawable always reports TRANSLUCENT. Use its public fill/shape APIs
// to recognize an opaque rectangle without reading back any window pixels.
val type = drawable.javaClass
val fill = type.getMethod("getFillColor").invoke(drawable) as? ColorStateList
val tint = type.getMethod("getTintList").invoke(drawable) as? ColorStateList
val style = type.getMethod("getPaintStyle").invoke(drawable) as? Paint.Style
fill?.isOpaque == true && tint?.isOpaque != false && (style == Paint.Style.FILL || style == Paint.Style.FILL_AND_STROKE) &&
drawable.transparentRegion?.isEmpty == true
} catch (_: Throwable) {
false
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import java.util.concurrent.atomic.AtomicBoolean
/**
* Owns the reusable destination bitmap used by session replay PixelCopy requests.
*
* One bitmap is cached per recording run. Captures while that bitmap is in use receive
* One bitmap is cached per recording run and reconfigured across dimensions and pixel
* formats. Its allocation grows when needed and retains that capacity until the run closes.
* Captures while that bitmap is in use receive
* uncached leases, so a timed-out request does not block later capture attempts.
* Closing a run recycles the idle bitmap and detaches in-flight leases so late callbacks
* cannot return their bitmaps to a later run.
Expand Down Expand Up @@ -95,16 +97,17 @@ internal class PixelCopyBitmapBuffer {

val bitmap = idleBitmap
idleBitmap = null
if (bitmap == null || bitmap.isRecycled || bitmap.config != bitmapConfig) {
if (bitmap == null || bitmap.isRecycled) {
bitmap?.recycle()
return Bitmap.createBitmap(width, height, bitmapConfig)
}
if (bitmap.width == width && bitmap.height == height) {
return bitmap
}

return try {
bitmap.reconfigure(width, height, bitmapConfig)
if (bitmap.width != width || bitmap.height != height || bitmap.config != bitmapConfig) {
// Reconfiguration keeps the backing allocation, including capacity grown for
// a larger window. Only insufficient capacity requires a replacement bitmap.
bitmap.reconfigure(width, height, bitmapConfig)
}
bitmap.setHasAlpha(bitmapConfig == Bitmap.Config.ARGB_8888)
bitmap
} catch (_: IllegalArgumentException) {
bitmap.recycle()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ internal data class MaskCaptureToken(
private class ActiveMaskCapture(
val token: MaskCaptureToken,
val armedDrawCount: Long,
val crop: DialogScreenshotCrop?,
// Null until the pre-walk fixes it via setBaseline.
var baselineRects: List<Rect>? = null,
var invalid: Boolean = false,
Expand All @@ -36,6 +37,7 @@ private class ActiveMaskCapture(
internal class DrawSampleSession(
val token: MaskCaptureToken,
val compareBaseline: List<Rect>,
val crop: DialogScreenshotCrop?,
)

// Outcome of fixing a capture baseline via setBaseline.
Expand Down Expand Up @@ -131,27 +133,47 @@ internal class WindowDrawState {
// another task while the old worker or a timed-out PixelCopy callback is still running.
private var captureScheduled = false
private var pixelCopyInFlight = false
private var onCaptureAvailable: (() -> Unit)? = null

fun tryScheduleCapture(): Boolean =
fun tryScheduleCapture(onAvailable: (() -> Unit)? = null): Boolean =
synchronized(captureLock) {
if (captureScheduled || pixelCopyInFlight) {
// Register the wakeup with the rejection so completion cannot overtake it.
// Multiple requests need only one notification to capture the latest scene.
if (onAvailable != null) onCaptureAvailable = onAvailable
false
} else {
captureScheduled = true
true
}
}

// Called under captureLock; invoke the returned callback only after releasing the lock.
private fun takeCaptureAvailableCallback(): (() -> Unit)? {
if (captureScheduled || pixelCopyInFlight) return null
return onCaptureAvailable.also { onCaptureAvailable = null }
}

fun finishScheduledCapture() {
synchronized(captureLock) { captureScheduled = false }
val onAvailable =
synchronized(captureLock) {
captureScheduled = false
takeCaptureAvailableCallback()
}
onAvailable?.invoke()
}

fun beginPixelCopy() {
synchronized(captureLock) { pixelCopyInFlight = true }
}

fun finishPixelCopy() {
synchronized(captureLock) { pixelCopyInFlight = false }
val onAvailable =
synchronized(captureLock) {
pixelCopyInFlight = false
takeCaptureAvailableCallback()
}
onAvailable?.invoke()
}

fun reset() {
Expand Down Expand Up @@ -190,10 +212,10 @@ internal class WindowDrawState {
}

// Arms detection BEFORE the pre-walk, so a draw overlapping it cannot go unnoticed.
fun beginMaskCapture(): MaskCaptureToken {
fun beginMaskCapture(crop: DialogScreenshotCrop? = null): MaskCaptureToken {
synchronized(captureLock) {
val token = MaskCaptureToken(++nextCaptureId)
activeCapture = ActiveMaskCapture(token, armedDrawCount = drawCount)
activeCapture = ActiveMaskCapture(token, armedDrawCount = drawCount, crop = crop)
return token
}
}
Expand Down Expand Up @@ -239,7 +261,7 @@ internal class WindowDrawState {
return null
}
capture.drawSamplesInProgress++
return DrawSampleSession(capture.token, baseline)
return DrawSampleSession(capture.token, baseline, capture.crop)
}
}

Expand Down
Loading