diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/capture/CaptureSource.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/capture/CaptureSource.kt index 1a8df4fa3..02eb08f30 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/capture/CaptureSource.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/capture/CaptureSource.kt @@ -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 @@ -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 @@ -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 @@ -47,13 +48,13 @@ class CaptureSource( data class CaptureResult( val windowEntry: WindowEntry, val bitmap: Bitmap, - val masks: List + val masks: List ) private val _captureEventFlow = MutableSharedFlow() val captureFlow: SharedFlow = _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 @@ -78,7 +79,7 @@ class CaptureSource( private suspend fun doCapture(): CaptureEvent? = withContext(DispatcherProviderHolder.current.main) { // Synchronize with UI rendering frame - suspendCancellableCoroutine { continuation -> + suspendCancellableCoroutine { continuation -> Choreographer.getInstance().postFrameCallback { if (continuation.isActive) { continuation.resume(Unit) @@ -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? { @@ -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()) @@ -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) @@ -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) { + private fun drawMasks(canvas: Canvas, masks: List) { 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) } } } diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/capture/WindowEntry.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/capture/WindowEntry.kt index b72d036ee..f39cb2f04 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/capture/WindowEntry.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/capture/WindowEntry.kt @@ -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 diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/capture/WindowInspector.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/capture/WindowInspector.kt index 30b40b18a..56fb9cc93 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/capture/WindowInspector.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/capture/WindowInspector.kt @@ -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 { val appUid = appContext?.applicationInfo?.uid - val views = getRootViews() ?: return emptyList() + val views: List = 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 @@ -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 @@ -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() ) } } @@ -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 { @@ -100,6 +107,7 @@ class WindowInspector(private val logger: LDLogger) { return null } + @SuppressLint("PrivateApi") fun getRootViews(): List { return try { val wmgClass = Class.forName("android.view.WindowManagerGlobal") @@ -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() is List<*> -> result.filterIsInstance() else -> emptyList() @@ -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() is List<*> -> result.filterIsInstance() else -> emptyList() diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/masking/ComposeMaskTarget.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/masking/ComposeMaskTarget.kt index bf32610b1..8bf0caaa7 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/masking/ComposeMaskTarget.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/masking/ComposeMaskTarget.kt @@ -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 @@ -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, @@ -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) @@ -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 { diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/masking/Mask.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/masking/Mask.kt new file mode 100644 index 000000000..faf5e9be6 --- /dev/null +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/masking/Mask.kt @@ -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 + } +} diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/masking/MaskCollector.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/masking/MaskCollector.kt new file mode 100644 index 000000000..e060b35fb --- /dev/null +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/masking/MaskCollector.kt @@ -0,0 +1,96 @@ +package com.launchdarkly.observability.replay.masking + +import android.view.View +import android.view.ViewGroup +import androidx.compose.ui.platform.AbstractComposeView +import com.launchdarkly.logging.LDLogger +import kotlin.collections.plusAssign + +/** + * Collects sensitive screen areas that should be masked in session replay. + * + * This encapsulates both Jetpack Compose and native View detection logic. + */ +class MaskCollector(private val logger: LDLogger) { + /** + * Find sensitive areas from all views in the provided [root] view. + * + * @return a list of masks that represent sensitive areas that need to be masked + */ + fun collectMasks(root: View, matchers: List): List { + val resultMasks = mutableListOf() +// TODO: use matrix to calculate final coordinates the will be close to truth in animations +// val matrix = Matrix() +// val (rootX, rootY) = root.locationOnScreen() + + traverse(root, matchers, resultMasks) + return resultMasks + } + + fun traverseCompose(view: AbstractComposeView, matchers: List, masks: MutableList) { + val target = ComposeMaskTarget.from(view, logger) + if (target != null) { + traverseComposeNodes(target, matchers, masks) + } + + for (i in 0 until view.childCount) { + val child = view.getChildAt(i) + traverse(child, matchers, masks) + } + } + + fun traverseNative(view: View, matchers: List, masks: MutableList) { + val target = NativeMaskTarget(view) + if (shouldMask(target, matchers)) { + target.mask()?.let { masks += it } + } + + if (view !is ViewGroup) return + + for (i in 0 until view.childCount) { + val child = view.getChildAt(i) + traverse(child, matchers, masks) + } + } + + fun traverse(view: View, matchers: List, masks: MutableList) { + if (!view.isShown) return + + if (view is AbstractComposeView) { + traverseCompose(view, matchers, masks) + } else if (!view::class.java.name.contains("AndroidComposeView")) { + traverseNative(view, matchers, masks) + } + } + + /** + * Check if a native view is sensitive and add its bounds to the list if it is. + */ + private fun traverseComposeNodes( + target: ComposeMaskTarget, + matchers: List, + masks: MutableList + ) { + if (shouldMask(target, matchers)) { + target.mask()?.let { masks += it } + } + + for (child in target.rootNode.children) { + val childTarget = ComposeMaskTarget( + view = target.view, + rootNode = child, + config = child.config, + boundsInWindow = child.boundsInWindow + ) + traverseComposeNodes(childTarget, matchers, masks) + } + } + + private fun shouldMask( + target: MaskTarget, + matchers: List + ): Boolean { + return target.hasLDMask() + || matchers.any { matcher -> matcher.isMatch(target) } + } +} diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/masking/MaskMatcher.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/masking/MaskMatcher.kt index 5c2d9dafb..eedc8d80b 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/masking/MaskMatcher.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/masking/MaskMatcher.kt @@ -1,17 +1,17 @@ package com.launchdarkly.observability.replay.masking import android.view.View -import androidx.compose.ui.geometry.Rect as ComposeRect /** * Target to evaluate for masking. Sealed to restrict implementations to this module/package. */ sealed interface MaskTarget { val view: View + fun isTextInput(): Boolean fun isText(): Boolean fun isSensitive(sensitiveKeywords: List): Boolean - fun maskRect(): ComposeRect? + fun mask(): Mask? fun hasLDMask(): Boolean } diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/masking/NativeMaskTarget.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/masking/NativeMaskTarget.kt index 1d66b965f..af6b7ef04 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/masking/NativeMaskTarget.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/masking/NativeMaskTarget.kt @@ -4,9 +4,11 @@ import android.view.View import android.widget.EditText import android.widget.TextView import android.text.method.PasswordTransformationMethod -import androidx.compose.ui.geometry.Rect as ComposeRect +import android.text.InputType import kotlin.text.lowercase -import com.launchdarkly.observability.R +import com.launchdarkly.observability.R +import android.graphics.RectF + /** * Native view target */ @@ -29,6 +31,14 @@ data class NativeMaskTarget( return true } + // Check common password inputType variations seen in EditText/TextView + val inputType = view.inputType + when (inputType and InputType.TYPE_MASK_VARIATION) { + InputType.TYPE_TEXT_VARIATION_PASSWORD, + InputType.TYPE_NUMBER_VARIATION_PASSWORD, + InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD -> return true + } + // Check actual displayed text val lowerText = view.text?.toString()?.lowercase() if (!lowerText.isNullOrEmpty() && sensitiveKeywords.any { keyword -> lowerText.contains(keyword) }) { @@ -44,22 +54,20 @@ data class NativeMaskTarget( // Fallback to contentDescription check val lowerDesc = view.contentDescription?.toString()?.lowercase() - if (!lowerDesc.isNullOrEmpty() && sensitiveKeywords.any { keyword -> lowerDesc.contains(keyword) }) { - return true - } - - return false + return !lowerDesc.isNullOrEmpty() && sensitiveKeywords.any { keyword -> lowerDesc.contains(keyword) } } - override fun maskRect(): ComposeRect? { + override fun mask(): Mask? { + if (view.width <= 0 || view.height <= 0) { + return null + } + val location = IntArray(2) view.getLocationInWindow(location) val left = location[0].toFloat() val top = location[1].toFloat() - val right = left + view.width - val bottom = top + view.height - - return ComposeRect(left, top, right, bottom) + val rect = RectF(left, top, left + view.width, top + view.height) + return Mask(rect, view.id, matrix = null) } override fun hasLDMask(): Boolean { diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/masking/SensitiveAreasCollector.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/masking/SensitiveAreasCollector.kt deleted file mode 100644 index 38769a1c9..000000000 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/masking/SensitiveAreasCollector.kt +++ /dev/null @@ -1,130 +0,0 @@ -package com.launchdarkly.observability.replay.masking - -import android.view.View -import android.view.ViewGroup -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.semantics.SemanticsNode -import androidx.compose.ui.geometry.Rect as ComposeRect -import com.launchdarkly.logging.LDLogger - -/** - * Collects sensitive screen areas that should be masked in session replay. - * - * This encapsulates both Jetpack Compose and native View detection logic. - */ -class SensitiveAreasCollector(private val logger: LDLogger) { - /** - * Find sensitive areas from all views in the provided [activity]. - * - * @return a list of rects that represent sensitive areas that need to be masked - */ - fun collectFromActivity(view: View, matchers: List): List { - val allSensitiveRects = mutableListOf() - - try { - val views = findViews(view) - - views.forEach { view -> - when (view) { - is ComposeView -> - ComposeMaskTarget.from(view, logger)?.let { target -> - allSensitiveRects += findComposeSensitiveAreas(target, matchers) - } - else -> - allSensitiveRects += findNativeSensitiveRects(NativeMaskTarget(view), matchers) - } - } - } catch (ignored: Exception) { - // Best-effort collection; ignore failures accessing Compose internals - logger.warn("Failure building sensitive rects ") - } - - return allSensitiveRects - } - - /** - * Recursively find all views in the hierarchy. - */ - private fun findViews(view: View): List { - val views = mutableListOf() - - views.add(view) - - if (view is ViewGroup) { - for (i in 0 until view.childCount) { - val child = view.getChildAt(i) - views.addAll(findViews(child)) - } - } - - return views - } - - /** - * Find sensitive Compose areas by traversing the semantic node tree. - */ - private fun findComposeSensitiveAreas( - maskTarget: ComposeMaskTarget, - matchers: List - ): List { - // TODO: O11Y-629 - add logic to check for sensitive areas in Compose views - val sensitiveRects = mutableListOf() - - try { - traverseSemanticNode(maskTarget.rootNode, sensitiveRects, maskTarget, matchers) - } catch (ignored: Exception) { - // Ignore issues in semantics tree traversal - } - - return sensitiveRects - } - - /** - * Recursively traverse a semantic node and its children to find sensitive areas. - */ - private fun traverseSemanticNode( - node: SemanticsNode, - sensitiveRects: MutableList, - maskTarget: ComposeMaskTarget, - matchers: List - ) { - // current node target is provided as parameter - // check ldMask() modifier; do not return early so children are still traversed - val hasLDMask = maskTarget.hasLDMask() - if (hasLDMask || matchers.any { it.isMatch(maskTarget) }) { - maskTarget.maskRect()?.let { sensitiveRects.add(it) } - } - - node.children.forEach { child -> - val childTarget = ComposeMaskTarget( - view = maskTarget.view, - rootNode = maskTarget.rootNode, - config = child.config, - boundsInWindow = child.boundsInWindow - ) - traverseSemanticNode(child, sensitiveRects, childTarget, matchers) - } - } - - /** - * Check if a native view is sensitive and add its bounds to the list if it is. - */ - private fun findNativeSensitiveRects( - target: NativeMaskTarget, - matchers: List - ): List { - val sensitiveRects = mutableListOf() - var isSensitive = target.hasLDMask() - - if (!isSensitive) { - // Allow matchers to determine sensitivity for native views as well - isSensitive = matchers.any { matcher -> matcher.isMatch(target) } - } - - if (isSensitive) { - target.maskRect()?.let { sensitiveRects.add(it) } - } - - return sensitiveRects - } -} diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/utils/ViewGraphicsExtensions.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/utils/ViewGraphicsExtensions.kt new file mode 100644 index 000000000..e440ee46d --- /dev/null +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/utils/ViewGraphicsExtensions.kt @@ -0,0 +1,12 @@ +package com.launchdarkly.observability.replay.utils + +import android.view.View + +/** + * Returns this view's top-left screen coordinates as a Pair of Floats (x, y). + */ +fun View.locationOnScreen(): Pair { + val loc = IntArray(2) + getLocationOnScreen(loc) + return loc[0].toFloat() to loc[1].toFloat() +}