Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
88 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
04d2537
uncomment
abelonogov-ld Dec 2, 2025
a632d0e
works for Native views only
abelonogov-ld Dec 2, 2025
7584f01
rotation for XML Views
abelonogov-ld Dec 2, 2025
483930b
transformed coordinates for compose
abelonogov-ld Dec 3, 2025
3052849
reorder calls
abelonogov-ld Dec 3, 2025
50033e8
rebase fix
abelonogov-ld Dec 3, 2025
7a21080
Merge branch 'main' into andrey/transformed-coordinates
abelonogov-ld Dec 3, 2025
934607b
remove import
abelonogov-ld Dec 3, 2025
87c3415
cursor review
abelonogov-ld Dec 3, 2025
16a967c
address feedback
abelonogov-ld Dec 3, 2025
278da36
after before
abelonogov-ld Dec 3, 2025
fe4fa07
Merge branch 'andrey/transformed-coordinates' into andrey/double-masking
abelonogov-ld Dec 3, 2025
caac714
double painting
abelonogov-ld Dec 3, 2025
164dc72
array based capture works
abelonogov-ld Dec 4, 2025
8b2351a
masking first try
abelonogov-ld Dec 4, 2025
b2b1aed
Merge branch 'main' into andrey/double-masking
abelonogov-ld Dec 4, 2025
277ae2e
double masking wip
abelonogov-ld Dec 9, 2025
238b8bd
wip
abelonogov-ld Dec 10, 2025
1703c99
Merge branch 'main' into andrey/double-masking
abelonogov-ld Dec 16, 2025
842a899
delete unused
abelonogov-ld Dec 16, 2025
b80c0db
uncomment else
abelonogov-ld Dec 16, 2025
4720fb0
refactor out MaskApplier
abelonogov-ld Dec 16, 2025
d8574cc
remove mobile key
abelonogov-ld Dec 16, 2025
71d58cf
Merge branch 'main' into andrey/fix-masking-offset
abelonogov-ld Dec 16, 2025
556365a
Fix color
abelonogov-ld Dec 16, 2025
b92e75c
Merge branch 'andrey/fix-masking-offset' of github.com:launchdarkly/o…
abelonogov-ld Dec 16, 2025
45f64f9
Merge branch 'main' into andrey/fix-masking-offset
abelonogov-ld Dec 16, 2025
9e45460
Merge branch 'main' into andrey/fix-masking-offset
abelonogov-ld Dec 16, 2025
8a97643
remove context.root adjustment
abelonogov-ld Dec 16, 2025
a053d38
mask stabilizy refactor
abelonogov-ld Dec 17, 2025
854f5f6
Merge branch 'main' into andrey/fix-masking-offset
abelonogov-ld Dec 18, 2025
d463fd7
Merge branch 'main' into andrey/stabile-masks
abelonogov-ld Jan 6, 2026
feaa22f
no message
abelonogov-ld Jan 6, 2026
96fa993
Merge branch 'main' into andrey/stabile-masks
abelonogov-ld Jan 7, 2026
634faed
Merge branch 'main' into andrey/stabile-masks
abelonogov-ld Jan 8, 2026
5012a15
remove redundant comment
abelonogov-ld Jan 8, 2026
8983a0d
Revert "no message"
abelonogov-ld Jan 8, 2026
adc3d2f
address feedback
abelonogov-ld Jan 9, 2026
319c9cc
fix cases null cases
abelonogov-ld Jan 9, 2026
1aa24c9
use pair of masks
abelonogov-ld Jan 9, 2026
60fdfa6
Merge branch 'main' into andrey/double-masking
abelonogov-ld Jan 9, 2026
5038159
use throwable
abelonogov-ld Jan 9, 2026
c6cfb6d
address feedback
abelonogov-ld Jan 9, 2026
64f8cd1
address feedback
abelonogov-ld Jan 9, 2026
832e2cf
recycle early
abelonogov-ld Jan 9, 2026
356c484
another recycle
abelonogov-ld Jan 9, 2026
5bff2cd
Apply suggestion from @agrognetti
abelonogov-ld Jan 9, 2026
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
Expand Up @@ -89,8 +89,7 @@ fun UserInfoForm(modifier: Modifier = Modifier) {
) {
Text(
text = "User Information Form",
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.ldMask()
style = MaterialTheme.typography.headlineMedium
)

// Password Section
Expand All @@ -112,7 +111,8 @@ fun UserInfoForm(modifier: Modifier = Modifier) {
label = { Text("Password") },
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
modifier = Modifier.fillMaxWidth()
modifier = Modifier
.fillMaxWidth()
)
}
}
Expand Down Expand Up @@ -160,7 +160,9 @@ fun UserInfoForm(modifier: Modifier = Modifier) {
onValueChange = { zipCode = it },
label = { Text("ZIP Code") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth()
modifier = Modifier
.fillMaxWidth()
.ldMask()
)
}
}
Expand Down Expand Up @@ -208,8 +210,10 @@ fun UserInfoForm(modifier: Modifier = Modifier) {
label = { Text("CVV") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.weight(1f)
)
modifier = Modifier
.weight(1f)
.ldMask()
)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import android.widget.Toast
import android.graphics.Color
import android.graphics.PixelFormat
import android.view.Gravity
import android.view.ViewGroup
import android.view.WindowManager
import android.widget.FrameLayout
import android.widget.TextView
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ package com.launchdarkly.observability.replay.capture

