Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -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,
),
),
Expand All @@ -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)
Expand All @@ -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
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Original file line number Diff line number Diff line change
@@ -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()
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -40,7 +42,7 @@ import com.launchdarkly.observability.replay.masking.MaskApplier
*/
class CaptureSource(
private val sessionManager: SessionManager,
private val maskMatchers: List<MaskMatcher>,
private val options: ReplayOptions,
private val logger: LDLogger,
// TODO: O11Y-628 - add captureQuality options
) {
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -180,7 +187,7 @@ class CaptureSource(
}
tiledSignature = newSignature

createCaptureEvent(baseResult.bitmap, rect, timestamp, session)
createCaptureEvent(baseResult.bitmap, timestamp, session)
}
} finally {
recycleCaptureResults(captureResults)
Expand Down Expand Up @@ -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
}
}
Expand All @@ -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)
}
}

Expand All @@ -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())
Expand Down Expand Up @@ -290,25 +298,33 @@ 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) {
logger.warn("Failed to draw Canvas. This view might be better processed by PixelCopy", t)
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
Expand All @@ -318,7 +334,6 @@ class CaptureSource(

private fun createCaptureEvent(
postMask: Bitmap,
rect: Rect,
timestamp: Long,
session: String
): CaptureEvent {
Expand All @@ -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
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -19,12 +20,14 @@ class MaskApplier {

private val maskIntRect = Rect()

fun drawMasks(canvas: Canvas, maskPairsList: List<Pair<Mask, Mask?>>) {
fun drawMasks(canvas: Canvas, maskPairsList: List<Pair<Mask, Mask?>>, 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)
}
}
}

Expand Down
Loading