Skip to content

Commit 803fb83

Browse files
feat: Android double masking with frame drop (#342)
## Summary Introduces double masking and if number of masks changed the frame being dropped. <img width="504" height="531" alt="image" src="https://github.com/user-attachments/assets/99c1ec8a-970d-40bf-8dd9-ba86df9ecc38" /> ## 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] > Introduces double-masking to stabilize sensitive-region masking and avoid flicker by dropping unstable frames. > > - Capture: collect masks for each window both before and after a synced frame (`CaptureSource`), merge via `MaskApplier.mergeMasksMap` with position tolerance; if counts/IDs shift or movement exceeds threshold, drop the frame > - Rendering: replace inline mask drawing with `MaskApplier.drawMasks`; compose layered windows onto base bitmap, recycle intermediate bitmaps, and improve PixelCopy error handling and cleanup > - Window selection: `pickBaseWindow` now returns index; capture proceeds from base through overlays; maintains tiled signature dedupe > - Mask targets: use `localToWindow` in Compose masking; minor matrix reuse comment in native target > - E2E demo: add `.ldMask()` to ZIP and CVV fields; minor cleanup in Compose/XML samples > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 5bff2cd. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Agustin Grognetti <agrognetti@launchdarkly.com>
1 parent ed5ee7a commit 803fb83

6 files changed

Lines changed: 258 additions & 107 deletions

File tree

e2e/android/app/src/main/java/com/example/androidobservability/masking/ComposeUserForm.kt

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,7 @@ fun UserInfoForm(modifier: Modifier = Modifier) {
8989
) {
9090
Text(
9191
text = "User Information Form",
92-
style = MaterialTheme.typography.headlineMedium,
93-
modifier = Modifier.ldMask()
92+
style = MaterialTheme.typography.headlineMedium
9493
)
9594

9695
// Password Section
@@ -112,7 +111,8 @@ fun UserInfoForm(modifier: Modifier = Modifier) {
112111
label = { Text("Password") },
113112
visualTransformation = PasswordVisualTransformation(),
114113
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
115-
modifier = Modifier.fillMaxWidth()
114+
modifier = Modifier
115+
.fillMaxWidth()
116116
)
117117
}
118118
}
@@ -160,7 +160,9 @@ fun UserInfoForm(modifier: Modifier = Modifier) {
160160
onValueChange = { zipCode = it },
161161
label = { Text("ZIP Code") },
162162
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
163-
modifier = Modifier.fillMaxWidth()
163+
modifier = Modifier
164+
.fillMaxWidth()
165+
.ldMask()
164166
)
165167
}
166168
}
@@ -208,8 +210,10 @@ fun UserInfoForm(modifier: Modifier = Modifier) {
208210
label = { Text("CVV") },
209211
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
210212
visualTransformation = PasswordVisualTransformation(),
211-
modifier = Modifier.weight(1f)
212-
)
213+
modifier = Modifier
214+
.weight(1f)
215+
.ldMask()
216+
)
213217
}
214218
}
215219
}

e2e/android/app/src/main/java/com/example/androidobservability/masking/XMLMaskingActivity.kt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import android.widget.Toast
1111
import android.graphics.Color
1212
import android.graphics.PixelFormat
1313
import android.view.Gravity
14-
import android.view.ViewGroup
1514
import android.view.WindowManager
1615
import android.widget.FrameLayout
1716
import android.widget.TextView

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

Lines changed: 124 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@ package com.launchdarkly.observability.replay.capture
22

33
import android.graphics.Bitmap
44
import android.graphics.Canvas
5-
import android.graphics.Color
6-
import android.graphics.Paint
75
import android.graphics.Rect
86
import android.os.Build
97
import android.os.Handler
@@ -16,7 +14,6 @@ import android.view.Window
1614
import android.view.WindowManager.LayoutParams.TYPE_APPLICATION
1715
import android.view.WindowManager.LayoutParams.TYPE_BASE_APPLICATION
1816
import androidx.annotation.RequiresApi
19-
import android.graphics.Path
2017
import com.launchdarkly.logging.LDLogger
2118
import com.launchdarkly.observability.coroutines.DispatcherProviderHolder
2219
import com.launchdarkly.observability.replay.masking.MaskMatcher
@@ -33,6 +30,7 @@ import kotlin.coroutines.resume
3330
import androidx.core.graphics.withTranslation
3431
import com.launchdarkly.observability.replay.masking.Mask
3532
import androidx.core.graphics.createBitmap
33+
import com.launchdarkly.observability.replay.masking.MaskApplier
3634