import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Rect
import android.os.Build
import android.os.Handler
Expand All @@ -16,7 +14,6 @@ import android.view.Window
import android.view.WindowManager.LayoutParams.TYPE_APPLICATION
import android.view.WindowManager.LayoutParams.TYPE_BASE_APPLICATION
import androidx.annotation.RequiresApi
import android.graphics.Path
import com.launchdarkly.logging.LDLogger
import com.launchdarkly.observability.coroutines.DispatcherProviderHolder
import com.launchdarkly.observability.replay.masking.MaskMatcher
Expand All @@ -33,6 +30,7 @@ import kotlin.coroutines.resume
import androidx.core.graphics.withTranslation
import com.launchdarkly.observability.replay.masking.Mask
import androidx.core.graphics.createBitmap
import com.launchdarkly.observability.replay.masking.MaskApplier

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

private val _captureEventFlow = MutableSharedFlow<CaptureEvent>()
val captureFlow: SharedFlow<CaptureEvent> = _captureEventFlow.asSharedFlow()
private val windowInspector = WindowInspector(logger)
private val maskCollector = MaskCollector(logger)
private val maskPaint = Paint().apply {
color = Color.GRAY
style = Paint.Style.FILL
}
private val maskApplier = MaskApplier()

private val tiledSignatureManager = TiledSignatureManager()

@Volatile
private var tiledSignature: TiledSignature? = null

/**
* Requests a [CaptureEvent] be taken now.
*/
Expand Down Expand Up @@ -96,7 +92,8 @@ class CaptureSource(
return@withContext null
}

val baseWindowEntry = pickBaseWindow(windowsEntries) ?: return@withContext null
val baseIndex = pickBaseWindow(windowsEntries) ?: return@withContext null
val baseWindowEntry = windowsEntries[baseIndex]
val rect = baseWindowEntry.rect()

