diff --git a/.changeset/sixty-chefs-hug.md b/.changeset/sixty-chefs-hug.md new file mode 100644 index 000000000..38376ac4b --- /dev/null +++ b/.changeset/sixty-chefs-hug.md @@ -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 preserve transparency with ARGB8888. Reuse capture bitmap capacity across windows. Cache successful window images independently so dialog updates and capture failures do not repeat unchanged Activity readbacks. 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. 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 c0d545ca0..27cc69655 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 @@ -1,7 +1,9 @@ package com.posthog.android.replay import android.annotation.SuppressLint +import android.app.Activity import android.content.Context +import android.content.ContextWrapper import android.content.res.Resources import android.graphics.Bitmap import android.graphics.Canvas @@ -23,6 +25,7 @@ import android.graphics.drawable.VectorDrawable import android.os.Build import android.os.Handler import android.os.HandlerThread +import android.os.IBinder import android.os.Looper import android.text.InputType import android.util.TypedValue @@ -103,6 +106,7 @@ import curtains.touchEventInterceptors import curtains.windowAttachCount import java.lang.ref.WeakReference import java.util.Collections +import java.util.IdentityHashMap import java.util.WeakHashMap import java.util.concurrent.CountDownLatch import java.util.concurrent.ExecutorService @@ -111,6 +115,7 @@ import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean import kotlin.math.ceil import kotlin.math.floor +import kotlin.math.roundToInt public class PostHogReplayIntegration( private val context: Context, @@ -128,6 +133,34 @@ public class PostHogReplayIntegration( // session must invalidate work too, so session identity alone is not a sufficient lease. private var snapshotGeneration: Long = 0 + // Scene state is committed under decorViews, like the existing per-root snapshot history. + private val sceneImages = WeakHashMap() + private val sceneDirtyViews = Collections.newSetFromMap(IdentityHashMap()) + private val sceneIds = WeakHashMap() + private var nextSceneId = 10_000_001 + private var lastScene: List? = null + private var sceneViewport: Pair? = null + + private data class SceneLayer( + val view: View, + val window: Window, + val status: ViewTreeSnapshotStatus, + val x: Int, + val y: Int, + val width: Int, + val height: Int, + val dimAmount: Float, + val windowAlpha: Float, + ) + + private data class SceneImage( + val wireframe: RRWireframe, + val windowWidth: Int, + val windowHeight: Int, + val windowAlpha: Float, + val bitmapConfig: Bitmap.Config, + ) + private val passwordInputTypes = setOf( InputType.TYPE_TEXT_VARIATION_PASSWORD, @@ -348,24 +381,35 @@ public class PostHogReplayIntegration( return@onNextDraw } - submitCapture(drawState) { - try { - if (decorViews[decorView]?.drawState !== drawState) { - return@submitCapture + if (config.sessionReplayConfig.screenshot) { + requestSceneCapture(decorView, drawState) + } else { + submitCapture(drawState) { + try { + if (decorViews[decorView]?.drawState !== drawState) { + return@submitCapture + } + generateSnapshot(WeakReference(decorView), WeakReference(window)) + } catch (e: Throwable) { + config.logger.log("Session Replay generateSnapshot failed: $e.") } - generateSnapshot(WeakReference(decorView), WeakReference(window)) - } catch (e: Throwable) { - config.logger.log("Session Replay generateSnapshot failed: $e.") } } } val layoutListener = - ViewTreeObserver.OnGlobalLayoutListener { drawState.recordLayout() } + ViewTreeObserver.OnGlobalLayoutListener { + // Invalidate immediately; the following draw requests the capture + // through the same throttle as other ordinary view updates. + drawState.recordLayout() + } decorView.viewTreeObserver?.addOnGlobalLayoutListener(layoutListener) val status = ViewTreeSnapshotStatus(listener, layoutListener, drawState = drawState) decorViews[decorView] = status + if (isActive() && isNativeSdk && config.sessionReplayConfig.screenshot) { + mainHandler.handler.post { requestSceneCapture(decorView, drawState) } + } } catch (e: Throwable) { config.logger.log("Session Replay onDecorViewReady failed: $e.") } @@ -394,9 +438,10 @@ public class PostHogReplayIntegration( // redundant captures. Draw-time mask verification still runs for every draw. internal fun submitCapture( drawState: WindowDrawState, + onAvailable: (() -> Unit)? = null, capture: () -> Unit, ): Boolean { - if (!drawState.tryScheduleCapture()) return false + if (!drawState.tryScheduleCapture(onAvailable)) return false try { executor.submit { try { @@ -413,9 +458,259 @@ public class PostHogReplayIntegration( return true } + private fun requestSceneCapture( + changedView: View? = null, + drawState: WindowDrawState? = null, + ) { + if (!isNativeSdk || !config.sessionReplayConfig.screenshot) return + val generation = + synchronized(decorViews) { + if (!isActive()) return + if (changedView != null) { + val status = decorViews[changedView] ?: return + if (drawState != null && status.drawState !== drawState) return + sceneDirtyViews.add(changedView) + } + snapshotGeneration + } + // Pin the lease when selecting the topology, before any work can queue behind it. + val layers = selectSceneLayers() ?: return + val base = layers.first() + submitCapture( + base.status.drawState, + onAvailable = { mainHandler.handler.post { requestSceneCapture() } }, + ) { + try { + generateSceneSnapshot(layers, generation) + } catch (e: Throwable) { + config.logger.log("Session Replay scene capture failed: $e.") + } + } + } + + private fun Context.activityOwner(): Activity? { + var current: Context = this + repeat(10) { + if (current is Activity) return current + current = (current as? ContextWrapper)?.baseContext ?: return null + } + return null + } + + internal fun isSceneWindow( + baseToken: IBinder, + baseOwner: Activity?, + params: WindowManager.LayoutParams, + window: Window, + ): Boolean = params.token == baseToken || (baseOwner != null && window.context.activityOwner() === baseOwner) + + // Curtains' root list is read on main, after its attachment callback completes. + // A dialog can attach before its activity decor. On older Android versions its + // public window token can differ from the activity's, so use the owning Activity + // as well as the window token to associate ordinary PhoneWindow dialogs. + private fun selectSceneLayers(): List? { + val roots = Curtains.rootViews + val base = + roots.lastOrNull { + it.isAliveAndAttachedToWindow() && it.isShown && + (it.layoutParams as? WindowManager.LayoutParams)?.type == WindowManager.LayoutParams.TYPE_BASE_APPLICATION + } ?: return null + val token = (base.layoutParams as? WindowManager.LayoutParams)?.token ?: return null + val displayId = base.display?.displayId + val owner = base.phoneWindow?.context?.activityOwner() + val layers = mutableListOf() + for (view in listOf(base) + roots.filter { it !== base }) { + if (!view.isAliveAndAttachedToWindow() || !view.isShown || view.display?.displayId != displayId) continue + val params = view.layoutParams as? WindowManager.LayoutParams ?: continue + if (view !== base && params.type != WindowManager.LayoutParams.TYPE_APPLICATION) continue + val window = view.phoneWindow ?: continue + if (view !== base && !isSceneWindow(token, owner, params, window)) { + continue + } + val status = decorViews[view] ?: return null + if (view.width <= 0 || view.height <= 0) return null + val position = IntArray(2) + view.getLocationOnScreen(position) + val attributes = window.attributes + layers.add( + SceneLayer( + view, + window, + status, + position[0], + position[1], + view.width, + view.height, + if (attributes.flags and WindowManager.LayoutParams.FLAG_DIM_BEHIND != 0) attributes.dimAmount else 0f, + if (view !== base) attributes.alpha else 1f, + ), + ) + } + return layers.takeIf { it.firstOrNull()?.view === base } + } + + private fun generateSceneSnapshot( + layers: List, + generation: Long, + ): Boolean { + val postHog = postHog ?: return false + val sessionId = PostHogSessionManager.getActiveSessionId()?.toString() ?: return false + synchronized(decorViews) { + if (!isActive() || replaySessionId != sessionId || snapshotGeneration != generation) return false + } + val timestamp = config.dateProvider.currentTimeMillis() + val colorMode = config.sessionReplayConfig.screenshotColorMode + val images = mutableListOf() + + fun restoreDirty(layer: SceneLayer) { + synchronized(decorViews) { + if (isActive() && snapshotGeneration == generation && decorViews[layer.view] === layer.status) { + sceneDirtyViews.add(layer.view) + } + } + } + for (layer in layers) { + val (id, cached, dirty) = + synchronized(decorViews) { + val id = sceneIds.getOrPut(layer.view) { nextSceneId++ } + Triple(id, sceneImages[layer.view], sceneDirtyViews.remove(layer.view)) + } + val isDialog = layer !== layers.first() + val bitmapConfig = + if (colorMode == PostHogScreenshotColorMode.RGB_565 && !isDialog) { + Bitmap.Config.RGB_565 + } else { + Bitmap.Config.ARGB_8888 + } + // Older dialog surfaces cannot be aligned with decor-coordinate masks. Recheck + // privacy on every scene capture rather than reusing a previously unmasked image. + val recheckDialogMasks = isDialog && Build.VERSION.SDK_INT < Build.VERSION_CODES.P + val image = + if (!recheckDialogMasks && !dirty && cached != null && + cached.windowWidth == layer.width && cached.windowHeight == layer.height && + cached.windowAlpha == layer.windowAlpha && cached.bitmapConfig == bitmapConfig + ) { + cached.wireframe + } else { + val drawState = layer.status.drawState + // The Activity owns the scene worker; dialogs also need their own copy gate. + if (isDialog && + !drawState.tryScheduleCapture { + mainHandler.handler.post { requestSceneCapture() } + } + ) { + restoreDirty(layer) + return false + } + try { + layer.view.toScreenshotWireframe( + layer.window, + drawState, + isDialog = isDialog, + bitmapConfig = bitmapConfig, + windowAlpha = layer.windowAlpha, + ) + ?: run { + restoreDirty(layer) + return false + } + } finally { + if (isDialog) drawState.finishScheduledCapture() + } + } + val positioned = + image.copy( + id = id, + x = layer.x.densityValue(screenDensity), + y = layer.y.densityValue(screenDensity), + width = layer.width.densityValue(screenDensity), + height = layer.height.densityValue(screenDensity), + ) + synchronized(decorViews) { + if (!isActive() || snapshotGeneration != generation || + PostHogSessionManager.peekSessionId()?.toString() != sessionId || + decorViews[layer.view] !== layer.status || !layer.view.isShown || + layer.view.width != layer.width || layer.view.height != layer.height + ) { + restoreDirty(layer) + return false + } + // Keep each validated image even if a later layer fails. Draws arriving after + // this layer's dirty flag was consumed remain pending for the next capture. + sceneImages[layer.view] = SceneImage(positioned, layer.width, layer.height, layer.windowAlpha, bitmapConfig) + } + images.add(positioned) + } + + // Android applies the topmost requesting window's dim to the scene beneath it. + val dimIndex = layers.indexOfLast { it.dimAmount > 0f } + val scene = mutableListOf() + images.forEachIndexed { index, image -> + if (index == dimIndex) { + val base = images.first() + scene.add( + RRWireframe( + id = 10_000_000, + x = base.x, + y = base.y, + width = base.width, + height = base.height, + type = "rectangle", + style = RRStyle(backgroundColor = "rgba(0,0,0,${layers[index].dimAmount})"), + ), + ) + } + scene.add(image) + } + val screenSize = layers.first().view.context.screenSize() ?: return false + val viewport = screenSize.width to screenSize.height + if (layers.size > 1 && runOnMainThreadBlocking { + layers.drop(1).all { + it.window.attributes.alpha == it.windowAlpha + } + } != true + ) { + return false + } + val events = + synchronized(decorViews) { + if (!isActive() || snapshotGeneration != generation || + PostHogSessionManager.peekSessionId()?.toString() != sessionId || + layers.any { layer -> + decorViews[layer.view] !== layer.status || !layer.view.isShown || + layer.view.width != layer.width || layer.view.height != layer.height + } + ) { + return false + } + val result = mutableListOf() + val sceneChanged = lastScene != scene + if (sceneViewport != viewport || sceneChanged) { + val title = layers.first().window.attributes.title?.toString()?.substringAfter("/") ?: "" + result.add(RRMetaEvent(width = viewport.first, height = viewport.second, timestamp = timestamp, href = title)) + sceneViewport = viewport + } + if (sceneChanged) { + result.add(RRFullSnapshotEvent(scene, 0, 0, timestamp)) + lastScene = scene + } + val base = layers.first() + val (visible, keyboardEvent) = detectKeyboardVisibility(base.view, base.status.keyboardVisible) + base.status.keyboardVisible = visible + keyboardEvent?.let { result.add(it) } + result + } + captureSnapshotEvents(postHog, events, sessionId) + return true + } + private val onRootViewsChangedListener = OnRootViewsChangedListener { view, added -> addView(view, added) + if (isActive() && isNativeSdk && config.sessionReplayConfig.screenshot) { + // Curtains notifies before it updates rootViews. Read the completed topology later. + mainHandler.handler.post { requestSceneCapture() } + } } private fun detectKeyboardVisibility( @@ -559,6 +854,10 @@ public class PostHogReplayIntegration( } decorViews.remove(view) + synchronized(decorViews) { + sceneImages.remove(view) + sceneDirtyViews.remove(view) + } } @Synchronized @@ -817,6 +1116,15 @@ public class PostHogReplayIntegration( // Commit above is the producer boundary. Do not invoke SDK/user callbacks under its lock. // A rotation after commit must not relabel this frame during core enrichment. + captureSnapshotEvents(postHog, events, sessionId) + return true + } + + private fun captureSnapshotEvents( + postHog: PostHogInterface, + events: List, + sessionId: String, + ) { if (events.isNotEmpty()) { postHog.capture( PostHogEventName.SNAPSHOT.event, @@ -829,7 +1137,6 @@ public class PostHogReplayIntegration( ), ) } - return true } // Called under decorViews so a reset cannot be overwritten by an in-flight frame. @@ -1740,6 +2047,13 @@ public class PostHogReplayIntegration( private fun View.toScreenshotWireframe( window: Window, drawState: WindowDrawState, + isDialog: Boolean = false, + bitmapConfig: Bitmap.Config = + when (config.sessionReplayConfig.screenshotColorMode) { + PostHogScreenshotColorMode.ARGB_8888 -> Bitmap.Config.ARGB_8888 + PostHogScreenshotColorMode.RGB_565 -> Bitmap.Config.RGB_565 + }, + windowAlpha: Float = 1f, ): RRWireframe? { val view = this if (!view.isVisible()) { @@ -1762,16 +2076,26 @@ public class PostHogReplayIntegration( val sourceHeight = view.height val width = sourceWidth.densityValue(screenDensity) val height = sourceHeight.densityValue(screenDensity) + val maskWholeDialog = isDialog && Build.VERSION.SDK_INT < Build.VERSION_CODES.P + + fun maskedDialog(): RRWireframe = + RRWireframe( + id = viewId, + x = x, + y = y, + width = width, + height = height, + type = "rectangle", + style = RRStyle(backgroundColor = "#000000"), + ) + val screenshotScale = config.sessionReplayConfig.screenshotScale val compressionQuality = config.sessionReplayConfig.screenshotCompressionQuality - val bitmapConfig = - when (config.sessionReplayConfig.screenshotColorMode) { - PostHogScreenshotColorMode.ARGB_8888 -> Bitmap.Config.ARGB_8888 - PostHogScreenshotColorMode.RGB_565 -> Bitmap.Config.RGB_565 - } var base64: String? = null - val verifyMaskAlignment = shouldVerifyMaskAlignment(view, drawState) + // API 26–27 copy the entire dialog surface, including its insets, into the + // decor-sized bitmap. Only a verified empty mask walk permits copying pixels. + val verifyMaskAlignment = maskWholeDialog || shouldVerifyMaskAlignment(view, drawState) val armedCapture = if (verifyMaskAlignment) { drawState.reset() @@ -1785,7 +2109,11 @@ public class PostHogReplayIntegration( if (verifyMaskAlignment && armedCapture == null) { config.logger.log("Session Replay screenshot discarded due to screen changes.") recordScreenshotDiscarded(drawState) - return null + return if (maskWholeDialog) maskedDialog() else null + } + if (maskWholeDialog && armedCapture!!.preWalk.rects.isNotEmpty()) { + finishScreenshotCapture(drawState, armedCapture, verifyMaskAlignment) + return maskedDialog() } if (sourceWidth <= 0 || sourceHeight <= 0 || view.width != sourceWidth || view.height != sourceHeight) { @@ -1894,6 +2222,14 @@ public class PostHogReplayIntegration( releaseFromWaiter = true if (requestState.succeeded()) { try { + // PixelCopy captures surface pixels before the compositor applies window opacity. + // Multiply alpha only after redaction, preserving partial pixel transparency. + if (windowAlpha != 1f) { + Canvas(bitmap).drawColor( + Color.argb((windowAlpha.coerceIn(0f, 1f) * 255).roundToInt(), 0, 0, 0), + PorterDuff.Mode.DST_IN, + ) + } base64 = bitmap.webpBase64(compressionQuality) } catch (e: Throwable) { config.logger.log("Session Replay screenshot encoding failed: $e.") @@ -1920,7 +2256,7 @@ public class PostHogReplayIntegration( // instead; the caller retries on the next capture. if (base64 == null) { recordScreenshotDiscarded(drawState) - return null + return if (maskWholeDialog) maskedDialog() else null } drawState.resetScreenshotDiscards() @@ -2501,6 +2837,10 @@ public class PostHogReplayIntegration( } } + if (isNativeSdk && config.sessionReplayConfig.screenshot) { + mainHandler.handler.post { requestSceneCapture() } + } + if (!resumeCurrent) { // Without this, on a static UI the first user-driven onDraw can be tens of seconds // away — and incremental events (type:3) would ship under the new session before @@ -2517,6 +2857,12 @@ public class PostHogReplayIntegration( // clear state so it starts with a full snapshot again synchronized(decorViews) { snapshotGeneration++ + sceneImages.clear() + sceneDirtyViews.clear() + sceneIds.clear() + nextSceneId = 10_000_001 + lastScene = null + sceneViewport = null decorViews.entries.forEach { resetViewSnapshotStates(it.value) } @@ -2536,6 +2882,10 @@ public class PostHogReplayIntegration( isSessionReplayActive = false snapshotGeneration++ pixelCopyBitmapBuffer.close() + sceneImages.clear() + sceneDirtyViews.clear() + lastScene = null + sceneViewport = null decorViews.values.forEach { it.drawState.invalidateMaskCapture() } } } 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 bc3c492a8..7c3c05165 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 @@ -112,8 +112,9 @@ 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 ARGB_8888 to preserve transparency. Devices that reject RGB_565 fall + * back to ARGB_8888. Does not enable screenshot capture. */ @PostHogExperimental @Volatile diff --git a/posthog-android/src/main/java/com/posthog/android/replay/internal/PixelCopyBitmapBuffer.kt b/posthog-android/src/main/java/com/posthog/android/replay/internal/PixelCopyBitmapBuffer.kt index e468db780..01eacb78e 100644 --- a/posthog-android/src/main/java/com/posthog/android/replay/internal/PixelCopyBitmapBuffer.kt +++ b/posthog-android/src/main/java/com/posthog/android/replay/internal/PixelCopyBitmapBuffer.kt @@ -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. @@ -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() diff --git a/posthog-android/src/main/java/com/posthog/android/replay/internal/ViewTreeSnapshotStatus.kt b/posthog-android/src/main/java/com/posthog/android/replay/internal/ViewTreeSnapshotStatus.kt index b732d2df3..1c2c29cdd 100644 --- a/posthog-android/src/main/java/com/posthog/android/replay/internal/ViewTreeSnapshotStatus.kt +++ b/posthog-android/src/main/java/com/posthog/android/replay/internal/ViewTreeSnapshotStatus.kt @@ -131,10 +131,14 @@ 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 @@ -142,8 +146,19 @@ internal class WindowDrawState { } } + // 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() { @@ -151,7 +166,12 @@ internal class WindowDrawState { } fun finishPixelCopy() { - synchronized(captureLock) { pixelCopyInFlight = false } + val onAvailable = + synchronized(captureLock) { + pixelCopyInFlight = false + takeCaptureAvailableCallback() + } + onAvailable?.invoke() } fun reset() { 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 11918b245..c3eb95b7e 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 @@ -1,6 +1,7 @@ package com.posthog.android.replay import android.app.Activity +import android.app.Dialog import android.content.Context import android.graphics.Bitmap import android.graphics.BitmapFactory @@ -9,6 +10,7 @@ import android.graphics.Point import android.graphics.Rect import android.graphics.RectF import android.graphics.drawable.BitmapDrawable +import android.os.Binder import android.os.Handler import android.os.Looper import android.util.Base64 @@ -31,6 +33,7 @@ import com.posthog.android.PostHogAndroidConfig import com.posthog.android.createPostHogFake import com.posthog.android.internal.MainHandler import com.posthog.android.internal.webpBase64 +import com.posthog.android.replay.internal.DialogSceneFixture import com.posthog.android.replay.internal.NextDrawListener import com.posthog.android.replay.internal.PixelCopyBitmapBuffer import com.posthog.android.replay.internal.ViewTreeSnapshotStatus @@ -71,6 +74,7 @@ import org.robolectric.ParameterizedRobolectricTestRunner import org.robolectric.ParameterizedRobolectricTestRunner.Parameters import org.robolectric.Robolectric import org.robolectric.Shadows.shadowOf +import org.robolectric.android.controller.ActivityController import org.robolectric.annotation.Config import org.robolectric.annotation.GraphicsMode import org.robolectric.annotation.Implementation @@ -114,6 +118,7 @@ internal class PostHogReplayIntegrationTest { private val context = mock() private val replayExecutors = mutableListOf() + private val activityControllers = mutableListOf>() private class FakeQueue : PostHogQueueInterface { override val size: Int = 0 @@ -170,13 +175,23 @@ internal class PostHogReplayIntegrationTest { @AfterTest fun `tear down`() { + activityControllers.asReversed().forEach { it.pause().stop().destroy() } + activityControllers.clear() // Flush listener removals posted by uninstall before Robolectric resets the main looper. shadowOf(Looper.getMainLooper()).idle() PostHogSessionManager.isReactNative = false PostHogSessionManager.endSession() PostHogSessionManager.setAppInBackground(true) replayExecutors.forEach { it.shutdownNow() } + replayExecutors.forEach { assertTrue(it.awaitTermination(2, TimeUnit.SECONDS)) } replayExecutors.clear() + shadowOf(Looper.getMainLooper()).idle() + } + + private fun createActivity(): Activity { + val controller = Robolectric.buildActivity(Activity::class.java).setup() + activityControllers.add(controller) + return controller.get() } private fun createReplayExecutor(): ExecutorService { @@ -315,6 +330,25 @@ internal class PostHogReplayIntegrationTest { } } + @Test + fun `dialog belongs to its activity scene even when window tokens differ`() { + val first = Robolectric.buildActivity(Activity::class.java).setup() + val second = Robolectric.buildActivity(Activity::class.java).setup() + try { + val sut = getSut() + val baseToken = Binder() + val dialogParams = WindowManager.LayoutParams().apply { token = Binder() } + assertTrue(sut.isSceneWindow(baseToken, first.get(), dialogParams, Dialog(first.get()).window!!)) + assertFalse(sut.isSceneWindow(baseToken, first.get(), dialogParams, Dialog(second.get()).window!!)) + assertFalse(sut.isSceneWindow(baseToken, null, dialogParams, Dialog(first.get()).window!!)) + dialogParams.token = baseToken + assertTrue(sut.isSceneWindow(baseToken, null, dialogParams, Dialog(second.get()).window!!)) + } finally { + second.pause().stop().destroy() + first.pause().stop().destroy() + } + } + @Test fun `running capture blocks duplicates but not other windows`() { val executor = createReplayExecutor() @@ -1171,6 +1205,7 @@ internal class PostHogReplayIntegrationTest { samplingPasses: Boolean = true, preloadFeatureFlags: Boolean = true, integrationContext: Context = mock(), + captureExecutor: ExecutorService? = null, ): RealQueueFixture { val remoteConfig = mock { @@ -1196,7 +1231,7 @@ internal class PostHogReplayIntegrationTest { ) val replayQueue = PostHogReplayQueue(config, innerQueue, storagePrefix, executor) config.replayQueueHolder = replayQueue - val sut = PostHogReplayIntegration(integrationContext, config, MainHandler()) + val sut = PostHogReplayIntegration(integrationContext, config, MainHandler(), captureExecutor ?: createReplayExecutor()) return RealQueueFixture(sut, replayQueue, innerQueue, config, remoteConfig) } @@ -2106,12 +2141,16 @@ internal class PostHogReplayIntegrationTest { private fun screenshotFixture( enableMaskAlignmentVerification: Boolean = true, captureTouches: Boolean = true, + captureExecutor: ExecutorService? = null, ): Pair { val fx = createIntegrationWithRealQueue( flagActive = true, hasFetched = true, integrationContext = ApplicationProvider.getApplicationContext(), + // These fixtures invoke capture explicitly. Automatic scheduling is covered + // separately using real root/layout/draw notifications and a queued executor. + captureExecutor = captureExecutor ?: mock(), ) fx.config.sessionReplayConfig.captureTouches = captureTouches fx.config.sessionReplayConfig.screenshot = true @@ -2299,7 +2338,7 @@ internal class PostHogReplayIntegrationTest { val (fx, fake) = screenshotFixture() try { assertTrue(fx.sut.isActive()) - val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val activity = createActivity() shadowOf(Looper.getMainLooper()).idle() val decorView = activity.window.decorView makeWindowVisible(decorView) @@ -2376,7 +2415,7 @@ internal class PostHogReplayIntegrationTest { val (fx, fake) = screenshotFixture() try { assertTrue(fx.sut.isActive()) - val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val activity = createActivity() shadowOf(Looper.getMainLooper()).idle() val decorView = activity.window.decorView makeWindowVisible(decorView) @@ -2925,7 +2964,7 @@ internal class PostHogReplayIntegrationTest { @Test fun `disabled verification does not run a draw-time mask walk during capture`() { val (fx, _) = screenshotFixture(enableMaskAlignmentVerification = false) - val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val activity = createActivity() shadowOf(Looper.getMainLooper()).idle() var visibleRectCalls = 0 val child = VisibleRectHookView(activity) { _, _ -> visibleRectCalls++ } @@ -2965,7 +3004,7 @@ internal class PostHogReplayIntegrationTest { // so the legacy animation-redraw classifier never runs for them. val (fx, _) = screenshotFixture(enableMaskAlignmentVerification = false) try { - val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val activity = createActivity() shadowOf(Looper.getMainLooper()).idle() val root = FrameLayout(activity).apply { addView(FakeAndroidComposeView(activity)) } root.setHasTransientState(true) @@ -2984,7 +3023,7 @@ internal class PostHogReplayIntegrationTest { fun `compose rooted cache invalidates when layout changes`() { val (fx, _) = screenshotFixture(enableMaskAlignmentVerification = false) try { - val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val activity = createActivity() shadowOf(Looper.getMainLooper()).idle() val root = FrameLayout(activity).apply { @@ -3021,7 +3060,7 @@ internal class PostHogReplayIntegrationTest { override fun isEnabled(): Boolean = true } try { - val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val activity = createActivity() shadowOf(Looper.getMainLooper()).idle() val root = ThrowingChildFrameLayout(activity).apply { @@ -3056,7 +3095,7 @@ internal class PostHogReplayIntegrationTest { fun `compose detection failure is not cached and re-runs on the next draw`() { val (fx, _) = screenshotFixture(enableMaskAlignmentVerification = false) try { - val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val activity = createActivity() shadowOf(Looper.getMainLooper()).idle() val root = OnceThrowingChildFrameLayout(activity).apply { @@ -3091,7 +3130,7 @@ internal class PostHogReplayIntegrationTest { fun `positive compose verdict survives a layout without re-walking the tree`() { val (fx, _) = screenshotFixture(enableMaskAlignmentVerification = false) try { - val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val activity = createActivity() shadowOf(Looper.getMainLooper()).idle() val root = CountingChildFrameLayout(activity).apply { @@ -3121,7 +3160,7 @@ internal class PostHogReplayIntegrationTest { fun `negative compose verdict is not re-walked on the first draw after every layout`() { val (fx, _) = screenshotFixture(enableMaskAlignmentVerification = false) try { - val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val activity = createActivity() shadowOf(Looper.getMainLooper()).idle() val root = CountingChildFrameLayout(activity).apply { @@ -3153,7 +3192,7 @@ internal class PostHogReplayIntegrationTest { // animation-redraw classifier. val (fx, _) = screenshotFixture(enableMaskAlignmentVerification = false) try { - val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val activity = createActivity() shadowOf(Looper.getMainLooper()).idle() val root = FrameLayout(activity).apply { addView(View(activity)) } root.setHasTransientState(true) @@ -3183,7 +3222,7 @@ internal class PostHogReplayIntegrationTest { override fun isEnabled(): Boolean = true } try { - val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val activity = createActivity() shadowOf(Looper.getMainLooper()).idle() val decorView = activity.window.decorView makeWindowVisible(decorView) @@ -3303,7 +3342,7 @@ internal class PostHogReplayIntegrationTest { }, ) fx.sut.start(resumeCurrent = true) - val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val activity = createActivity() shadowOf(Looper.getMainLooper()).idle() val guardedEntered = CountDownLatch(1) @@ -3559,6 +3598,238 @@ internal class PostHogReplayIntegrationTest { } } + private fun sceneLayer( + view: View, + window: Window, + status: ViewTreeSnapshotStatus, + x: Int = 0, + y: Int = 0, + dimAmount: Float = 0f, + windowAlpha: Float = 1f, + ): Any { + val constructor = + PostHogReplayIntegration::class.java.declaredClasses.single { it.simpleName == "SceneLayer" } + .declaredConstructors.single().apply { isAccessible = true } + return constructor.newInstance(view, window, status, x, y, view.width, view.height, dimAmount, windowAlpha) + } + + private fun captureScene( + sut: PostHogReplayIntegration, + layers: List, + ): Boolean = + ReflectionHelpers.callInstanceMethod( + sut, + "generateSceneSnapshot", + ReflectionHelpers.ClassParameter.from(List::class.java, layers), + ReflectionHelpers.ClassParameter.from( + Long::class.javaPrimitiveType, + ReflectionHelpers.getField(sut, "snapshotGeneration"), + ), + ) + + @RunWith(AndroidJUnit4::class) + @Config(sdk = [28, 35], qualifiers = "w600dp-h1000dp-mdpi", shadows = [RecordingShadowPixelCopy::class]) + @GraphicsMode(GraphicsMode.Mode.NATIVE) + class DialogSceneTest { + private val fixture = PostHogReplayIntegrationTest() + + @get:Rule + val tmpDir = fixture.tmpDir + + @BeforeTest + fun setUp() = fixture.`set up`() + + @AfterTest + fun tearDown() = fixture.`tear down`() + + private fun withDialog(test: (DialogSceneFixture, RealQueueFixture, PostHogFake, ViewTreeSnapshotStatus) -> Unit) { + DialogSceneFixture().use { h -> + fixture.mockCurtainsRoot(h.activity.window.decorView).use { + val (fx, fake) = fixture.screenshotFixture(captureExecutor = mock()) + shadowOf(Looper.getMainLooper()).idle() + h.layoutContent() + val status = ViewTreeSnapshotStatus(mock()) + fx.sut.decorViews[h.root] = status + fx.config.sessionReplayConfig.screenshotScale = 0.5f + fx.config.sessionReplayConfig.screenshotColorMode = PostHogScreenshotColorMode.RGB_565 + RecordingShadowPixelCopy.reset() + try { + test(h, fx, fake, status) + } finally { + fx.sut.uninstall() + RecordingShadowPixelCopy.reset() + } + } + } + } + + private fun capture( + h: DialogSceneFixture, + fx: RealQueueFixture, + status: ViewTreeSnapshotStatus, + ): Boolean { + val base = h.activity.window.decorView + val baseStatus = fx.sut.decorViews.getOrPut(base) { ViewTreeSnapshotStatus(mock()) } + val position = IntArray(2).also { h.root.getLocationOnScreen(it) } + val layers = + listOf( + fixture.sceneLayer(base, h.activity.window, baseStatus), + fixture.sceneLayer( + h.root, + h.dialog.window!!, + status, + position[0], + position[1], + 0.48f, + h.dialog.window!!.attributes.alpha, + ), + ) + return fixture.captureScene(fx.sut, layers) + } + + private fun images(fake: PostHogFake): List { + @Suppress("UNCHECKED_CAST") + val events = fake.properties!!["\$snapshot_data"] as List + @Suppress("UNCHECKED_CAST") + return (events.first { it.type == RREventType.FullSnapshot }.data as Map<*, *>)["wireframes"] as List + } + + private fun bitmap(image: RRWireframe): Bitmap { + val bytes = Base64.decode(assertNotNull(image.base64).substringAfter(','), Base64.DEFAULT) + return assertNotNull(BitmapFactory.decodeByteArray(bytes, 0, bytes.size)) + } + + @Test + fun `scene scales dialog masks and preserves window and dim placement`() = + withDialog { h, fx, fake, status -> + RecordingShadowPixelCopy.onRequest = { it.eraseColor(Color.RED) } + assertTrue(capture(h, fx, status)) + val request = RecordingShadowPixelCopy.requests.last() + assertEquals(Bitmap.Config.ARGB_8888, request.config) + assertEquals(300, request.width) + assertEquals(500, request.height) + val scene = images(fake) + val dialogImage = scene.last() + val density = h.root.resources.displayMetrics.density + val position = IntArray(2).also { h.root.getLocationOnScreen(it) } + assertEquals((position[0] / density).toInt(), dialogImage.x) + assertEquals((position[1] / density).toInt(), dialogImage.y) + assertEquals((h.root.width / density).toInt(), dialogImage.width) + assertEquals((h.root.height / density).toInt(), dialogImage.height) + assertEquals(scene.first().width, scene[1].width) + assertEquals(scene.first().height, scene[1].height) + assertEquals("rgba(0,0,0,0.48)", scene[1].style?.backgroundColor) + val mask = Rect().also { assertTrue(h.masked.getGlobalVisibleRect(it)) } + val image = bitmap(dialogImage) + try { + val pixel = image.getPixel((mask.centerX() - position[0]) / 2, (mask.centerY() - position[1]) / 2) + assertTrue(Color.red(pixel) < 10 && Color.green(pixel) < 10 && Color.blue(pixel) < 10) + assertTrue(Color.red(image.getPixel(200, 400)) > 200) + } finally { + image.recycle() + } + } + + @Test + fun `new sensitive content during dialog copy discards instead of using stale masks`() = + withDialog { h, fx, _, status -> + h.masked.visibility = View.GONE + RecordingShadowPixelCopy.onRequest = { + if (RecordingShadowPixelCopy.requests.last().window === h.dialog.window) { + h.masked.visibility = View.VISIBLE + fx.sut.onDrawCallback(h.root, status.drawState) + } + } + assertFalse(capture(h, fx, status)) + } + + @Test + fun `window opacity invalidates cached dialog pixels and scales redaction alpha`() = + withDialog { h, fx, fake, status -> + RecordingShadowPixelCopy.onRequest = { it.eraseColor(Color.RED) } + assertTrue(capture(h, fx, status)) + val copies = RecordingShadowPixelCopy.requests.size + val window = h.dialog.window!! + window.attributes = window.attributes.apply { alpha = 0.5f } + assertTrue(capture(h, fx, status)) + assertEquals(copies + 1, RecordingShadowPixelCopy.requests.size) + val image = bitmap(images(fake).last()) + val mask = Rect().also { assertTrue(h.masked.getGlobalVisibleRect(it)) } + val position = IntArray(2).also { h.root.getLocationOnScreen(it) } + try { + assertEquals(128, Color.alpha(image.getPixel(200, 400))) + val maskedPixel = image.getPixel((mask.centerX() - position[0]) / 2, (mask.centerY() - position[1]) / 2) + assertEquals(128, Color.alpha(maskedPixel)) + assertTrue(Color.red(maskedPixel) < 20) + } finally { + image.recycle() + } + assertTrue(capture(h, fx, status)) + assertEquals(copies + 1, RecordingShadowPixelCopy.requests.size) + window.attributes = window.attributes.apply { alpha = 0.25f } + assertTrue(capture(h, fx, status)) + assertEquals(copies + 2, RecordingShadowPixelCopy.requests.size) + val quarterOpacity = bitmap(images(fake).last()) + try { + assertEquals(64, Color.alpha(quarterOpacity.getPixel(200, 400))) + } finally { + quarterOpacity.recycle() + } + window.attributes = window.attributes.apply { alpha = 1f } + assertTrue(capture(h, fx, status)) + assertEquals(copies + 3, RecordingShadowPixelCopy.requests.size) + assertEquals(Bitmap.Config.ARGB_8888, RecordingShadowPixelCopy.requests.last().config) + } + + @Test + fun `color mode changes invalidate Activity pixels while dialog pixels remain reusable`() = + withDialog { h, fx, _, status -> + RecordingShadowPixelCopy.onRequest = { it.eraseColor(Color.RED) } + assertTrue(capture(h, fx, status)) + val copies = RecordingShadowPixelCopy.requests.size + fx.config.sessionReplayConfig.screenshotColorMode = PostHogScreenshotColorMode.ARGB_8888 + assertTrue(capture(h, fx, status)) + assertEquals(copies + 1, RecordingShadowPixelCopy.requests.size) + assertEquals(Bitmap.Config.ARGB_8888, RecordingShadowPixelCopy.requests.last().config) + fx.config.sessionReplayConfig.screenshotColorMode = PostHogScreenshotColorMode.RGB_565 + assertTrue(capture(h, fx, status)) + assertEquals(copies + 2, RecordingShadowPixelCopy.requests.size) + assertEquals(Bitmap.Config.RGB_565, RecordingShadowPixelCopy.requests.last().config) + } + + @Test + fun `scene reads configured color mode once before capturing its layers`() = + withDialog { h, fx, _, status -> + RecordingShadowPixelCopy.onRequest = { + it.eraseColor(Color.RED) + fx.config.sessionReplayConfig.screenshotColorMode = PostHogScreenshotColorMode.ARGB_8888 + } + assertTrue(capture(h, fx, status)) + assertEquals( + listOf(Bitmap.Config.RGB_565, Bitmap.Config.ARGB_8888), + RecordingShadowPixelCopy.requests.map { it.config }, + ) + val copies = RecordingShadowPixelCopy.requests.size + assertTrue(capture(h, fx, status)) + assertEquals(copies + 1, RecordingShadowPixelCopy.requests.size) + assertEquals(Bitmap.Config.ARGB_8888, RecordingShadowPixelCopy.requests.last().config) + } + + @Test + fun `window opacity changing during capture discards the stale scene`() = + withDialog { h, fx, fake, status -> + val window = h.dialog.window!! + RecordingShadowPixelCopy.onRequest = { + it.eraseColor(Color.RED) + window.attributes = window.attributes.apply { alpha = 0.5f } + } + assertFalse(capture(h, fx, status)) + assertEquals(0, fake.captures) + assertTrue(capture(h, fx, status)) + assertEquals(Bitmap.Config.ARGB_8888, RecordingShadowPixelCopy.requests.last().config) + } + } + @Implements(PixelCopy::class) class CountingShadowPixelCopy { companion object { @@ -3581,6 +3852,7 @@ internal class PostHogReplayIntegrationTest { @Implements(PixelCopy::class) class RecordingShadowPixelCopy { data class Request( + val window: Window, val bitmap: Bitmap, val listener: PixelCopy.OnPixelCopyFinishedListener, ) { @@ -3603,7 +3875,7 @@ internal class PostHogReplayIntegrationTest { listener: PixelCopy.OnPixelCopyFinishedListener, handler: Handler, ) { - requests.add(Request(bitmap, listener)) + requests.add(Request(window, bitmap, listener)) onRequest?.invoke(bitmap) if (!defer) { listener.onPixelCopyFinished(result) @@ -3668,9 +3940,12 @@ internal class PostHogReplayIntegrationTest { ) // A capturable window with a masked child, so the walks produce a rect to compare. - private fun screenshotCaptureHarness(enableMaskAlignmentVerification: Boolean = true): ScreenshotCaptureHarness { - val (fx, fake) = screenshotFixture(enableMaskAlignmentVerification) - val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + private fun screenshotCaptureHarness( + enableMaskAlignmentVerification: Boolean = true, + captureExecutor: ExecutorService? = null, + ): ScreenshotCaptureHarness { + val (fx, fake) = screenshotFixture(enableMaskAlignmentVerification, captureExecutor = captureExecutor) + val activity = createActivity() shadowOf(Looper.getMainLooper()).idle() val child = TextView(activity).apply { tag = "ph-no-capture" } val hookLayout = WalkHookLayout(activity) @@ -3685,6 +3960,225 @@ internal class PostHogReplayIntegrationTest { return ScreenshotCaptureHarness(fx, fake, hookLayout, status, child, activity.window) } + private fun captureDialog(h: ScreenshotCaptureHarness): RRWireframe? = + ReflectionHelpers.callInstanceMethod( + h.fx.sut, + "toScreenshotWireframe", + ReflectionHelpers.ClassParameter.from(View::class.java, h.hookLayout), + ReflectionHelpers.ClassParameter.from(Window::class.java, h.window), + ReflectionHelpers.ClassParameter.from(WindowDrawState::class.java, h.status.drawState), + ReflectionHelpers.ClassParameter.from(Boolean::class.javaPrimitiveType, true), + ReflectionHelpers.ClassParameter.from(Bitmap.Config::class.java, Bitmap.Config.ARGB_8888), + ReflectionHelpers.ClassParameter.from(Float::class.javaPrimitiveType, 1f), + ) + + @Test + @Config(sdk = [26, 27], shadows = [RecordingShadowPixelCopy::class]) + fun `legacy dialog with masked content becomes an opaque rectangle without copying pixels`() { + val h = screenshotCaptureHarness(enableMaskAlignmentVerification = false, captureExecutor = mock()) + RecordingShadowPixelCopy.reset() + try { + val image = assertNotNull(captureDialog(h)) + assertEquals("rectangle", image.type) + assertEquals("#000000", image.style?.backgroundColor) + assertEquals(null, image.base64) + assertTrue(image.width!! > 0) + assertTrue(image.height!! > 0) + assertTrue(RecordingShadowPixelCopy.requests.isEmpty()) + } finally { + h.fx.sut.uninstall() + RecordingShadowPixelCopy.reset() + } + } + + @Test + @Config(sdk = [26, 27], shadows = [RecordingShadowPixelCopy::class]) + fun `legacy dialog resumes pixel capture after its masked content is removed`() { + val h = screenshotCaptureHarness(captureExecutor = mock()) + RecordingShadowPixelCopy.reset() + try { + assertEquals("rectangle", captureDialog(h)?.type) + h.child.tag = "ph-no-mask" + assertEquals("screenshot", captureDialog(h)?.type) + assertEquals(1, RecordingShadowPixelCopy.requests.size) + h.child.tag = "ph-no-capture" + assertEquals("rectangle", captureDialog(h)?.type) + assertEquals(1, RecordingShadowPixelCopy.requests.size) + } finally { + h.fx.sut.uninstall() + RecordingShadowPixelCopy.reset() + } + } + + @Test + @Config(sdk = [26, 27], shadows = [RecordingShadowPixelCopy::class]) + fun `legacy dialog with uncertain mask geometry becomes an opaque rectangle`() { + val h = screenshotCaptureHarness(captureExecutor = mock()) + RecordingShadowPixelCopy.reset() + try { + val animation = mock() + whenever(animation.hasStarted()).thenReturn(true) + whenever(animation.hasEnded()).thenReturn(false) + h.child.animation = animation + assertEquals("rectangle", captureDialog(h)?.type) + assertTrue(RecordingShadowPixelCopy.requests.isEmpty()) + } finally { + h.fx.sut.uninstall() + RecordingShadowPixelCopy.reset() + } + } + + @Test + @Config(sdk = [26, 27], shadows = [RecordingShadowPixelCopy::class]) + fun `legacy dialog masks the whole window if sensitive content appears during pixel copy`() { + val h = screenshotCaptureHarness(enableMaskAlignmentVerification = false, captureExecutor = mock()) + RecordingShadowPixelCopy.reset() + try { + h.child.tag = "ph-no-mask" + RecordingShadowPixelCopy.onRequest = { h.child.tag = "ph-no-capture" } + val image = assertNotNull(captureDialog(h)) + assertEquals("rectangle", image.type) + assertEquals(null, image.base64) + assertEquals(1, RecordingShadowPixelCopy.requests.size) + } finally { + h.fx.sut.uninstall() + RecordingShadowPixelCopy.reset() + } + } + + @Test + @Config(sdk = [26, 27], shadows = [RecordingShadowPixelCopy::class]) + fun `legacy dialog scene replaces cached pixels with a mask and preserves the activity and dim layer`() { + val h = screenshotCaptureHarness(captureExecutor = mock()) + val dialog = Dialog(h.hookLayout.context) + val content = + TextView(h.hookLayout.context).apply { + text = "Synthetic private dialog text" + tag = "ph-no-mask" + } + dialog.setContentView(content) + dialog.show() + val window = dialog.window!! + val decor = window.decorView + shadowOf(Looper.getMainLooper()).idle() + makeWindowVisible(decor) + decor.measure( + View.MeasureSpec.makeMeasureSpec(400, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(400, View.MeasureSpec.EXACTLY), + ) + decor.layout(0, 0, 400, 400) + val status = ViewTreeSnapshotStatus(mock()) + h.fx.sut.decorViews[decor] = status + val layers = + listOf( + sceneLayer(h.hookLayout, h.window, h.status), + sceneLayer(decor, window, status, x = 10, y = 20, dimAmount = 0.35f), + ) + + fun captureScene(): List { + assertTrue(captureScene(h.fx.sut, layers)) + val events = h.fake.properties!!["\$snapshot_data"] as List<*> + val snapshot = events.filterIsInstance().single() + @Suppress("UNCHECKED_CAST") + return (snapshot.data as Map<*, *>)["wireframes"] as List + } + RecordingShadowPixelCopy.reset() + try { + val before = captureScene() + assertEquals(listOf("screenshot", "rectangle", "screenshot"), before.map { it.type }) + val copies = RecordingShadowPixelCopy.requests.size + content.tag = "ph-no-capture" + // No dirty notification: privacy must be checked before a cached image can be reused. + ReflectionHelpers.getField>(h.fx.sut, "sceneDirtyViews").clear() + val masked = captureScene() + assertEquals(before.take(2), masked.take(2)) + assertEquals("rgba(0,0,0,0.35)", masked[1].style?.backgroundColor) + assertEquals("rectangle", masked.last().type) + assertEquals("#000000", masked.last().style?.backgroundColor) + assertEquals(null, masked.last().base64) + assertEquals(before.last().id, masked.last().id) + assertEquals(before.last().x, masked.last().x) + assertEquals(before.last().y, masked.last().y) + assertEquals(before.last().width, masked.last().width) + assertEquals(before.last().height, masked.last().height) + assertEquals(copies, RecordingShadowPixelCopy.requests.size) + content.tag = "ph-no-mask" + val restored = captureScene() + assertEquals("screenshot", restored.last().type) + assertEquals(copies + 1, RecordingShadowPixelCopy.requests.size) + } finally { + dialog.dismiss() + h.fx.sut.uninstall() + RecordingShadowPixelCopy.reset() + } + } + + @Test + @Config(sdk = [28], shadows = [RecordingShadowPixelCopy::class]) + fun `scene keeps RGB565 Activity captures while dialogs preserve alpha`() { + val h = screenshotCaptureHarness(captureExecutor = mock()) + h.fx.config.sessionReplayConfig.screenshotColorMode = PostHogScreenshotColorMode.RGB_565 + h.fx.config.sessionReplayConfig.screenshotScale = 0.5f + val dialog = Dialog(h.hookLayout.context) + dialog.setContentView(View(h.hookLayout.context).apply { tag = "ph-no-mask" }) + dialog.show() + val window = dialog.window!! + val decor = window.decorView + shadowOf(Looper.getMainLooper()).idle() + makeWindowVisible(decor) + decor.measure( + View.MeasureSpec.makeMeasureSpec(400, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(400, View.MeasureSpec.EXACTLY), + ) + decor.layout(0, 0, 400, 400) + val status = ViewTreeSnapshotStatus(mock()) + h.fx.sut.decorViews[decor] = status + val activityLayer = sceneLayer(h.hookLayout, h.window, h.status) + val dialogLayer = sceneLayer(decor, window, status, x = 10, y = 20, dimAmount = 0.35f) + + fun capture(layers: List) { + assertTrue(captureScene(h.fx.sut, layers)) + } + + RecordingShadowPixelCopy.reset() + try { + capture(listOf(activityLayer)) + assertEquals(Bitmap.Config.RGB_565, RecordingShadowPixelCopy.requests.single().config) + assertEquals(50, RecordingShadowPixelCopy.requests.single().width) + assertEquals(50, RecordingShadowPixelCopy.requests.single().height) + capture(listOf(activityLayer, dialogLayer)) + assertEquals(Bitmap.Config.ARGB_8888, RecordingShadowPixelCopy.requests.last().config) + assertEquals(200, RecordingShadowPixelCopy.requests.last().width) + assertEquals(200, RecordingShadowPixelCopy.requests.last().height) + ReflectionHelpers.getField>(h.fx.sut, "sceneDirtyViews").add(h.hookLayout) + capture(listOf(activityLayer, dialogLayer)) + assertEquals(Bitmap.Config.RGB_565, RecordingShadowPixelCopy.requests.last().config) + dialog.dismiss() + ReflectionHelpers.getField>(h.fx.sut, "sceneDirtyViews").add(h.hookLayout) + capture(listOf(activityLayer)) + assertEquals(Bitmap.Config.RGB_565, RecordingShadowPixelCopy.requests.last().config) + assertEquals(4, RecordingShadowPixelCopy.requests.size) + } finally { + dialog.dismiss() + h.fx.sut.uninstall() + RecordingShadowPixelCopy.reset() + } + } + + @Test + @Config(sdk = [28], shadows = [RecordingShadowPixelCopy::class]) + fun `modern dialog retains element level screenshot masking`() { + val h = screenshotCaptureHarness(captureExecutor = mock()) + RecordingShadowPixelCopy.reset() + try { + assertEquals("screenshot", captureDialog(h)?.type) + assertEquals(1, RecordingShadowPixelCopy.requests.size) + } finally { + h.fx.sut.uninstall() + RecordingShadowPixelCopy.reset() + } + } + @Test @Config(sdk = [26], shadows = [ThrowingShadowPixelCopy::class]) fun `poisoned mask walk stops before visiting later sibling subtree`() { @@ -4152,7 +4646,7 @@ internal class PostHogReplayIntegrationTest { fun `a timed-out arming does not leave an orphaned active capture`() { // On timeout the posted arming Runnable still finishes later; it must cancel its own // token rather than leave WindowDrawState reporting an active capture nobody will read. - val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val activity = createActivity() shadowOf(Looper.getMainLooper()).idle() val view = View(activity) val drawState = WindowDrawState() diff --git a/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplaySceneSchedulingTest.kt b/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplaySceneSchedulingTest.kt new file mode 100644 index 000000000..19da045c8 --- /dev/null +++ b/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplaySceneSchedulingTest.kt @@ -0,0 +1,705 @@ +package com.posthog.android.replay + +import android.app.Activity +import android.app.Dialog +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Color +import android.os.Binder +import android.os.Handler +import android.os.Looper +import android.os.SystemClock +import android.util.Base64 +import android.view.PixelCopy +import android.view.View +import android.view.Window +import android.view.WindowManager +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.posthog.PostHogFake +import com.posthog.PostHogInterface +import com.posthog.android.API_KEY +import com.posthog.android.PostHogAndroidConfig +import com.posthog.android.internal.MainHandler +import com.posthog.android.replay.internal.ViewTreeSnapshotStatus +import com.posthog.internal.PostHogDateProvider +import com.posthog.internal.PostHogRemoteConfig +import com.posthog.internal.PostHogSessionManager +import com.posthog.internal.replay.RRFullSnapshotEvent +import com.posthog.internal.replay.RRMetaEvent +import com.posthog.internal.replay.RRWireframe +import curtains.Curtains +import curtains.OnRootViewsChangedListener +import org.junit.runner.RunWith +import org.mockito.MockedStatic +import org.mockito.Mockito.mockStatic +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.robolectric.Robolectric +import org.robolectric.Shadows.shadowOf +import org.robolectric.android.controller.ActivityController +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import org.robolectric.annotation.Implementation +import org.robolectric.annotation.Implements +import org.robolectric.annotation.LooperMode +import org.robolectric.util.ReflectionHelpers +import java.util.UUID +import java.util.concurrent.AbstractExecutorService +import java.util.concurrent.FutureTask +import java.util.concurrent.TimeUnit +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [28], shadows = [PostHogReplaySceneSchedulingTest.ScenePixelCopy::class]) +@LooperMode(LooperMode.Mode.PAUSED) +internal class PostHogReplaySceneSchedulingTest { + private class QueuedExecutor : AbstractExecutorService() { + val tasks = ArrayDeque() + private var stopped = false + + override fun execute(command: Runnable) { + check(!stopped) + tasks.addLast(command) + } + + fun runNext() { + val task = tasks.removeFirst() + task.run() + (task as FutureTask<*>).get() + } + + override fun shutdown() { + stopped = true + } + + override fun shutdownNow(): MutableList = + tasks.toMutableList().also { + tasks.clear() + shutdown() + } + + override fun isShutdown(): Boolean = stopped + + override fun isTerminated(): Boolean = stopped && tasks.isEmpty() + + override fun awaitTermination( + timeout: Long, + unit: TimeUnit, + ): Boolean = isTerminated + } + + @Implements(PixelCopy::class) + class ScenePixelCopy { + companion object { + var copies = 0 + val formats = mutableListOf>() + var fillColor: Int? = null + var onCopy: ((Window) -> Int)? = null + var deferWindow: Window? = null + val pending = mutableListOf>() + + fun completePendingCopies() { + val callbacks = pending.toList() + pending.clear() + callbacks.forEach { it.second.onPixelCopyFinished(PixelCopy.SUCCESS) } + } + + @JvmStatic + @Implementation + fun request( + window: Window, + bitmap: Bitmap, + listener: PixelCopy.OnPixelCopyFinishedListener, + handler: Handler, + ) { + copies++ + formats.add(window to bitmap.config) + bitmap.eraseColor(fillColor ?: Color.rgb(copies % 255, 40, 80)) + if (window === deferWindow) { + pending.add(bitmap to listener) + } else { + listener.onPixelCopyFinished(onCopy?.invoke(window) ?: PixelCopy.SUCCESS) + } + } + } + } + + private val roots = mutableListOf() + private val rootListeners = mutableListOf() + private val executor = QueuedExecutor() + private val fake = PostHogFake() + private val dialogs = mutableListOf() + private val otherActivities = mutableListOf>() + private lateinit var controller: ActivityController + private lateinit var curtains: MockedStatic + private lateinit var sut: PostHogReplayIntegration + private lateinit var config: PostHogAndroidConfig + private var consumePostsImmediately = false + private val activityView: View get() = controller.get().window.decorView + private val activityStatus: ViewTreeSnapshotStatus get() = assertNotNull(sut.decorViews[activityView]) + + @BeforeTest + fun setUp() { + ScenePixelCopy.copies = 0 + ScenePixelCopy.formats.clear() + ScenePixelCopy.fillColor = null + ScenePixelCopy.onCopy = null + ScenePixelCopy.deferWindow = null + ScenePixelCopy.pending.clear() + PostHogSessionManager.isReactNative = false + PostHogSessionManager.setAppInBackground(false) + PostHogSessionManager.endSession() + PostHogSessionManager.startSession() + curtains = mockStatic(Curtains::class.java) + curtains.`when`> { Curtains.rootViews }.thenReturn(roots) + curtains.`when`> { Curtains.onRootViewsChangedListeners }.thenReturn(rootListeners) + controller = Robolectric.buildActivity(Activity::class.java).setup().visible() + prepareWindow(controller.get().window, WindowManager.LayoutParams.TYPE_BASE_APPLICATION) + roots.add(activityView) + config = + PostHogAndroidConfig(API_KEY).apply { + sessionReplay = true + sessionReplayConfig.screenshot = true + sessionReplayConfig.verifyScreenshotMaskAlignment = true + sessionReplayConfig.throttleDelayMs = 0 + remoteConfigHolder = + mock { + on { isSessionReplayFlagActive() } doReturn true + on { hasRemoteConfigFetched() } doReturn true + on { makeSamplingDecision(any()) } doReturn true + on { getEventTriggers() } doReturn emptySet() + } + dateProvider = + mock { + on { currentTimeMillis() } doAnswer { 1_000L + SystemClock.uptimeMillis() } + on { nanoTime() } doAnswer { TimeUnit.MILLISECONDS.toNanos(10_000L + SystemClock.uptimeMillis()) } + } + } + val realHandler = Handler(Looper.getMainLooper()) + val handler = + mock { + on { looper } doReturn Looper.getMainLooper() + on { post(any()) } doAnswer { + val runnable = it.getArgument(0) + // Model main consuming a worker's post before the worker can continue. + if (consumePostsImmediately) { + runnable.run() + true + } else { + realHandler.post(runnable) + } + } + on { postDelayed(any(), any()) } doAnswer { + realHandler.postDelayed(it.getArgument(0), it.getArgument(1)) + } + } + val mainHandler = + mock { + on { this.handler } doReturn handler + on { mainLooper } doReturn Looper.getMainLooper() + } + sut = PostHogReplayIntegration(ApplicationProvider.getApplicationContext(), config, mainHandler, executor) + } + + @AfterTest + fun tearDown() { + consumePostsImmediately = false + ScenePixelCopy.onCopy = null + ScenePixelCopy.deferWindow = null + sut.uninstall() + ScenePixelCopy.completePendingCopies() + executor.shutdownNow() + dialogs.forEach { it.dismiss() } + otherActivities.asReversed().forEach { it.pause().stop().destroy() } + controller.pause().stop().destroy() + shadowOf(Looper.getMainLooper()).idle() + curtains.close() + PostHogSessionManager.endSession() + PostHogSessionManager.setAppInBackground(true) + } + + private fun prepareWindow( + window: Window, + type: Int, + ) { + val view = window.decorView + shadowOf(Looper.getMainLooper()).idle() + val attachInfo = ReflectionHelpers.getField(view, "mAttachInfo") + ReflectionHelpers.setField(attachInfo, "mWindowVisibility", View.VISIBLE) + (view.layoutParams as WindowManager.LayoutParams).apply { + this.type = type + token = Binder() + } + view.measure( + View.MeasureSpec.makeMeasureSpec(300, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(500, View.MeasureSpec.EXACTLY), + ) + view.layout(0, 0, 300, 500) + } + + private fun start() { + sut.install( + object : PostHogInterface by fake { + override fun getSessionId(): UUID? = PostHogSessionManager.getActiveSessionId() + }, + ) + sut.start(resumeCurrent = true) + shadowOf(Looper.getMainLooper()).idle() + } + + private fun drain() { + repeat(20) { + shadowOf(Looper.getMainLooper()).idle() + if (executor.tasks.isEmpty()) return + executor.runNext() + } + error("Scene scheduling did not settle") + } + + private fun showDialog(): Dialog { + val dialog = Dialog(controller.get()).also { dialogs.add(it) } + dialog.setContentView(View(controller.get())) + dialog.window!!.addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) + dialog.window!!.setDimAmount(0.35f) + dialog.show() + prepareWindow(dialog.window!!, WindowManager.LayoutParams.TYPE_APPLICATION) + val decor = dialog.window!!.decorView + rootListeners.toList().forEach { it.onRootViewsChanged(decor, true) } + roots.add(decor) + shadowOf(Looper.getMainLooper()).idle() + return dialog + } + + private fun timeoutDialogCopy(): Dialog { + start() + drain() + val dialog = showDialog() + drain() + ScenePixelCopy.formats.clear() + ScenePixelCopy.deferWindow = dialog.window + assertNotNull(sut.decorViews[dialog.window!!.decorView]).listener.onDraw() + drain() + assertEquals(1, ScenePixelCopy.pending.size) + return dialog + } + + private fun removeDialog(dialog: Dialog) { + val decor = dialog.window!!.decorView + rootListeners.toList().forEach { it.onRootViewsChanged(decor, false) } + roots.remove(decor) + dialog.dismiss() + } + + private fun dirtyViews(): Set = ReflectionHelpers.getField(sut, "sceneDirtyViews") + + private fun assertSceneMetadata(title: String): Map<*, *> { + val events = fake.properties!!["\$snapshot_data"] as List<*> + assertTrue(events[0] is RRMetaEvent) + assertTrue(events[1] is RRFullSnapshotEvent) + val metadata = (events[0] as RRMetaEvent).data as Map<*, *> + assertEquals(title, metadata["href"]) + return metadata + } + + @Test + fun `each changed full scene includes current metadata before its keyframe`() { + controller.get().window.attributes.title = "test/CheckoutActivity" + start() + drain() + val first = assertSceneMetadata("CheckoutActivity") + val captures = fake.captures + + shadowOf(Looper.getMainLooper()).idleFor(100, TimeUnit.MILLISECONDS) + activityStatus.listener.onDraw() + drain() + + assertEquals(captures + 1, fake.captures) + assertEquals(first, assertSceneMetadata("CheckoutActivity")) + } + + @Test + fun `same sized activity transition includes the new activity metadata`() { + controller.get().window.attributes.title = "test/CheckoutActivity" + start() + drain() + val first = assertSceneMetadata("CheckoutActivity") + val second = Robolectric.buildActivity(Activity::class.java).setup().visible() + otherActivities.add(second) + second.get().window.attributes.title = "test/ReceiptActivity" + prepareWindow(second.get().window, WindowManager.LayoutParams.TYPE_BASE_APPLICATION) + val decor = second.get().window.decorView + rootListeners.toList().forEach { it.onRootViewsChanged(decor, true) } + roots.add(decor) + drain() + + val metadata = assertSceneMetadata("ReceiptActivity") + assertEquals(first["width"], metadata["width"]) + assertEquals(first["height"], metadata["height"]) + } + + private fun assertSceneColorMode( + mode: PostHogScreenshotColorMode, + expected: Bitmap.Config, + dialogExpected: Bitmap.Config, + ) { + config.sessionReplayConfig.screenshotColorMode = mode + start() + drain() + assertTrue(ScenePixelCopy.formats.isNotEmpty()) + assertTrue(ScenePixelCopy.formats.all { it.second == expected }) + + val dialog = showDialog() + drain() + + val dialogFormats = ScenePixelCopy.formats.filter { it.first === dialog.window } + assertTrue(dialogFormats.isNotEmpty()) + assertTrue(dialogFormats.all { it.second == dialogExpected }) + } + + @Test + fun `registered scene captures keep RGB565 Activity and use ARGB for dialogs`() { + assertSceneColorMode(PostHogScreenshotColorMode.RGB_565, Bitmap.Config.RGB_565, Bitmap.Config.ARGB_8888) + } + + @Test + fun `registered scene captures preserve the default ARGB format for every layer`() { + assertSceneColorMode(PostHogScreenshotColorMode.ARGB_8888, Bitmap.Config.ARGB_8888, Bitmap.Config.ARGB_8888) + } + + @Test + @GraphicsMode(GraphicsMode.Mode.NATIVE) + fun `registered dialog capture multiplies pixel alpha by window opacity`() { + config.sessionReplayConfig.screenshotColorMode = PostHogScreenshotColorMode.RGB_565 + start() + drain() + val dialog = showDialog() + drain() + ScenePixelCopy.fillColor = Color.argb(128, 200, 80, 40) + val window = dialog.window!! + window.attributes = window.attributes.apply { alpha = 0.5f } + val copies = ScenePixelCopy.copies + shadowOf(Looper.getMainLooper()).idleFor(100, TimeUnit.MILLISECONDS) + assertNotNull(sut.decorViews[window.decorView]).listener.onDraw() + drain() + assertEquals(copies + 1, ScenePixelCopy.copies) + + assertTrue(ScenePixelCopy.formats.filter { it.first === controller.get().window }.all { it.second == Bitmap.Config.RGB_565 }) + assertEquals(Bitmap.Config.ARGB_8888, ScenePixelCopy.formats.last().second) + val events = fake.properties!!["\$snapshot_data"] as List<*> + val full = events.filterIsInstance().single() + val frames = (full.data as Map<*, *>)["wireframes"] as List<*> + val image = frames.last() as RRWireframe + val bytes = Base64.decode(assertNotNull(image.base64).substringAfter(','), Base64.DEFAULT) + val bitmap = assertNotNull(BitmapFactory.decodeByteArray(bytes, 0, bytes.size)) + try { + assertEquals(64, Color.alpha(bitmap.getPixel(bitmap.width / 2, bitmap.height / 2))) + } finally { + bitmap.recycle() + } + } + + @Test + fun `a delayed draw cannot dirty a removed dialog`() { + config.sessionReplayConfig.throttleDelayMs = 1000 + start() + val dialog = showDialog() + drain() + val decor = dialog.window!!.decorView + val status = assertNotNull(sut.decorViews[decor]) + status.listener.onDraw() + drain() + status.listener.onDraw() + removeDialog(dialog) + shadowOf(Looper.getMainLooper()).idleFor(2, TimeUnit.SECONDS) + drain() + assertFalse(decor in dirtyViews()) + } + + @Test + fun `a failed copy cannot restore a removed dialog to dirty state`() { + start() + val dialog = showDialog() + ScenePixelCopy.onCopy = { window -> + if (window === dialog.window) { + removeDialog(dialog) + PixelCopy.ERROR_SOURCE_INVALID + } else { + PixelCopy.SUCCESS + } + } + drain() + assertFalse(dialog.window!!.decorView in dirtyViews()) + } + + @Test + fun `a failed copy after stop cannot restore dirty state`() { + start() + ScenePixelCopy.onCopy = { + sut.stop() + PixelCopy.ERROR_SOURCE_INVALID + } + drain() + assertTrue(dirtyViews().isEmpty()) + } + + @Test + fun `ordinary layouts share the throttled draw cadence`() { + config.sessionReplayConfig.throttleDelayMs = 1000 + start() + drain() + activityStatus.listener.onDraw() + drain() + val copies = ScenePixelCopy.copies + repeat(9) { + shadowOf(Looper.getMainLooper()).idleFor(100, TimeUnit.MILLISECONDS) + activityStatus.layoutListener!!.onGlobalLayout() + activityStatus.listener.onDraw() + drain() + } + assertEquals(copies, ScenePixelCopy.copies) + shadowOf(Looper.getMainLooper()).idleFor(100, TimeUnit.MILLISECONDS) + drain() + assertEquals(copies + 1, ScenePixelCopy.copies) + } + + @Test + fun `queued topology is followed by the latest scene when a dialog appears`() { + start() + val dialog = showDialog() + executor.runNext() + assertEquals(1, fake.captures) + assertEquals(listOf(controller.get().window), ScenePixelCopy.formats.map { it.first }) + drain() + assertEquals(listOf(controller.get().window, dialog.window), ScenePixelCopy.formats.map { it.first }) + val snapshot = (fake.properties!!["\$snapshot_data"] as List<*>).filterIsInstance().single() + + @Suppress("UNCHECKED_CAST") + val frames = (snapshot.data as Map<*, *>)["wireframes"] as List + assertEquals(listOf("screenshot", "rectangle", "screenshot"), frames.map { it.type }) + assertTrue(dialog.window!!.decorView in sut.decorViews) + } + + @Test + fun `a dialog redraw during Activity capture does not discard completed images`() { + start() + drain() + val dialog = showDialog() + drain() + ScenePixelCopy.formats.clear() + val captures = fake.captures + activityStatus.listener.onDraw() + ScenePixelCopy.onCopy = { window -> + if (window === controller.get().window) { + ScenePixelCopy.onCopy = null + assertNotNull(sut.decorViews[dialog.window!!.decorView]).listener.onDraw() + } + PixelCopy.SUCCESS + } + + executor.runNext() + assertEquals(captures + 1, fake.captures) + drain() + + assertEquals(listOf(controller.get().window, dialog.window), ScenePixelCopy.formats.map { it.first }) + assertEquals(captures + 1, fake.captures) + } + + @Test + fun `an Activity update during dialog capture remains pending`() { + start() + drain() + val dialog = showDialog() + drain() + ScenePixelCopy.formats.clear() + val captures = fake.captures + activityStatus.listener.onDraw() + assertNotNull(sut.decorViews[dialog.window!!.decorView]).listener.onDraw() + ScenePixelCopy.onCopy = { window -> + if (window === dialog.window) { + ScenePixelCopy.onCopy = null + activityStatus.listener.onDraw() + } + PixelCopy.SUCCESS + } + + executor.runNext() + assertEquals(captures + 1, fake.captures) + assertTrue(activityView in dirtyViews()) + drain() + + assertEquals(listOf(controller.get().window, dialog.window, controller.get().window), ScenePixelCopy.formats.map { it.first }) + assertEquals(captures + 2, fake.captures) + } + + @Test + fun lateDialogCopyDoesNotStartAnotherCopyForSameWindow() { + val dialog = timeoutDialogCopy() + val decor = dialog.window!!.decorView + val status = assertNotNull(sut.decorViews[decor]) + val captures = fake.captures + assertFalse(ScenePixelCopy.pending.single().first.isRecycled) + + repeat(5) { + status.listener.onDraw() + drain() + assertEquals(1, ScenePixelCopy.pending.size) + assertTrue(decor in dirtyViews()) + assertEquals(captures, fake.captures) + } + + ScenePixelCopy.deferWindow = null + ScenePixelCopy.completePendingCopies() + drain() + + assertEquals(listOf(dialog.window, dialog.window), ScenePixelCopy.formats.map { it.first }) + assertEquals(captures + 1, fake.captures) + assertFalse(decor in dirtyViews()) + } + + @Test + fun `late dialog completion after stop does not resume capture`() { + val dialog = timeoutDialogCopy() + assertNotNull(sut.decorViews[dialog.window!!.decorView]).listener.onDraw() + drain() + assertEquals(1, ScenePixelCopy.pending.size) + val captures = fake.captures + + sut.stop() + ScenePixelCopy.deferWindow = null + ScenePixelCopy.completePendingCopies() + drain() + + assertEquals(captures, fake.captures) + assertEquals(listOf(dialog.window), ScenePixelCopy.formats.map { it.first }) + assertTrue(dirtyViews().isEmpty()) + } + + @Test + fun `late dialog completion after removal cannot restore the dialog`() { + val dialog = timeoutDialogCopy() + val decor = dialog.window!!.decorView + assertNotNull(sut.decorViews[decor]).listener.onDraw() + drain() + assertEquals(1, ScenePixelCopy.pending.size) + removeDialog(dialog) + drain() + val captures = fake.captures + + ScenePixelCopy.deferWindow = null + ScenePixelCopy.completePendingCopies() + drain() + + assertEquals(captures, fake.captures) + assertEquals(listOf(dialog.window), ScenePixelCopy.formats.map { it.first }) + assertFalse(decor in sut.decorViews) + assertFalse(decor in dirtyViews()) + } + + @Test + fun `a failed dialog leaves an unchanged cached Activity reusable`() { + start() + drain() + val dialog = showDialog() + drain() + val decor = dialog.window!!.decorView + ScenePixelCopy.formats.clear() + assertNotNull(sut.decorViews[decor]).listener.onDraw() + ScenePixelCopy.onCopy = { PixelCopy.ERROR_SOURCE_NO_DATA } + drain() + + assertFalse(activityView in dirtyViews()) + assertTrue(decor in dirtyViews()) + ScenePixelCopy.onCopy = null + assertNotNull(sut.decorViews[decor]).listener.onDraw() + drain() + + assertEquals(listOf(dialog.window, dialog.window), ScenePixelCopy.formats.map { it.first }) + } + + @Test + fun `a successful Activity image is retained when dialog capture fails`() { + start() + drain() + val dialog = showDialog() + drain() + val decor = dialog.window!!.decorView + ScenePixelCopy.formats.clear() + val captures = fake.captures + activityStatus.listener.onDraw() + assertNotNull(sut.decorViews[decor]).listener.onDraw() + ScenePixelCopy.onCopy = { window -> + if (window === dialog.window) PixelCopy.ERROR_SOURCE_NO_DATA else PixelCopy.SUCCESS + } + executor.runNext() + + assertEquals(captures, fake.captures) + assertFalse(activityView in dirtyViews()) + assertTrue(decor in dirtyViews()) + ScenePixelCopy.onCopy = null + drain() + + assertEquals(listOf(controller.get().window, dialog.window, dialog.window), ScenePixelCopy.formats.map { it.first }) + assertEquals(captures + 1, fake.captures) + } + + @Test + fun `stop during dialog capture clears independently cached images`() { + start() + drain() + val dialog = showDialog() + drain() + activityStatus.listener.onDraw() + assertNotNull(sut.decorViews[dialog.window!!.decorView]).listener.onDraw() + val captures = fake.captures + ScenePixelCopy.onCopy = { window -> + if (window === dialog.window) sut.stop() + PixelCopy.SUCCESS + } + drain() + + val images = ReflectionHelpers.getField>(sut, "sceneImages") + assertTrue(images.isEmpty()) + assertTrue(dirtyViews().isEmpty()) + assertEquals(captures, fake.captures) + } + + @Test + fun `a queued capture cannot adopt a restarted recording generation`() { + start() + sut.stop() + sut.start(resumeCurrent = true) + shadowOf(Looper.getMainLooper()).idle() + executor.runNext() + assertEquals(0, fake.captures) + drain() + assertTrue(fake.captures > 0) + } + + @Test + fun `follow up survives main consuming the notification before the worker continues`() { + start() + drain() + activityStatus.listener.onDraw() + ScenePixelCopy.onCopy = { + ScenePixelCopy.onCopy = null + activityStatus.listener.onDraw() + consumePostsImmediately = true + PixelCopy.SUCCESS + } + executor.runNext() + consumePostsImmediately = false + assertEquals(1, executor.tasks.size) + val captures = fake.captures + drain() + assertEquals(captures + 1, fake.captures) + } +} diff --git a/posthog-android/src/test/java/com/posthog/android/replay/WindowDrawStateCaptureSchedulingTest.kt b/posthog-android/src/test/java/com/posthog/android/replay/WindowDrawStateCaptureSchedulingTest.kt new file mode 100644 index 000000000..f8d8c6abb --- /dev/null +++ b/posthog-android/src/test/java/com/posthog/android/replay/WindowDrawStateCaptureSchedulingTest.kt @@ -0,0 +1,75 @@ +package com.posthog.android.replay + +import com.posthog.android.replay.internal.WindowDrawState +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +internal class WindowDrawStateCaptureSchedulingTest { + @Test + fun `busy requests coalesce into one wakeup after the scheduling gate opens`() { + val state = WindowDrawState() + var wakeups = 0 + assertTrue(state.tryScheduleCapture()) + repeat(100) { + assertFalse( + state.tryScheduleCapture { + wakeups++ + assertTrue(state.tryScheduleCapture()) + }, + ) + } + // A caller without a wakeup must not clear another caller's pending request. + assertFalse(state.tryScheduleCapture()) + assertEquals(0, wakeups) + state.finishScheduledCapture() + assertEquals(1, wakeups) + state.finishScheduledCapture() + assertEquals(1, wakeups) + } + + @Test + fun `pixel copy completion does not wake a request while its worker still owns the gate`() { + val state = WindowDrawState() + var wakeups = 0 + assertTrue(state.tryScheduleCapture()) + state.beginPixelCopy() + assertFalse(state.tryScheduleCapture { wakeups++ }) + state.finishPixelCopy() + assertEquals(0, wakeups) + state.finishScheduledCapture() + assertEquals(1, wakeups) + assertTrue(state.tryScheduleCapture()) + } + + @Test + fun `timed out pixel copy keeps the wakeup pending until the callback returns`() { + val state = WindowDrawState() + var wakeups = 0 + assertTrue(state.tryScheduleCapture()) + state.beginPixelCopy() + assertFalse(state.tryScheduleCapture { wakeups++ }) + state.finishScheduledCapture() + assertEquals(0, wakeups) + assertFalse(state.tryScheduleCapture()) + state.finishPixelCopy() + assertEquals(1, wakeups) + assertTrue(state.tryScheduleCapture()) + } + + @Test + fun `a request arriving after the worker timeout is woken by pixel copy completion`() { + val state = WindowDrawState() + var wakeups = 0 + assertTrue(state.tryScheduleCapture()) + state.beginPixelCopy() + state.finishScheduledCapture() + assertFalse(state.tryScheduleCapture { wakeups++ }) + assertEquals(0, wakeups) + state.finishPixelCopy() + assertEquals(1, wakeups) + state.finishPixelCopy() + assertEquals(1, wakeups) + } +} diff --git a/posthog-android/src/test/java/com/posthog/android/replay/internal/DialogSceneFixture.kt b/posthog-android/src/test/java/com/posthog/android/replay/internal/DialogSceneFixture.kt new file mode 100644 index 000000000..d9f3bec01 --- /dev/null +++ b/posthog-android/src/test/java/com/posthog/android/replay/internal/DialogSceneFixture.kt @@ -0,0 +1,56 @@ +package com.posthog.android.replay.internal + +import android.app.Activity +import android.app.Dialog +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.os.Looper +import android.view.View +import android.widget.FrameLayout +import android.widget.TextView +import org.robolectric.Robolectric +import org.robolectric.Shadows.shadowOf +import java.io.Closeable + +internal class DialogSceneFixture : Closeable { + private val controller = Robolectric.buildActivity(Activity::class.java).setup() + val activity = controller.get() + val content = FrameLayout(activity) + val masked = + TextView(activity).apply { + text = "sensitive content" + tag = "ph-no-capture" + } + val dialog = Dialog(activity) + val root: View + + init { + content.addView(masked) + dialog.setContentView(content) + dialog.window!!.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + dialog.show() + shadowOf(Looper.getMainLooper()).idle() + root = dialog.window!!.decorView + root.measure( + View.MeasureSpec.makeMeasureSpec(600, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(1000, View.MeasureSpec.EXACTLY), + ) + root.layout(0, 0, 600, 1000) + layoutContent() + for (view in listOf(activity.window.decorView, root)) { + val info = View::class.java.getDeclaredField("mAttachInfo").apply { isAccessible = true }.get(view) + info.javaClass.getDeclaredField("mWindowVisibility").apply { isAccessible = true }.setInt(info, View.VISIBLE) + } + } + + fun layoutContent() { + content.layout(0, 0, 600, 1000) + masked.layout(40, 50, 240, 100) + } + + override fun close() { + dialog.dismiss() + controller.pause().stop().destroy() + shadowOf(Looper.getMainLooper()).idle() + } +} diff --git a/posthog-android/src/test/java/com/posthog/android/replay/internal/PixelCopyBitmapBufferTest.kt b/posthog-android/src/test/java/com/posthog/android/replay/internal/PixelCopyBitmapBufferTest.kt index c2080b1b4..cfe5e0764 100644 --- a/posthog-android/src/test/java/com/posthog/android/replay/internal/PixelCopyBitmapBufferTest.kt +++ b/posthog-android/src/test/java/com/posthog/android/replay/internal/PixelCopyBitmapBufferTest.kt @@ -4,6 +4,7 @@ import android.graphics.Bitmap import androidx.test.ext.junit.runners.AndroidJUnit4 import org.junit.runner.RunWith import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -35,7 +36,9 @@ internal class PixelCopyBitmapBufferTest { } @Test - fun `changing color mode replaces the idle bitmap without disabling RGB565`() { + @Config(sdk = [28, 35]) + @GraphicsMode(GraphicsMode.Mode.NATIVE) + fun `grows for ARGB then keeps the allocation when switching back to RGB565`() { val buffer = PixelCopyBitmapBuffer() buffer.open() val rgb = buffer.acquire(20, 20, Bitmap.Config.RGB_565)!! @@ -48,8 +51,48 @@ internal class PixelCopyBitmapBufferTest { val rgbAgain = buffer.acquire(20, 20, Bitmap.Config.RGB_565)!! assertEquals(Bitmap.Config.RGB_565, rgbAgain.bitmap.config) - assertTrue(argb.bitmap.isRecycled) + assertSame(argb.bitmap, rgbAgain.bitmap) + assertEquals(20 * 20 * 4, rgbAgain.bitmap.allocationByteCount) + assertEquals(20 * 20 * 2, rgbAgain.bitmap.byteCount) + assertFalse(rgbAgain.bitmap.hasAlpha()) rgbAgain.release() + + val argbAgain = buffer.acquire(20, 20, Bitmap.Config.ARGB_8888)!! + assertSame(argb.bitmap, argbAgain.bitmap) + assertTrue(argbAgain.bitmap.hasAlpha()) + argbAgain.release() + buffer.close() + assertTrue(argb.bitmap.isRecycled) + } + + @Test + @Config(sdk = [28, 35]) + @GraphicsMode(GraphicsMode.Mode.NATIVE) + fun `small ARGB dialog reuses the RGB565 activity allocation`() { + val buffer = PixelCopyBitmapBuffer() + buffer.open() + val activity = buffer.acquire(100, 100, Bitmap.Config.RGB_565)!! + val capacity = activity.bitmap.allocationByteCount + assertEquals(100 * 100 * 2, capacity) + activity.release() + + repeat(20) { + val dialog = buffer.acquire(40, 40, Bitmap.Config.ARGB_8888)!! + assertSame(activity.bitmap, dialog.bitmap) + assertEquals(capacity, dialog.bitmap.allocationByteCount) + assertTrue(dialog.bitmap.hasAlpha()) + dialog.bitmap.setHasAlpha(false) + dialog.release() + val dialogAgain = buffer.acquire(40, 40, Bitmap.Config.ARGB_8888)!! + assertSame(activity.bitmap, dialogAgain.bitmap) + assertTrue(dialogAgain.bitmap.hasAlpha()) + dialogAgain.release() + val reused = buffer.acquire(100, 100, Bitmap.Config.RGB_565)!! + assertSame(activity.bitmap, reused.bitmap) + assertEquals(capacity, reused.bitmap.allocationByteCount) + assertFalse(reused.bitmap.hasAlpha()) + reused.release() + } buffer.close() } @@ -64,6 +107,9 @@ internal class PixelCopyBitmapBufferTest { uncached.release() assertTrue(uncached.bitmap.isRecycled) assertFalse(pending.bitmap.isRecycled) + assertEquals(Bitmap.Config.ARGB_8888, pending.bitmap.config) + assertEquals(20, pending.bitmap.width) + assertEquals(20, pending.bitmap.height) pending.release() val reused = buffer.acquire(20, 20, Bitmap.Config.ARGB_8888)!! diff --git a/posthog-samples/posthog-android-sample/build.gradle.kts b/posthog-samples/posthog-android-sample/build.gradle.kts index dd9d384e1..c4514454c 100644 --- a/posthog-samples/posthog-android-sample/build.gradle.kts +++ b/posthog-samples/posthog-android-sample/build.gradle.kts @@ -96,6 +96,7 @@ dependencies { implementation(platform("com.squareup.okhttp3:okhttp-bom:${PosthogBuildConfig.Dependencies.OKHTTP}")) implementation("com.squareup.okhttp3:okhttp") debugImplementation("androidx.compose.ui:ui-tooling") + debugImplementation("com.google.android.material:material:1.12.0") debugImplementation("androidx.compose.ui:ui-test-manifest") debugImplementation("com.squareup.leakcanary:leakcanary-android:2.14") } diff --git a/posthog-samples/posthog-android-sample/gradle.lockfile b/posthog-samples/posthog-android-sample/gradle.lockfile index 5c7de298c..903336b9c 100644 --- a/posthog-samples/posthog-android-sample/gradle.lockfile +++ b/posthog-samples/posthog-android-sample/gradle.lockfile @@ -8,9 +8,12 @@ androidx.activity:activity:1.13.0=debugAndroidTestCompileClasspath,debugCompileC androidx.annotation:annotation-experimental:1.4.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.annotation:annotation-jvm:1.9.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.annotation:annotation:1.9.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.appcompat:appcompat-resources:1.6.1=debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath +androidx.appcompat:appcompat:1.6.1=debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath androidx.arch.core:core-common:2.2.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.arch.core:core-runtime:2.2.0=debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.arch.core:core-runtime:2.2.0=debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath androidx.autofill:autofill:1.0.0=debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.cardview:cardview:1.0.0=debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath androidx.collection:collection-jvm:1.5.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.collection:collection-ktx:1.5.0=debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath androidx.collection:collection:1.5.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath @@ -57,22 +60,30 @@ androidx.compose.ui:ui-util:1.11.4=debugAndroidTestCompileClasspath,debugCompile androidx.compose.ui:ui:1.11.4=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.compose:compose-bom:2026.06.01=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.concurrent:concurrent-futures:1.1.0=debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.constraintlayout:constraintlayout-solver:2.0.1=debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath +androidx.constraintlayout:constraintlayout:2.0.1=debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath +androidx.coordinatorlayout:coordinatorlayout:1.1.0=debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath androidx.core:core-ktx:1.18.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.core:core-viewtree:1.0.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.core:core:1.18.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.cursoradapter:cursoradapter:1.0.0=debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath androidx.customview:customview-poolingcontainer:1.0.0=debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath -androidx.documentfile:documentfile:1.0.0=debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath -androidx.dynamicanimation:dynamicanimation:1.0.0=debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.customview:customview:1.1.0=debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath +androidx.documentfile:documentfile:1.0.0=debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.drawerlayout:drawerlayout:1.1.1=debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath +androidx.dynamicanimation:dynamicanimation:1.0.0=debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.emoji2:emoji2-views-helper:1.4.0=debugRuntimeClasspath,debugUnitTestRuntimeClasspath androidx.emoji2:emoji2:1.4.0=debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.fragment:fragment:1.3.6=debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath androidx.graphics:graphics-path:1.0.1=debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath -androidx.interpolator:interpolator:1.0.0=debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath -androidx.legacy:legacy-support-core-utils:1.0.0=debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.interpolator:interpolator:1.0.0=debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.legacy:legacy-support-core-utils:1.0.0=debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath androidx.lifecycle:lifecycle-common-java8:2.9.4=debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath androidx.lifecycle:lifecycle-common-jvm:2.9.4=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.lifecycle:lifecycle-common:2.9.4=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.lifecycle:lifecycle-livedata-core-ktx:2.9.4=debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.lifecycle:lifecycle-livedata-core-ktx:2.9.4=debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath androidx.lifecycle:lifecycle-livedata-core:2.9.4=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.lifecycle:lifecycle-livedata:2.9.4=debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.lifecycle:lifecycle-livedata:2.9.4=debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath androidx.lifecycle:lifecycle-process:2.9.4=debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath androidx.lifecycle:lifecycle-runtime-android:2.9.4=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.lifecycle:lifecycle-runtime-compose-android:2.9.4=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath @@ -85,14 +96,16 @@ androidx.lifecycle:lifecycle-viewmodel-ktx:2.9.4=debugRuntimeClasspath,debugUnit androidx.lifecycle:lifecycle-viewmodel-savedstate-android:2.9.4=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.lifecycle:lifecycle-viewmodel-savedstate:2.9.4=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.lifecycle:lifecycle-viewmodel:2.9.4=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.loader:loader:1.0.0=debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath -androidx.localbroadcastmanager:localbroadcastmanager:1.0.0=debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.loader:loader:1.0.0=debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.localbroadcastmanager:localbroadcastmanager:1.0.0=debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath androidx.navigationevent:navigationevent-android:1.0.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.navigationevent:navigationevent-compose-android:1.0.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.navigationevent:navigationevent-compose:1.0.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.navigationevent:navigationevent:1.0.0=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath -androidx.print:print:1.0.0=debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.print:print:1.0.0=debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath androidx.profileinstaller:profileinstaller:1.4.0=debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.recyclerview:recyclerview:1.1.0=debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath +androidx.resourceinspection:resourceinspection-annotation:1.0.1=debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath androidx.savedstate:savedstate-android:1.3.2=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.savedstate:savedstate-compose-android:1.3.2=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.savedstate:savedstate-compose:1.3.2=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath @@ -100,8 +113,12 @@ androidx.savedstate:savedstate-ktx:1.3.2=debugRuntimeClasspath,debugUnitTestRunt androidx.savedstate:savedstate:1.3.2=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath androidx.startup:startup-runtime:1.1.1=debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath androidx.tracing:tracing:1.2.0=debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath -androidx.transition:transition:1.6.0=debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.transition:transition:1.6.0=debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath +androidx.vectordrawable:vectordrawable-animated:1.1.0=debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath +androidx.vectordrawable:vectordrawable:1.1.0=debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath androidx.versionedparcelable:versionedparcelable:1.1.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath,releaseUnitTestCompileClasspath,releaseUnitTestRuntimeClasspath +androidx.viewpager2:viewpager2:1.0.0=debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath +androidx.viewpager:viewpager:1.0.0=debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath androidx.window:window-core-android:1.5.0=debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath androidx.window:window-core:1.5.0=debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath androidx.window:window:1.5.0=debugRuntimeClasspath,debugUnitTestRuntimeClasspath,releaseRuntimeClasspath,releaseUnitTestRuntimeClasspath @@ -140,6 +157,7 @@ com.fasterxml.jackson.module:jackson-module-jaxb-annotations:2.12.7=dokkaGfmPart com.fasterxml.jackson.module:jackson-module-kotlin:2.12.7=dokkaGfmPartialPlugin,dokkaGfmPartialRuntime,dokkaGfmPlugin,dokkaGfmRuntime,dokkaHtmlPartialPlugin,dokkaHtmlPartialRuntime,dokkaHtmlPlugin,dokkaHtmlRuntime,dokkaJavadocPartialPlugin,dokkaJavadocPartialRuntime,dokkaJavadocPlugin,dokkaJavadocRuntime,dokkaJekyllPartialPlugin,dokkaJekyllPartialRuntime,dokkaJekyllPlugin,dokkaJekyllRuntime com.fasterxml.jackson:jackson-bom:2.12.7=dokkaGfmPartialPlugin,dokkaGfmPartialRuntime,dokkaGfmPlugin,dokkaGfmRuntime,dokkaHtmlPartialPlugin,dokkaHtmlPartialRuntime,dokkaHtmlPlugin,dokkaHtmlRuntime,dokkaJavadocPartialPlugin,dokkaJavadocPartialRuntime,dokkaJavadocPlugin,dokkaJavadocRuntime,dokkaJekyllPartialPlugin,dokkaJekyllPartialRuntime,dokkaJekyllPlugin,dokkaJekyllRuntime com.fasterxml.woodstox:woodstox-core:6.2.4=dokkaGfmPartialRuntime,dokkaGfmRuntime,dokkaHtmlPartialRuntime,dokkaHtmlRuntime,dokkaJavadocPartialRuntime,dokkaJavadocRuntime,dokkaJekyllPartialRuntime,dokkaJekyllRuntime +com.google.android.material:material:1.12.0=debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath com.google.android:annotations:4.1.1.4=_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core com.google.api.grpc:proto-google-common-protos:2.17.0=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core com.google.auto.service:auto-service-annotations:1.1.1=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle @@ -150,6 +168,7 @@ com.google.code.gson:gson:2.10.1=_internal-unified-test-platform-android-test-pl com.google.code.gson:gson:2.8.9=_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-launcher com.google.crypto.tink:tink:1.7.0=_internal-unified-test-platform-android-test-plugin-host-emulator-control com.google.dagger:dagger:2.48=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core +com.google.errorprone:error_prone_annotations:2.15.0=debugRuntimeClasspath,debugUnitTestRuntimeClasspath com.google.errorprone:error_prone_annotations:2.23.0=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher com.google.guava:failureaccess:1.0.1=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher com.google.guava:guava:32.0.1-jre=_internal-unified-test-platform-android-device-provider-ddmlib,_internal-unified-test-platform-android-device-provider-gradle,_internal-unified-test-platform-android-driver-instrumentation,_internal-unified-test-platform-android-test-plugin,_internal-unified-test-platform-android-test-plugin-host-additional-test-output,_internal-unified-test-platform-android-test-plugin-host-apk-installer,_internal-unified-test-platform-android-test-plugin-host-coverage,_internal-unified-test-platform-android-test-plugin-host-device-info,_internal-unified-test-platform-android-test-plugin-host-emulator-control,_internal-unified-test-platform-android-test-plugin-host-logcat,_internal-unified-test-platform-android-test-plugin-host-retention,_internal-unified-test-platform-android-test-plugin-result-listener-gradle,_internal-unified-test-platform-core,_internal-unified-test-platform-launcher @@ -226,6 +245,7 @@ org.jetbrains.dokka:analysis-markdown:1.9.20=dokkaGfmPartialPlugin,dokkaGfmPlugi org.jetbrains.dokka:dokka-base:1.9.20=dokkaGfmPartialPlugin,dokkaGfmPlugin,dokkaHtmlPartialPlugin,dokkaHtmlPlugin,dokkaJavadocPartialPlugin,dokkaJavadocPlugin,dokkaJekyllPartialPlugin,dokkaJekyllPlugin org.jetbrains.dokka:dokka-core:1.9.20=dokkaGfmPartialRuntime,dokkaGfmRuntime,dokkaHtmlPartialRuntime,dokkaHtmlRuntime,dokkaJavadocPartialRuntime,dokkaJavadocRuntime,dokkaJekyllPartialRuntime,dokkaJekyllRuntime org.jetbrains.intellij.deps:trove4j:1.0.20200330=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath +org.jetbrains.kotlin:kotlin-bom:1.8.22=debugRuntimeClasspath,debugUnitTestRuntimeClasspath org.jetbrains.kotlin:kotlin-build-tools-api:2.1.21=kotlinBuildToolsApiClasspath org.jetbrains.kotlin:kotlin-build-tools-impl:2.1.21=kotlinBuildToolsApiClasspath org.jetbrains.kotlin:kotlin-compiler-embeddable:2.1.21=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath diff --git a/posthog-samples/posthog-android-sample/src/debug/AndroidManifest.xml b/posthog-samples/posthog-android-sample/src/debug/AndroidManifest.xml index b6eb7f426..3be41d170 100644 --- a/posthog-samples/posthog-android-sample/src/debug/AndroidManifest.xml +++ b/posthog-samples/posthog-android-sample/src/debug/AndroidManifest.xml @@ -4,6 +4,11 @@ + tools:replace="android:usesCleartextTraffic"> + + diff --git a/posthog-samples/posthog-android-sample/src/debug/java/com/posthog/android/sample/CheckoutProof.kt b/posthog-samples/posthog-android-sample/src/debug/java/com/posthog/android/sample/CheckoutProof.kt new file mode 100644 index 000000000..2d057f232 --- /dev/null +++ b/posthog-samples/posthog-android-sample/src/debug/java/com/posthog/android/sample/CheckoutProof.kt @@ -0,0 +1,240 @@ +package com.posthog.android.sample + +import android.app.Activity +import android.graphics.Color +import android.graphics.Typeface +import android.graphics.drawable.GradientDrawable +import android.os.Handler +import android.os.Looper +import android.util.Log +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.view.WindowManager +import android.widget.FrameLayout +import android.widget.LinearLayout +import android.widget.TextView +import com.google.android.material.bottomsheet.BottomSheetDialog + +/** A recognizable, synthetic checkout for inspecting screenshot-scene recordings. */ +internal class CheckoutProof(private val activity: Activity) { + private val handler = Handler(Looper.getMainLooper()) + private val ink = Color.rgb(26, 37, 54) + private val muted = Color.rgb(103, 116, 135) + private val navy = Color.rgb(29, 47, 79) + private val accent = Color.rgb(55, 108, 177) + private val surface = Color.WHITE + + fun show() { + val screen = + LinearLayout(activity).apply { + orientation = LinearLayout.VERTICAL + setBackgroundColor(Color.rgb(245, 247, 251)) + } + val header = + LinearLayout(activity).apply { + orientation = LinearLayout.VERTICAL + setPadding(dp(24), dp(26), dp(24), dp(28)) + setBackgroundColor(navy) + } + header.stack(label("NORTHSTAR MARKET", 12f, Color.rgb(170, 205, 246), true)) + header.stack(label("Checkout", 30f, surface, true), top = 7) + header.stack(label("Your order is almost on its way", 14f, Color.rgb(214, 225, 239)), top = 4) + screen.stack(header) + + val body = + LinearLayout(activity).apply { + orientation = LinearLayout.VERTICAL + setPadding(dp(20), dp(20), dp(20), dp(16)) + } + screen.addView(body, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)) + body.stack(label("Order details", 20f, ink, true)) + + val delivery = panel() + delivery.stack(label("DELIVERY", 11f, accent, true)) + val deliveryName = label("Standard delivery", 18f, ink, true) + delivery.stack(deliveryName, top = 6) + val deliveryEta = label("Arrives in 3–5 business days", 13f, muted) + delivery.stack(deliveryEta, top = 3) + body.stack(delivery, top = 16) + + val item = panel() + item.stack(label("YOUR ITEM", 11f, muted, true)) + val itemRow = + LinearLayout(activity).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + } + val productImage = + FrameLayout(activity).apply { + background = rounded(Color.rgb(232, 238, 248), 12) + addView(label("🎧", 39f, ink).apply { gravity = Gravity.CENTER }, FrameLayout.LayoutParams(-1, -1)) + } + itemRow.addView(productImage, LinearLayout.LayoutParams(dp(72), dp(72))) + val productText = LinearLayout(activity).apply { orientation = LinearLayout.VERTICAL } + productText.stack(label("Wireless headphones", 15f, ink, true)) + productText.stack(label("Midnight blue · Qty 1", 12f, muted), top = 4) + productText.stack(label("$89.00", 15f, ink, true), top = 7) + itemRow.addView(productText, LinearLayout.LayoutParams(0, -2, 1f).apply { leftMargin = dp(15) }) + item.stack(itemRow, top = 12) + body.stack(item, top = 14) + + val address = panel() + address.stack(label("DELIVER TO", 11f, muted, true)) + address.stack( + label("Alex Example · 123 Demo Street", 14f, ink).apply { tag = "ph-no-capture" }, + top = 8, + ) + address.stack(label("San Francisco, CA", 13f, muted), top = 3) + body.stack(address, top = 14) + + body.stack(label("Payment summary", 17f, ink, true), top = 23) + body.stack(summaryRow("Subtotal", "$89.00"), top = 10) + val shippingAmount = label("FREE", 14f, accent, true) + body.stack(summaryRow("Shipping", shippingAmount), top = 9) + val totalAmount = label("$89.00", 18f, ink, true) + body.stack(summaryRow("Total", totalAmount), top = 15) + body.addView(View(activity), LinearLayout.LayoutParams(1, 0, 1f)) + + val sheet = + LinearLayout(activity).apply { + orientation = LinearLayout.VERTICAL + setPadding(dp(24), dp(14), dp(24), dp(28)) + background = rounded(surface, 24) + } + val handle = View(activity).apply { background = rounded(Color.rgb(198, 206, 216), 4) } + sheet.addView( + LinearLayout(activity).apply { + gravity = Gravity.CENTER + addView(handle, LinearLayout.LayoutParams(dp(38), dp(4))) + }, + LinearLayout.LayoutParams(-1, dp(18)), + ) + sheet.stack(label("Choose delivery", 24f, ink, true), top = 10) + sheet.stack(label("When should we bring your order?", 14f, muted), top = 5) + sheet.stack(deliveryOption("Standard", "3–5 business days", "FREE", false), top = 21) + sheet.stack(deliveryOption("Express", "Tomorrow, by 8 pm", "$10.00", true), top = 10) + sheet.stack(label("DELIVERING TO", 11f, muted, true), top = 21) + sheet.stack( + label("Alex Example · 123 Demo Street", 13f, ink).apply { tag = "ph-no-capture" }, + top = 7, + ) + + val dialog = + BottomSheetDialog(activity).apply { + setContentView(sheet) + window?.addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) + window?.setDimAmount(0.48f) + } + Log.i("WindowCompositionProof", "checkout windowClass=${dialog.javaClass.name}") + + fun selectExpress() { + deliveryName.text = "Express delivery" + deliveryEta.text = "Arrives tomorrow, by 8 pm" + shippingAmount.text = "$10.00" + totalAmount.text = "$99.00" + dialog.dismiss() + } + sheet.stack(action("Use express delivery") { selectExpress() }, top = 22) + body.stack(action("Change delivery option") { dialog.show() }, top = 20) + body.stack(label("Secure checkout · Demo order", 12f, muted).apply { gravity = Gravity.CENTER }, top = 13) + activity.setContentView(screen) + + handler.postDelayed({ if (!activity.isFinishing && !dialog.isShowing) dialog.show() }, 4000) + handler.postDelayed({ if (!activity.isFinishing && dialog.isShowing) selectExpress() }, 10000) + } + + private fun deliveryOption( + title: String, + subtitle: String, + price: String, + selected: Boolean, + ): View { + val row = + LinearLayout(activity).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + setPadding(dp(16), dp(13), dp(16), dp(13)) + background = + rounded( + if (selected) Color.rgb(238, 245, 255) else surface, + 13, + if (selected) accent else Color.rgb(218, 224, 232), + ) + } + row.addView(label(if (selected) "◉" else "○", 24f, accent), LinearLayout.LayoutParams(dp(32), -2)) + val copy = LinearLayout(activity).apply { orientation = LinearLayout.VERTICAL } + copy.stack(label(title, 15f, ink, true)) + copy.stack(label(subtitle, 12f, muted), top = 3) + row.addView(copy, LinearLayout.LayoutParams(0, -2, 1f)) + row.addView(label(price, 13f, ink, true)) + return row + } + + private fun summaryRow( + name: String, + amount: String, + ): View = summaryRow(name, label(amount, 14f, ink)) + + private fun summaryRow( + name: String, + amount: TextView, + ): View = + LinearLayout(activity).apply { + orientation = LinearLayout.HORIZONTAL + addView(label(name, 14f, muted), LinearLayout.LayoutParams(0, -2, 1f)) + addView(amount) + } + + private fun panel(): LinearLayout = + LinearLayout(activity).apply { + orientation = LinearLayout.VERTICAL + setPadding(dp(17), dp(16), dp(17), dp(16)) + background = rounded(surface, 15) + elevation = dp(2).toFloat() + } + + private fun action( + title: String, + onClick: () -> Unit, + ): TextView = + label(title, 15f, surface, true).apply { + gravity = Gravity.CENTER + background = rounded(navy, 12) + setOnClickListener { onClick() } + minimumHeight = dp(53) + } + + private fun label( + value: String, + size: Float, + color: Int, + bold: Boolean = false, + ): TextView = + TextView(activity).apply { + text = value + textSize = size + setTextColor(color) + if (bold) typeface = Typeface.DEFAULT_BOLD + } + + private fun rounded( + color: Int, + radius: Int, + border: Int? = null, + ): GradientDrawable = + GradientDrawable().apply { + setColor(color) + cornerRadius = dp(radius).toFloat() + if (border != null) setStroke(dp(1), border) + } + + private fun dp(value: Int): Int = (value * activity.resources.displayMetrics.density + 0.5f).toInt() + + private fun LinearLayout.stack( + child: View, + top: Int = 0, + ) { + addView(child, LinearLayout.LayoutParams(-1, -2).apply { topMargin = dp(top) }) + } +} diff --git a/posthog-samples/posthog-android-sample/src/debug/java/com/posthog/android/sample/WindowCompositionProofActivity.kt b/posthog-samples/posthog-android-sample/src/debug/java/com/posthog/android/sample/WindowCompositionProofActivity.kt new file mode 100644 index 000000000..6535af1c2 --- /dev/null +++ b/posthog-samples/posthog-android-sample/src/debug/java/com/posthog/android/sample/WindowCompositionProofActivity.kt @@ -0,0 +1,313 @@ +package com.posthog.android.sample + +import android.app.Activity +import android.app.Dialog +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Rect +import android.graphics.drawable.ColorDrawable +import android.os.Build +import android.os.Bundle +import android.os.Handler +import android.os.HandlerThread +import android.os.Looper +import android.util.Log +import android.view.Gravity +import android.view.PixelCopy +import android.view.View +import android.view.ViewGroup +import android.view.Window +import android.view.WindowManager +import android.widget.FrameLayout +import android.widget.TextView +import androidx.annotation.RequiresApi +import java.io.ByteArrayOutputStream +import java.io.File +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +class WindowCompositionProofActivity : Activity() { + private val mainHandler = Handler(Looper.getMainLooper()) + private lateinit var overlay: Dialog + private var nestedOverlay: Dialog? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (intent.getStringExtra("scene") == "bottom-sheet") { + CheckoutProof(this).show() + return + } + val root = FrameLayout(this).apply { setBackgroundColor(Color.rgb(20, 70, 180)) } + root.addView( + TextView(this).apply { + text = "PROOF PRIVATE ACTIVITY" + tag = "ph-no-capture" + textSize = 21f + setTextColor(Color.YELLOW) + setBackgroundColor(Color.rgb(120, 30, 100)) + }, + FrameLayout.LayoutParams(450, 100).apply { + leftMargin = 60 + topMargin = 340 + }, + ) + setContentView(root) + val scene = intent.getStringExtra("scene") ?: "transparent" + if (scene == "memory-single") return + val wrap = scene in setOf("wrap", "nested", "memory-nested", "visibility", "nonfocus", "windowalpha") + overlay = + Dialog(this).apply { + window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + val content = FrameLayout(this@WindowCompositionProofActivity) + val card = FrameLayout(this@WindowCompositionProofActivity) + card.addView( + object : View(this@WindowCompositionProofActivity) { + private val paint = Paint(Paint.ANTI_ALIAS_FLAG) + + override fun onDraw(canvas: Canvas) { + paint.color = Color.argb(128, 255, 0, 0) + canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), paint) + paint.color = Color.WHITE + canvas.drawCircle(width / 2f, height / 2f, width / 4f, paint) + } + }, + FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT), + ) + card.addView( + TextView(this@WindowCompositionProofActivity).apply { + text = "PROOF PRIVATE DIALOG" + tag = "ph-no-capture" + textSize = 15f + setTextColor(Color.YELLOW) + setBackgroundColor(Color.rgb(40, 120, 40)) + }, + FrameLayout.LayoutParams(320, 70, Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL), + ) + content.addView(card, FrameLayout.LayoutParams(360, 360, Gravity.CENTER)) + setContentView(content) + window?.addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) + if (scene == "nonfocus") window?.addFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE) + window?.setDimAmount(0.35f) + window?.setLayout( + if (wrap) WindowManager.LayoutParams.WRAP_CONTENT else WindowManager.LayoutParams.MATCH_PARENT, + if (wrap) WindowManager.LayoutParams.WRAP_CONTENT else WindowManager.LayoutParams.MATCH_PARENT, + ) + show() + if (scene == "windowalpha") { + window?.let { it.attributes = it.attributes.apply { alpha = 0.5f } } + } + window?.setLayout( + if (wrap) WindowManager.LayoutParams.WRAP_CONTENT else WindowManager.LayoutParams.MATCH_PARENT, + if (wrap) WindowManager.LayoutParams.WRAP_CONTENT else WindowManager.LayoutParams.MATCH_PARENT, + ) + } + if (scene == "nested" || scene == "memory-nested") { + nestedOverlay = + Dialog(this).apply { + window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + setContentView(View(this@WindowCompositionProofActivity).apply { setBackgroundColor(Color.GREEN) }) + window?.addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) + window?.setDimAmount(0.60f) + show() + window?.setLayout(280, 280) + } + } + if (scene != "memory-nested") mainHandler.postDelayed({ capture(scene) }, 1200) + if (scene == "visibility") { + mainHandler.postDelayed({ + overlay.hide() + logVisibility("hidden") + }, 5000) + mainHandler.postDelayed({ + overlay.show() + logVisibility("shown") + }, 8000) + mainHandler.postDelayed({ + overlay.dismiss() + logVisibility("dismissed") + }, 11000) + } + } + + private fun logVisibility(state: String) { + val decorView = overlay.window?.decorView + Log.i( + TAG, + "visibility $state attached=${decorView?.isAttachedToWindow} shown=${decorView?.isShown} visibility=${decorView?.visibility}", + ) + } + + private fun capture(scene: String) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + Log.i(TAG, "PixelCopy probes require API 26 or newer") + return + } + val activityWindow = window + val dialogWindow = overlay.window ?: return + val activityView = activityWindow.decorView + val dialogView = dialogWindow.decorView + val activityLocation = IntArray(2).also(activityView::getLocationOnScreen) + val dialogLocation = IntArray(2).also(dialogView::getLocationOnScreen) + val dialogWindowLocation = IntArray(2).also(dialogView::getLocationInWindow) + val dialogSurfaceLocation = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) IntArray(2).also(dialogView::getLocationInSurface) else null + val nestedView = nestedOverlay?.window?.decorView + val nestedLocation = nestedView?.let { IntArray(2).also(it::getLocationOnScreen).contentToString() } + Log.i( + TAG, + "scene=$scene windowClass=${overlay.javaClass.name} " + + "activity=${activityView.width}x${activityView.height}@${activityLocation.contentToString()} " + + "dialog=${dialogView.width}x${dialogView.height}@${dialogLocation.contentToString()} " + + "inWindow=${dialogWindowLocation.contentToString()} " + + "inSurface=${dialogSurfaceLocation?.contentToString() ?: "unavailable"} " + + "dim=${dialogWindow.attributes.dimAmount} dialogFocus=${dialogView.hasWindowFocus()} " + + "opacity=${dialogWindow.attributes.alpha} nested=${nestedView?.width}x${nestedView?.height}@$nestedLocation " + + "nestedDim=${nestedOverlay?.window?.attributes?.dimAmount}", + ) + Thread { + val handlerThread = HandlerThread("proof-pixel-copy").apply { start() } + try { + copy(scene, "activity", activityWindow, activityView.width, activityView.height, Bitmap.Config.ARGB_8888, handlerThread) + copy(scene, "dialog-argb", dialogWindow, dialogView.width, dialogView.height, Bitmap.Config.ARGB_8888, handlerThread) + copy( + scene, + "dialog-decor-crop", + dialogWindow, + dialogView.width, + dialogView.height, + Bitmap.Config.ARGB_8888, + handlerThread, + Rect(0, 0, dialogView.width, dialogView.height), + ) + if (dialogSurfaceLocation != null && scene == "wrap") { + copy( + scene, + "dialog-surface-crop", + dialogWindow, + dialogView.width, + dialogView.height, + Bitmap.Config.ARGB_8888, + handlerThread, + Rect( + dialogSurfaceLocation[0], + dialogSurfaceLocation[1], + dialogView.width + dialogSurfaceLocation[0], + dialogView.height + dialogSurfaceLocation[1], + ), + ) + } + copy(scene, "dialog-rgb565", dialogWindow, dialogView.width, dialogView.height, Bitmap.Config.RGB_565, handlerThread) + val width = dialogView.width + val height = dialogView.height + if (width > 400 && height > 400) { + val crop = Rect(width / 2 - 200, height / 2 - 200, width / 2 + 200, height / 2 + 200) + copy(scene, "dialog-crop", dialogWindow, 200, 200, Bitmap.Config.ARGB_8888, handlerThread, crop) + } + copyAcrossFormats(scene, dialogWindow, width, height, activityView.width, activityView.height, handlerThread) + nestedOverlay?.window?.let { nestedWindow -> + nestedView?.let { + copy(scene, "nested-argb", nestedWindow, it.width, it.height, Bitmap.Config.ARGB_8888, handlerThread) + } + } + } finally { + handlerThread.quitSafely() + } + }.start() + } + + @RequiresApi(Build.VERSION_CODES.O) + private fun copy( + scene: String, + name: String, + source: Window, + width: Int, + height: Int, + config: Bitmap.Config, + handlerThread: HandlerThread, + crop: Rect? = null, + ) { + if (width <= 0 || height <= 0) return + val bitmap = Bitmap.createBitmap(width, height, config) + bitmap.eraseColor(Color.MAGENTA) + val latch = CountDownLatch(1) + var result = -1 + val handler = Handler(handlerThread.looper) + PixelCopy.request(source, crop, bitmap, { + result = it + latch.countDown() + }, handler) + val finished = latch.await(5, TimeUnit.SECONDS) + if (!finished || result != PixelCopy.SUCCESS) { + Log.i(TAG, "$scene/$name PixelCopy finished=$finished result=$result") + bitmap.recycle() + return + } + val pixels = IntArray(width * height) + bitmap.getPixels(pixels, 0, width, 0, 0, width, height) + val transparent = pixels.count { Color.alpha(it) == 0 } + val partial = pixels.count { Color.alpha(it) in 1..254 } + val opaque = pixels.size - transparent - partial + val encoded = ByteArrayOutputStream().also { bitmap.compress(Bitmap.CompressFormat.WEBP, 80, it) }.toByteArray() + val decoded = android.graphics.BitmapFactory.decodeByteArray(encoded, 0, encoded.size) + val center = decoded.getPixel(width / 2, height / 2) + val corner = decoded.getPixel(0, 0) + val output = File(getExternalFilesDir(null), "$scene-$name.webp") + output.writeBytes(encoded) + Log.i( + TAG, + "$scene/$name config=$config allocation=${bitmap.allocationByteCount} " + + "alpha=[$transparent,$partial,$opaque] decodedCenter=${Integer.toHexString(center)} " + + "decodedCorner=${Integer.toHexString(corner)} webp=${encoded.size} output=$output", + ) + decoded.recycle() + bitmap.recycle() + } + + @RequiresApi(Build.VERSION_CODES.O) + private fun copyAcrossFormats( + scene: String, + source: Window, + width: Int, + height: Int, + maximumWidth: Int, + maximumHeight: Int, + handlerThread: HandlerThread, + ) { + val scratch = Bitmap.createBitmap(maximumWidth, maximumHeight, Bitmap.Config.ARGB_8888) + val allocation = scratch.allocationByteCount + try { + for (config in listOf(Bitmap.Config.RGB_565, Bitmap.Config.ARGB_8888)) { + scratch.reconfigure(width, height, config) + val beforeAlpha = scratch.hasAlpha() + val latch = CountDownLatch(1) + var result = -1 + PixelCopy.request(source, scratch, { + result = it + latch.countDown() + }, Handler(handlerThread.looper)) + val finished = latch.await(5, TimeUnit.SECONDS) + val pixels = IntArray(width * height) + if (finished && result == PixelCopy.SUCCESS) { + scratch.getPixels(pixels, 0, width, 0, 0, width, height) + } + val transparent = pixels.count { Color.alpha(it) == 0 } + val partial = pixels.count { Color.alpha(it) in 1..254 } + Log.i( + TAG, + "$scene/reconfigured $config finished=$finished result=$result " + + "beforeAlpha=$beforeAlpha afterAlpha=${scratch.hasAlpha()} " + + "allocation=${scratch.allocationByteCount} initialAllocation=$allocation " + + "transparent=$transparent partial=$partial corner=${Integer.toHexString(scratch.getPixel(0, 0))}", + ) + } + } finally { + scratch.recycle() + } + } + + companion object { + private const val TAG = "WindowCompositionProof" + } +} diff --git a/posthog-samples/posthog-android-sample/src/debug/res/values/themes.xml b/posthog-samples/posthog-android-sample/src/debug/res/values/themes.xml new file mode 100644 index 000000000..5620c1fd7 --- /dev/null +++ b/posthog-samples/posthog-android-sample/src/debug/res/values/themes.xml @@ -0,0 +1,4 @@ + + +