3735
/**
3836
* A source of [CaptureEvent]s taken from the lowest visible window. Captures
@@ -49,21 +47,19 @@ class CaptureSource(
4947
data class CaptureResult(
5048
val windowEntry: WindowEntry,
5149
val bitmap: Bitmap,
52-
val masks: List<Mask>
5350
)
5451

5552
private val _captureEventFlow = MutableSharedFlow<CaptureEvent>()
5653
val captureFlow: SharedFlow<CaptureEvent> = _captureEventFlow.asSharedFlow()
5754
private val windowInspector = WindowInspector(logger)
5855
private val maskCollector = MaskCollector(logger)
59-
private val maskPaint = Paint().apply {
60-
color = Color.GRAY
61-
style = Paint.Style.FILL
62-
}
56+
private val maskApplier = MaskApplier()
6357

6458
private val tiledSignatureManager = TiledSignatureManager()
59+
6560
@Volatile
6661
private var tiledSignature: TiledSignature? = null
62+
6763
/**
6864
* Requests a [CaptureEvent] be taken now.
6965
*/
@@ -96,7 +92,8 @@ class CaptureSource(
9692
return@withContext null
9793
}
9894

99-
val baseWindowEntry = pickBaseWindow(windowsEntries) ?: return@withContext null
95+
val baseIndex = pickBaseWindow(windowsEntries) ?: return@withContext null
96+
val baseWindowEntry = windowsEntries[baseIndex]
10097
val rect = baseWindowEntry.rect()
10198

