Skip to content

Commit ee9f061

Browse files
feat: recursive mask collection (#308)
## Summary 1. Refactored to use recursive collection to allow to gather Matrix transformations and hierarchical privacy options 2. Use AbstractComposeView as the base for compose to catch non-standard ComposeViews 3. Filter out AndroidComposeViews, they are always internal 4. 0 warning state of SR code <img width="1092" height="905" alt="image" src="https://github.com/user-attachments/assets/9a729721-4ed5-46c8-bbba-2b7834ac57ad" /> ## How did you test this change? <!-- Frontend - Leave a screencast or a screenshot to visually describe the changes. --> ## Are there any deployment considerations? <!-- Backend - Do we need to consider migrations or backfilling data? --> <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Switches to a recursive mask collection using a new Mask model, updates capture to draw masks across windows, and improves window inspection on Q+. > > - **Masking**: > - Introduces `Mask` model and new recursive `MaskCollector` that traverses native and Compose (`AbstractComposeView`) hierarchies; filters internal `AndroidComposeView`. > - Updates `ComposeMaskTarget`/`NativeMaskTarget` to return `Mask`; adds stronger password detection via `InputType` checks. > - Removes `SensitiveAreasCollector` and `maskRect` API in favor of `mask()`. > - **Capture**: > - `CaptureSource` now collects `Mask`s and draws them; bases capture on lowest visible window and composites additional windows with translations. > - Refactors bitmap creation (`createBitmap`), adds `@RequiresApi` to `pixelCopy`, simplifies `canvasDraw`. > - **Window inspection**: > - `WindowInspector` uses Q+ `WindowInspector.getGlobalWindowViews()` and a new `locationOnScreen()` util for coordinates; cleans up root view reflection. > - **Utils**: > - Adds `View.locationOnScreen()` extension. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 1180d8b. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 199374a commit ee9f061

File tree

10 files changed

+208
-192
lines changed

10 files changed

+208
-192
lines changed

sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/capture/CaptureSource.kt

Lines changed: 24 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
package com.launchdarkly.observability.replay.capture
22

3-
import android.annotation.SuppressLint
43
import android.graphics.Bitmap
54
import android.graphics.Canvas
65
import android.graphics.Color
@@ -16,10 +15,11 @@ import android.view.View
1615
import android.view.Window
1716
import android.view.WindowManager.LayoutParams.TYPE_APPLICATION
1817
import android.view.WindowManager.LayoutParams.TYPE_BASE_APPLICATION
18+
import androidx.annotation.RequiresApi
1919
import com.launchdarkly.logging.LDLogger
2020
import com.launchdarkly.observability.coroutines.DispatcherProviderHolder
2121
import com.launchdarkly.observability.replay.masking.MaskMatcher
22-
import com.launchdarkly.observability.replay.masking.SensitiveAreasCollector
22+
import com.launchdarkly.observability.replay.masking.MaskCollector
2323
import io.opentelemetry.android.session.SessionManager
2424
import kotlinx.coroutines.Dispatchers
2525
import kotlinx.coroutines.flow.MutableSharedFlow
@@ -29,11 +29,12 @@ import kotlinx.coroutines.suspendCancellableCoroutine
2929
import kotlinx.coroutines.withContext
3030
import java.io.ByteArrayOutputStream
3131
import kotlin.coroutines.resume
32-
import androidx.compose.ui.geometry.Rect as ComposeRect
3332
import androidx.core.graphics.withTranslation
33+
import com.launchdarkly.observability.replay.masking.Mask
34+
import androidx.core.graphics.createBitmap
3435

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

5354
private val _captureEventFlow = MutableSharedFlow<CaptureEvent>()
5455
val captureFlow: SharedFlow<CaptureEvent> = _captureEventFlow.asSharedFlow()
5556
private val windowInspector = WindowInspector(logger)
56-
private val sensitiveAreasCollector = SensitiveAreasCollector(logger)
57+
private val maskCollector = MaskCollector(logger)
5758
private val maskPaint = Paint().apply {
5859
color = Color.GRAY
5960
style = Paint.Style.FILL
@@ -78,7 +79,7 @@ class CaptureSource(
7879
private suspend fun doCapture(): CaptureEvent? =
7980
withContext(DispatcherProviderHolder.current.main) {
8081
// Synchronize with UI rendering frame
81-
suspendCancellableCoroutine<Unit> { continuation ->
82+
suspendCancellableCoroutine { continuation ->
8283
Choreographer.getInstance().postFrameCallback {
8384
if (continuation.isActive) {
8485
continuation.resume(Unit)
@@ -167,8 +168,8 @@ class CaptureSource(
167168

168169
private suspend fun captureViewResult(windowEntry: WindowEntry): CaptureResult? {
169170
val bitmap = captureViewBitmap(windowEntry) ?: return null
170-
val sensitiveComposeRects = sensitiveAreasCollector.collectFromActivity(windowEntry.rootView, maskMatchers)
171-
return CaptureResult(windowEntry, bitmap, sensitiveComposeRects)
171+
val masks = maskCollector.collectMasks(windowEntry.rootView, maskMatchers)
172+
return CaptureResult(windowEntry, bitmap, masks)
172173
}
173174

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

190-
return@withContext canvasDraw(view, windowEntry.rect())
191+
return@withContext canvasDraw(view)
191192
}
192193
}
193-
194-
@SuppressLint("NewApi")
194+
@RequiresApi(Build.VERSION_CODES.O)
195195
private suspend fun pixelCopy(
196196
window: Window,
197197
view: View,
198198
rect: Rect,
199199
): Bitmap? {
200-
val bitmap = Bitmap.createBitmap(
201-
view.width,
202-
view.height,
203-
Bitmap.Config.ARGB_8888
204-
)
200+
val bitmap = createBitmap(view.width, view.height)
205201

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

230226
private fun canvasDraw(
231-
view: View,
232-
rect: Rect,
233-
): Bitmap? {
234-
val bitmap = Bitmap.createBitmap(
235-
view.width,
236-
view.height,
237-
Bitmap.Config.ARGB_8888
238-
)
227+
view: View
228+
): Bitmap {
229+
val bitmap = createBitmap(view.width, view.height)
239230

240231
val canvas = Canvas(bitmap)
241232
view.draw(canvas)
@@ -287,17 +278,17 @@ class CaptureSource(
287278
* Applies masking rectangles to the provided [canvas] using the provided [masks].
288279
*
289280
* @param canvas The canvas to mask
290-
* @param masks rects that will be masked
281+
* @param masks areas that will be masked
291282
*/
292-
private fun drawMasks(canvas: Canvas, masks: List<ComposeRect>) {
283+
private fun drawMasks(canvas: Canvas, masks: List<Mask>) {
293284
masks.forEach { mask ->
294-
val androidRect = Rect(
295-
mask.left.toInt(),
296-
mask.top.toInt(),
297-
mask.right.toInt(),
298-
mask.bottom.toInt()
285+
val integerRect = Rect(
286+
mask.rect.left.toInt(),
287+
mask.rect.top.toInt(),
288+
mask.rect.right.toInt(),
289+
mask.rect.bottom.toInt()
299290
)
300-
canvas.drawRect(androidRect, maskPaint)
291+
canvas.drawRect(integerRect, maskPaint)
301292
}
302293
}
303294
}

sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/capture/WindowEntry.kt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package com.launchdarkly.observability.replay.capture
22

33
import android.graphics.Rect
4-
import android.os.Build
54
import android.view.View
65
import android.view.WindowManager
76
import android.view.WindowManager.LayoutParams.TYPE_APPLICATION_STARTING

sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/capture/WindowInspector.kt

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,27 @@
11
package com.launchdarkly.observability.replay.capture
22

3+
import android.annotation.SuppressLint
34
import android.app.Activity
45
import android.content.Context
56
import android.content.ContextWrapper
67
import android.graphics.Rect
8+
import android.os.Build
79
import android.view.View
810
import android.view.Window
911
import android.view.WindowManager
1012
import com.launchdarkly.logging.LDLogger
13+
import com.launchdarkly.observability.replay.utils.locationOnScreen
1114
import kotlin.jvm.javaClass
1215

1316
class WindowInspector(private val logger: LDLogger) {
1417

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

27-
val loc = IntArray(2)
28-
view.getLocationOnScreen(loc)
34+
val (screenX, screenY) = view.locationOnScreen()
2935

3036
val layoutParams = view.layoutParams as? WindowManager.LayoutParams
3137
val wmType = layoutParams?.type ?: 0
@@ -36,8 +42,8 @@ class WindowInspector(private val logger: LDLogger) {
3642
layoutParams = layoutParams,
3743
width = view.width,
3844
height = view.height,
39-
screenLeft = loc[0],
40-
screenTop = loc[1]
45+
screenLeft = screenX.toInt(),
46+
screenTop = screenY.toInt()
4147
)
4248
}
4349
}
@@ -50,6 +56,7 @@ class WindowInspector(private val logger: LDLogger) {
5056
* 2) Reflection to call getWindow() if present
5157
* 3) Context unwrap to Activity and return activity.window (best-effort fallback)
5258
*/
59+
@SuppressLint("PrivateApi")
5360
fun findWindow(rootView: View): Window? {
5461
// 1) Try to read a private field "mWindow" (present on DecorView/PopupDecorView)
5562
try {
@@ -100,6 +107,7 @@ class WindowInspector(private val logger: LDLogger) {
100107
return null
101108
}
102109

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

113121
if (getRootViewsMethod != null) {
114-
val result = getRootViewsMethod.invoke(instance)
115-
return when (result) {
122+
return when (val result = getRootViewsMethod.invoke(instance)) {
116123
is Array<*> -> result.filterIsInstance<View>()
117124
is List<*> -> result.filterIsInstance<View>()
118125
else -> emptyList()
@@ -125,8 +132,7 @@ class WindowInspector(private val logger: LDLogger) {
125132
}.getOrNull()
126133

127134
if (mViewsField != null) {
128-
val result = mViewsField.get(instance)
129-
return when (result) {
135+
return when (val result = mViewsField.get(instance)) {
130136
is Array<*> -> result.filterIsInstance<View>()
131137
is List<*> -> result.filterIsInstance<View>()
132138
else -> emptyList()

sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/masking/ComposeMaskTarget.kt

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
package com.launchdarkly.observability.replay.masking
22

33
import android.view.View
4-
import androidx.compose.ui.platform.ComposeView
4+
import androidx.compose.ui.graphics.toAndroidRectF
5+
import androidx.compose.ui.platform.AbstractComposeView
56
import androidx.compose.ui.semantics.SemanticsNode
67
import androidx.compose.ui.semantics.SemanticsOwner
78
import androidx.compose.ui.semantics.SemanticsConfiguration
@@ -23,7 +24,7 @@ data class ComposeMaskTarget(
2324
val boundsInWindow: MaskRect,
2425
) : MaskTarget {
2526
companion object {
26-
fun from(composeView: ComposeView, logger: LDLogger): ComposeMaskTarget? {
27+
fun from(composeView: AbstractComposeView, logger: LDLogger): ComposeMaskTarget? {
2728
val root = getRootSemanticsNode(composeView, logger) ?: return null
2829
return ComposeMaskTarget(
2930
view = composeView,
@@ -37,7 +38,7 @@ data class ComposeMaskTarget(
3738
* Gets the SemanticsOwner from a ComposeView using reflection. This is necessary because
3839
* AndroidComposeView and semanticsOwner are not publicly exposed.
3940
*/
40-
private fun getRootSemanticsNode(composeView: ComposeView, logger: LDLogger): SemanticsNode? {
41+
private fun getRootSemanticsNode(composeView: AbstractComposeView, logger: LDLogger): SemanticsNode? {
4142
return try {
4243
if (composeView.isNotEmpty()) {
4344
val androidComposeView = composeView.getChildAt(0)
@@ -72,8 +73,12 @@ data class ComposeMaskTarget(
7273
return config.contains(SemanticsProperties.Text)
7374
}
7475

75-
override fun maskRect(): MaskRect? {
76-
return boundsInWindow
76+
override fun mask(): Mask? {
77+
val rect = boundsInWindow.toAndroidRectF()
78+
if (rect.width() <= 0f || rect.height() <= 0f) {
79+
return null
80+
}
81+
return Mask(boundsInWindow.toAndroidRectF(), view.id)
7782
}
7883

7984
override fun hasLDMask(): Boolean {
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package com.launchdarkly.observability.replay.masking
2+
import android.graphics.RectF
3+
import androidx.compose.ui.graphics.Matrix
4+
5+
data class Mask(
6+
val rect: RectF,
7+
val viewId: Int,
8+
val points: FloatArray? = null,
9+
val matrix: Matrix? = null
10+
){
11+
// Implemented to suppress warning
12+
override fun equals(other: Any?): Boolean {
13+
if (this === other) return true
14+
if (other !is Mask) return false
15+
return rect == other.rect &&
16+
viewId == other.viewId &&
17+
points.contentEquals(other.points) &&
18+
matrix == other.matrix
19+
}
20+
21+
// Implemented to suppress warning
22+
override fun hashCode(): Int {
23+
var result = rect.hashCode()
24+
result = 31 * result + viewId
25+
result = 31 * result + points.contentHashCode()
26+
result = 31 * result + (matrix?.hashCode() ?: 0)
27+
return result
28+
}
29+
}

0 commit comments

Comments
 (0)