Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
61069a5
add floating button
abelonogov-ld Nov 30, 2025
69799b5
sample of adding floating views
abelonogov-ld Nov 30, 2025
81c369f
consolidate in one view
abelonogov-ld Nov 30, 2025
8b0c650
use Canvas for certain types of windows
abelonogov-ld Nov 30, 2025
6dafcb3
space
abelonogov-ld Nov 30, 2025
04b4c75
Attached dialog sample
abelonogov-ld Nov 30, 2025
55b00a7
no message
abelonogov-ld Dec 1, 2025
8ddba24
fix size bug
abelonogov-ld Dec 1, 2025
008a414
Recycle bitmap early
abelonogov-ld Dec 1, 2025
4d5536b
Fix feedback
abelonogov-ld Dec 1, 2025
ce45b42
address feedback
abelonogov-ld Dec 1, 2025
50aee55
Volatile
abelonogov-ld Dec 1, 2025
04c252e
Unit tests
abelonogov-ld Dec 1, 2025
54f08de
create Mask
abelonogov-ld Dec 1, 2025
d6fd378
add floating button
abelonogov-ld Nov 30, 2025
59c2f6d
sample of adding floating views
abelonogov-ld Nov 30, 2025
3ff3e81
consolidate in one view
abelonogov-ld Nov 30, 2025
41e2645
use Canvas for certain types of windows
abelonogov-ld Nov 30, 2025
ac99967
space
abelonogov-ld Nov 30, 2025
801ae96
Attached dialog sample
abelonogov-ld Nov 30, 2025
e618524
Matrix
abelonogov-ld Dec 1, 2025
a92e869
Merge branch 'andrey/nonstandard-views' into andrey/delayed-mask2
abelonogov-ld Dec 1, 2025
d86bd09
Merge branch 'andrey/do-not-send-duplicate-windows' into andrey/delay…
abelonogov-ld Dec 1, 2025
1ece408
renaming to MaskCollector
abelonogov-ld Dec 1, 2025
3e570b4
add floating button
abelonogov-ld Nov 30, 2025
aef3154
sample of adding floating views
abelonogov-ld Nov 30, 2025
12ed36c
consolidate in one view
abelonogov-ld Nov 30, 2025
343690e
use Canvas for certain types of windows
abelonogov-ld Nov 30, 2025
a4cf491
space
abelonogov-ld Nov 30, 2025
b117d79
Attached dialog sample
abelonogov-ld Nov 30, 2025
55e29da
Filter AndroidComposeView
abelonogov-ld Dec 2, 2025
4c0db23
fix mask warning
abelonogov-ld Dec 2, 2025
0144cef
using root.locationOnScreen
abelonogov-ld Dec 2, 2025
31bb519
address feadback
abelonogov-ld Dec 2, 2025
4daa13f
Merge branch 'andrey/nonstandard-views' into andrey/delayed-mask2
abelonogov-ld Dec 2, 2025
4558bbe
Fix warnings
abelonogov-ld Dec 2, 2025
7cb314b
fix warnings
abelonogov-ld Dec 2, 2025
15825ce
Merge branch 'main' into andrey/mask-collector
abelonogov-ld Dec 2, 2025
89ba6c5
remove ressurectued import
abelonogov-ld Dec 2, 2025
0079a42
address feedback
abelonogov-ld Dec 3, 2025
1180d8b
delete renamed
abelonogov-ld Dec 3, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package com.launchdarkly.observability.replay.capture

import android.annotation.SuppressLint
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
Expand All @@ -16,10 +15,11 @@ import android.view.View
import android.view.Window
import android.view.WindowManager.LayoutParams.TYPE_APPLICATION
import android.view.WindowManager.LayoutParams.TYPE_BASE_APPLICATION
import androidx.annotation.RequiresApi
import com.launchdarkly.logging.LDLogger
import com.launchdarkly.observability.coroutines.DispatcherProviderHolder
import com.launchdarkly.observability.replay.masking.MaskMatcher
import com.launchdarkly.observability.replay.masking.SensitiveAreasCollector
import com.launchdarkly.observability.replay.masking.MaskCollector
import io.opentelemetry.android.session.SessionManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
Expand All @@ -29,11 +29,12 @@ import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import java.io.ByteArrayOutputStream
import kotlin.coroutines.resume
import androidx.compose.ui.geometry.Rect as ComposeRect
import androidx.core.graphics.withTranslation
import com.launchdarkly.observability.replay.masking.Mask
import androidx.core.graphics.createBitmap

