diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/InteractionSource.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/InteractionSource.kt index a9befecbaf..9d0e11aa38 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/InteractionSource.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/InteractionSource.kt @@ -22,6 +22,7 @@ import kotlinx.coroutines.flow.asSharedFlow */ class InteractionSource( private val sessionManager: SessionManager, + private val scale: Float?, ) : Application.ActivityLifecycleCallbacks { // Configure with buffer capacity to prevent blocking on emission @@ -78,6 +79,9 @@ class InteractionSource( .takeIf { motionEvent.findPointerIndex(it) != -1 } //continue using watched pointer if it exists ?: motionEvent.getPointerId(0) // otherwise use first pointer val pointerIndex = motionEvent.findPointerIndex(_watchedPointerId) + if (pointerIndex < 0) return + + val scaleFactor = calculateScaleFactor(scale, window.decorView) val eventTimeReference = System.currentTimeMillis() - SystemClock.uptimeMillis() when (motionEvent.actionMasked) { @@ -86,8 +90,8 @@ class InteractionSource( action = motionEvent.action, positions = listOf( Position( - x = motionEvent.getX(pointerIndex).toInt(), - y = motionEvent.getY(pointerIndex).toInt(), + x = scaleCoordinate(motionEvent.getX(pointerIndex), scaleFactor), + y = scaleCoordinate(motionEvent.getY(pointerIndex), scaleFactor), timestamp = eventTimeReference + motionEvent.eventTime, ), ), @@ -98,8 +102,8 @@ class InteractionSource( } MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { - val x = motionEvent.getX(pointerIndex).toInt() - val y = motionEvent.getY(pointerIndex).toInt() + val x = scaleCoordinate(motionEvent.getX(pointerIndex), scaleFactor) + val y = scaleCoordinate(motionEvent.getY(pointerIndex), scaleFactor) val timestamp = eventTimeReference + motionEvent.eventTime _moveGrouper.completeWithLastPosition(x, y, timestamp) @@ -126,16 +130,16 @@ class InteractionSource( // handle non-current positions for (h in 0 until motionEvent.historySize) { _moveGrouper.handleMove( - x = motionEvent.getHistoricalX(pointerIndex, h).toInt(), - y = motionEvent.getHistoricalY(pointerIndex, h).toInt(), + x = scaleCoordinate(motionEvent.getHistoricalX(pointerIndex, h), scaleFactor), + y = scaleCoordinate(motionEvent.getHistoricalY(pointerIndex, h), scaleFactor), timestamp = eventTimeReference + motionEvent.getHistoricalEventTime(h) ) } // handle current position _moveGrouper.handleMove( - x = motionEvent.getX(pointerIndex).toInt(), - y = motionEvent.getY(pointerIndex).toInt(), + x = scaleCoordinate(motionEvent.getX(pointerIndex), scaleFactor), + y = scaleCoordinate(motionEvent.getY(pointerIndex), scaleFactor), timestamp = eventTimeReference + motionEvent.eventTime ) } diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/ReplayInstrumentation.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/ReplayInstrumentation.kt index ef610d6320..5d5aea0989 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/ReplayInstrumentation.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/ReplayInstrumentation.kt @@ -92,10 +92,10 @@ class ReplayInstrumentation( sessionManager = ctx.sessionManager captureSource = CaptureSource( sessionManager = ctx.sessionManager, - maskMatchers = options.privacyProfile.asMatchersList(), + options = options, logger = observabilityContext.logger ) - interactionSource = InteractionSource(ctx.sessionManager) + interactionSource = InteractionSource(ctx.sessionManager, options.scale) val initialIdentifyItemPayload = IdentifyItemPayload.from( contextFriendlyName = observabilityContext.options.contextFriendlyName, diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/ReplayOptions.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/ReplayOptions.kt index a01e523eea..1c0d27a75a 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/ReplayOptions.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/ReplayOptions.kt @@ -6,12 +6,15 @@ package com.launchdarkly.observability.replay * @property debug enables verbose logging if true as well as other debug functionality. Defaults to false. * @property privacyProfile privacy profile that controls masking behavior * @property capturePeriodMillis period between captures + * @property scale optional replay scale override. When null, no additional scaling is applied. Usually from 1-4. 1 = 160DPI * @property enabled controls whether session replay starts capturing immediately on initialization */ data class ReplayOptions( val enabled: Boolean = true, val debug: Boolean = false, val privacyProfile: PrivacyProfile = PrivacyProfile(), - val capturePeriodMillis: Long = 1000, + val capturePeriodMillis: Long = 1000, // defaults to ever 1 second + /** Optional replay scale. Null disables scaling override. */ + val scale: Float? = 1.0f // TODO O11Y-623 - Add storage options ) diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/ScaleFactor.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/ScaleFactor.kt new file mode 100644 index 0000000000..d750c54132 --- /dev/null +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/ScaleFactor.kt @@ -0,0 +1,14 @@ +package com.launchdarkly.observability.replay + +import android.view.View +import kotlin.math.roundToInt + +fun calculateScaleFactor(scale: Float?, view: View): Float { + if (scale == null) return 1f + val density = view.resources.displayMetrics.density + return if (density > 0f) scale / density else 1f +} + +fun scaleCoordinate(value: Float, scaleFactor: Float): Int { + return (value * scaleFactor).roundToInt() +} 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 4b717e2649..d7e9ef8b86 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 @@ -16,7 +16,6 @@ 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.MaskCollector import io.opentelemetry.android.session.SessionManager import kotlinx.coroutines.Dispatchers @@ -30,7 +29,10 @@ 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.ReplayOptions +import com.launchdarkly.observability.replay.calculateScaleFactor import com.launchdarkly.observability.replay.masking.MaskApplier +import com.launchdarkly.observability.replay.scaleCoordinate /** * A source of [CaptureEvent]s taken from the lowest visible window. Captures @@ -40,7 +42,7 @@ import com.launchdarkly.observability.replay.masking.MaskApplier */ class CaptureSource( private val sessionManager: SessionManager, - private val maskMatchers: List, + private val options: ReplayOptions, private val logger: LDLogger, // TODO: O11Y-628 - add captureQuality options ) { @@ -54,7 +56,7 @@ class CaptureSource( private val windowInspector = WindowInspector(logger) private val maskCollector = MaskCollector(logger) private val maskApplier = MaskApplier() - + private val maskMatchers = options.privacyProfile.asMatchersList() private val tiledSignatureManager = TiledSignatureManager() @Volatile @@ -96,6 +98,8 @@ class CaptureSource( val baseWindowEntry = windowsEntries[baseIndex] val rect = baseWindowEntry.rect() + val scaleFactor = calculateScaleFactor(options.scale, baseWindowEntry.rootView) + // protect against race condition where decor view has no size if (rect.right <= 0 || rect.bottom <= 0) { return@withContext null @@ -114,7 +118,10 @@ class CaptureSource( var captured = 0 for (i in capturingWindowEntries.indices) { val windowEntry = capturingWindowEntries[i] - val captureResult = captureViewResult(windowEntry) + val captureResult = captureViewResult( + windowEntry, + scaleFactor = scaleFactor + ) if (captureResult == null) { if (i == 0) { return@withContext null @@ -155,17 +162,17 @@ class CaptureSource( // 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) } + mergedMasks[0]?.let { maskApplier.drawMasks(canvas, it, scaleFactor = scaleFactor) } 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() + val dx = (entry.screenLeft - baseWindowEntry.screenLeft).toFloat() * scaleFactor + val dy = (entry.screenTop - baseWindowEntry.screenTop).toFloat() * scaleFactor canvas.withTranslation(dx, dy) { drawBitmap(res.bitmap, 0f, 0f, null) - mergedMasks[i]?.let { maskApplier.drawMasks(canvas, it) } + mergedMasks[i]?.let { maskApplier.drawMasks(canvas, it, scaleFactor = scaleFactor) } } if (!res.bitmap.isRecycled) { res.bitmap.recycle() @@ -180,7 +187,7 @@ class CaptureSource( } tiledSignature = newSignature - createCaptureEvent(baseResult.bitmap, rect, timestamp, session) + createCaptureEvent(baseResult.bitmap, timestamp, session) } } finally { recycleCaptureResults(captureResults) @@ -225,18 +232,18 @@ class CaptureSource( return if (windowsEntries.isNotEmpty()) 0 else null } - private suspend fun captureViewResult(windowEntry: WindowEntry): CaptureResult? { - val bitmap = captureViewBitmap(windowEntry) ?: return null + private suspend fun captureViewResult(windowEntry: WindowEntry, scaleFactor: Float): CaptureResult? { + val bitmap = captureViewBitmap(windowEntry, scaleFactor) ?: return null return CaptureResult(windowEntry, bitmap) } - private suspend fun captureViewBitmap(windowEntry: WindowEntry): Bitmap? { + private suspend fun captureViewBitmap(windowEntry: WindowEntry, scaleFactor: Float): Bitmap? { val view = windowEntry.rootView if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && windowEntry.isPixelCopyCandidate()) { val window = windowInspector.findWindow(view) if (window != null) { - pixelCopy(window, view, windowEntry.rect())?.let { + pixelCopy(window, view, windowEntry.rect(), scaleFactor)?.let { return it } } @@ -246,7 +253,7 @@ class CaptureSource( return withContext(Dispatchers.Main.immediate) { if (!view.isAttachedToWindow || !view.isShown) return@withContext null - return@withContext canvasDraw(view) + return@withContext canvasDrawBitmap(view, scaleFactor) } } @@ -255,8 +262,9 @@ class CaptureSource( window: Window, view: View, rect: Rect, + scaleFactor: Float ): Bitmap? { - val bitmap = createBitmapForView(view) ?: return null + val bitmap = createBitmapForView(view, scaleFactor) ?: return null val result = suspendCancellableCoroutine { continuation -> val handler = Handler(Looper.getMainLooper()) @@ -290,12 +298,16 @@ class CaptureSource( return result } - private fun canvasDraw( - view: View + private fun canvasDrawBitmap( + view: View, + scaleFactor: Float ): Bitmap? { - val bitmap = createBitmapForView(view) ?: return null + val bitmap = createBitmapForView(view, scaleFactor) ?: return null val canvas = Canvas(bitmap) + canvas.save() + canvas.scale(scaleFactor, scaleFactor) + try { view.draw(canvas) } catch (t: Throwable) { @@ -303,12 +315,16 @@ class CaptureSource( bitmap.recycle() return null } + finally { + canvas.restore() + } + return bitmap } - private fun createBitmapForView(view: View): Bitmap? { - val width = view.width - val height = view.height + private fun createBitmapForView(view: View, scaleFactor: Float): Bitmap? { + val width = scaleCoordinate(view.width.toFloat(), scaleFactor) + val height = scaleCoordinate(view.height.toFloat(), scaleFactor) if (width <= 0 || height <= 0) { logger.warn("Cannot draw view with zero dimensions: ${view.width}x${view.height}") return null @@ -318,7 +334,6 @@ class CaptureSource( private fun createCaptureEvent( postMask: Bitmap, - rect: Rect, timestamp: Long, session: String ): CaptureEvent { @@ -340,8 +355,8 @@ class CaptureSource( CaptureEvent( imageBase64 = compressedImage, - origWidth = rect.width(), - origHeight = rect.height(), + origWidth = postMask.width, + origHeight = postMask.height, timestamp = timestamp, session = session ) diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/masking/MaskApplier.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/masking/MaskApplier.kt index 987a7b9c70..8d27cd745f 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/masking/MaskApplier.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/masking/MaskApplier.kt @@ -5,6 +5,7 @@ import android.graphics.Color import android.graphics.Paint import android.graphics.Path import android.graphics.Rect +import androidx.core.graphics.withScale import kotlin.math.abs class MaskApplier { @@ -19,12 +20,14 @@ class MaskApplier { private val maskIntRect = Rect() - fun drawMasks(canvas: Canvas, maskPairsList: List>) { + fun drawMasks(canvas: Canvas, maskPairsList: List>, scaleFactor: Float) { if (maskPairsList.isEmpty()) return - val path = Path() - maskPairsList.forEach { pairOfMasks -> - drawMask(pairOfMasks, path, canvas) + canvas.withScale(scaleFactor, scaleFactor) { + val path = Path() + maskPairsList.forEach { pairOfMasks -> + drawMask(pairOfMasks, path, this) + } } }