// protect against race condition where decor view has no size
Expand All @@ -107,72 +104,130 @@ class CaptureSource(
// TODO: O11Y-625 - optimize memory allocations
// TODO: O11Y-625 - see if holding bitmap is more efficient than base64 encoding immediately after compression
// TODO: O11Y-628 - use captureQuality option for scaling and adjust this bitmap accordingly, may need to investigate power of 2 rounding for performance
// Create a bitmap with the window dimensions
val baseResult = captureViewResult(baseWindowEntry) ?: return@withContext null

// capture rest of views on top of base
val pairs = mutableListOf<CaptureResult>()
var afterBase = false
for (windowEntry in windowsEntries) {
if (afterBase) {
captureViewResult(windowEntry)?.let { result ->
pairs.add(result)

val capturingWindowEntries = windowsEntries.subList(baseIndex, windowsEntries.size)

val beforeMasks = collectMasks(capturingWindowEntries)

val captureResults: MutableList<CaptureResult?> = MutableList(capturingWindowEntries.size) { null }
try {
var captured = 0
for (i in capturingWindowEntries.indices) {
val windowEntry = capturingWindowEntries[i]
val captureResult = captureViewResult(windowEntry)
if (captureResult == null) {
if (i == 0) {
return@withContext null
}
beforeMasks[i] = null
continue
}
} else if (windowEntry === baseWindowEntry) {
afterBase = true

captured++
captureResults[i] = captureResult
}
if (captured == 0) {
return@withContext null
}
}

return@withContext withContext(DispatcherProviderHolder.current.default) {
// off the main thread to avoid blocking the UI thread
if (pairs.isNotEmpty() || baseResult.masks.isNotEmpty()) {
val canvas = Canvas(baseResult.bitmap)
drawMasks(canvas, baseResult.masks)

for (res in pairs) {
val entry = res.windowEntry
val dx = (entry.screenLeft - baseWindowEntry.screenLeft).toFloat()
val dy = (entry.screenTop - baseWindowEntry.screenTop).toFloat()

canvas.withTranslation(dx, dy) {
drawBitmap(res.bitmap, 0f, 0f, null)
drawMasks(canvas, res.masks)
// Synchronize with UI rendering frame
suspendCancellableCoroutine { continuation ->
Choreographer.getInstance().postFrameCallback {
if (continuation.isActive) {
continuation.resume(Unit)
}
res.bitmap.recycle()
}
}

val newSignature = tiledSignatureManager.compute(baseResult.bitmap, 64)
if (newSignature != null && newSignature == tiledSignature) {
baseResult.bitmap.recycle()
// the similar bitmap not send
return@withContext null
val afterMasks = collectMasksFromResults(captureResults)

// off the main thread to avoid blocking the UI thread
return@withContext withContext(DispatcherProviderHolder.current.default) {
val baseResult = captureResults[0] ?: return@withContext null

val mergedMasks = maskApplier.mergeMasksMap(beforeMasks, afterMasks)
?: run {
// Mask instability is expected during animations/scrolling; ensure we always
// recycle already-captured bitmaps before bailing out to avoid native OOM.
return@withContext null
}

// if need to draw something on base bitmap additionally
if (captureResults.size > 1 || (mergedMasks.isNotEmpty() && mergedMasks[0] != null)) {
val canvas = Canvas(baseResult.bitmap)
mergedMasks[0]?.let { maskApplier.drawMasks(canvas, it) }

for (i in 1 until captureResults.size) {
val res = captureResults[i] ?: continue
val entry = res.windowEntry
val dx = (entry.screenLeft - baseWindowEntry.screenLeft).toFloat()
val dy = (entry.screenTop - baseWindowEntry.screenTop).toFloat()

canvas.withTranslation(dx, dy) {
drawBitmap(res.bitmap, 0f, 0f, null)
mergedMasks[i]?.let { maskApplier.drawMasks(canvas, it) }
}
if (!res.bitmap.isRecycled) {
res.bitmap.recycle()
}
}
}

val newSignature = tiledSignatureManager.compute(baseResult.bitmap, 64)
if (newSignature != null && newSignature == tiledSignature) {
// the similar bitmap not send
return@withContext null
}
tiledSignature = newSignature

createCaptureEvent(baseResult.bitmap, rect, timestamp, session)
}
tiledSignature = newSignature
} finally {
recycleCaptureResults(captureResults)
}
}

createCaptureEvent(baseResult.bitmap, rect, timestamp, session)
private fun recycleCaptureResults(captureResults: List<CaptureResult?>) {
for (res in captureResults) {
val bitmap = res?.bitmap ?: continue
if (!bitmap.isRecycled) {
bitmap.recycle()
}
}
}

private fun pickBaseWindow(windowsEntries: List<WindowEntry>): WindowEntry? {
windowsEntries.firstOrNull {
private fun collectMasks(capturingWindowEntries: List<WindowEntry>): MutableList<List<Mask>?> {
return capturingWindowEntries.map {
maskCollector.collectMasks( it.rootView, maskMatchers)
}.toMutableList()
}

private fun collectMasksFromResults(captureResults: List<CaptureResult?>): MutableList<List<Mask>?> {
return captureResults.map { result ->
result?.windowEntry?.rootView?.let { rv -> maskCollector.collectMasks(rv, maskMatchers) }
}.toMutableList()
}

private fun pickBaseWindow(windowsEntries: List<WindowEntry>): Int? {
val appIdx = windowsEntries.indexOfFirst {
val wmType = it.layoutParams?.type ?: 0
(wmType == TYPE_APPLICATION || wmType == TYPE_BASE_APPLICATION)
}?.let { return it }
wmType == TYPE_APPLICATION || wmType == TYPE_BASE_APPLICATION
}
if (appIdx >= 0) return appIdx

windowsEntries.firstOrNull { it.type == WindowType.ACTIVITY }?.let { return it }
val activityIdx = windowsEntries.indexOfFirst { it.type == WindowType.ACTIVITY }
if (activityIdx >= 0) return activityIdx

windowsEntries.firstOrNull { it.type == WindowType.DIALOG }?.let { return it }
val dialogIdx = windowsEntries.indexOfFirst { it.type == WindowType.DIALOG }
if (dialogIdx >= 0) return dialogIdx

// Fallback to the first available
return windowsEntries.firstOrNull()
return if (windowsEntries.isNotEmpty()) 0 else null
}

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

return CaptureResult(windowEntry, bitmap, masks)
return CaptureResult(windowEntry, bitmap)
}

private suspend fun captureViewBitmap(windowEntry: WindowEntry): Bitmap? {
Expand All @@ -194,6 +249,7 @@ class CaptureSource(
return@withContext canvasDraw(view)
}
}

@RequiresApi(Build.VERSION_CODES.O)
private suspend fun pixelCopy(
window: Window,
Expand All @@ -202,28 +258,36 @@ class CaptureSource(
): Bitmap? {
val bitmap = createBitmapForView(view) ?: return null

return suspendCancellableCoroutine { continuation ->
val result = suspendCancellableCoroutine { continuation ->
val handler = Handler(Looper.getMainLooper())
try {
PixelCopy.request(
window,
rect,
bitmap,
{ result ->
if (!continuation.isActive) return@request
if (result == PixelCopy.SUCCESS) {
{ copyResult ->
if (!continuation.isActive) {
bitmap.recycle()
return@request
}
if (copyResult == PixelCopy.SUCCESS) {
continuation.resume(bitmap)
} else {
continuation.resume(null)
}
}, handler
)
} catch (exp: Exception) {
} catch (t: Throwable) {
// It could normally happen when view is being closed during screenshot
logger.warn("Failed to capture window", exp)
logger.warn("Failed to capture window", t)
continuation.resume(null)
}
}

if (result == null && !bitmap.isRecycled) {
bitmap.recycle()
}
return result
}

private fun canvasDraw(
Expand Down Expand Up @@ -292,39 +356,4 @@ class CaptureSource(
}
}
}

/**
* Applies masking rectangles to the provided [canvas] using the provided [masks].
*
* @param canvas The canvas to mask
* @param masks areas that will be masked
*/
private fun drawMasks(canvas: Canvas, masks: List<Mask>) {
val path = Path()
masks.forEach { mask ->
drawMask(mask, path, canvas, maskPaint)
}
}

private val maskIntRect = Rect()
private fun drawMask(mask: Mask, path: Path, canvas: Canvas, paint: Paint) {
if (mask.points != null) {
val pts = mask.points

path.reset()
path.moveTo(pts[0], pts[1])
path.lineTo(pts[2], pts[3])
path.lineTo(pts[4], pts[5])
path.lineTo(pts[6], pts[7])
path.close()

canvas.drawPath(path, paint)
} else {
maskIntRect.left = mask.rect.left.toInt()
maskIntRect.top = mask.rect.top.toInt()
maskIntRect.right = mask.rect.right.toInt()
maskIntRect.bottom = mask.rect.bottom.toInt()
canvas.drawRect(maskIntRect, paint)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -123,10 +123,10 @@ data class ComposeMaskTarget(
return null
}

val t1 = coordinates.localToScreen(Offset(0f, 0f))
val t2 = coordinates.localToScreen(Offset(size.width.toFloat(), 0f))
val t3 = coordinates.localToScreen(Offset(size.width.toFloat(), size.height.toFloat()))
val t4 = coordinates.localToScreen(Offset(0f, size.height.toFloat()))
val t1 = coordinates.localToWindow(Offset(0f, 0f))
val t2 = coordinates.localToWindow(Offset(size.width.toFloat(), 0f))
val t3 = coordinates.localToWindow(Offset(size.width.toFloat(), size.height.toFloat()))
val t4 = coordinates.localToWindow(Offset(0f, size.height.toFloat()))

val pts = floatArrayOf(
t1.x, t1.y,
Expand Down
Loading
Loading