/**
* A source of [CaptureEvent]s taken from the most recently resumed [Activity]s window. Captures
* A source of [CaptureEvent]s taken from the lowest visible window. Captures
* are emitted on the [captureFlow] property of this class.
*
* @param sessionManager Used to get current session for tagging [CaptureEvent] with session id
Expand All @@ -47,13 +48,13 @@ class CaptureSource(
data class CaptureResult(
val windowEntry: WindowEntry,
val bitmap: Bitmap,
val masks: List<ComposeRect>
val masks: List<Mask>
)

private val _captureEventFlow = MutableSharedFlow<CaptureEvent>()
val captureFlow: SharedFlow<CaptureEvent> = _captureEventFlow.asSharedFlow()
private val windowInspector = WindowInspector(logger)
private val sensitiveAreasCollector = SensitiveAreasCollector(logger)
private val maskCollector = MaskCollector(logger)
private val maskPaint = Paint().apply {
color = Color.GRAY
style = Paint.Style.FILL
Expand All @@ -78,7 +79,7 @@ class CaptureSource(
private suspend fun doCapture(): CaptureEvent? =
withContext(DispatcherProviderHolder.current.main) {
// Synchronize with UI rendering frame
suspendCancellableCoroutine<Unit> { continuation ->
suspendCancellableCoroutine { continuation ->
Choreographer.getInstance().postFrameCallback {
if (continuation.isActive) {
continuation.resume(Unit)
Expand Down Expand Up @@ -167,8 +168,8 @@ class CaptureSource(

private suspend fun captureViewResult(windowEntry: WindowEntry): CaptureResult? {
val bitmap = captureViewBitmap(windowEntry) ?: return null
val sensitiveComposeRects = sensitiveAreasCollector.collectFromActivity(windowEntry.rootView, maskMatchers)
return CaptureResult(windowEntry, bitmap, sensitiveComposeRects)
val masks = maskCollector.collectMasks(windowEntry.rootView, maskMatchers)
return CaptureResult(windowEntry, bitmap, masks)
}

private suspend fun captureViewBitmap(windowEntry: WindowEntry): Bitmap? {
Expand All @@ -187,21 +188,16 @@ class CaptureSource(
return withContext(Dispatchers.Main.immediate) {
if (!view.isAttachedToWindow || !view.isShown) return@withContext null

return@withContext canvasDraw(view, windowEntry.rect())
return@withContext canvasDraw(view)
}
}

@SuppressLint("NewApi")
@RequiresApi(Build.VERSION_CODES.O)
private suspend fun pixelCopy(
window: Window,
view: View,
rect: Rect,
): Bitmap? {
val bitmap = Bitmap.createBitmap(
view.width,
view.height,
Bitmap.Config.ARGB_8888
)
val bitmap = createBitmap(view.width, view.height)

return suspendCancellableCoroutine { continuation ->
val handler = Handler(Looper.getMainLooper())
Expand All @@ -228,14 +224,9 @@ class CaptureSource(
}

private fun canvasDraw(
view: View,
rect: Rect,
): Bitmap? {
val bitmap = Bitmap.createBitmap(
view.width,
view.height,
Bitmap.Config.ARGB_8888
)
view: View
): Bitmap {
val bitmap = createBitmap(view.width, view.height)

val canvas = Canvas(bitmap)
view.draw(canvas)
Expand Down Expand Up @@ -287,17 +278,17 @@ class CaptureSource(
* Applies masking rectangles to the provided [canvas] using the provided [masks].
*
* @param canvas The canvas to mask
* @param masks rects that will be masked
* @param masks areas that will be masked
*/
private fun drawMasks(canvas: Canvas, masks: List<ComposeRect>) {
private fun drawMasks(canvas: Canvas, masks: List<Mask>) {
masks.forEach { mask ->
val androidRect = Rect(
mask.left.toInt(),
mask.top.toInt(),
mask.right.toInt(),
mask.bottom.toInt()
val integerRect = Rect(
mask.rect.left.toInt(),
mask.rect.top.toInt(),
mask.rect.right.toInt(),
mask.rect.bottom.toInt()
)
canvas.drawRect(androidRect, maskPaint)
canvas.drawRect(integerRect, maskPaint)
}
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package com.launchdarkly.observability.replay.capture

import android.graphics.Rect
import android.os.Build
import android.view.View
import android.view.WindowManager
import android.view.WindowManager.LayoutParams.TYPE_APPLICATION_STARTING
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,27 @@
package com.launchdarkly.observability.replay.capture

import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.graphics.Rect
import android.os.Build
import android.view.View
import android.view.Window
import android.view.WindowManager
import com.launchdarkly.logging.LDLogger
import com.launchdarkly.observability.replay.utils.locationOnScreen
import kotlin.jvm.javaClass

class WindowInspector(private val logger: LDLogger) {

fun appWindows(appContext: Context? = null): List<WindowEntry> {
val appUid = appContext?.applicationInfo?.uid
val views = getRootViews() ?: return emptyList()
val views: List<View> = if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q){
android.view.inspector.WindowInspector.getGlobalWindowViews().map { it.rootView }
} else {
getRootViews()
}
return views.mapNotNull { view ->
if (appUid != null && view.context.applicationInfo?.uid != appUid) return@mapNotNull null
if (!view.isAttachedToWindow || !view.isShown) return@mapNotNull null
Expand All @@ -24,8 +31,7 @@ class WindowInspector(private val logger: LDLogger) {
if (!view.getGlobalVisibleRect(visibleRect)) return@mapNotNull null
if (visibleRect.width() == 0 || visibleRect.height() == 0) return@mapNotNull null

val loc = IntArray(2)
view.getLocationOnScreen(loc)
val (screenX, screenY) = view.locationOnScreen()

val layoutParams = view.layoutParams as? WindowManager.LayoutParams
val wmType = layoutParams?.type ?: 0
Expand All @@ -36,8 +42,8 @@ class WindowInspector(private val logger: LDLogger) {
layoutParams = layoutParams,
width = view.width,
height = view.height,
screenLeft = loc[0],
screenTop = loc[1]
screenLeft = screenX.toInt(),
screenTop = screenY.toInt()
)
}
}
Expand All @@ -50,6 +56,7 @@ class WindowInspector(private val logger: LDLogger) {
* 2) Reflection to call getWindow() if present
* 3) Context unwrap to Activity and return activity.window (best-effort fallback)
*/
@SuppressLint("PrivateApi")
fun findWindow(rootView: View): Window? {
// 1) Try to read a private field "mWindow" (present on DecorView/PopupDecorView)
try {
Expand Down Expand Up @@ -100,6 +107,7 @@ class WindowInspector(private val logger: LDLogger) {
return null
}

@SuppressLint("PrivateApi")
fun getRootViews(): List<View> {
return try {
val wmgClass = Class.forName("android.view.WindowManagerGlobal")
Expand All @@ -111,8 +119,7 @@ class WindowInspector(private val logger: LDLogger) {
}.getOrNull()

if (getRootViewsMethod != null) {
val result = getRootViewsMethod.invoke(instance)
return when (result) {
return when (val result = getRootViewsMethod.invoke(instance)) {
is Array<*> -> result.filterIsInstance<View>()
is List<*> -> result.filterIsInstance<View>()
else -> emptyList()
Expand All @@ -125,8 +132,7 @@ class WindowInspector(private val logger: LDLogger) {
}.getOrNull()

if (mViewsField != null) {
val result = mViewsField.get(instance)
return when (result) {
return when (val result = mViewsField.get(instance)) {
is Array<*> -> result.filterIsInstance<View>()
is List<*> -> result.filterIsInstance<View>()
else -> emptyList()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
package com.launchdarkly.observability.replay.masking

import android.view.View
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.graphics.toAndroidRectF
import androidx.compose.ui.platform.AbstractComposeView
import androidx.compose.ui.semantics.SemanticsNode
import androidx.compose.ui.semantics.SemanticsOwner
import androidx.compose.ui.semantics.SemanticsConfiguration
Expand All @@ -23,7 +24,7 @@ data class ComposeMaskTarget(
val boundsInWindow: MaskRect,
) : MaskTarget {
companion object {
fun from(composeView: ComposeView, logger: LDLogger): ComposeMaskTarget? {
fun from(composeView: AbstractComposeView, logger: LDLogger): ComposeMaskTarget? {
val root = getRootSemanticsNode(composeView, logger) ?: return null
return ComposeMaskTarget(
view = composeView,
Expand All @@ -37,7 +38,7 @@ data class ComposeMaskTarget(
* Gets the SemanticsOwner from a ComposeView using reflection. This is necessary because
* AndroidComposeView and semanticsOwner are not publicly exposed.
*/
private fun getRootSemanticsNode(composeView: ComposeView, logger: LDLogger): SemanticsNode? {
private fun getRootSemanticsNode(composeView: AbstractComposeView, logger: LDLogger): SemanticsNode? {
return try {
if (composeView.isNotEmpty()) {
val androidComposeView = composeView.getChildAt(0)
Expand Down Expand Up @@ -72,8 +73,12 @@ data class ComposeMaskTarget(
return config.contains(SemanticsProperties.Text)
}

override fun maskRect(): MaskRect? {
return boundsInWindow
override fun mask(): Mask? {
val rect = boundsInWindow.toAndroidRectF()
if (rect.width() <= 0f || rect.height() <= 0f) {
return null
}
return Mask(boundsInWindow.toAndroidRectF(), view.id)
}

override fun hasLDMask(): Boolean {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.launchdarkly.observability.replay.masking
import android.graphics.RectF
import androidx.compose.ui.graphics.Matrix

data class Mask(
val rect: RectF,
val viewId: Int,
val points: FloatArray? = null,
val matrix: Matrix? = null
){
// Implemented to suppress warning
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is Mask) return false
return rect == other.rect &&
viewId == other.viewId &&
points.contentEquals(other.points) &&
matrix == other.matrix
}

// Implemented to suppress warning
override fun hashCode(): Int {
var result = rect.hashCode()
result = 31 * result + viewId
result = 31 * result + points.contentHashCode()
result = 31 * result + (matrix?.hashCode() ?: 0)
return result
}
}
Loading
Loading