10299
// protect against race condition where decor view has no size
@@ -107,72 +104,130 @@ class CaptureSource(
107104
// TODO: O11Y-625 - optimize memory allocations
108105
// TODO: O11Y-625 - see if holding bitmap is more efficient than base64 encoding immediately after compression
109106
// TODO: O11Y-628 - use captureQuality option for scaling and adjust this bitmap accordingly, may need to investigate power of 2 rounding for performance
110-
// Create a bitmap with the window dimensions
111-
val baseResult = captureViewResult(baseWindowEntry) ?: return@withContext null
112-
113-
// capture rest of views on top of base
114-
val pairs = mutableListOf<CaptureResult>()
115-
var afterBase = false
116-
for (windowEntry in windowsEntries) {
117-
if (afterBase) {
118-
captureViewResult(windowEntry)?.let { result ->
119-
pairs.add(result)
107+
108+
val capturingWindowEntries = windowsEntries.subList(baseIndex, windowsEntries.size)
109+
110+
val beforeMasks = collectMasks(capturingWindowEntries)
111+
112+
val captureResults: MutableList<CaptureResult?> = MutableList(capturingWindowEntries.size) { null }
113+
try {
114+
var captured = 0
115+
for (i in capturingWindowEntries.indices) {
116+
val windowEntry = capturingWindowEntries[i]
117+
val captureResult = captureViewResult(windowEntry)
118+
if (captureResult == null) {
119+
if (i == 0) {
120+
return@withContext null
121+
}
122+
beforeMasks[i] = null
123+
continue
120124
}
121-
} else if (windowEntry === baseWindowEntry) {
122-
afterBase = true
125+
126+
captured++
127+
captureResults[i] = captureResult
128+
}
129+
if (captured == 0) {
130+
return@withContext null
123131
}
124-
}
125132

126-
return@withContext withContext(DispatcherProviderHolder.current.default) {
127-
// off the main thread to avoid blocking the UI thread
128-
if (pairs.isNotEmpty() || baseResult.masks.isNotEmpty()) {
129-
val canvas = Canvas(baseResult.bitmap)
130-
drawMasks(canvas, baseResult.masks)
131-
132-
for (res in pairs) {
133-
val entry = res.windowEntry
134-
val dx = (entry.screenLeft - baseWindowEntry.screenLeft).toFloat()
135-
val dy = (entry.screenTop - baseWindowEntry.screenTop).toFloat()
136-
137-
canvas.withTranslation(dx, dy) {
138-
drawBitmap(res.bitmap, 0f, 0f, null)
139-
drawMasks(canvas, res.masks)
133+
// Synchronize with UI rendering frame
134+
suspendCancellableCoroutine { continuation ->
135+
Choreographer.getInstance().postFrameCallback {
136+
if (continuation.isActive) {
137+
continuation.resume(Unit)
140138
}
141-
res.bitmap.recycle()
142139
}
143140
}
144141

145-
val newSignature = tiledSignatureManager.compute(baseResult.bitmap, 64)
146-
if (newSignature != null && newSignature == tiledSignature) {
147-
baseResult.bitmap.recycle()
148-
// the similar bitmap not send
149-
return@withContext null
142+
val afterMasks = collectMasksFromResults(captureResults)
143+
144+
// off the main thread to avoid blocking the UI thread
145+
return@withContext withContext(DispatcherProviderHolder.current.default) {
146+
val baseResult = captureResults[0] ?: return@withContext null
147+
148+
val mergedMasks = maskApplier.mergeMasksMap(beforeMasks, afterMasks)
149+
?: run {
150+
// Mask instability is expected during animations/scrolling; ensure we always
151+
// recycle already-captured bitmaps before bailing out to avoid native OOM.
152+
return@withContext null
153+
}
154+
155+
// if need to draw something on base bitmap additionally
156+
if (captureResults.size > 1 || (mergedMasks.isNotEmpty() && mergedMasks[0] != null)) {
157+
val canvas = Canvas(baseResult.bitmap)
158+
mergedMasks[0]?.let { maskApplier.drawMasks(canvas, it) }
159+
160+
for (i in 1 until captureResults.size) {
161+
val res = captureResults[i] ?: continue
162+
val entry = res.windowEntry
163+
val dx = (entry.screenLeft - baseWindowEntry.screenLeft).toFloat()
164+
val dy = (entry.screenTop - baseWindowEntry.screenTop).toFloat()
165+
166+
canvas.withTranslation(dx, dy) {
167+
drawBitmap(res.bitmap, 0f, 0f, null)
168+
mergedMasks[i]?.let { maskApplier.drawMasks(canvas, it) }
169+
}
170+
if (!res.bitmap.isRecycled) {
171+
res.bitmap.recycle()
172+
}
173+
}
174+
}
175+
176+
val newSignature = tiledSignatureManager.compute(baseResult.bitmap, 64)
177+
if (newSignature != null && newSignature == tiledSignature) {
178+
// the similar bitmap not send
179+
return@withContext null
180+
}
181+
tiledSignature = newSignature
182+
183+
createCaptureEvent(baseResult.bitmap, rect, timestamp, session)
150184
}
151-
tiledSignature = newSignature
185+
} finally {
186+
recycleCaptureResults(captureResults)
187+
}
188+
}
152189

153-
createCaptureEvent(baseResult.bitmap, rect, timestamp, session)
190+
private fun recycleCaptureResults(captureResults: List<CaptureResult?>) {
191+
for (res in captureResults) {
192+
val bitmap = res?.bitmap ?: continue
193+
if (!bitmap.isRecycled) {
194+
bitmap.recycle()
154195
}
155196
}
197+
}
156198

157-
private fun pickBaseWindow(windowsEntries: List<WindowEntry>): WindowEntry? {
158-
windowsEntries.firstOrNull {
199+
private fun collectMasks(capturingWindowEntries: List<WindowEntry>): MutableList<List<Mask>?> {
200+
return capturingWindowEntries.map {
201+
maskCollector.collectMasks( it.rootView, maskMatchers)
202+
}.toMutableList()
203+
}
204+
205+
private fun collectMasksFromResults(captureResults: List<CaptureResult?>): MutableList<List<Mask>?> {
206+
return captureResults.map { result ->
207+
result?.windowEntry?.rootView?.let { rv -> maskCollector.collectMasks(rv, maskMatchers) }
208+
}.toMutableList()
209+
}
210+
211+
private fun pickBaseWindow(windowsEntries: List<WindowEntry>): Int? {
212+
val appIdx = windowsEntries.indexOfFirst {
159213
val wmType = it.layoutParams?.type ?: 0
160-
(wmType == TYPE_APPLICATION || wmType == TYPE_BASE_APPLICATION)
161-
}?.let { return it }
214+
wmType == TYPE_APPLICATION || wmType == TYPE_BASE_APPLICATION
215+
}
216+
if (appIdx >= 0) return appIdx
162217

163-
windowsEntries.firstOrNull { it.type == WindowType.ACTIVITY }?.let { return it }
218+
val activityIdx = windowsEntries.indexOfFirst { it.type == WindowType.ACTIVITY }
219+
if (activityIdx >= 0) return activityIdx
164220

165-
windowsEntries.firstOrNull { it.type == WindowType.DIALOG }?.let { return it }
221+
val dialogIdx = windowsEntries.indexOfFirst { it.type == WindowType.DIALOG }
222+
if (dialogIdx >= 0) return dialogIdx
166223

167224
// Fallback to the first available
168-
return windowsEntries.firstOrNull()
225+
return if (windowsEntries.isNotEmpty()) 0 else null
169226
}
170227

171228
private suspend fun captureViewResult(windowEntry: WindowEntry): CaptureResult? {
172229
val bitmap = captureViewBitmap(windowEntry) ?: return null
173-
val masks = maskCollector.collectMasks(windowEntry.rootView, maskMatchers)
174-
175-
return CaptureResult(windowEntry, bitmap, masks)
230+
return CaptureResult(windowEntry, bitmap)
176231
}
177232

178233
private suspend fun captureViewBitmap(windowEntry: WindowEntry): Bitmap? {
@@ -194,6 +249,7 @@ class CaptureSource(
194249
return@withContext canvasDraw(view)
195250
}
196251
}
252+
197253
@RequiresApi(Build.VERSION_CODES.O)
198254
private suspend fun pixelCopy(
199255
window: Window,
@@ -202,28 +258,36 @@ class CaptureSource(
202258
): Bitmap? {
203259
val bitmap = createBitmapForView(view) ?: return null
204260

205-
return suspendCancellableCoroutine { continuation ->
261+
val result = suspendCancellableCoroutine { continuation ->
206262
val handler = Handler(Looper.getMainLooper())
207263
try {
208264
PixelCopy.request(
209265
window,
210266
rect,
211267
bitmap,
212-
{ result ->
213-
if (!continuation.isActive) return@request
214-
if (result == PixelCopy.SUCCESS) {
268+
{ copyResult ->
269+
if (!continuation.isActive) {
270+
bitmap.recycle()
271+
return@request
272+
}
273+
if (copyResult == PixelCopy.SUCCESS) {
215274
continuation.resume(bitmap)
216275
} else {
217276
continuation.resume(null)
218277
}
219278
}, handler
220279
)
221-
} catch (exp: Exception) {
280+
} catch (t: Throwable) {
222281
// It could normally happen when view is being closed during screenshot
223-
logger.warn("Failed to capture window", exp)
282+
logger.warn("Failed to capture window", t)
224283
continuation.resume(null)
225284
}
226285
}
286+
287+
if (result == null && !bitmap.isRecycled) {
288+
bitmap.recycle()
289+
}
290+
return result
227291
}
228292

229293
private fun canvasDraw(
@@ -292,39 +356,4 @@ class CaptureSource(
292356
}
293357
}
294358
}
295-
296-
/**
297-
* Applies masking rectangles to the provided [canvas] using the provided [masks].
298-
*
299-
* @param canvas The canvas to mask
300-
* @param masks areas that will be masked
301-
*/
302-
private fun drawMasks(canvas: Canvas, masks: List<Mask>) {
303-
val path = Path()
304-
masks.forEach { mask ->
305-
drawMask(mask, path, canvas, maskPaint)
306-
}
307-
}
308-
309-
private val maskIntRect = Rect()
310-
private fun drawMask(mask: Mask, path: Path, canvas: Canvas, paint: Paint) {
311-
if (mask.points != null) {
312-
val pts = mask.points
313-
314-
path.reset()
315-
path.moveTo(pts[0], pts[1])
316-
path.lineTo(pts[2], pts[3])
317-
path.lineTo(pts[4], pts[5])
318-
path.lineTo(pts[6], pts[7])
319-
path.close()
320-
321-
canvas.drawPath(path, paint)
322-
} else {
323-
maskIntRect.left = mask.rect.left.toInt()
324-
maskIntRect.top = mask.rect.top.toInt()
325-
maskIntRect.right = mask.rect.right.toInt()
326-
maskIntRect.bottom = mask.rect.bottom.toInt()
327-
canvas.drawRect(maskIntRect, paint)
328-
}
329-
}
330359
}

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -123,10 +123,10 @@ data class ComposeMaskTarget(
123123
return null
124124
}
125125

126-
val t1 = coordinates.localToScreen(Offset(0f, 0f))
127-
val t2 = coordinates.localToScreen(Offset(size.width.toFloat(), 0f))
128-
val t3 = coordinates.localToScreen(Offset(size.width.toFloat(), size.height.toFloat()))
129-
val t4 = coordinates.localToScreen(Offset(0f, size.height.toFloat()))
126+
val t1 = coordinates.localToWindow(Offset(0f, 0f))
127+
val t2 = coordinates.localToWindow(Offset(size.width.toFloat(), 0f))
128+
val t3 = coordinates.localToWindow(Offset(size.width.toFloat(), size.height.toFloat()))
129+
val t4 = coordinates.localToWindow(Offset(0f, size.height.toFloat()))
130130

131131
val pts = floatArrayOf(
132132
t1.x, t1.y,

0 commit comments

Comments
 (0)