From 2dbe2ce5cff19b796b959667e09a03b906fd4210 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 1 Feb 2024 16:40:27 +0100 Subject: [PATCH 01/89] Add new sentry-android-replay module --- build.gradle.kts | 5 +- buildSrc/src/main/java/Config.kt | 1 + sentry-android-replay/.gitignore | 1 + sentry-android-replay/build.gradle.kts | 78 +++++++++++++++++++ sentry-android-replay/proguard-rules.pro | 3 + sentry-android-replay/src/main/res/public.xml | 4 + settings.gradle.kts | 1 + 7 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 sentry-android-replay/.gitignore create mode 100644 sentry-android-replay/build.gradle.kts create mode 100644 sentry-android-replay/proguard-rules.pro create mode 100644 sentry-android-replay/src/main/res/public.xml diff --git a/build.gradle.kts b/build.gradle.kts index 3f255d4c40e..ee983ce5aea 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -112,6 +112,7 @@ subprojects { "sentry-android-ndk", "sentry-android-okhttp", "sentry-android-sqlite", + "sentry-android-replay", "sentry-android-timber" ) if (jacocoAndroidModules.contains(name)) { @@ -305,7 +306,9 @@ private val androidLibs = setOf( "sentry-android-navigation", "sentry-android-okhttp", "sentry-android-timber", - "sentry-compose-android" + "sentry-compose-android", + "sentry-android-sqlite", + "sentry-android-replay" ) private val androidXLibs = listOf( diff --git a/buildSrc/src/main/java/Config.kt b/buildSrc/src/main/java/Config.kt index 7a0081d5f47..8a02ce0e655 100644 --- a/buildSrc/src/main/java/Config.kt +++ b/buildSrc/src/main/java/Config.kt @@ -34,6 +34,7 @@ object Config { val minSdkVersion = 19 val minSdkVersionOkHttp = 21 + val minSdkVersionReplay = 26 val minSdkVersionNdk = 19 val minSdkVersionCompose = 21 val targetSdkVersion = sdkVersion diff --git a/sentry-android-replay/.gitignore b/sentry-android-replay/.gitignore new file mode 100644 index 00000000000..42afabfd2ab --- /dev/null +++ b/sentry-android-replay/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/sentry-android-replay/build.gradle.kts b/sentry-android-replay/build.gradle.kts new file mode 100644 index 00000000000..69fa4ef2b41 --- /dev/null +++ b/sentry-android-replay/build.gradle.kts @@ -0,0 +1,78 @@ +import io.gitlab.arturbosch.detekt.Detekt +import org.jetbrains.kotlin.config.KotlinCompilerVersion + +plugins { + id("com.android.library") + kotlin("android") + jacoco + id(Config.QualityPlugins.jacocoAndroid) + id(Config.QualityPlugins.gradleVersions) + id(Config.QualityPlugins.detektPlugin) +} + +android { + compileSdk = Config.Android.compileSdkVersion + namespace = "io.sentry.android.replay" + + defaultConfig { + targetSdk = Config.Android.targetSdkVersion + minSdk = Config.Android.minSdkVersionReplay + + // for AGP 4.1 + buildConfigField("String", "VERSION_NAME", "\"${project.version}\"") + } + + buildTypes { + getByName("debug") + getByName("release") + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_1_8.toString() + kotlinOptions.languageVersion = Config.kotlinCompatibleLanguageVersion + } + + testOptions { + animationsDisabled = true + unitTests.apply { + isReturnDefaultValues = true + isIncludeAndroidResources = true + } + } + + lint { + warningsAsErrors = true + checkDependencies = true + + // We run a full lint analysis as build part in CI, so skip vital checks for assemble tasks. + checkReleaseBuilds = false + } + + variantFilter { + if (Config.Android.shouldSkipDebugVariant(buildType.name)) { + ignore = true + } + } +} + +kotlin { + explicitApi() +} + +dependencies { + api(projects.sentry) + + implementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION)) + + // tests + testImplementation(projects.sentryTestSupport) + testImplementation(Config.TestLibs.kotlinTestJunit) + testImplementation(Config.TestLibs.androidxJunit) + testImplementation(Config.TestLibs.mockitoKotlin) + testImplementation(Config.TestLibs.mockitoInline) +} + +tasks.withType { + // Target version of the generated JVM bytecode. It is used for type resolution. + jvmTarget = JavaVersion.VERSION_1_8.toString() +} diff --git a/sentry-android-replay/proguard-rules.pro b/sentry-android-replay/proguard-rules.pro new file mode 100644 index 00000000000..738204b4c8b --- /dev/null +++ b/sentry-android-replay/proguard-rules.pro @@ -0,0 +1,3 @@ +# Uncomment this to preserve the line number information for +# debugging stack traces. +-keepattributes SourceFile,LineNumberTable diff --git a/sentry-android-replay/src/main/res/public.xml b/sentry-android-replay/src/main/res/public.xml new file mode 100644 index 00000000000..379be515be2 --- /dev/null +++ b/sentry-android-replay/src/main/res/public.xml @@ -0,0 +1,4 @@ + + + + diff --git a/settings.gradle.kts b/settings.gradle.kts index 028037372d5..760c6e69054 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -21,6 +21,7 @@ include( "sentry-android-fragment", "sentry-android-navigation", "sentry-android-sqlite", + "sentry-android-replay", "sentry-compose", "sentry-compose-helper", "sentry-apollo", From d702876c1aa333f23fcd8b1f71d0c365f2f587cc Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 1 Feb 2024 16:46:30 +0100 Subject: [PATCH 02/89] Add screenshot recorder --- .../android/replay/ScreenshotRecorder.kt | 150 +++++++++++++ .../sentry/android/replay/WindowRecorder.kt | 89 ++++++++ .../java/io/sentry/android/replay/Windows.kt | 211 ++++++++++++++++++ .../android/replay/video/SimpleFrameMuxer.kt | 20 ++ .../replay/video/SimpleMp4FrameMuxer.kt | 53 +++++ .../replay/video/SimpleVideoEncoder.kt | 166 ++++++++++++++ .../replay/viewhierarchy/ViewHierarchyNode.kt | 157 +++++++++++++ 7 files changed, 846 insertions(+) create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleFrameMuxer.kt create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleMp4FrameMuxer.kt create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt new file mode 100644 index 00000000000..93a416291f4 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -0,0 +1,150 @@ +package io.sentry.android.replay + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.RectF +import android.os.Handler +import android.os.HandlerThread +import android.os.Looper +import android.os.SystemClock +import android.util.Log +import android.view.PixelCopy +import android.view.View +import android.view.ViewGroup +import android.view.ViewTreeObserver +import io.sentry.android.replay.video.SimpleVideoEncoder +import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode +import java.lang.ref.WeakReference +import java.util.WeakHashMap +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit.MILLISECONDS +import kotlin.system.measureTimeMillis + +class ScreenshotRecorder( + val rootView: WeakReference, + val encoder: SimpleVideoEncoder +) : ViewTreeObserver.OnDrawListener { + + private val thread = HandlerThread("SentryReplay").also { it.start() } + private val handler = Handler(thread.looper) + private val bitmapToVH = WeakHashMap() + + companion object { + const val TAG = "ScreenshotRecorder" + } + + private var lastCapturedAtMs: Long? = null + override fun onDraw() { + // cheap debounce for testing + val now = SystemClock.uptimeMillis() + if (lastCapturedAtMs != null && (now - lastCapturedAtMs!!) < 1000L) { + return + } + lastCapturedAtMs = now + + val root = rootView.get() + if (root == null || root.width <= 0 || root.height <= 0) { + return + } + + val window = root.phoneWindow ?: return + val bitmap = Bitmap.createBitmap( + root.width, + root.height, + Bitmap.Config.ARGB_8888 + ) + Log.e("BITMAP CREATED", bitmap.toString()) + + val time = measureTimeMillis { + val rootNode = ViewHierarchyNode.fromView(root) + root.traverse(rootNode) + bitmapToVH[bitmap] = rootNode + } + Log.e("TIME", time.toString()) + +// val latch = CountDownLatch(1) + + Handler(Looper.getMainLooper()).postAtFrontOfQueue { + PixelCopy.request( + window, + bitmap, + { copyResult: Int -> + Log.d(TAG, "PixelCopy result: $copyResult") + if (copyResult != PixelCopy.SUCCESS) { + Log.e(TAG, "Failed to capture screenshot") + return@request + } + + Log.e("BITMAP CAPTURED", bitmap.toString()) + val viewHierarchy = bitmapToVH[bitmap] + + if (viewHierarchy != null) { + val canvas = Canvas(bitmap) + viewHierarchy.traverse { + if (it.shouldRedact && (it.width > 0 && it.height > 0)) { + it.visibleRect ?: return@traverse + + val paint = Paint().apply { + color = it.dominantColor ?: Color.BLACK + } + canvas.drawRoundRect(RectF(it.visibleRect), 10f, 10f, paint) + } + } + } + + val scaledBitmap = Bitmap.createScaledBitmap( + bitmap, + encoder.muxerConfig.videoWidth, + encoder.muxerConfig.videoHeight, + true + ) +// val baos = ByteArrayOutputStream() +// scaledBitmap.compress(Bitmap.CompressFormat.JPEG, 75, baos) +// val bmp = BitmapFactory.decodeByteArray(baos.toByteArray(), 0, baos.size()) + encoder.encode(scaledBitmap) +// bmp.recycle() + scaledBitmap.recycle() + bitmap.recycle() + Log.i(TAG, "Captured a screenshot") +// latch.countDown() + }, + handler + ) + } + +// val success = latch.await(200, MILLISECONDS) +// Log.i(TAG, "Captured a screenshot: $success") + } + + private fun ViewHierarchyNode.traverse(callback: (ViewHierarchyNode) -> Unit) { + callback(this) + if (this.children != null) { + this.children!!.forEach { + it.traverse(callback) + } + } + } + + private fun View.traverse(parentNode: ViewHierarchyNode) { + if (this !is ViewGroup) { + return + } + + if (this.childCount == 0) { + return + } + + val childNodes = ArrayList(this.childCount) + for (i in 0 until childCount) { + val child = getChildAt(i) + if (child != null) { + val childNode = ViewHierarchyNode.fromView(child) + childNodes.add(childNode) + child.traverse(childNode) + } + } + parentNode.children = childNodes + } +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt new file mode 100644 index 00000000000..5d0abe558a0 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt @@ -0,0 +1,89 @@ +package io.sentry.android.replay + +import android.content.Context +import android.content.res.Configuration +import android.content.res.Configuration.ORIENTATION_LANDSCAPE +import android.media.CamcorderProfile +import android.util.DisplayMetrics +import android.view.View +import android.view.ViewTreeObserver +import android.view.WindowManager +import io.sentry.android.replay.video.MuxerConfig +import io.sentry.android.replay.video.SimpleVideoEncoder +import java.io.File +import java.lang.ref.WeakReference +import java.util.WeakHashMap +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.LazyThreadSafetyMode.NONE +import kotlin.math.roundToInt + +class WindowRecorder { + + companion object { + private const val TAG = "WindowRecorder" + } + + private val rootViewsSpy by lazy(NONE) { + RootViewsSpy.install() + } + + private var encoder: SimpleVideoEncoder? = null + private val isRecording = AtomicBoolean(false) + private val recorders: WeakHashMap = WeakHashMap() + + private val onRootViewsChangedListener = OnRootViewsChangedListener { root, added -> + if (added) { + // stop tracking other windows so they don't interfere in the recording like a 25th frame effect + recorders.entries.forEach { + it.key.viewTreeObserver.removeOnDrawListener(it.value) + } + + val recorder = ScreenshotRecorder(WeakReference(root), encoder!!) + recorders[root] = recorder + root.viewTreeObserver?.addOnDrawListener(recorder) + } else { + root.viewTreeObserver?.removeOnDrawListener(recorders[root]) + recorders.remove(root) + + recorders.entries.forEach { + it.key.viewTreeObserver.addOnDrawListener(it.value) + } + } + } + + fun startRecording(context: Context) { + if (isRecording.getAndSet(true)) { + return + } + + val wm = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager +// val (height, width) = (wm.currentWindowMetrics.bounds.bottom / +// context.resources.displayMetrics.density).roundToInt() to +// (wm.currentWindowMetrics.bounds.right / +// context.resources.displayMetrics.density).roundToInt() + val aspectRatio = wm.currentWindowMetrics.bounds.bottom.toFloat() / wm.currentWindowMetrics.bounds.right.toFloat() + + val videoFile = File(context.cacheDir, "sentry-sr.mp4") + encoder = SimpleVideoEncoder( + MuxerConfig( + videoFile, + videoWidth = (720 / aspectRatio).roundToInt(), + videoHeight = 720, + frameRate = 1f, + bitrate = 500 * 1000, + ) + ) + encoder?.start() + rootViewsSpy.listeners += onRootViewsChangedListener + } + + fun stopRecording() { + rootViewsSpy.listeners -= onRootViewsChangedListener + recorders.entries.forEach { + it.key.viewTreeObserver.removeOnDrawListener(it.value) + } + recorders.clear() + encoder?.startRelease() + encoder = null + } +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt new file mode 100644 index 00000000000..eff39a9394f --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt @@ -0,0 +1,211 @@ +package io.sentry.android.replay + +import android.annotation.SuppressLint +import android.os.Build +import android.os.Build.VERSION.SDK_INT +import android.util.Log +import android.view.View +import android.view.Window +import java.util.concurrent.CopyOnWriteArrayList +import kotlin.LazyThreadSafetyMode.NONE + +/** + * If this view is part of the view hierarchy from a [android.app.Activity], [android.app.Dialog] or + * [android.service.dreams.DreamService], then this returns the [android.view.Window] instance + * associated to it. Otherwise, this returns null. + * + * Note: this property is called [phoneWindow] because the only implementation of [Window] is + * the internal class android.view.PhoneWindow. + */ +val View.phoneWindow: Window? + get() { + return WindowSpy.pullWindow(rootView) + } + + +internal object WindowSpy { + + /** + * Originally, DecorView was an inner class of PhoneWindow. In the initial import in 2009, + * PhoneWindow is in com.android.internal.policy.impl.PhoneWindow and that didn't change until + * API 23. + * In API 22: https://android.googlesource.com/platform/frameworks/base/+/android-5.1.1_r38/policy/src/com/android/internal/policy/impl/PhoneWindow.java + * PhoneWindow was then moved to android.view and then again to com.android.internal.policy + * https://android.googlesource.com/platform/frameworks/base/+/b10e33ff804a831c71be9303146cea892b9aeb5d + * https://android.googlesource.com/platform/frameworks/base/+/6711f3b34c2ad9c622f56a08b81e313795fe7647 + * In API 23: https://android.googlesource.com/platform/frameworks/base/+/android-6.0.0_r1/core/java/com/android/internal/policy/PhoneWindow.java + * Then DecorView moved out of PhoneWindow into its own class: + * https://android.googlesource.com/platform/frameworks/base/+/8804af2b63b0584034f7ec7d4dc701d06e6a8754 + * In API 24: https://android.googlesource.com/platform/frameworks/base/+/android-7.0.0_r1/core/java/com/android/internal/policy/DecorView.java + */ + private val decorViewClass by lazy(NONE) { + val sdkInt = SDK_INT + val decorViewClassName = when { + sdkInt >= 24 -> "com.android.internal.policy.DecorView" + sdkInt == 23 -> "com.android.internal.policy.PhoneWindow\$DecorView" + else -> "com.android.internal.policy.impl.PhoneWindow\$DecorView" + } + try { + Class.forName(decorViewClassName) + } catch (ignored: Throwable) { + Log.d( + "WindowSpy", "Unexpected exception loading $decorViewClassName on API $sdkInt", ignored + ) + null + } + } + + /** + * See [decorViewClass] for the AOSP history of the DecorView class. + * Between the latest API 23 release and the first API 24 release, DecorView first became a + * static class: + * https://android.googlesource.com/platform/frameworks/base/+/0daf2102a20d224edeb4ee45dd4ee91889ef3e0c + * Then it was extracted into a separate class. + * + * Hence the change of window field name from "this$0" to "mWindow" on API 24+. + */ + private val windowField by lazy(NONE) { + decorViewClass?.let { decorViewClass -> + val sdkInt = SDK_INT + val fieldName = if (sdkInt >= 24) "mWindow" else "this$0" + try { + decorViewClass.getDeclaredField(fieldName).apply { isAccessible = true } + } catch (ignored: NoSuchFieldException) { + Log.d( + "WindowSpy", + "Unexpected exception retrieving $decorViewClass#$fieldName on API $sdkInt", ignored + ) + null + } + } + } + + fun attachedToPhoneWindow(maybeDecorView: View): Boolean { + return decorViewClass?.let { decorViewClass -> + decorViewClass.isInstance(maybeDecorView) + } ?: false + } + + fun pullWindow(maybeDecorView: View): Window? { + return decorViewClass?.let { decorViewClass -> + if (decorViewClass.isInstance(maybeDecorView)) { + windowField?.let { windowField -> + windowField[maybeDecorView] as Window + } + } else { + null + } + } + } +} + +/** + * Listener added to [Curtains.onRootViewsChangedListeners]. + * If you only care about either attached or detached, consider implementing [OnRootViewAddedListener] + * or [OnRootViewRemovedListener] instead. + */ +fun interface OnRootViewsChangedListener { + /** + * Called when [android.view.WindowManager.addView] and [android.view.WindowManager.removeView] + * are called. + */ + fun onRootViewsChanged( + view: View, + added: Boolean + ) +} + +/** + * A utility that holds the list of root views that WindowManager updates. + */ +internal class RootViewsSpy private constructor() { + + val listeners = CopyOnWriteArrayList() + + fun copyRootViewList(): List { + return delegatingViewList.toList() + } + + private val delegatingViewList = object : ArrayList() { + override fun add(element: View): Boolean { + listeners.forEach { it.onRootViewsChanged(element, true) } + return super.add(element) + } + + override fun removeAt(index: Int): View { + val removedView = super.removeAt(index) + listeners.forEach { it.onRootViewsChanged(removedView, false) } + return removedView + } + } + + companion object { + fun install(): RootViewsSpy { + return RootViewsSpy().apply { + WindowManagerSpy.swapWindowManagerGlobalMViews { mViews -> + delegatingViewList.apply { addAll(mViews) } + } + } + } + } +} + +internal object WindowManagerSpy { + + private val windowManagerClass by lazy(NONE) { + val className = "android.view.WindowManagerGlobal" + try { + Class.forName(className) + } catch (ignored: Throwable) { + Log.w("WindowManagerSpy", ignored) + null + } + } + + private val windowManagerInstance by lazy(NONE) { + windowManagerClass?.getMethod("getInstance")?.invoke(null) + } + + private val mViewsField by lazy(NONE) { + windowManagerClass?.let { windowManagerClass -> + windowManagerClass.getDeclaredField("mViews").apply { isAccessible = true } + } + } + + // You can discourage me all you want I'll still do it. + @SuppressLint("PrivateApi", "ObsoleteSdkInt", "DiscouragedPrivateApi") + fun swapWindowManagerGlobalMViews(swap: (ArrayList) -> ArrayList) { + if (SDK_INT < 19) { + return + } + try { + windowManagerInstance?.let { windowManagerInstance -> + mViewsField?.let { mViewsField -> + @Suppress("UNCHECKED_CAST") + val mViews = mViewsField[windowManagerInstance] as ArrayList + mViewsField[windowManagerInstance] = swap(mViews) + } + } + } catch (ignored: Throwable) { + Log.w("WindowManagerSpy", ignored) + } + } + + fun windowManagerMViewsArray(): Array { + val sdkInt = SDK_INT + if (sdkInt >= 19) { + return arrayOf() + } + try { + windowManagerInstance?.let { windowManagerInstance -> + mViewsField?.let { mViewsField -> + @Suppress("UNCHECKED_CAST") + return mViewsField[windowManagerInstance] as Array + } + } + } catch (ignored: Throwable) { + Log.w("WindowManagerSpy", ignored) + } + return arrayOf() + } +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleFrameMuxer.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleFrameMuxer.kt new file mode 100644 index 00000000000..435153e3768 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleFrameMuxer.kt @@ -0,0 +1,20 @@ +package io.sentry.android.replay.video + +import android.media.MediaCodec +import android.media.MediaFormat +import java.nio.ByteBuffer + +/** + * modified from https://github.com/israel-fl/bitmap2video/blob/develop/library/src/main/java/com/homesoft/encoder/FrameMuxer.kt + */ +interface SimpleFrameMuxer { + fun isStarted(): Boolean + + fun start(videoFormat: MediaFormat) + + fun muxVideoFrame(encodedData: ByteBuffer, bufferInfo: MediaCodec.BufferInfo) + + fun release() + + fun getVideoTime(): Long +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleMp4FrameMuxer.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleMp4FrameMuxer.kt new file mode 100644 index 00000000000..f382aa6b4ec --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleMp4FrameMuxer.kt @@ -0,0 +1,53 @@ +package io.sentry.android.replay.video + +import android.media.MediaCodec +import android.media.MediaFormat +import android.media.MediaMuxer +import android.util.Log +import java.nio.ByteBuffer +import java.util.concurrent.TimeUnit + +/** + * modified from https://github.com/israel-fl/bitmap2video/blob/develop/library/src/main/java/com/homesoft/encoder/Mp4FrameMuxer.kt + */ +class SimpleMp4FrameMuxer(path: String, private val fps: Float) : SimpleFrameMuxer { + private val frameUsec: Long = (TimeUnit.SECONDS.toMicros(1L) / fps).toLong() + + private val muxer: MediaMuxer = MediaMuxer(path, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4) + + private var started = false + private var videoTrackIndex = 0 + private var videoFrames = 0 + private var finalVideoTime: Long = 0 + + override fun isStarted(): Boolean = started + + override fun start(videoFormat: MediaFormat) { + videoTrackIndex = muxer.addTrack(videoFormat) + Log.i("SimpleMp4FrameMuxer", "start() videoFormat=$videoFormat videoTrackIndex=$videoTrackIndex") + muxer.start() + started = true + } + + override fun muxVideoFrame(encodedData: ByteBuffer, bufferInfo: MediaCodec.BufferInfo) { + // This code will break if the encoder supports B frames. + // Ideally we would use set the value in the encoder, + // don't know how to do that without using OpenGL + finalVideoTime = frameUsec * videoFrames++ + bufferInfo.presentationTimeUs = finalVideoTime + +// encodedData.position(bufferInfo.offset) +// encodedData.limit(bufferInfo.offset + bufferInfo.size) + + muxer.writeSampleData(videoTrackIndex, encodedData, bufferInfo) + } + + override fun release() { + muxer.stop() + muxer.release() + } + + override fun getVideoTime(): Long { + return finalVideoTime + } +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt new file mode 100644 index 00000000000..e8b156fa76c --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt @@ -0,0 +1,166 @@ +package io.sentry.android.replay.video + +import android.graphics.Bitmap +import android.media.MediaCodec +import android.media.MediaCodecInfo +import android.media.MediaCodecList +import android.media.MediaCodecList.REGULAR_CODECS +import android.media.MediaFormat +import android.os.Handler +import android.os.Looper +import android.util.Log +import android.view.Surface +import io.sentry.android.replay.video.SimpleFrameMuxer +import io.sentry.android.replay.video.SimpleMp4FrameMuxer +import java.io.File + +class SimpleVideoEncoder( + val muxerConfig: MuxerConfig, +) { + companion object { + const val TAG = "SimpleVideoEncoder" + } + + private val mediaFormat: MediaFormat = run { + Log.i(TAG, "mediaFormat creation begin") + + val format = MediaFormat.createVideoFormat( + muxerConfig.mimeType, + muxerConfig.videoWidth, + muxerConfig.videoHeight + ) + + // Set some properties. Failing to specify some of these can cause the MediaCodec + // configure() call to throw an unhelpful exception. + format.setInteger( + MediaFormat.KEY_COLOR_FORMAT, + MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface + ) + format.setInteger(MediaFormat.KEY_BIT_RATE, muxerConfig.bitrate) + format.setFloat(MediaFormat.KEY_FRAME_RATE, muxerConfig.frameRate) + format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 10) + + Log.i(TAG, "mediaFormat creation end format=$format") + + format + } + + private val mediaCodec: MediaCodec = run { + Log.i(TAG, "mediaCodec creation begin") + +// val codecs = MediaCodecList(REGULAR_CODECS) +// val codecName = codecs.findEncoderForFormat(mediaFormat) +// val codec = MediaCodec.createByCodecName(codecName) + val codec = MediaCodec.createEncoderByType(muxerConfig.mimeType) + + Log.i(TAG, "mediaCodec creation end codec=$codec") + + codec + } + + private val frameMuxer = muxerConfig.frameMuxer + + private var surface: Surface? = null + + fun start() { + mediaCodec.setCallback(createMediaCodecCallback(), Handler(Looper.getMainLooper())) + + mediaCodec.configure(mediaFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) + surface = mediaCodec.createInputSurface() + mediaCodec.start() + +// drainCodec(false) + } + + private fun createMediaCodecCallback(): MediaCodec.Callback { + return object : MediaCodec.Callback() { + override fun onInputBufferAvailable(codec: MediaCodec, index: Int) { + } + + override fun onOutputBufferAvailable( + codec: MediaCodec, + index: Int, + info: MediaCodec.BufferInfo + ) { + // need to catch, since this is from callback, so there are no + // things like pigeon auto-catch + val encodedData = codec.getOutputBuffer(index)!! + + var effectiveSize = info.size + + if (info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0) { + // The codec config data was pulled out and fed to the muxer when we got + // the INFO_OUTPUT_FORMAT_CHANGED status. Ignore it. + effectiveSize = 0 + } + + if (effectiveSize != 0) { + if (!frameMuxer.isStarted()) { + throw RuntimeException("muxer hasn't started") + } + frameMuxer.muxVideoFrame(encodedData, info) + } + + mediaCodec.releaseOutputBuffer(index, false) + + if (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) { + Log.i(TAG, "drainCodec end of stream reached") + actualRelease() + } + } + + override fun onError(codec: MediaCodec, e: MediaCodec.CodecException) { + Log.e(TAG, "onError (MediaCodec.Callback)", e) + } + + override fun onOutputFormatChanged(codec: MediaCodec, format: MediaFormat) { + Log.i(TAG, "onOutputFormatChanged format=$format") + + // should happen before receiving buffers, and should only happen once + if (frameMuxer.isStarted()) { + throw RuntimeException("format changed twice") + } + val newFormat: MediaFormat = mediaCodec.outputFormat + Log.i(TAG, "encoder output format changed: $newFormat") + + // now that we have the Magic Goodies, start the muxer + frameMuxer.start(newFormat) + } + } + } + + fun encode(image: Bitmap) { + // NOTE do not use `lockCanvas` like what is done in bitmap2video + // This is because https://developer.android.com/reference/android/media/MediaCodec#createInputSurface() + // says that, "Surface.lockCanvas(android.graphics.Rect) may fail or produce unexpected results." + val canvas = surface?.lockHardwareCanvas() + canvas?.drawBitmap(image, 0f, 0f, null) + surface?.unlockCanvasAndPost(canvas) + } + + /** + * can only *start* releasing, since it is asynchronous + */ + fun startRelease() { +// drainCodec(true) + mediaCodec.signalEndOfInputStream() + } + + private fun actualRelease() { + mediaCodec.stop() + mediaCodec.release() + surface?.release() + + frameMuxer.release() + } +} + +data class MuxerConfig( + val file: File, + val videoWidth: Int, + val videoHeight: Int, + val mimeType: String = MediaFormat.MIMETYPE_VIDEO_AVC, + val frameRate: Float, + val bitrate: Int, + val frameMuxer: SimpleFrameMuxer = SimpleMp4FrameMuxer(file.absolutePath, frameRate), +) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt new file mode 100644 index 00000000000..d127d790760 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt @@ -0,0 +1,157 @@ +package io.sentry.android.replay.viewhierarchy + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Rect +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.Drawable +import android.graphics.drawable.GradientDrawable +import android.graphics.drawable.InsetDrawable +import android.graphics.drawable.VectorDrawable +import android.view.View +import android.view.accessibility.AccessibilityNodeInfo +import android.widget.ImageView +import android.widget.TextView + +data class ViewHierarchyNode( + val x: Float, + val y: Float, + val width: Int, + val height: Int, + val shouldRedact: Boolean = false, + val dominantColor: Int? = null, + val visibleRect: Rect? = null +) { + + var children: List? = null + + companion object { + + private fun isVisible(view: View?): Boolean { + if (view == null || !view.isShown) { + return false + } + val actualPosition = Rect() + view.getGlobalVisibleRect(actualPosition) + val screen = Rect( + 0, + 0, + view.context.resources.displayMetrics.widthPixels, + view.context.resources.displayMetrics.heightPixels + ) + return actualPosition.intersects(screen.left, screen.top, screen.right, screen.bottom) + } + + fun adjustAlpha(color: Int): Int { + val alpha = 255 + val red = Color.red(color) + val green = Color.green(color) + val blue = Color.blue(color) + return Color.argb(alpha, red, green, blue) + } + + fun fromView(view: View): ViewHierarchyNode { + var shouldRedact = false + var dominantColor: Int? = null + var rect: Rect? = null + when (view) { + is TextView -> { + val nodeInfo = AccessibilityNodeInfo() + view.onInitializeAccessibilityNodeInfo(nodeInfo) + shouldRedact = nodeInfo.isVisibleToUser + if (shouldRedact) { + val bounds = Rect() + val text = view.text.toString() + view.paint.getTextBounds(text, 0, text.length, bounds) + dominantColor = adjustAlpha(view.currentTextColor) + rect = Rect() + view.getGlobalVisibleRect(rect) + + var textEnd = Int.MIN_VALUE + var textStart = Int.MAX_VALUE + if (view.layout != null) { + for (i in 0 until view.layout.lineCount) { + val min = view.layout.getLineStart(i) + val minPosition = view.layout.getPrimaryHorizontal(min).toInt() + val max = view.layout.getLineVisibleEnd(i) + val maxPosition = view.layout.getPrimaryHorizontal(max).toInt() + if (minPosition < textStart) { + textStart = minPosition + } + if (maxPosition > textEnd) { + textEnd = maxPosition + } + } + } else { + textEnd = rect.right - rect.left + textStart = 0 + } + // TODO: support known 3rd-party widgets like MaterialButton with an icon + rect.left += textStart + view.paddingStart + rect.right = rect.left + (textEnd - textStart) + } + } + + is ImageView -> { + shouldRedact = isVisible(view) && (view.drawable?.isRedactable() ?: false) + if (shouldRedact) { + dominantColor = adjustAlpha((view.drawable?.pickDominantColor() ?: Color.BLACK)) + rect = Rect() + view.getGlobalVisibleRect(rect) + } + } + } + return ViewHierarchyNode( + view.x, + view.y, + view.width, + view.height, + shouldRedact, + dominantColor, + rect + ) + } + + private fun Drawable.isRedactable(): Boolean { + return when (this) { + is InsetDrawable, is ColorDrawable, is VectorDrawable, is GradientDrawable -> false + is BitmapDrawable -> !bitmap.isRecycled && bitmap.height > 0 && bitmap.width > 0 + else -> true + } + } + + private fun Drawable.pickDominantColor(): Int { + // TODO: pick default color based on dark/light default theme + return when (this) { + is BitmapDrawable -> { + val newBitmap = Bitmap.createScaledBitmap(bitmap, 1, 1, true) + val color = newBitmap.getPixel(0, 0) + newBitmap.recycle() + color + } + + else -> { + if (intrinsicHeight > 0 && intrinsicWidth > 0) { + val bmp = + Bitmap.createBitmap(this.intrinsicWidth, intrinsicHeight, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bmp) + try { + draw(canvas) + val newBitmap = Bitmap.createScaledBitmap(bmp, 1, 1, true) + val color = newBitmap.getPixel(0, 0) + newBitmap.recycle() + bmp.recycle() + color + } catch (e: Throwable) { + Color.BLACK + } + } else { + Color.BLACK + } + } + } + } + } +} From b1ced8534d462686bf27ca0ad2867c65c0c6e29f Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 1 Feb 2024 16:47:15 +0100 Subject: [PATCH 03/89] Add sentry replay envelope and event --- .../core/DefaultAndroidEventProcessor.java | 17 + .../src/main/java/io/sentry/DataCategory.java | 1 + .../main/java/io/sentry/EventProcessor.java | 12 + sentry/src/main/java/io/sentry/Hint.java | 15 + sentry/src/main/java/io/sentry/Hub.java | 22 ++ .../src/main/java/io/sentry/HubAdapter.java | 6 + sentry/src/main/java/io/sentry/IHub.java | 3 + .../main/java/io/sentry/ISentryClient.java | 3 + .../java/io/sentry/MainEventProcessor.java | 10 + sentry/src/main/java/io/sentry/NoOpHub.java | 5 + .../main/java/io/sentry/NoOpSentryClient.java | 6 + .../main/java/io/sentry/ReplayRecording.java | 101 ++++++ sentry/src/main/java/io/sentry/Sentry.java | 5 + .../src/main/java/io/sentry/SentryClient.java | 115 +++++++ .../java/io/sentry/SentryEnvelopeItem.java | 83 ++++- .../main/java/io/sentry/SentryItemType.java | 2 + .../java/io/sentry/SentryReplayEvent.java | 290 ++++++++++++++++++ 17 files changed, 694 insertions(+), 2 deletions(-) create mode 100644 sentry/src/main/java/io/sentry/ReplayRecording.java create mode 100644 sentry/src/main/java/io/sentry/SentryReplayEvent.java diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java index 8dcfe196c26..0e1ddc7a3a6 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java @@ -10,6 +10,7 @@ import io.sentry.SentryBaseEvent; import io.sentry.SentryEvent; import io.sentry.SentryLevel; +import io.sentry.SentryReplayEvent; import io.sentry.android.core.internal.util.AndroidMainThreadChecker; import io.sentry.android.core.performance.AppStartMetrics; import io.sentry.android.core.performance.TimeSpan; @@ -256,4 +257,20 @@ private void setSideLoadedInfo(final @NotNull SentryBaseEvent event) { return transaction; } + + @Override + public @NotNull SentryReplayEvent process( + final @NotNull SentryReplayEvent event, final @NotNull Hint hint) { + final boolean applyScopeData = shouldApplyScopeData(event, hint); + if (applyScopeData) { + // we only set memory data if it's not a hard crash, when it's a hard crash the event is + // enriched on restart, so non static data might be wrong, eg lowMemory or availMem will + // be different if the App. crashes because of OOM. + processNonCachedEvent(event, hint); + } + + setCommons(event, false, applyScopeData); + + return event; + } } diff --git a/sentry/src/main/java/io/sentry/DataCategory.java b/sentry/src/main/java/io/sentry/DataCategory.java index 6b119b43e4c..3e54f9da4b0 100644 --- a/sentry/src/main/java/io/sentry/DataCategory.java +++ b/sentry/src/main/java/io/sentry/DataCategory.java @@ -13,6 +13,7 @@ public enum DataCategory { Monitor("monitor"), Profile("profile"), Transaction("transaction"), + Replay("replay"), Security("security"), UserReport("user_report"), Unknown("unknown"); diff --git a/sentry/src/main/java/io/sentry/EventProcessor.java b/sentry/src/main/java/io/sentry/EventProcessor.java index ba675086142..9e52408edbc 100644 --- a/sentry/src/main/java/io/sentry/EventProcessor.java +++ b/sentry/src/main/java/io/sentry/EventProcessor.java @@ -32,4 +32,16 @@ default SentryEvent process(@NotNull SentryEvent event, @NotNull Hint hint) { default SentryTransaction process(@NotNull SentryTransaction transaction, @NotNull Hint hint) { return transaction; } + + /** + * May mutate or drop a SentryEvent + * + * @param event the SentryEvent + * @param hint the Hint + * @return the event itself, a mutated SentryEvent or null + */ + @Nullable + default SentryReplayEvent process(@NotNull SentryReplayEvent event, @NotNull Hint hint) { + return event; + } } diff --git a/sentry/src/main/java/io/sentry/Hint.java b/sentry/src/main/java/io/sentry/Hint.java index 07dde3cb807..a638d240ab4 100644 --- a/sentry/src/main/java/io/sentry/Hint.java +++ b/sentry/src/main/java/io/sentry/Hint.java @@ -27,6 +27,7 @@ public final class Hint { private final @NotNull Map internalStorage = new HashMap(); private final @NotNull List attachments = new ArrayList<>(); + private final @NotNull List replayRecordings = new ArrayList<>(); private @Nullable Attachment screenshot = null; private @Nullable Attachment viewHierarchy = null; @@ -70,6 +71,12 @@ public synchronized void remove(@NotNull String name) { internalStorage.remove(name); } + public void addReplayRecording(final @Nullable ReplayRecording recording) { + if (recording != null) { + replayRecordings.add(recording); + } + } + public void addAttachment(@Nullable Attachment attachment) { if (attachment != null) { attachments.add(attachment); @@ -86,6 +93,10 @@ public void addAttachments(@Nullable List attachments) { return new ArrayList<>(attachments); } + public @NotNull List getReplayRecordings() { + return new ArrayList<>(replayRecordings); + } + public void replaceAttachments(@Nullable List attachments) { clearAttachments(); addAttachments(attachments); @@ -95,6 +106,10 @@ public void clearAttachments() { attachments.clear(); } + public void clearReplayRecordings() { + replayRecordings.clear(); + } + /** * Clears all attributes added via {@link #set(String, Object)} Note: SDK internal attributes are * being kept. This is useful to avoid leaking any objects (e.g. Android activities) being diff --git a/sentry/src/main/java/io/sentry/Hub.java b/sentry/src/main/java/io/sentry/Hub.java index e7942722fc7..1d449d401da 100644 --- a/sentry/src/main/java/io/sentry/Hub.java +++ b/sentry/src/main/java/io/sentry/Hub.java @@ -920,6 +920,28 @@ private IScope buildLocalScope( return sentryId; } + @Override + public @NotNull SentryId captureReplay( + final @NotNull SentryReplayEvent replay, final @Nullable Hint hint) { + SentryId sentryId = SentryId.EMPTY_ID; + if (!isEnabled()) { + options + .getLogger() + .log( + SentryLevel.WARNING, + "Instance is disabled and this 'captureReplay' call is a no-op."); + } else { + try { + StackItem item = stack.peek(); + sentryId = item.getClient().captureReplayEvent(replay, item.getScope(), null); + } catch (Throwable e) { + options.getLogger().log(SentryLevel.ERROR, "Error while capturing check-in for slug", e); + } + } + this.lastEventId = sentryId; + return sentryId; + } + @ApiStatus.Internal @Override public @Nullable RateLimiter getRateLimiter() { diff --git a/sentry/src/main/java/io/sentry/HubAdapter.java b/sentry/src/main/java/io/sentry/HubAdapter.java index 68a9bdf11dd..ecae05cc98b 100644 --- a/sentry/src/main/java/io/sentry/HubAdapter.java +++ b/sentry/src/main/java/io/sentry/HubAdapter.java @@ -262,6 +262,12 @@ public void reportFullyDisplayed() { return Sentry.captureCheckIn(checkIn); } + @Override + public @NotNull SentryId captureReplay( + final @NotNull SentryReplayEvent replay, final @Nullable Hint hint) { + return Sentry.getCurrentHub().captureReplay(replay, hint); + } + @ApiStatus.Internal @Override public @Nullable RateLimiter getRateLimiter() { diff --git a/sentry/src/main/java/io/sentry/IHub.java b/sentry/src/main/java/io/sentry/IHub.java index 01431043f09..f6b2eab81d5 100644 --- a/sentry/src/main/java/io/sentry/IHub.java +++ b/sentry/src/main/java/io/sentry/IHub.java @@ -572,6 +572,9 @@ TransactionContext continueTrace( @NotNull SentryId captureCheckIn(final @NotNull CheckIn checkIn); + @NotNull + SentryId captureReplay(@NotNull SentryReplayEvent replay, @Nullable Hint hint); + @ApiStatus.Internal @Nullable RateLimiter getRateLimiter(); diff --git a/sentry/src/main/java/io/sentry/ISentryClient.java b/sentry/src/main/java/io/sentry/ISentryClient.java index 15b5f25c4ba..5ea2e5f847b 100644 --- a/sentry/src/main/java/io/sentry/ISentryClient.java +++ b/sentry/src/main/java/io/sentry/ISentryClient.java @@ -147,6 +147,9 @@ public interface ISentryClient { return captureException(throwable, scope, null); } + @NotNull SentryId captureReplayEvent( + @NotNull SentryReplayEvent event, @Nullable IScope scope, @Nullable Hint hint); + /** * Captures a manually created user feedback and sends it to Sentry. * diff --git a/sentry/src/main/java/io/sentry/MainEventProcessor.java b/sentry/src/main/java/io/sentry/MainEventProcessor.java index abbf21c84e5..813d3aaf264 100644 --- a/sentry/src/main/java/io/sentry/MainEventProcessor.java +++ b/sentry/src/main/java/io/sentry/MainEventProcessor.java @@ -149,6 +149,16 @@ private void processNonCachedEvent(final @NotNull SentryBaseEvent event) { return transaction; } + @Override + public @NotNull SentryReplayEvent process(@NotNull SentryReplayEvent event, @NotNull Hint hint) { + setCommons(event); + setDebugMeta(event); + + if (shouldApplyScopeData(event, hint)) { + processNonCachedEvent(event); + } + return event; + } private void setCommons(final @NotNull SentryBaseEvent event) { setPlatform(event); } diff --git a/sentry/src/main/java/io/sentry/NoOpHub.java b/sentry/src/main/java/io/sentry/NoOpHub.java index d186c69ca2f..b238704cc54 100644 --- a/sentry/src/main/java/io/sentry/NoOpHub.java +++ b/sentry/src/main/java/io/sentry/NoOpHub.java @@ -218,6 +218,11 @@ public void reportFullyDisplayed() {} return SentryId.EMPTY_ID; } + @Override + public @NotNull SentryId captureReplay(@NotNull SentryReplayEvent replay, @Nullable Hint hint) { + return SentryId.EMPTY_ID; + } + @Override public @Nullable RateLimiter getRateLimiter() { return null; diff --git a/sentry/src/main/java/io/sentry/NoOpSentryClient.java b/sentry/src/main/java/io/sentry/NoOpSentryClient.java index a37d09eb89f..757b074f822 100644 --- a/sentry/src/main/java/io/sentry/NoOpSentryClient.java +++ b/sentry/src/main/java/io/sentry/NoOpSentryClient.java @@ -62,6 +62,12 @@ public SentryId captureEnvelope(@NotNull SentryEnvelope envelope, @Nullable Hint return SentryId.EMPTY_ID; } + @Override + public @NotNull SentryId captureReplayEvent(@NotNull SentryReplayEvent event, + @Nullable IScope scope, @Nullable Hint hint) { + return SentryId.EMPTY_ID; + } + @Override public @Nullable RateLimiter getRateLimiter() { return null; diff --git a/sentry/src/main/java/io/sentry/ReplayRecording.java b/sentry/src/main/java/io/sentry/ReplayRecording.java new file mode 100644 index 00000000000..26b4d73370a --- /dev/null +++ b/sentry/src/main/java/io/sentry/ReplayRecording.java @@ -0,0 +1,101 @@ +package io.sentry; + +import io.sentry.vendor.gson.stream.JsonToken; +import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class ReplayRecording implements JsonUnknown, JsonSerializable { + + public static final class JsonKeys { + public static final String SEGMENT_ID = "segment_id"; + } + + private @Nullable Integer segmentId; + private @Nullable Map unknown; + + // TODO spec it out, good enough for now + private @Nullable List payload; + + @Nullable + public Integer getSegmentId() { + return segmentId; + } + + public void setSegmentId(@Nullable Integer segmentId) { + this.segmentId = segmentId; + } + + @Nullable + public List getPayload() { + return payload; + } + + public void setPayload(@Nullable List payload) { + this.payload = payload; + } + + @Override + public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger logger) + throws IOException { + writer.beginObject(); + if (segmentId != null) { + writer.name(JsonKeys.SEGMENT_ID).value(segmentId); + } + + if (unknown != null) { + for (String key : unknown.keySet()) { + Object value = unknown.get(key); + writer.name(key).value(logger, value); + } + } + writer.endObject(); + } + + @Override + public @Nullable Map getUnknown() { + return unknown; + } + + @Override + public void setUnknown(@Nullable Map unknown) { + this.unknown = unknown; + } + + public static final class Deserializer implements JsonDeserializer { + + @Override + public @NotNull ReplayRecording deserialize( + @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + + final ReplayRecording replay = new ReplayRecording(); + + @Nullable Map unknown = null; + @Nullable Integer segmentId = null; + + reader.beginObject(); + while (reader.peek() == JsonToken.NAME) { + final String nextName = reader.nextName(); + switch (nextName) { + case JsonKeys.SEGMENT_ID: + segmentId = reader.nextIntegerOrNull(); + break; + default: + if (unknown == null) { + unknown = new HashMap<>(); + } + reader.nextUnknown(logger, unknown, nextName); + break; + } + } + reader.endObject(); + + replay.setSegmentId(segmentId); + replay.setUnknown(unknown); + return replay; + } + } +} diff --git a/sentry/src/main/java/io/sentry/Sentry.java b/sentry/src/main/java/io/sentry/Sentry.java index 0aff89c0d06..392dd0cef24 100644 --- a/sentry/src/main/java/io/sentry/Sentry.java +++ b/sentry/src/main/java/io/sentry/Sentry.java @@ -1025,4 +1025,9 @@ public interface OptionsConfiguration { public static @NotNull SentryId captureCheckIn(final @NotNull CheckIn checkIn) { return getCurrentHub().captureCheckIn(checkIn); } + + public static void captureReplay( + final @NotNull SentryReplayEvent replay, final @Nullable Hint hint) { + getCurrentHub().captureReplay(replay, hint); + } } diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index 2973c1f8bce..0b8da468b69 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -246,6 +246,53 @@ private boolean shouldApplyScopeData(final @NotNull CheckIn event, final @NotNul return sentryId; } + @Override + public @NotNull SentryId captureReplayEvent( + @NotNull SentryReplayEvent event, final @Nullable IScope scope, @Nullable Hint hint) { + Objects.requireNonNull(event, "SessionReplay is required."); + + if (hint == null) { + hint = new Hint(); + } + + if (shouldApplyScopeData(event, hint)) { + applyScope(event, scope); + } + + options.getLogger().log(SentryLevel.DEBUG, "Capturing session replay: %s", event.getEventId()); + + SentryId sentryId = SentryId.EMPTY_ID; + if (event.getReplayId() != null) { + sentryId = event.getReplayId(); + } + + event = processReplayEvent(event, hint, options.getEventProcessors()); + + if (event == null) { + options.getLogger().log(SentryLevel.DEBUG, "Replay was dropped by Event processors."); + return SentryId.EMPTY_ID; + } + + try { + final SentryEnvelope envelope = + buildEnvelope(event, hint.getReplayRecordings()); + + hint.clear(); + if (envelope != null) { + transport.send(envelope, hint); + } else { + sentryId = SentryId.EMPTY_ID; + } + } catch (IOException e) { + options.getLogger().log(SentryLevel.WARNING, e, "Capturing event %s failed.", sentryId); + + // if there was an error capturing the event, we return an emptyId + sentryId = SentryId.EMPTY_ID; + } + + return sentryId; + } + private void addScopeAttachmentsToHint(@Nullable IScope scope, @NotNull Hint hint) { if (scope != null) { hint.addAttachments(scope.getAttachments()); @@ -432,6 +479,40 @@ private SentryTransaction processTransaction( return transaction; } + @Nullable + private SentryReplayEvent processReplayEvent( + @NotNull SentryReplayEvent replayEvent, + final @NotNull Hint hint, + final @NotNull List eventProcessors) { + for (final EventProcessor processor : eventProcessors) { + try { + replayEvent = processor.process(replayEvent, hint); + } catch (Throwable e) { + options + .getLogger() + .log( + SentryLevel.ERROR, + e, + "An exception occurred while processing replay event by processor: %s", + processor.getClass().getName()); + } + + if (replayEvent == null) { + options + .getLogger() + .log( + SentryLevel.DEBUG, + "Replay event was dropped by a processor: %s", + processor.getClass().getName()); + options + .getClientReportRecorder() + .recordLostEvent(DiscardReason.EVENT_PROCESSOR, DataCategory.Replay); + break; + } + } + return replayEvent; + } + @Override public void captureUserFeedback(final @NotNull UserFeedback userFeedback) { Objects.requireNonNull(userFeedback, "SentryEvent is required."); @@ -485,6 +566,40 @@ public void captureUserFeedback(final @NotNull UserFeedback userFeedback) { return new SentryEnvelope(envelopeHeader, envelopeItems); } + private @Nullable SentryEnvelope buildEnvelope( + final @Nullable SentryReplayEvent event, + final @Nullable List replayRecordings + ) { + SentryId sentryId = null; + final List envelopeItems = new ArrayList<>(); + + if (event != null) { + final SentryEnvelopeItem eventItem = + SentryEnvelopeItem.fromEvent(options.getSerializer(), event); + envelopeItems.add(eventItem); + sentryId = event.getEventId(); + } + + if (replayRecordings != null) { + for (final ReplayRecording replayRecording : replayRecordings) { + final SentryEnvelopeItem replayItem = + SentryEnvelopeItem.fromReplayRecording( + options.getSerializer(), options.getLogger(), replayRecording); + envelopeItems.add(replayItem); + } + } + + + if (!envelopeItems.isEmpty()) { + final SentryEnvelopeHeader envelopeHeader = + new SentryEnvelopeHeader(sentryId, options.getSdkVersion()); + + return new SentryEnvelope(envelopeHeader, envelopeItems); + } + + return null; + } + /** * Updates the session data based on the event, hint and scope data * diff --git a/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java index acd6b36aa91..f583ff0f0a9 100644 --- a/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java +++ b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java @@ -19,6 +19,7 @@ import java.io.InputStreamReader; import java.io.OutputStreamWriter; import java.io.Reader; +import java.io.StringWriter; import java.io.Writer; import java.nio.charset.Charset; import java.util.concurrent.Callable; @@ -102,8 +103,7 @@ public final class SentryEnvelopeItem { } public static @NotNull SentryEnvelopeItem fromEvent( - final @NotNull ISerializer serializer, final @NotNull SentryBaseEvent event) - throws IOException { + final @NotNull ISerializer serializer, final @NotNull SentryBaseEvent event) { Objects.requireNonNull(serializer, "ISerializer is required."); Objects.requireNonNull(event, "SentryEvent is required."); @@ -342,6 +342,85 @@ public ClientReport getClientReport(final @NotNull ISerializer serializer) throw } } + public static SentryEnvelopeItem fromReplayRecording( + final @NotNull ISerializer serializer, + final @NotNull ILogger logger, + final @NotNull ReplayRecording replayRecording) { + + final CachedItem cachedItem = + new CachedItem( + () -> { + try { + try (final ByteArrayOutputStream stream = new ByteArrayOutputStream(); + final Writer writer = + new BufferedWriter(new OutputStreamWriter(stream, UTF_8))) { + + // session replay recording format + // {"segment_id":0}\n{json-serialized-gzipped-rrweb-protocol} + + serializer.serialize(replayRecording, writer); + writer.write("\n"); + if (replayRecording.getPayload() != null) { + serializer.serialize(replayRecording.getPayload(), writer); + } + + // final byte[] payload = compressRecordingPayload(serializer, replayRecording); + // stream.write(payload); + + writer.flush(); + stream.flush(); + return stream.toByteArray(); + } + } catch (Throwable t) { + logger.log(SentryLevel.ERROR, "Could not serialize replay recording", t); + return null; + } + }); + + // try { + // final byte[] data = cachedItem.getBytes(); + // final String dataStr = new String(data, UTF_8); + // + // final String[] items = dataStr.split("\n", 2); + // final String header = items[0]; + // final String payload = items[1]; + // + // final ByteArrayInputStream byteArrayInputStream = new + // ByteArrayInputStream(payload.getBytes(UTF_8)); + // final GZIPInputStream inputStream = new GZIPInputStream(byteArrayInputStream); + // + // final ByteArrayOutputStream decodedData = new ByteArrayOutputStream(); + // + // byte[] buf = new byte[4096]; + // int readLen; + // while ((readLen = inputStream.read(buf, 0, buf.length)) != -1) { + // decodedData.write(buf, 0, readLen); + // } + // + // } catch (Exception e) { + // + // } + + final SentryEnvelopeItemHeader itemHeader = + new SentryEnvelopeItemHeader( + SentryItemType.ReplayRecording, () -> cachedItem.getBytes().length, null, null); + + // avoid method refs on Android due to some issues with older AGP setups + // noinspection Convert2MethodRef + SentryEnvelopeItem item = new SentryEnvelopeItem(itemHeader, () -> cachedItem.getBytes()); + + try { + StringWriter writer = new StringWriter(); + serializer.serialize(item.header, writer); + writer.flush(); + writer.flush(); + } catch (Exception e) { + logger.log(SentryLevel.ERROR, "f", e); + } + return item; + } + + private static class CachedItem { private @Nullable byte[] bytes; private final @Nullable Callable dataFactory; diff --git a/sentry/src/main/java/io/sentry/SentryItemType.java b/sentry/src/main/java/io/sentry/SentryItemType.java index c4535cb6a1b..69aa7b7a929 100644 --- a/sentry/src/main/java/io/sentry/SentryItemType.java +++ b/sentry/src/main/java/io/sentry/SentryItemType.java @@ -32,6 +32,8 @@ public static SentryItemType resolve(Object item) { return Session; } else if (item instanceof ClientReport) { return ClientReport; + } else if (item instanceof SentryReplayEvent) { + return ReplayEvent; } else { return Attachment; } diff --git a/sentry/src/main/java/io/sentry/SentryReplayEvent.java b/sentry/src/main/java/io/sentry/SentryReplayEvent.java new file mode 100644 index 00000000000..8de75994226 --- /dev/null +++ b/sentry/src/main/java/io/sentry/SentryReplayEvent.java @@ -0,0 +1,290 @@ +package io.sentry; + +import io.sentry.protocol.SentryId; +import io.sentry.vendor.gson.stream.JsonToken; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class SentryReplayEvent extends SentryBaseEvent + implements JsonUnknown, JsonSerializable { + + public static final class JsonKeys { + public static final String TYPE = "type"; + public static final String REPLAY_TYPE = "replay_type"; + public static final String REPLAY_ID = "replay_id"; + public static final String SEGMENT_ID = "segment_id"; + public static final String TIMESTAMP = "timestamp"; + public static final String REPLAY_START_TIMESTAMP = "replay_start_timestamp"; + public static final String URLS = "urls"; + public static final String ERROR_IDS = "error_ids"; + public static final String TRACE_IDS = "trace_ids"; + } + + private @Nullable String type; + private @Nullable String replayType; + private @Nullable SentryId replayId; + private @Nullable Integer segmentId; + private @Nullable Double timestamp; + private @Nullable Double replayStartTimestamp; + private @Nullable List urls; + private @Nullable List errorIds; + private @Nullable List traceIds; + private @Nullable Map unknown; + + public SentryReplayEvent() { + super(); + this.replayId = this.getEventId(); + this.type = "replay_event"; + this.replayType = "session"; + this.errorIds = new ArrayList<>(); + this.traceIds = new ArrayList<>(); + this.urls = new ArrayList<>(); + } + + @Nullable + public String getType() { + return type; + } + + public void setType(final @Nullable String type) { + this.type = type; + } + + @Nullable + public SentryId getReplayId() { + return replayId; + } + + public void setReplayId(final @Nullable SentryId replayId) { + this.replayId = replayId; + } + + @Nullable + public Integer getSegmentId() { + return segmentId; + } + + public void setSegmentId(final @Nullable Integer segmentId) { + this.segmentId = segmentId; + } + + @Nullable + public Double getTimestamp() { + return timestamp; + } + + public void setTimestamp(final @Nullable Double timestamp) { + this.timestamp = timestamp; + } + + @Nullable + public Double getReplayStartTimestamp() { + return replayStartTimestamp; + } + + public void setReplayStartTimestamp(final @Nullable Double replayStartTimestamp) { + this.replayStartTimestamp = replayStartTimestamp; + } + + @Nullable + public List getUrls() { + return urls; + } + + public void setUrls(final @Nullable List urls) { + this.urls = urls; + } + + @Nullable + public List getErrorIds() { + return errorIds; + } + + public void setErrorIds(final @Nullable List errorIds) { + this.errorIds = errorIds; + } + + @Nullable + public List getTraceIds() { + return traceIds; + } + + public void setTraceIds(final @Nullable List traceIds) { + this.traceIds = traceIds; + } + + @Nullable + public String getReplayType() { + return replayType; + } + + public void setReplayType(@Nullable String replayType) { + this.replayType = replayType; + } + + @Override + public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger logger) + throws IOException { + writer.beginObject(); + if (type != null) { + writer.name(JsonKeys.TYPE).value(type); + } + if (replayType != null) { + writer.name(JsonKeys.REPLAY_TYPE).value(replayType); + } + if (replayId != null) { + writer.name(JsonKeys.REPLAY_ID).value(logger, replayId); + } + if (segmentId != null) { + writer.name(JsonKeys.SEGMENT_ID).value(segmentId); + } + if (timestamp != null) { + writer.name(JsonKeys.TIMESTAMP).value(logger, timestamp); + } + if (replayStartTimestamp != null) { + writer.name(JsonKeys.REPLAY_START_TIMESTAMP).value(logger, replayStartTimestamp); + } + if (urls != null) { + writer.name(JsonKeys.URLS).value(logger, urls); + } + if (errorIds != null) { + writer.name(JsonKeys.ERROR_IDS).value(logger, errorIds); + } + if (traceIds != null) { + writer.name(JsonKeys.TRACE_IDS).value(logger, traceIds); + } + + new SentryBaseEvent.Serializer().serialize(this, writer, logger); + + if (unknown != null) { + for (String key : unknown.keySet()) { + Object value = unknown.get(key); + writer.name(key).value(logger, value); + } + } + writer.endObject(); + } + + @Override + public @Nullable Map getUnknown() { + return unknown; + } + + @Override + public void setUnknown(final @Nullable Map unknown) { + this.unknown = unknown; + } + + public static final class Deserializer implements JsonDeserializer { + + @Override + public @NotNull SentryReplayEvent deserialize( + final @NotNull JsonObjectReader reader, final @NotNull ILogger logger) throws Exception { + + SentryBaseEvent.Deserializer baseEventDeserializer = new SentryBaseEvent.Deserializer(); + + final SentryReplayEvent replay = new SentryReplayEvent(); + + @Nullable Map unknown = null; + @Nullable String type = null; + @Nullable String replayType = null; + @Nullable SentryId replayId = null; + @Nullable Integer segmentId = null; + @Nullable Double timestamp = null; + @Nullable Double replayStartTimestamp = null; + @Nullable List urls = null; + @Nullable List errorIds = null; + @Nullable List traceIds = null; + + reader.beginObject(); + while (reader.peek() == JsonToken.NAME) { + final String nextName = reader.nextName(); + switch (nextName) { + case JsonKeys.TYPE: + type = reader.nextStringOrNull(); + break; + case JsonKeys.REPLAY_TYPE: + replayType = reader.nextStringOrNull(); + break; + case JsonKeys.REPLAY_ID: + replayId = reader.nextOrNull(logger, new SentryId.Deserializer()); + break; + case JsonKeys.SEGMENT_ID: + segmentId = reader.nextIntegerOrNull(); + break; + case JsonKeys.TIMESTAMP: + timestamp = nextTimestamp(reader, logger); + break; + case JsonKeys.REPLAY_START_TIMESTAMP: + replayStartTimestamp = nextTimestamp(reader, logger); + break; + case JsonKeys.URLS: + urls = nextStringList(reader); + break; + case JsonKeys.ERROR_IDS: + errorIds = nextStringList(reader); + break; + case JsonKeys.TRACE_IDS: + traceIds = nextStringList(reader); + break; + default: + if (!baseEventDeserializer.deserializeValue(replay, nextName, reader, logger)) { + if (unknown == null) { + unknown = new HashMap<>(); + } + reader.nextUnknown(logger, unknown, nextName); + } + break; + } + } + reader.endObject(); + + replay.setType(type); + replay.setReplayType(replayType); + replay.setReplayId(replayId); + replay.setSegmentId(segmentId); + replay.setTimestamp(timestamp); + replay.setReplayStartTimestamp(replayStartTimestamp); + replay.setUrls(urls); + replay.setErrorIds(errorIds); + replay.setTraceIds(traceIds); + replay.setUnknown(unknown); + return replay; + } + + @Nullable + private static Double nextTimestamp( + final @NotNull JsonObjectReader reader, final @NotNull ILogger logger) throws IOException { + @Nullable Double result; + try { + result = reader.nextDoubleOrNull(); + } catch (NumberFormatException e) { + final Date date = reader.nextDateOrNull(logger); + result = date != null ? DateUtils.dateToSeconds(date) : null; + } + return result; + } + + @Nullable + private static List nextStringList(final @NotNull JsonObjectReader reader) + throws IOException { + @Nullable List result = null; + final @Nullable Object data = reader.nextObjectOrNull(); + if (data instanceof List) { + result = new ArrayList<>(((List) data).size()); + for (Object item : (List) data) { + if (item instanceof String) { + result.add((String) item); + } + } + } + return result; + } + } +} From 11f699cee5a031b685b6577c251589d194ecb005 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 13 Feb 2024 11:44:48 +0100 Subject: [PATCH 04/89] Add TODOs and license headers --- .../android/replay/ScreenshotRecorder.kt | 3 +- .../sentry/android/replay/WindowRecorder.kt | 5 +- .../java/io/sentry/android/replay/Windows.kt | 47 +++++++--------- .../android/replay/video/SimpleFrameMuxer.kt | 33 ++++++++++- .../replay/video/SimpleMp4FrameMuxer.kt | 32 ++++++++++- .../replay/video/SimpleVideoEncoder.kt | 56 ++++++++++--------- .../replay/viewhierarchy/ViewHierarchyNode.kt | 7 +++ 7 files changed, 118 insertions(+), 65 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt index 93a416291f4..feae4557a61 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -22,7 +22,7 @@ import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit.MILLISECONDS import kotlin.system.measureTimeMillis -class ScreenshotRecorder( +internal class ScreenshotRecorder( val rootView: WeakReference, val encoder: SimpleVideoEncoder ) : ViewTreeObserver.OnDrawListener { @@ -37,7 +37,6 @@ class ScreenshotRecorder( private var lastCapturedAtMs: Long? = null override fun onDraw() { - // cheap debounce for testing val now = SystemClock.uptimeMillis() if (lastCapturedAtMs != null && (now - lastCapturedAtMs!!) < 1000L) { return diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt index 5d0abe558a0..7ded87f7859 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt @@ -19,10 +19,6 @@ import kotlin.math.roundToInt class WindowRecorder { - companion object { - private const val TAG = "WindowRecorder" - } - private val rootViewsSpy by lazy(NONE) { RootViewsSpy.install() } @@ -61,6 +57,7 @@ class WindowRecorder { // context.resources.displayMetrics.density).roundToInt() to // (wm.currentWindowMetrics.bounds.right / // context.resources.displayMetrics.density).roundToInt() + // TODO: support this for api level < 30 val aspectRatio = wm.currentWindowMetrics.bounds.bottom.toFloat() / wm.currentWindowMetrics.bounds.right.toFloat() val videoFile = File(context.cacheDir, "sentry-sr.mp4") diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt index eff39a9394f..b621465c20f 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt @@ -1,3 +1,21 @@ +/** + * Adapted from https://github.com/square/curtains + * + * Copyright 2021 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package io.sentry.android.replay import android.annotation.SuppressLint @@ -40,6 +58,7 @@ internal object WindowSpy { */ private val decorViewClass by lazy(NONE) { val sdkInt = SDK_INT + // TODO: we can only consider API 26 val decorViewClassName = when { sdkInt >= 24 -> "com.android.internal.policy.DecorView" sdkInt == 23 -> "com.android.internal.policy.PhoneWindow\$DecorView" @@ -80,12 +99,6 @@ internal object WindowSpy { } } - fun attachedToPhoneWindow(maybeDecorView: View): Boolean { - return decorViewClass?.let { decorViewClass -> - decorViewClass.isInstance(maybeDecorView) - } ?: false - } - fun pullWindow(maybeDecorView: View): Window? { return decorViewClass?.let { decorViewClass -> if (decorViewClass.isInstance(maybeDecorView)) { @@ -122,10 +135,6 @@ internal class RootViewsSpy private constructor() { val listeners = CopyOnWriteArrayList() - fun copyRootViewList(): List { - return delegatingViewList.toList() - } - private val delegatingViewList = object : ArrayList() { override fun add(element: View): Boolean { listeners.forEach { it.onRootViewsChanged(element, true) } @@ -190,22 +199,4 @@ internal object WindowManagerSpy { Log.w("WindowManagerSpy", ignored) } } - - fun windowManagerMViewsArray(): Array { - val sdkInt = SDK_INT - if (sdkInt >= 19) { - return arrayOf() - } - try { - windowManagerInstance?.let { windowManagerInstance -> - mViewsField?.let { mViewsField -> - @Suppress("UNCHECKED_CAST") - return mViewsField[windowManagerInstance] as Array - } - } - } catch (ignored: Throwable) { - Log.w("WindowManagerSpy", ignored) - } - return arrayOf() - } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleFrameMuxer.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleFrameMuxer.kt index 435153e3768..70bb8cff462 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleFrameMuxer.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleFrameMuxer.kt @@ -1,12 +1,39 @@ +/** + * Adapted from https://github.com/fzyzcjy/flutter_screen_recorder/blob/master/packages/fast_screen_recorder/android/src/main/kotlin/com/cjy/fast_screen_recorder/SimpleFrameMuxer.kt + * + * Copyright (c) 2021 fzyzcjy + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + * In addition to the standard MIT license, this library requires the following: + * The recorder itself only saves data on user's phone locally, thus it does not have any privacy problem. + * However, if you are going to get the records out of the local storage (e.g. upload the records to your server), + * please explicitly ask the user for permission, and promise to only use the records to debug your app. + * This is a part of the license of this library. + */ + package io.sentry.android.replay.video import android.media.MediaCodec import android.media.MediaFormat import java.nio.ByteBuffer -/** - * modified from https://github.com/israel-fl/bitmap2video/blob/develop/library/src/main/java/com/homesoft/encoder/FrameMuxer.kt - */ interface SimpleFrameMuxer { fun isStarted(): Boolean diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleMp4FrameMuxer.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleMp4FrameMuxer.kt index f382aa6b4ec..69f92701d35 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleMp4FrameMuxer.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleMp4FrameMuxer.kt @@ -1,3 +1,32 @@ +/** + * Adapted from https://github.com/fzyzcjy/flutter_screen_recorder/blob/master/packages/fast_screen_recorder/android/src/main/kotlin/com/cjy/fast_screen_recorder/SimpleMp4FrameMuxer.kt + * + * Copyright (c) 2021 fzyzcjy + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + * In addition to the standard MIT license, this library requires the following: + * The recorder itself only saves data on user's phone locally, thus it does not have any privacy problem. + * However, if you are going to get the records out of the local storage (e.g. upload the records to your server), + * please explicitly ask the user for permission, and promise to only use the records to debug your app. + * This is a part of the license of this library. + */ package io.sentry.android.replay.video import android.media.MediaCodec @@ -7,9 +36,6 @@ import android.util.Log import java.nio.ByteBuffer import java.util.concurrent.TimeUnit -/** - * modified from https://github.com/israel-fl/bitmap2video/blob/develop/library/src/main/java/com/homesoft/encoder/Mp4FrameMuxer.kt - */ class SimpleMp4FrameMuxer(path: String, private val fps: Float) : SimpleFrameMuxer { private val frameUsec: Long = (TimeUnit.SECONDS.toMicros(1L) / fps).toLong() diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt index e8b156fa76c..5b52347957f 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt @@ -1,3 +1,32 @@ +/** + * Adapted from https://github.com/fzyzcjy/flutter_screen_recorder/blob/master/packages/fast_screen_recorder/android/src/main/kotlin/com/cjy/fast_screen_recorder/SimpleVideoEncoder.kt + * + * Copyright (c) 2021 fzyzcjy + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + * In addition to the standard MIT license, this library requires the following: + * The recorder itself only saves data on user's phone locally, thus it does not have any privacy problem. + * However, if you are going to get the records out of the local storage (e.g. upload the records to your server), + * please explicitly ask the user for permission, and promise to only use the records to debug your app. + * This is a part of the license of this library. + */ package io.sentry.android.replay.video import android.graphics.Bitmap @@ -14,16 +43,10 @@ import io.sentry.android.replay.video.SimpleFrameMuxer import io.sentry.android.replay.video.SimpleMp4FrameMuxer import java.io.File -class SimpleVideoEncoder( +internal class SimpleVideoEncoder( val muxerConfig: MuxerConfig, ) { - companion object { - const val TAG = "SimpleVideoEncoder" - } - private val mediaFormat: MediaFormat = run { - Log.i(TAG, "mediaFormat creation begin") - val format = MediaFormat.createVideoFormat( muxerConfig.mimeType, muxerConfig.videoWidth, @@ -40,21 +63,15 @@ class SimpleVideoEncoder( format.setFloat(MediaFormat.KEY_FRAME_RATE, muxerConfig.frameRate) format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 10) - Log.i(TAG, "mediaFormat creation end format=$format") - format } private val mediaCodec: MediaCodec = run { - Log.i(TAG, "mediaCodec creation begin") - // val codecs = MediaCodecList(REGULAR_CODECS) // val codecName = codecs.findEncoderForFormat(mediaFormat) // val codec = MediaCodec.createByCodecName(codecName) val codec = MediaCodec.createEncoderByType(muxerConfig.mimeType) - Log.i(TAG, "mediaCodec creation end codec=$codec") - codec } @@ -68,8 +85,6 @@ class SimpleVideoEncoder( mediaCodec.configure(mediaFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) surface = mediaCodec.createInputSurface() mediaCodec.start() - -// drainCodec(false) } private fun createMediaCodecCallback(): MediaCodec.Callback { @@ -82,8 +97,6 @@ class SimpleVideoEncoder( index: Int, info: MediaCodec.BufferInfo ) { - // need to catch, since this is from callback, so there are no - // things like pigeon auto-catch val encodedData = codec.getOutputBuffer(index)!! var effectiveSize = info.size @@ -104,25 +117,19 @@ class SimpleVideoEncoder( mediaCodec.releaseOutputBuffer(index, false) if (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) { - Log.i(TAG, "drainCodec end of stream reached") actualRelease() } } override fun onError(codec: MediaCodec, e: MediaCodec.CodecException) { - Log.e(TAG, "onError (MediaCodec.Callback)", e) } override fun onOutputFormatChanged(codec: MediaCodec, format: MediaFormat) { - Log.i(TAG, "onOutputFormatChanged format=$format") - // should happen before receiving buffers, and should only happen once if (frameMuxer.isStarted()) { throw RuntimeException("format changed twice") } val newFormat: MediaFormat = mediaCodec.outputFormat - Log.i(TAG, "encoder output format changed: $newFormat") - // now that we have the Magic Goodies, start the muxer frameMuxer.start(newFormat) } @@ -142,7 +149,6 @@ class SimpleVideoEncoder( * can only *start* releasing, since it is asynchronous */ fun startRelease() { -// drainCodec(true) mediaCodec.signalEndOfInputStream() } @@ -155,7 +161,7 @@ class SimpleVideoEncoder( } } -data class MuxerConfig( +internal data class MuxerConfig( val file: File, val videoWidth: Int, val videoHeight: Int, diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt index d127d790760..d6d92c48582 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt @@ -15,6 +15,7 @@ import android.view.accessibility.AccessibilityNodeInfo import android.widget.ImageView import android.widget.TextView +// TODO: merge with ViewHierarchyNode from sentry-core maybe? data class ViewHierarchyNode( val x: Float, val y: Float, @@ -53,6 +54,8 @@ data class ViewHierarchyNode( } fun fromView(view: View): ViewHierarchyNode { + // TODO: Extract redacting into its own class/function + // TODO: extract redacting into a separate thread? var shouldRedact = false var dominantColor: Int? = null var rect: Rect? = null @@ -89,6 +92,7 @@ data class ViewHierarchyNode( textStart = 0 } // TODO: support known 3rd-party widgets like MaterialButton with an icon + // TODO: also calculate height properly based on text bounds rect.left += textStart + view.paddingStart rect.right = rect.left + (textEnd - textStart) } @@ -134,6 +138,9 @@ data class ViewHierarchyNode( else -> { if (intrinsicHeight > 0 && intrinsicWidth > 0) { + // this is needed to pick a dominant color when there's a drawable from a 3rd-party image-loading lib, e.g. Coil + // we request the bitmap to draw onto the canvas and then downscale it to 1x1 pixels to get the dominant color + // TODO: maybe we should provide an option to disable this and just use black color for rectangles to save cpu time val bmp = Bitmap.createBitmap(this.intrinsicWidth, intrinsicHeight, Bitmap.Config.ARGB_8888) val canvas = Canvas(bmp) From dd0e9a4892aa652c472af627851d2245f2d52266 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 13 Feb 2024 12:03:40 +0100 Subject: [PATCH 05/89] Api dump --- .../api/sentry-android-replay.api | 63 +++++++++++++++++++ .../java/io/sentry/android/replay/Windows.kt | 4 +- 2 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 sentry-android-replay/api/sentry-android-replay.api diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api new file mode 100644 index 00000000000..b6029a33702 --- /dev/null +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -0,0 +1,63 @@ +public final class io/sentry/android/replay/BuildConfig { + public static final field BUILD_TYPE Ljava/lang/String; + public static final field DEBUG Z + public static final field LIBRARY_PACKAGE_NAME Ljava/lang/String; + public static final field VERSION_NAME Ljava/lang/String; + public fun ()V +} + +public final class io/sentry/android/replay/WindowRecorder { + public fun ()V + public final fun startRecording (Landroid/content/Context;)V + public final fun stopRecording ()V +} + +public abstract interface class io/sentry/android/replay/video/SimpleFrameMuxer { + public abstract fun getVideoTime ()J + public abstract fun isStarted ()Z + public abstract fun muxVideoFrame (Ljava/nio/ByteBuffer;Landroid/media/MediaCodec$BufferInfo;)V + public abstract fun release ()V + public abstract fun start (Landroid/media/MediaFormat;)V +} + +public final class io/sentry/android/replay/video/SimpleMp4FrameMuxer : io/sentry/android/replay/video/SimpleFrameMuxer { + public fun (Ljava/lang/String;F)V + public fun getVideoTime ()J + public fun isStarted ()Z + public fun muxVideoFrame (Ljava/nio/ByteBuffer;Landroid/media/MediaCodec$BufferInfo;)V + public fun release ()V + public fun start (Landroid/media/MediaFormat;)V +} + +public final class io/sentry/android/replay/viewhierarchy/ViewHierarchyNode { + public static final field Companion Lio/sentry/android/replay/viewhierarchy/ViewHierarchyNode$Companion; + public fun (FFIIZLjava/lang/Integer;Landroid/graphics/Rect;)V + public synthetic fun (FFIIZLjava/lang/Integer;Landroid/graphics/Rect;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()F + public final fun component2 ()F + public final fun component3 ()I + public final fun component4 ()I + public final fun component5 ()Z + public final fun component6 ()Ljava/lang/Integer; + public final fun component7 ()Landroid/graphics/Rect; + public final fun copy (FFIIZLjava/lang/Integer;Landroid/graphics/Rect;)Lio/sentry/android/replay/viewhierarchy/ViewHierarchyNode; + public static synthetic fun copy$default (Lio/sentry/android/replay/viewhierarchy/ViewHierarchyNode;FFIIZLjava/lang/Integer;Landroid/graphics/Rect;ILjava/lang/Object;)Lio/sentry/android/replay/viewhierarchy/ViewHierarchyNode; + public fun equals (Ljava/lang/Object;)Z + public final fun getChildren ()Ljava/util/List; + public final fun getDominantColor ()Ljava/lang/Integer; + public final fun getHeight ()I + public final fun getShouldRedact ()Z + public final fun getVisibleRect ()Landroid/graphics/Rect; + public final fun getWidth ()I + public final fun getX ()F + public final fun getY ()F + public fun hashCode ()I + public final fun setChildren (Ljava/util/List;)V + public fun toString ()Ljava/lang/String; +} + +public final class io/sentry/android/replay/viewhierarchy/ViewHierarchyNode$Companion { + public final fun adjustAlpha (I)I + public final fun fromView (Landroid/view/View;)Lio/sentry/android/replay/viewhierarchy/ViewHierarchyNode; +} + diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt index b621465c20f..fc45aebf0ff 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt @@ -35,7 +35,7 @@ import kotlin.LazyThreadSafetyMode.NONE * Note: this property is called [phoneWindow] because the only implementation of [Window] is * the internal class android.view.PhoneWindow. */ -val View.phoneWindow: Window? +internal val View.phoneWindow: Window? get() { return WindowSpy.pullWindow(rootView) } @@ -117,7 +117,7 @@ internal object WindowSpy { * If you only care about either attached or detached, consider implementing [OnRootViewAddedListener] * or [OnRootViewRemovedListener] instead. */ -fun interface OnRootViewsChangedListener { +internal fun interface OnRootViewsChangedListener { /** * Called when [android.view.WindowManager.addView] and [android.view.WindowManager.removeView] * are called. From d34ddee5d836060f2ba9a2e719b23b14c9747b18 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 13 Feb 2024 12:06:53 +0100 Subject: [PATCH 06/89] Formatting --- .../android/replay/ScreenshotRecorder.kt | 206 +++++++------- .../sentry/android/replay/WindowRecorder.kt | 104 ++++---- .../java/io/sentry/android/replay/Windows.kt | 249 ++++++++--------- .../replay/video/SimpleVideoEncoder.kt | 219 ++++++++------- .../replay/viewhierarchy/ViewHierarchyNode.kt | 252 +++++++++--------- 5 files changed, 510 insertions(+), 520 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt index feae4557a61..442e740796d 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -18,132 +18,130 @@ import io.sentry.android.replay.video.SimpleVideoEncoder import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode import java.lang.ref.WeakReference import java.util.WeakHashMap -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit.MILLISECONDS import kotlin.system.measureTimeMillis internal class ScreenshotRecorder( - val rootView: WeakReference, - val encoder: SimpleVideoEncoder + val rootView: WeakReference, + val encoder: SimpleVideoEncoder ) : ViewTreeObserver.OnDrawListener { - private val thread = HandlerThread("SentryReplay").also { it.start() } - private val handler = Handler(thread.looper) - private val bitmapToVH = WeakHashMap() + private val thread = HandlerThread("SentryReplay").also { it.start() } + private val handler = Handler(thread.looper) + private val bitmapToVH = WeakHashMap() - companion object { - const val TAG = "ScreenshotRecorder" - } - - private var lastCapturedAtMs: Long? = null - override fun onDraw() { - val now = SystemClock.uptimeMillis() - if (lastCapturedAtMs != null && (now - lastCapturedAtMs!!) < 1000L) { - return + companion object { + const val TAG = "ScreenshotRecorder" } - lastCapturedAtMs = now - val root = rootView.get() - if (root == null || root.width <= 0 || root.height <= 0) { - return - } - - val window = root.phoneWindow ?: return - val bitmap = Bitmap.createBitmap( - root.width, - root.height, - Bitmap.Config.ARGB_8888 - ) - Log.e("BITMAP CREATED", bitmap.toString()) - - val time = measureTimeMillis { - val rootNode = ViewHierarchyNode.fromView(root) - root.traverse(rootNode) - bitmapToVH[bitmap] = rootNode - } - Log.e("TIME", time.toString()) + private var lastCapturedAtMs: Long? = null + override fun onDraw() { + val now = SystemClock.uptimeMillis() + if (lastCapturedAtMs != null && (now - lastCapturedAtMs!!) < 1000L) { + return + } + lastCapturedAtMs = now + + val root = rootView.get() + if (root == null || root.width <= 0 || root.height <= 0) { + return + } + + val window = root.phoneWindow ?: return + val bitmap = Bitmap.createBitmap( + root.width, + root.height, + Bitmap.Config.ARGB_8888 + ) + Log.e("BITMAP CREATED", bitmap.toString()) + + val time = measureTimeMillis { + val rootNode = ViewHierarchyNode.fromView(root) + root.traverse(rootNode) + bitmapToVH[bitmap] = rootNode + } + Log.e("TIME", time.toString()) // val latch = CountDownLatch(1) - Handler(Looper.getMainLooper()).postAtFrontOfQueue { - PixelCopy.request( - window, - bitmap, - { copyResult: Int -> - Log.d(TAG, "PixelCopy result: $copyResult") - if (copyResult != PixelCopy.SUCCESS) { - Log.e(TAG, "Failed to capture screenshot") - return@request - } - - Log.e("BITMAP CAPTURED", bitmap.toString()) - val viewHierarchy = bitmapToVH[bitmap] - - if (viewHierarchy != null) { - val canvas = Canvas(bitmap) - viewHierarchy.traverse { - if (it.shouldRedact && (it.width > 0 && it.height > 0)) { - it.visibleRect ?: return@traverse - - val paint = Paint().apply { - color = it.dominantColor ?: Color.BLACK - } - canvas.drawRoundRect(RectF(it.visibleRect), 10f, 10f, paint) - } - } - } - - val scaledBitmap = Bitmap.createScaledBitmap( - bitmap, - encoder.muxerConfig.videoWidth, - encoder.muxerConfig.videoHeight, - true - ) + Handler(Looper.getMainLooper()).postAtFrontOfQueue { + PixelCopy.request( + window, + bitmap, + { copyResult: Int -> + Log.d(TAG, "PixelCopy result: $copyResult") + if (copyResult != PixelCopy.SUCCESS) { + Log.e(TAG, "Failed to capture screenshot") + return@request + } + + Log.e("BITMAP CAPTURED", bitmap.toString()) + val viewHierarchy = bitmapToVH[bitmap] + + if (viewHierarchy != null) { + val canvas = Canvas(bitmap) + viewHierarchy.traverse { + if (it.shouldRedact && (it.width > 0 && it.height > 0)) { + it.visibleRect ?: return@traverse + + val paint = Paint().apply { + color = it.dominantColor ?: Color.BLACK + } + canvas.drawRoundRect(RectF(it.visibleRect), 10f, 10f, paint) + } + } + } + + val scaledBitmap = Bitmap.createScaledBitmap( + bitmap, + encoder.muxerConfig.videoWidth, + encoder.muxerConfig.videoHeight, + true + ) // val baos = ByteArrayOutputStream() // scaledBitmap.compress(Bitmap.CompressFormat.JPEG, 75, baos) // val bmp = BitmapFactory.decodeByteArray(baos.toByteArray(), 0, baos.size()) - encoder.encode(scaledBitmap) + encoder.encode(scaledBitmap) // bmp.recycle() - scaledBitmap.recycle() - bitmap.recycle() - Log.i(TAG, "Captured a screenshot") + scaledBitmap.recycle() + bitmap.recycle() + Log.i(TAG, "Captured a screenshot") // latch.countDown() - }, - handler - ) - } + }, + handler + ) + } // val success = latch.await(200, MILLISECONDS) // Log.i(TAG, "Captured a screenshot: $success") - } - - private fun ViewHierarchyNode.traverse(callback: (ViewHierarchyNode) -> Unit) { - callback(this) - if (this.children != null) { - this.children!!.forEach { - it.traverse(callback) - } - } - } - - private fun View.traverse(parentNode: ViewHierarchyNode) { - if (this !is ViewGroup) { - return } - if (this.childCount == 0) { - return + private fun ViewHierarchyNode.traverse(callback: (ViewHierarchyNode) -> Unit) { + callback(this) + if (this.children != null) { + this.children!!.forEach { + it.traverse(callback) + } + } } - val childNodes = ArrayList(this.childCount) - for (i in 0 until childCount) { - val child = getChildAt(i) - if (child != null) { - val childNode = ViewHierarchyNode.fromView(child) - childNodes.add(childNode) - child.traverse(childNode) - } + private fun View.traverse(parentNode: ViewHierarchyNode) { + if (this !is ViewGroup) { + return + } + + if (this.childCount == 0) { + return + } + + val childNodes = ArrayList(this.childCount) + for (i in 0 until childCount) { + val child = getChildAt(i) + if (child != null) { + val childNode = ViewHierarchyNode.fromView(child) + childNodes.add(childNode) + child.traverse(childNode) + } + } + parentNode.children = childNodes } - parentNode.children = childNodes - } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt index 7ded87f7859..125c5ce461b 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt @@ -1,10 +1,6 @@ package io.sentry.android.replay import android.content.Context -import android.content.res.Configuration -import android.content.res.Configuration.ORIENTATION_LANDSCAPE -import android.media.CamcorderProfile -import android.util.DisplayMetrics import android.view.View import android.view.ViewTreeObserver import android.view.WindowManager @@ -19,68 +15,68 @@ import kotlin.math.roundToInt class WindowRecorder { - private val rootViewsSpy by lazy(NONE) { - RootViewsSpy.install() - } + private val rootViewsSpy by lazy(NONE) { + RootViewsSpy.install() + } - private var encoder: SimpleVideoEncoder? = null - private val isRecording = AtomicBoolean(false) - private val recorders: WeakHashMap = WeakHashMap() + private var encoder: SimpleVideoEncoder? = null + private val isRecording = AtomicBoolean(false) + private val recorders: WeakHashMap = WeakHashMap() - private val onRootViewsChangedListener = OnRootViewsChangedListener { root, added -> - if (added) { - // stop tracking other windows so they don't interfere in the recording like a 25th frame effect - recorders.entries.forEach { - it.key.viewTreeObserver.removeOnDrawListener(it.value) - } + private val onRootViewsChangedListener = OnRootViewsChangedListener { root, added -> + if (added) { + // stop tracking other windows so they don't interfere in the recording like a 25th frame effect + recorders.entries.forEach { + it.key.viewTreeObserver.removeOnDrawListener(it.value) + } - val recorder = ScreenshotRecorder(WeakReference(root), encoder!!) - recorders[root] = recorder - root.viewTreeObserver?.addOnDrawListener(recorder) - } else { - root.viewTreeObserver?.removeOnDrawListener(recorders[root]) - recorders.remove(root) + val recorder = ScreenshotRecorder(WeakReference(root), encoder!!) + recorders[root] = recorder + root.viewTreeObserver?.addOnDrawListener(recorder) + } else { + root.viewTreeObserver?.removeOnDrawListener(recorders[root]) + recorders.remove(root) - recorders.entries.forEach { - it.key.viewTreeObserver.addOnDrawListener(it.value) - } + recorders.entries.forEach { + it.key.viewTreeObserver.addOnDrawListener(it.value) + } + } } - } - fun startRecording(context: Context) { - if (isRecording.getAndSet(true)) { - return - } + fun startRecording(context: Context) { + if (isRecording.getAndSet(true)) { + return + } - val wm = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager + val wm = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager // val (height, width) = (wm.currentWindowMetrics.bounds.bottom / // context.resources.displayMetrics.density).roundToInt() to // (wm.currentWindowMetrics.bounds.right / // context.resources.displayMetrics.density).roundToInt() - // TODO: support this for api level < 30 - val aspectRatio = wm.currentWindowMetrics.bounds.bottom.toFloat() / wm.currentWindowMetrics.bounds.right.toFloat() + // TODO: support this for api level < 30 + val aspectRatio = wm.currentWindowMetrics.bounds.bottom.toFloat() / wm.currentWindowMetrics.bounds.right.toFloat() - val videoFile = File(context.cacheDir, "sentry-sr.mp4") - encoder = SimpleVideoEncoder( - MuxerConfig( - videoFile, - videoWidth = (720 / aspectRatio).roundToInt(), - videoHeight = 720, - frameRate = 1f, - bitrate = 500 * 1000, - ) - ) - encoder?.start() - rootViewsSpy.listeners += onRootViewsChangedListener - } + val videoFile = File(context.cacheDir, "sentry-sr.mp4") + encoder = SimpleVideoEncoder( + MuxerConfig( + videoFile, + videoWidth = (720 / aspectRatio).roundToInt(), + videoHeight = 720, + frameRate = 1f, + bitrate = 500 * 1000 + ) + ) + encoder?.start() + rootViewsSpy.listeners += onRootViewsChangedListener + } - fun stopRecording() { - rootViewsSpy.listeners -= onRootViewsChangedListener - recorders.entries.forEach { - it.key.viewTreeObserver.removeOnDrawListener(it.value) + fun stopRecording() { + rootViewsSpy.listeners -= onRootViewsChangedListener + recorders.entries.forEach { + it.key.viewTreeObserver.removeOnDrawListener(it.value) + } + recorders.clear() + encoder?.startRelease() + encoder = null } - recorders.clear() - encoder?.startRelease() - encoder = null - } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt index fc45aebf0ff..7238c8cc2dd 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt @@ -19,7 +19,6 @@ package io.sentry.android.replay import android.annotation.SuppressLint -import android.os.Build import android.os.Build.VERSION.SDK_INT import android.util.Log import android.view.View @@ -36,80 +35,82 @@ import kotlin.LazyThreadSafetyMode.NONE * the internal class android.view.PhoneWindow. */ internal val View.phoneWindow: Window? - get() { - return WindowSpy.pullWindow(rootView) - } - + get() { + return WindowSpy.pullWindow(rootView) + } internal object WindowSpy { - /** - * Originally, DecorView was an inner class of PhoneWindow. In the initial import in 2009, - * PhoneWindow is in com.android.internal.policy.impl.PhoneWindow and that didn't change until - * API 23. - * In API 22: https://android.googlesource.com/platform/frameworks/base/+/android-5.1.1_r38/policy/src/com/android/internal/policy/impl/PhoneWindow.java - * PhoneWindow was then moved to android.view and then again to com.android.internal.policy - * https://android.googlesource.com/platform/frameworks/base/+/b10e33ff804a831c71be9303146cea892b9aeb5d - * https://android.googlesource.com/platform/frameworks/base/+/6711f3b34c2ad9c622f56a08b81e313795fe7647 - * In API 23: https://android.googlesource.com/platform/frameworks/base/+/android-6.0.0_r1/core/java/com/android/internal/policy/PhoneWindow.java - * Then DecorView moved out of PhoneWindow into its own class: - * https://android.googlesource.com/platform/frameworks/base/+/8804af2b63b0584034f7ec7d4dc701d06e6a8754 - * In API 24: https://android.googlesource.com/platform/frameworks/base/+/android-7.0.0_r1/core/java/com/android/internal/policy/DecorView.java - */ - private val decorViewClass by lazy(NONE) { - val sdkInt = SDK_INT - // TODO: we can only consider API 26 - val decorViewClassName = when { - sdkInt >= 24 -> "com.android.internal.policy.DecorView" - sdkInt == 23 -> "com.android.internal.policy.PhoneWindow\$DecorView" - else -> "com.android.internal.policy.impl.PhoneWindow\$DecorView" - } - try { - Class.forName(decorViewClassName) - } catch (ignored: Throwable) { - Log.d( - "WindowSpy", "Unexpected exception loading $decorViewClassName on API $sdkInt", ignored - ) - null + /** + * Originally, DecorView was an inner class of PhoneWindow. In the initial import in 2009, + * PhoneWindow is in com.android.internal.policy.impl.PhoneWindow and that didn't change until + * API 23. + * In API 22: https://android.googlesource.com/platform/frameworks/base/+/android-5.1.1_r38/policy/src/com/android/internal/policy/impl/PhoneWindow.java + * PhoneWindow was then moved to android.view and then again to com.android.internal.policy + * https://android.googlesource.com/platform/frameworks/base/+/b10e33ff804a831c71be9303146cea892b9aeb5d + * https://android.googlesource.com/platform/frameworks/base/+/6711f3b34c2ad9c622f56a08b81e313795fe7647 + * In API 23: https://android.googlesource.com/platform/frameworks/base/+/android-6.0.0_r1/core/java/com/android/internal/policy/PhoneWindow.java + * Then DecorView moved out of PhoneWindow into its own class: + * https://android.googlesource.com/platform/frameworks/base/+/8804af2b63b0584034f7ec7d4dc701d06e6a8754 + * In API 24: https://android.googlesource.com/platform/frameworks/base/+/android-7.0.0_r1/core/java/com/android/internal/policy/DecorView.java + */ + private val decorViewClass by lazy(NONE) { + val sdkInt = SDK_INT + // TODO: we can only consider API 26 + val decorViewClassName = when { + sdkInt >= 24 -> "com.android.internal.policy.DecorView" + sdkInt == 23 -> "com.android.internal.policy.PhoneWindow\$DecorView" + else -> "com.android.internal.policy.impl.PhoneWindow\$DecorView" + } + try { + Class.forName(decorViewClassName) + } catch (ignored: Throwable) { + Log.d( + "WindowSpy", + "Unexpected exception loading $decorViewClassName on API $sdkInt", + ignored + ) + null + } } - } - - /** - * See [decorViewClass] for the AOSP history of the DecorView class. - * Between the latest API 23 release and the first API 24 release, DecorView first became a - * static class: - * https://android.googlesource.com/platform/frameworks/base/+/0daf2102a20d224edeb4ee45dd4ee91889ef3e0c - * Then it was extracted into a separate class. - * - * Hence the change of window field name from "this$0" to "mWindow" on API 24+. - */ - private val windowField by lazy(NONE) { - decorViewClass?.let { decorViewClass -> - val sdkInt = SDK_INT - val fieldName = if (sdkInt >= 24) "mWindow" else "this$0" - try { - decorViewClass.getDeclaredField(fieldName).apply { isAccessible = true } - } catch (ignored: NoSuchFieldException) { - Log.d( - "WindowSpy", - "Unexpected exception retrieving $decorViewClass#$fieldName on API $sdkInt", ignored - ) - null - } + + /** + * See [decorViewClass] for the AOSP history of the DecorView class. + * Between the latest API 23 release and the first API 24 release, DecorView first became a + * static class: + * https://android.googlesource.com/platform/frameworks/base/+/0daf2102a20d224edeb4ee45dd4ee91889ef3e0c + * Then it was extracted into a separate class. + * + * Hence the change of window field name from "this$0" to "mWindow" on API 24+. + */ + private val windowField by lazy(NONE) { + decorViewClass?.let { decorViewClass -> + val sdkInt = SDK_INT + val fieldName = if (sdkInt >= 24) "mWindow" else "this$0" + try { + decorViewClass.getDeclaredField(fieldName).apply { isAccessible = true } + } catch (ignored: NoSuchFieldException) { + Log.d( + "WindowSpy", + "Unexpected exception retrieving $decorViewClass#$fieldName on API $sdkInt", + ignored + ) + null + } + } } - } - fun pullWindow(maybeDecorView: View): Window? { - return decorViewClass?.let { decorViewClass -> - if (decorViewClass.isInstance(maybeDecorView)) { - windowField?.let { windowField -> - windowField[maybeDecorView] as Window + fun pullWindow(maybeDecorView: View): Window? { + return decorViewClass?.let { decorViewClass -> + if (decorViewClass.isInstance(maybeDecorView)) { + windowField?.let { windowField -> + windowField[maybeDecorView] as Window + } + } else { + null + } } - } else { - null - } } - } } /** @@ -118,14 +119,14 @@ internal object WindowSpy { * or [OnRootViewRemovedListener] instead. */ internal fun interface OnRootViewsChangedListener { - /** - * Called when [android.view.WindowManager.addView] and [android.view.WindowManager.removeView] - * are called. - */ - fun onRootViewsChanged( - view: View, - added: Boolean - ) + /** + * Called when [android.view.WindowManager.addView] and [android.view.WindowManager.removeView] + * are called. + */ + fun onRootViewsChanged( + view: View, + added: Boolean + ) } /** @@ -133,70 +134,70 @@ internal fun interface OnRootViewsChangedListener { */ internal class RootViewsSpy private constructor() { - val listeners = CopyOnWriteArrayList() + val listeners = CopyOnWriteArrayList() - private val delegatingViewList = object : ArrayList() { - override fun add(element: View): Boolean { - listeners.forEach { it.onRootViewsChanged(element, true) } - return super.add(element) - } + private val delegatingViewList = object : ArrayList() { + override fun add(element: View): Boolean { + listeners.forEach { it.onRootViewsChanged(element, true) } + return super.add(element) + } - override fun removeAt(index: Int): View { - val removedView = super.removeAt(index) - listeners.forEach { it.onRootViewsChanged(removedView, false) } - return removedView + override fun removeAt(index: Int): View { + val removedView = super.removeAt(index) + listeners.forEach { it.onRootViewsChanged(removedView, false) } + return removedView + } } - } - companion object { - fun install(): RootViewsSpy { - return RootViewsSpy().apply { - WindowManagerSpy.swapWindowManagerGlobalMViews { mViews -> - delegatingViewList.apply { addAll(mViews) } + companion object { + fun install(): RootViewsSpy { + return RootViewsSpy().apply { + WindowManagerSpy.swapWindowManagerGlobalMViews { mViews -> + delegatingViewList.apply { addAll(mViews) } + } + } } - } } - } } internal object WindowManagerSpy { - private val windowManagerClass by lazy(NONE) { - val className = "android.view.WindowManagerGlobal" - try { - Class.forName(className) - } catch (ignored: Throwable) { - Log.w("WindowManagerSpy", ignored) - null + private val windowManagerClass by lazy(NONE) { + val className = "android.view.WindowManagerGlobal" + try { + Class.forName(className) + } catch (ignored: Throwable) { + Log.w("WindowManagerSpy", ignored) + null + } } - } - - private val windowManagerInstance by lazy(NONE) { - windowManagerClass?.getMethod("getInstance")?.invoke(null) - } - private val mViewsField by lazy(NONE) { - windowManagerClass?.let { windowManagerClass -> - windowManagerClass.getDeclaredField("mViews").apply { isAccessible = true } + private val windowManagerInstance by lazy(NONE) { + windowManagerClass?.getMethod("getInstance")?.invoke(null) } - } - // You can discourage me all you want I'll still do it. - @SuppressLint("PrivateApi", "ObsoleteSdkInt", "DiscouragedPrivateApi") - fun swapWindowManagerGlobalMViews(swap: (ArrayList) -> ArrayList) { - if (SDK_INT < 19) { - return + private val mViewsField by lazy(NONE) { + windowManagerClass?.let { windowManagerClass -> + windowManagerClass.getDeclaredField("mViews").apply { isAccessible = true } + } } - try { - windowManagerInstance?.let { windowManagerInstance -> - mViewsField?.let { mViewsField -> - @Suppress("UNCHECKED_CAST") - val mViews = mViewsField[windowManagerInstance] as ArrayList - mViewsField[windowManagerInstance] = swap(mViews) + + // You can discourage me all you want I'll still do it. + @SuppressLint("PrivateApi", "ObsoleteSdkInt", "DiscouragedPrivateApi") + fun swapWindowManagerGlobalMViews(swap: (ArrayList) -> ArrayList) { + if (SDK_INT < 19) { + return + } + try { + windowManagerInstance?.let { windowManagerInstance -> + mViewsField?.let { mViewsField -> + @Suppress("UNCHECKED_CAST") + val mViews = mViewsField[windowManagerInstance] as ArrayList + mViewsField[windowManagerInstance] = swap(mViews) + } + } + } catch (ignored: Throwable) { + Log.w("WindowManagerSpy", ignored) } - } - } catch (ignored: Throwable) { - Log.w("WindowManagerSpy", ignored) } - } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt index 5b52347957f..70fe81d7a41 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt @@ -32,141 +32,136 @@ package io.sentry.android.replay.video import android.graphics.Bitmap import android.media.MediaCodec import android.media.MediaCodecInfo -import android.media.MediaCodecList -import android.media.MediaCodecList.REGULAR_CODECS import android.media.MediaFormat import android.os.Handler import android.os.Looper -import android.util.Log import android.view.Surface -import io.sentry.android.replay.video.SimpleFrameMuxer -import io.sentry.android.replay.video.SimpleMp4FrameMuxer import java.io.File internal class SimpleVideoEncoder( - val muxerConfig: MuxerConfig, + val muxerConfig: MuxerConfig ) { - private val mediaFormat: MediaFormat = run { - val format = MediaFormat.createVideoFormat( - muxerConfig.mimeType, - muxerConfig.videoWidth, - muxerConfig.videoHeight - ) - - // Set some properties. Failing to specify some of these can cause the MediaCodec - // configure() call to throw an unhelpful exception. - format.setInteger( - MediaFormat.KEY_COLOR_FORMAT, - MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface - ) - format.setInteger(MediaFormat.KEY_BIT_RATE, muxerConfig.bitrate) - format.setFloat(MediaFormat.KEY_FRAME_RATE, muxerConfig.frameRate) - format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 10) - - format - } - - private val mediaCodec: MediaCodec = run { + private val mediaFormat: MediaFormat = run { + val format = MediaFormat.createVideoFormat( + muxerConfig.mimeType, + muxerConfig.videoWidth, + muxerConfig.videoHeight + ) + + // Set some properties. Failing to specify some of these can cause the MediaCodec + // configure() call to throw an unhelpful exception. + format.setInteger( + MediaFormat.KEY_COLOR_FORMAT, + MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface + ) + format.setInteger(MediaFormat.KEY_BIT_RATE, muxerConfig.bitrate) + format.setFloat(MediaFormat.KEY_FRAME_RATE, muxerConfig.frameRate) + format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 10) + + format + } + + private val mediaCodec: MediaCodec = run { // val codecs = MediaCodecList(REGULAR_CODECS) // val codecName = codecs.findEncoderForFormat(mediaFormat) // val codec = MediaCodec.createByCodecName(codecName) - val codec = MediaCodec.createEncoderByType(muxerConfig.mimeType) + val codec = MediaCodec.createEncoderByType(muxerConfig.mimeType) - codec - } - - private val frameMuxer = muxerConfig.frameMuxer + codec + } - private var surface: Surface? = null + private val frameMuxer = muxerConfig.frameMuxer - fun start() { - mediaCodec.setCallback(createMediaCodecCallback(), Handler(Looper.getMainLooper())) + private var surface: Surface? = null - mediaCodec.configure(mediaFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) - surface = mediaCodec.createInputSurface() - mediaCodec.start() - } + fun start() { + mediaCodec.setCallback(createMediaCodecCallback(), Handler(Looper.getMainLooper())) - private fun createMediaCodecCallback(): MediaCodec.Callback { - return object : MediaCodec.Callback() { - override fun onInputBufferAvailable(codec: MediaCodec, index: Int) { - } + mediaCodec.configure(mediaFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) + surface = mediaCodec.createInputSurface() + mediaCodec.start() + } - override fun onOutputBufferAvailable( - codec: MediaCodec, - index: Int, - info: MediaCodec.BufferInfo - ) { - val encodedData = codec.getOutputBuffer(index)!! + private fun createMediaCodecCallback(): MediaCodec.Callback { + return object : MediaCodec.Callback() { + override fun onInputBufferAvailable(codec: MediaCodec, index: Int) { + } - var effectiveSize = info.size + override fun onOutputBufferAvailable( + codec: MediaCodec, + index: Int, + info: MediaCodec.BufferInfo + ) { + val encodedData = codec.getOutputBuffer(index)!! + + var effectiveSize = info.size + + if (info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0) { + // The codec config data was pulled out and fed to the muxer when we got + // the INFO_OUTPUT_FORMAT_CHANGED status. Ignore it. + effectiveSize = 0 + } + + if (effectiveSize != 0) { + if (!frameMuxer.isStarted()) { + throw RuntimeException("muxer hasn't started") + } + frameMuxer.muxVideoFrame(encodedData, info) + } + + mediaCodec.releaseOutputBuffer(index, false) + + if (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) { + actualRelease() + } + } - if (info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0) { - // The codec config data was pulled out and fed to the muxer when we got - // the INFO_OUTPUT_FORMAT_CHANGED status. Ignore it. - effectiveSize = 0 - } + override fun onError(codec: MediaCodec, e: MediaCodec.CodecException) { + } - if (effectiveSize != 0) { - if (!frameMuxer.isStarted()) { - throw RuntimeException("muxer hasn't started") + override fun onOutputFormatChanged(codec: MediaCodec, format: MediaFormat) { + // should happen before receiving buffers, and should only happen once + if (frameMuxer.isStarted()) { + throw RuntimeException("format changed twice") + } + val newFormat: MediaFormat = mediaCodec.outputFormat + // now that we have the Magic Goodies, start the muxer + frameMuxer.start(newFormat) } - frameMuxer.muxVideoFrame(encodedData, info) - } - - mediaCodec.releaseOutputBuffer(index, false) - - if (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) { - actualRelease() - } - } - - override fun onError(codec: MediaCodec, e: MediaCodec.CodecException) { - } - - override fun onOutputFormatChanged(codec: MediaCodec, format: MediaFormat) { - // should happen before receiving buffers, and should only happen once - if (frameMuxer.isStarted()) { - throw RuntimeException("format changed twice") - } - val newFormat: MediaFormat = mediaCodec.outputFormat - // now that we have the Magic Goodies, start the muxer - frameMuxer.start(newFormat) - } + } + } + + fun encode(image: Bitmap) { + // NOTE do not use `lockCanvas` like what is done in bitmap2video + // This is because https://developer.android.com/reference/android/media/MediaCodec#createInputSurface() + // says that, "Surface.lockCanvas(android.graphics.Rect) may fail or produce unexpected results." + val canvas = surface?.lockHardwareCanvas() + canvas?.drawBitmap(image, 0f, 0f, null) + surface?.unlockCanvasAndPost(canvas) + } + + /** + * can only *start* releasing, since it is asynchronous + */ + fun startRelease() { + mediaCodec.signalEndOfInputStream() + } + + private fun actualRelease() { + mediaCodec.stop() + mediaCodec.release() + surface?.release() + + frameMuxer.release() } - } - - fun encode(image: Bitmap) { - // NOTE do not use `lockCanvas` like what is done in bitmap2video - // This is because https://developer.android.com/reference/android/media/MediaCodec#createInputSurface() - // says that, "Surface.lockCanvas(android.graphics.Rect) may fail or produce unexpected results." - val canvas = surface?.lockHardwareCanvas() - canvas?.drawBitmap(image, 0f, 0f, null) - surface?.unlockCanvasAndPost(canvas) - } - - /** - * can only *start* releasing, since it is asynchronous - */ - fun startRelease() { - mediaCodec.signalEndOfInputStream() - } - - private fun actualRelease() { - mediaCodec.stop() - mediaCodec.release() - surface?.release() - - frameMuxer.release() - } } internal data class MuxerConfig( - val file: File, - val videoWidth: Int, - val videoHeight: Int, - val mimeType: String = MediaFormat.MIMETYPE_VIDEO_AVC, - val frameRate: Float, - val bitrate: Int, - val frameMuxer: SimpleFrameMuxer = SimpleMp4FrameMuxer(file.absolutePath, frameRate), + val file: File, + val videoWidth: Int, + val videoHeight: Int, + val mimeType: String = MediaFormat.MIMETYPE_VIDEO_AVC, + val frameRate: Float, + val bitrate: Int, + val frameMuxer: SimpleFrameMuxer = SimpleMp4FrameMuxer(file.absolutePath, frameRate) ) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt index d6d92c48582..9dcf117b91e 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt @@ -17,148 +17,148 @@ import android.widget.TextView // TODO: merge with ViewHierarchyNode from sentry-core maybe? data class ViewHierarchyNode( - val x: Float, - val y: Float, - val width: Int, - val height: Int, - val shouldRedact: Boolean = false, - val dominantColor: Int? = null, - val visibleRect: Rect? = null + val x: Float, + val y: Float, + val width: Int, + val height: Int, + val shouldRedact: Boolean = false, + val dominantColor: Int? = null, + val visibleRect: Rect? = null ) { - var children: List? = null + var children: List? = null - companion object { + companion object { - private fun isVisible(view: View?): Boolean { - if (view == null || !view.isShown) { - return false - } - val actualPosition = Rect() - view.getGlobalVisibleRect(actualPosition) - val screen = Rect( - 0, - 0, - view.context.resources.displayMetrics.widthPixels, - view.context.resources.displayMetrics.heightPixels - ) - return actualPosition.intersects(screen.left, screen.top, screen.right, screen.bottom) - } + private fun isVisible(view: View?): Boolean { + if (view == null || !view.isShown) { + return false + } + val actualPosition = Rect() + view.getGlobalVisibleRect(actualPosition) + val screen = Rect( + 0, + 0, + view.context.resources.displayMetrics.widthPixels, + view.context.resources.displayMetrics.heightPixels + ) + return actualPosition.intersects(screen.left, screen.top, screen.right, screen.bottom) + } - fun adjustAlpha(color: Int): Int { - val alpha = 255 - val red = Color.red(color) - val green = Color.green(color) - val blue = Color.blue(color) - return Color.argb(alpha, red, green, blue) - } + fun adjustAlpha(color: Int): Int { + val alpha = 255 + val red = Color.red(color) + val green = Color.green(color) + val blue = Color.blue(color) + return Color.argb(alpha, red, green, blue) + } - fun fromView(view: View): ViewHierarchyNode { - // TODO: Extract redacting into its own class/function - // TODO: extract redacting into a separate thread? - var shouldRedact = false - var dominantColor: Int? = null - var rect: Rect? = null - when (view) { - is TextView -> { - val nodeInfo = AccessibilityNodeInfo() - view.onInitializeAccessibilityNodeInfo(nodeInfo) - shouldRedact = nodeInfo.isVisibleToUser - if (shouldRedact) { - val bounds = Rect() - val text = view.text.toString() - view.paint.getTextBounds(text, 0, text.length, bounds) - dominantColor = adjustAlpha(view.currentTextColor) - rect = Rect() - view.getGlobalVisibleRect(rect) + fun fromView(view: View): ViewHierarchyNode { + // TODO: Extract redacting into its own class/function + // TODO: extract redacting into a separate thread? + var shouldRedact = false + var dominantColor: Int? = null + var rect: Rect? = null + when (view) { + is TextView -> { + val nodeInfo = AccessibilityNodeInfo() + view.onInitializeAccessibilityNodeInfo(nodeInfo) + shouldRedact = nodeInfo.isVisibleToUser + if (shouldRedact) { + val bounds = Rect() + val text = view.text.toString() + view.paint.getTextBounds(text, 0, text.length, bounds) + dominantColor = adjustAlpha(view.currentTextColor) + rect = Rect() + view.getGlobalVisibleRect(rect) - var textEnd = Int.MIN_VALUE - var textStart = Int.MAX_VALUE - if (view.layout != null) { - for (i in 0 until view.layout.lineCount) { - val min = view.layout.getLineStart(i) - val minPosition = view.layout.getPrimaryHorizontal(min).toInt() - val max = view.layout.getLineVisibleEnd(i) - val maxPosition = view.layout.getPrimaryHorizontal(max).toInt() - if (minPosition < textStart) { - textStart = minPosition + var textEnd = Int.MIN_VALUE + var textStart = Int.MAX_VALUE + if (view.layout != null) { + for (i in 0 until view.layout.lineCount) { + val min = view.layout.getLineStart(i) + val minPosition = view.layout.getPrimaryHorizontal(min).toInt() + val max = view.layout.getLineVisibleEnd(i) + val maxPosition = view.layout.getPrimaryHorizontal(max).toInt() + if (minPosition < textStart) { + textStart = minPosition + } + if (maxPosition > textEnd) { + textEnd = maxPosition + } + } + } else { + textEnd = rect.right - rect.left + textStart = 0 + } + // TODO: support known 3rd-party widgets like MaterialButton with an icon + // TODO: also calculate height properly based on text bounds + rect.left += textStart + view.paddingStart + rect.right = rect.left + (textEnd - textStart) + } } - if (maxPosition > textEnd) { - textEnd = maxPosition + + is ImageView -> { + shouldRedact = isVisible(view) && (view.drawable?.isRedactable() ?: false) + if (shouldRedact) { + dominantColor = adjustAlpha((view.drawable?.pickDominantColor() ?: Color.BLACK)) + rect = Rect() + view.getGlobalVisibleRect(rect) + } } - } - } else { - textEnd = rect.right - rect.left - textStart = 0 } - // TODO: support known 3rd-party widgets like MaterialButton with an icon - // TODO: also calculate height properly based on text bounds - rect.left += textStart + view.paddingStart - rect.right = rect.left + (textEnd - textStart) - } + return ViewHierarchyNode( + view.x, + view.y, + view.width, + view.height, + shouldRedact, + dominantColor, + rect + ) } - is ImageView -> { - shouldRedact = isVisible(view) && (view.drawable?.isRedactable() ?: false) - if (shouldRedact) { - dominantColor = adjustAlpha((view.drawable?.pickDominantColor() ?: Color.BLACK)) - rect = Rect() - view.getGlobalVisibleRect(rect) - } + private fun Drawable.isRedactable(): Boolean { + return when (this) { + is InsetDrawable, is ColorDrawable, is VectorDrawable, is GradientDrawable -> false + is BitmapDrawable -> !bitmap.isRecycled && bitmap.height > 0 && bitmap.width > 0 + else -> true + } } - } - return ViewHierarchyNode( - view.x, - view.y, - view.width, - view.height, - shouldRedact, - dominantColor, - rect - ) - } - - private fun Drawable.isRedactable(): Boolean { - return when (this) { - is InsetDrawable, is ColorDrawable, is VectorDrawable, is GradientDrawable -> false - is BitmapDrawable -> !bitmap.isRecycled && bitmap.height > 0 && bitmap.width > 0 - else -> true - } - } - private fun Drawable.pickDominantColor(): Int { - // TODO: pick default color based on dark/light default theme - return when (this) { - is BitmapDrawable -> { - val newBitmap = Bitmap.createScaledBitmap(bitmap, 1, 1, true) - val color = newBitmap.getPixel(0, 0) - newBitmap.recycle() - color - } + private fun Drawable.pickDominantColor(): Int { + // TODO: pick default color based on dark/light default theme + return when (this) { + is BitmapDrawable -> { + val newBitmap = Bitmap.createScaledBitmap(bitmap, 1, 1, true) + val color = newBitmap.getPixel(0, 0) + newBitmap.recycle() + color + } - else -> { - if (intrinsicHeight > 0 && intrinsicWidth > 0) { - // this is needed to pick a dominant color when there's a drawable from a 3rd-party image-loading lib, e.g. Coil - // we request the bitmap to draw onto the canvas and then downscale it to 1x1 pixels to get the dominant color - // TODO: maybe we should provide an option to disable this and just use black color for rectangles to save cpu time - val bmp = - Bitmap.createBitmap(this.intrinsicWidth, intrinsicHeight, Bitmap.Config.ARGB_8888) - val canvas = Canvas(bmp) - try { - draw(canvas) - val newBitmap = Bitmap.createScaledBitmap(bmp, 1, 1, true) - val color = newBitmap.getPixel(0, 0) - newBitmap.recycle() - bmp.recycle() - color - } catch (e: Throwable) { - Color.BLACK + else -> { + if (intrinsicHeight > 0 && intrinsicWidth > 0) { + // this is needed to pick a dominant color when there's a drawable from a 3rd-party image-loading lib, e.g. Coil + // we request the bitmap to draw onto the canvas and then downscale it to 1x1 pixels to get the dominant color + // TODO: maybe we should provide an option to disable this and just use black color for rectangles to save cpu time + val bmp = + Bitmap.createBitmap(this.intrinsicWidth, intrinsicHeight, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bmp) + try { + draw(canvas) + val newBitmap = Bitmap.createScaledBitmap(bmp, 1, 1, true) + val color = newBitmap.getPixel(0, 0) + newBitmap.recycle() + bmp.recycle() + color + } catch (e: Throwable) { + Color.BLACK + } + } else { + Color.BLACK + } + } } - } else { - Color.BLACK - } } - } } - } } From 0cca47c803a3cb142176eaaaf0c29fbe10f69e01 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 13 Feb 2024 12:47:30 +0100 Subject: [PATCH 07/89] Lint --- .../io/sentry/android/replay/WindowRecorder.kt | 15 +++++++++++++-- .../replay/viewhierarchy/ViewHierarchyNode.kt | 12 +++++++++++- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt index 125c5ce461b..e7c8e61b972 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt @@ -1,6 +1,9 @@ package io.sentry.android.replay import android.content.Context +import android.graphics.Point +import android.os.Build.VERSION +import android.os.Build.VERSION_CODES import android.view.View import android.view.ViewTreeObserver import android.view.WindowManager @@ -53,8 +56,16 @@ class WindowRecorder { // context.resources.displayMetrics.density).roundToInt() to // (wm.currentWindowMetrics.bounds.right / // context.resources.displayMetrics.density).roundToInt() - // TODO: support this for api level < 30 - val aspectRatio = wm.currentWindowMetrics.bounds.bottom.toFloat() / wm.currentWindowMetrics.bounds.right.toFloat() + // TODO: API level check + // PixelCopy takes screenshots including system bars, so we have to get the real size here + val aspectRatio = if (VERSION.SDK_INT >= VERSION_CODES.R) { + wm.currentWindowMetrics.bounds.bottom.toFloat() / wm.currentWindowMetrics.bounds.right.toFloat() + } else { + val screenResolution = Point() + @Suppress("DEPRECATION") + wm.defaultDisplay.getRealSize(screenResolution) + screenResolution.y.toFloat() / screenResolution.x.toFloat() + } val videoFile = File(context.cacheDir, "sentry-sr.mp4") encoder = SimpleVideoEncoder( diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt index 9dcf117b91e..ff598d3b781 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt @@ -1,5 +1,6 @@ package io.sentry.android.replay.viewhierarchy +import android.annotation.SuppressLint import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.Color @@ -10,6 +11,8 @@ import android.graphics.drawable.Drawable import android.graphics.drawable.GradientDrawable import android.graphics.drawable.InsetDrawable import android.graphics.drawable.VectorDrawable +import android.os.Build.VERSION +import android.os.Build.VERSION_CODES import android.view.View import android.view.accessibility.AccessibilityNodeInfo import android.widget.ImageView @@ -61,9 +64,16 @@ data class ViewHierarchyNode( var rect: Rect? = null when (view) { is TextView -> { - val nodeInfo = AccessibilityNodeInfo() + // TODO: API level check + // TODO: perhaps this is heavy, might reconsider + val nodeInfo = if (VERSION.SDK_INT >= VERSION_CODES.R) { + AccessibilityNodeInfo() + } else { + AccessibilityNodeInfo.obtain() + } view.onInitializeAccessibilityNodeInfo(nodeInfo) shouldRedact = nodeInfo.isVisibleToUser + nodeInfo.recycle() if (shouldRedact) { val bounds = Rect() val text = view.text.toString() From 5ebdfed87d56674699feadc2495cce66eecfba7a Mon Sep 17 00:00:00 2001 From: Sentry Github Bot Date: Tue, 13 Feb 2024 11:56:29 +0000 Subject: [PATCH 08/89] Format code --- .../io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt index ff598d3b781..f044ef08502 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt @@ -1,6 +1,5 @@ package io.sentry.android.replay.viewhierarchy -import android.annotation.SuppressLint import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.Color From 04f43ed67e2a88a80610e530d59efb039794dff9 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 13 Feb 2024 14:03:44 +0100 Subject: [PATCH 09/89] More comments --- .../main/java/io/sentry/android/replay/ScreenshotRecorder.kt | 1 + .../src/main/java/io/sentry/android/replay/WindowRecorder.kt | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt index 442e740796d..a37561b6492 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -35,6 +35,7 @@ internal class ScreenshotRecorder( private var lastCapturedAtMs: Long? = null override fun onDraw() { + // TODO: replace with Debouncer from sentry-core val now = SystemClock.uptimeMillis() if (lastCapturedAtMs != null && (now - lastCapturedAtMs!!) < 1000L) { return diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt index e7c8e61b972..5e24a0af244 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt @@ -28,6 +28,10 @@ class WindowRecorder { private val onRootViewsChangedListener = OnRootViewsChangedListener { root, added -> if (added) { + if (recorders.containsKey(root)) { + // TODO: log + return@OnRootViewsChangedListener + } // stop tracking other windows so they don't interfere in the recording like a 25th frame effect recorders.entries.forEach { it.key.viewTreeObserver.removeOnDrawListener(it.value) From b46184701c76c0df69acaf62344b8a3dde89b0d9 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 13 Feb 2024 15:51:52 +0100 Subject: [PATCH 10/89] Disable detekt plugin for now --- sentry-android-replay/build.gradle.kts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sentry-android-replay/build.gradle.kts b/sentry-android-replay/build.gradle.kts index 69fa4ef2b41..2314960f5e2 100644 --- a/sentry-android-replay/build.gradle.kts +++ b/sentry-android-replay/build.gradle.kts @@ -7,7 +7,8 @@ plugins { jacoco id(Config.QualityPlugins.jacocoAndroid) id(Config.QualityPlugins.gradleVersions) - id(Config.QualityPlugins.detektPlugin) + // TODO: enable it later +// id(Config.QualityPlugins.detektPlugin) } android { From a63cac1e40df9f7562a3d4acead57a9bf9687438 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 15 Feb 2024 18:00:04 +0100 Subject: [PATCH 11/89] WIP --- sentry/src/main/java/io/sentry/Hub.java | 4 +- .../main/java/io/sentry/JsonObjectWriter.java | 5 + .../main/java/io/sentry/JsonSerializer.java | 4 + .../src/main/java/io/sentry/ObjectWriter.java | 1 + .../main/java/io/sentry/ReplayRecording.java | 18 ++- .../java/io/sentry/SentryEnvelopeItem.java | 38 ----- .../main/java/io/sentry/SentryItemType.java | 3 + .../main/java/io/sentry/rrweb/RRWebEvent.java | 91 ++++++++++++ .../java/io/sentry/rrweb/RRWebEventType.java | 32 +++++ .../java/io/sentry/rrweb/RRWebMetaEvent.java | 133 ++++++++++++++++++ .../java/io/sentry/rrweb/RRWebVideoEvent.java | 46 ++++++ .../java/io/sentry/util/MapObjectWriter.java | 6 + .../ReplayRecordingSerializationTest.kt | 22 +++ .../rrweb/RRWebEventSerializationTest.kt | 78 ++++++++++ .../rrweb/RRWebMetaEventSerializationTest.kt | 42 ++++++ .../src/test/resources/json/rrweb_event.json | 4 + .../test/resources/json/rrweb_meta_event.json | 9 ++ 17 files changed, 491 insertions(+), 45 deletions(-) create mode 100644 sentry/src/main/java/io/sentry/rrweb/RRWebEvent.java create mode 100644 sentry/src/main/java/io/sentry/rrweb/RRWebEventType.java create mode 100644 sentry/src/main/java/io/sentry/rrweb/RRWebMetaEvent.java create mode 100644 sentry/src/main/java/io/sentry/rrweb/RRWebVideoEvent.java create mode 100644 sentry/src/test/java/io/sentry/protocol/ReplayRecordingSerializationTest.kt create mode 100644 sentry/src/test/java/io/sentry/rrweb/RRWebEventSerializationTest.kt create mode 100644 sentry/src/test/java/io/sentry/rrweb/RRWebMetaEventSerializationTest.kt create mode 100644 sentry/src/test/resources/json/rrweb_event.json create mode 100644 sentry/src/test/resources/json/rrweb_meta_event.json diff --git a/sentry/src/main/java/io/sentry/Hub.java b/sentry/src/main/java/io/sentry/Hub.java index 1d449d401da..25778256d68 100644 --- a/sentry/src/main/java/io/sentry/Hub.java +++ b/sentry/src/main/java/io/sentry/Hub.java @@ -933,9 +933,9 @@ private IScope buildLocalScope( } else { try { StackItem item = stack.peek(); - sentryId = item.getClient().captureReplayEvent(replay, item.getScope(), null); + sentryId = item.getClient().captureReplayEvent(replay, item.getScope(), hint); } catch (Throwable e) { - options.getLogger().log(SentryLevel.ERROR, "Error while capturing check-in for slug", e); + options.getLogger().log(SentryLevel.ERROR, "Error while capturing replay", e); } } this.lastEventId = sentryId; diff --git a/sentry/src/main/java/io/sentry/JsonObjectWriter.java b/sentry/src/main/java/io/sentry/JsonObjectWriter.java index b174ddb4847..3c0a326a5db 100644 --- a/sentry/src/main/java/io/sentry/JsonObjectWriter.java +++ b/sentry/src/main/java/io/sentry/JsonObjectWriter.java @@ -52,6 +52,11 @@ public JsonObjectWriter value(final @Nullable String value) throws IOException { return this; } + @Override public ObjectWriter jsonValue(@Nullable String value) throws IOException { + jsonWriter.jsonValue(value); + return this; + } + @Override public JsonObjectWriter nullValue() throws IOException { jsonWriter.nullValue(); diff --git a/sentry/src/main/java/io/sentry/JsonSerializer.java b/sentry/src/main/java/io/sentry/JsonSerializer.java index c22eb095b5a..62d6ddcb9d1 100644 --- a/sentry/src/main/java/io/sentry/JsonSerializer.java +++ b/sentry/src/main/java/io/sentry/JsonSerializer.java @@ -29,6 +29,8 @@ import io.sentry.protocol.User; import io.sentry.protocol.ViewHierarchy; import io.sentry.protocol.ViewHierarchyNode; +import io.sentry.rrweb.RRWebEventType; +import io.sentry.rrweb.RRWebMetaEvent; import io.sentry.util.Objects; import java.io.BufferedOutputStream; import java.io.BufferedWriter; @@ -89,6 +91,8 @@ public JsonSerializer(@NotNull SentryOptions options) { deserializersByClass.put( ProfileMeasurementValue.class, new ProfileMeasurementValue.Deserializer()); deserializersByClass.put(Request.class, new Request.Deserializer()); + deserializersByClass.put(RRWebEventType.class, new RRWebEventType.Deserializer()); + deserializersByClass.put(RRWebMetaEvent.class, new RRWebMetaEvent.Deserializer()); deserializersByClass.put(SdkInfo.class, new SdkInfo.Deserializer()); deserializersByClass.put(SdkVersion.class, new SdkVersion.Deserializer()); deserializersByClass.put(SentryEnvelopeHeader.class, new SentryEnvelopeHeader.Deserializer()); diff --git a/sentry/src/main/java/io/sentry/ObjectWriter.java b/sentry/src/main/java/io/sentry/ObjectWriter.java index ea8d4e83eac..0c424461bd1 100644 --- a/sentry/src/main/java/io/sentry/ObjectWriter.java +++ b/sentry/src/main/java/io/sentry/ObjectWriter.java @@ -16,6 +16,7 @@ public interface ObjectWriter { ObjectWriter name(final @NotNull String name) throws IOException; ObjectWriter value(final @Nullable String value) throws IOException; + ObjectWriter jsonValue(final @Nullable String value) throws IOException; ObjectWriter nullValue() throws IOException; diff --git a/sentry/src/main/java/io/sentry/ReplayRecording.java b/sentry/src/main/java/io/sentry/ReplayRecording.java index 26b4d73370a..64b7c31cda7 100644 --- a/sentry/src/main/java/io/sentry/ReplayRecording.java +++ b/sentry/src/main/java/io/sentry/ReplayRecording.java @@ -1,5 +1,6 @@ package io.sentry; +import io.sentry.rrweb.RRWebEvent; import io.sentry.vendor.gson.stream.JsonToken; import java.io.IOException; import java.util.HashMap; @@ -15,11 +16,9 @@ public static final class JsonKeys { } private @Nullable Integer segmentId; + private @Nullable List payload; private @Nullable Map unknown; - // TODO spec it out, good enough for now - private @Nullable List payload; - @Nullable public Integer getSegmentId() { return segmentId; @@ -30,11 +29,11 @@ public void setSegmentId(@Nullable Integer segmentId) { } @Nullable - public List getPayload() { + public List getPayload() { return payload; } - public void setPayload(@Nullable List payload) { + public void setPayload(@Nullable List payload) { this.payload = payload; } @@ -53,6 +52,15 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger } } writer.endObject(); + + // session replay recording format + // {"segment_id":0}\n{json-serialized-gzipped-rrweb-protocol} + + writer.jsonValue("\n"); + + if (payload != null) { + writer.value(logger, payload); + } } @Override diff --git a/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java index f583ff0f0a9..91a80a866ec 100644 --- a/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java +++ b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java @@ -354,21 +354,7 @@ public static SentryEnvelopeItem fromReplayRecording( try (final ByteArrayOutputStream stream = new ByteArrayOutputStream(); final Writer writer = new BufferedWriter(new OutputStreamWriter(stream, UTF_8))) { - - // session replay recording format - // {"segment_id":0}\n{json-serialized-gzipped-rrweb-protocol} - serializer.serialize(replayRecording, writer); - writer.write("\n"); - if (replayRecording.getPayload() != null) { - serializer.serialize(replayRecording.getPayload(), writer); - } - - // final byte[] payload = compressRecordingPayload(serializer, replayRecording); - // stream.write(payload); - - writer.flush(); - stream.flush(); return stream.toByteArray(); } } catch (Throwable t) { @@ -377,30 +363,6 @@ public static SentryEnvelopeItem fromReplayRecording( } }); - // try { - // final byte[] data = cachedItem.getBytes(); - // final String dataStr = new String(data, UTF_8); - // - // final String[] items = dataStr.split("\n", 2); - // final String header = items[0]; - // final String payload = items[1]; - // - // final ByteArrayInputStream byteArrayInputStream = new - // ByteArrayInputStream(payload.getBytes(UTF_8)); - // final GZIPInputStream inputStream = new GZIPInputStream(byteArrayInputStream); - // - // final ByteArrayOutputStream decodedData = new ByteArrayOutputStream(); - // - // byte[] buf = new byte[4096]; - // int readLen; - // while ((readLen = inputStream.read(buf, 0, buf.length)) != -1) { - // decodedData.write(buf, 0, readLen); - // } - // - // } catch (Exception e) { - // - // } - final SentryEnvelopeItemHeader itemHeader = new SentryEnvelopeItemHeader( SentryItemType.ReplayRecording, () -> cachedItem.getBytes().length, null, null); diff --git a/sentry/src/main/java/io/sentry/SentryItemType.java b/sentry/src/main/java/io/sentry/SentryItemType.java index 69aa7b7a929..7cd58a9de28 100644 --- a/sentry/src/main/java/io/sentry/SentryItemType.java +++ b/sentry/src/main/java/io/sentry/SentryItemType.java @@ -18,6 +18,7 @@ public enum SentryItemType implements JsonSerializable { ClientReport("client_report"), ReplayEvent("replay_event"), ReplayRecording("replay_recording"), + ReplayVideo("replay_video"), CheckIn("check_in"), Unknown("__unknown__"); // DataCategory.Unknown @@ -34,6 +35,8 @@ public static SentryItemType resolve(Object item) { return ClientReport; } else if (item instanceof SentryReplayEvent) { return ReplayEvent; + } else if (item instanceof ReplayRecording) { + return ReplayRecording; } else { return Attachment; } diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebEvent.java new file mode 100644 index 00000000000..ba7f7227ab4 --- /dev/null +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebEvent.java @@ -0,0 +1,91 @@ +package io.sentry.rrweb; + +import io.sentry.Breadcrumb; +import io.sentry.ILogger; +import io.sentry.JsonObjectReader; +import io.sentry.JsonSerializable; +import io.sentry.JsonUnknown; +import io.sentry.ObjectWriter; +import io.sentry.SentryBaseEvent; +import io.sentry.SentryLongDate; +import io.sentry.protocol.Contexts; +import io.sentry.protocol.DebugMeta; +import io.sentry.protocol.Request; +import io.sentry.protocol.SdkVersion; +import io.sentry.protocol.SentryId; +import io.sentry.protocol.User; +import io.sentry.util.CollectionUtils; +import java.io.IOException; +import java.util.Map; +import java.util.Objects; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public abstract class RRWebEvent { + + private @NotNull RRWebEventType type; + private long timestamp; + + protected RRWebEvent(final @NotNull RRWebEventType type) { + this.type = type; + this.timestamp = System.currentTimeMillis(); + } + + protected RRWebEvent() { + this(RRWebEventType.Custom); + } + + @NotNull + public RRWebEventType getType() { + return type; + } + + public void setType(final @NotNull RRWebEventType type) { + this.type = type; + } + + public long getTimestamp() { + return timestamp; + } + + public void setTimestamp(final long timestamp) { + this.timestamp = timestamp; + } + + // region json + public static final class JsonKeys { + public static final String TYPE = "type"; + public static final String TIMESTAMP = "timestamp"; + } + + public static final class Serializer { + public void serialize( + @NotNull RRWebEvent baseEvent, @NotNull ObjectWriter writer, @NotNull ILogger logger) + throws IOException { + writer.name(JsonKeys.TYPE).value(logger, baseEvent.type); + writer.name(JsonKeys.TIMESTAMP).value(baseEvent.timestamp); + } + } + + public static final class Deserializer { + @SuppressWarnings("unchecked") + public boolean deserializeValue( + @NotNull RRWebEvent baseEvent, + @NotNull String nextName, + @NotNull JsonObjectReader reader, + @NotNull ILogger logger) + throws Exception { + switch (nextName) { + case JsonKeys.TYPE: + baseEvent.type = + Objects.requireNonNull(reader.nextOrNull(logger, new RRWebEventType.Deserializer())); + return true; + case JsonKeys.TIMESTAMP: + baseEvent.timestamp = reader.nextLong(); + return true; + } + return false; + } + } + // endregion json +} diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebEventType.java b/sentry/src/main/java/io/sentry/rrweb/RRWebEventType.java new file mode 100644 index 00000000000..412ab234ab8 --- /dev/null +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebEventType.java @@ -0,0 +1,32 @@ +package io.sentry.rrweb; + +import io.sentry.ILogger; +import io.sentry.JsonDeserializer; +import io.sentry.JsonObjectReader; +import io.sentry.JsonSerializable; +import io.sentry.ObjectWriter; +import java.io.IOException; +import org.jetbrains.annotations.NotNull; + +public enum RRWebEventType implements JsonSerializable { + DomContentLoaded, + Load, + FullSnapshot, + IncrementalSnapshot, + Meta, + Custom, + Plugin; + + @Override public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) + throws IOException { + writer.value(ordinal()); + } + + public static final class Deserializer implements JsonDeserializer { + @Override + public @NotNull RRWebEventType deserialize( + @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + return RRWebEventType.values()[reader.nextInt()]; + } + } +} diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebMetaEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebMetaEvent.java new file mode 100644 index 00000000000..67f7a82e4da --- /dev/null +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebMetaEvent.java @@ -0,0 +1,133 @@ +package io.sentry.rrweb; + +import io.sentry.ILogger; +import io.sentry.JsonDeserializer; +import io.sentry.JsonObjectReader; +import io.sentry.JsonSerializable; +import io.sentry.JsonUnknown; +import io.sentry.ObjectWriter; +import io.sentry.vendor.gson.stream.JsonToken; +import java.io.IOException; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class RRWebMetaEvent extends RRWebEvent implements JsonUnknown, JsonSerializable { + + private @NotNull String href; + private int height; + private int width; + private @Nullable Map unknown; + + public RRWebMetaEvent() { + super(RRWebEventType.Meta); + this.href = ""; + } + + @NotNull + public String getHref() { + return href; + } + + public void setHref(final @NotNull String href) { + this.href = href; + } + + public int getHeight() { + return height; + } + + public void setHeight(final int height) { + this.height = height; + } + + public int getWidth() { + return width; + } + + public void setWidth(final int width) { + this.width = width; + } + + public static final class JsonKeys { + public static final String DATA = "data"; + public static final String HREF = "href"; + public static final String HEIGHT = "height"; + public static final String WIDTH = "width"; + } + + @Override + public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) + throws IOException { + writer.name(JsonKeys.DATA); + writer.beginObject(); + writer.name(JsonKeys.HREF).value(href); + writer.name(JsonKeys.HEIGHT).value(height); + writer.name(JsonKeys.WIDTH).value(width); + new RRWebEvent.Serializer().serialize(this, writer, logger); + if (unknown != null) { + for (String key : unknown.keySet()) { + Object value = unknown.get(key); + writer.name(key); + writer.value(logger, value); + } + } + writer.endObject(); + } + + @Override + public @Nullable Map getUnknown() { + return unknown; + } + + @Override + public void setUnknown(final @Nullable Map unknown) { + this.unknown = unknown; + } + + public static final class Deserializer implements JsonDeserializer { + + @SuppressWarnings("unchecked") + @Override + public @NotNull RRWebMetaEvent deserialize( + @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + reader.beginObject(); + RRWebMetaEvent event = new RRWebMetaEvent(); + Map unknown = null; + + RRWebEvent.Deserializer baseEventDeserializer = new RRWebEvent.Deserializer(); + + while (reader.peek() == JsonToken.NAME) { + final String nextName = reader.nextName(); + switch (nextName) { + case JsonKeys.DATA: + break; + case JsonKeys.HREF: + final String href = reader.nextStringOrNull(); + event.href = href == null ? "" : href; + break; + case JsonKeys.HEIGHT: + final Integer height = reader.nextIntegerOrNull(); + event.height = height == null ? 0 : height; + break; + case JsonKeys.WIDTH: + final Integer width = reader.nextIntegerOrNull(); + event.width = width == null ? 0 : width; + break; + default: + if (!baseEventDeserializer.deserializeValue(event, nextName, reader, logger)) { + if (unknown == null) { + unknown = new ConcurrentHashMap<>(); + } + reader.nextUnknown(logger, unknown, nextName); + } + break; + } + } + event.setUnknown(unknown); + reader.endObject(); + return event; + } + } +} diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebVideoEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebVideoEvent.java new file mode 100644 index 00000000000..17a54320b0e --- /dev/null +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebVideoEvent.java @@ -0,0 +1,46 @@ +package io.sentry.rrweb; + +import io.sentry.ILogger; +import io.sentry.JsonSerializable; +import io.sentry.JsonUnknown; +import io.sentry.ObjectWriter; +import java.io.IOException; +import java.util.Map; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class RRWebVideoEvent extends RRWebEvent implements JsonUnknown, JsonSerializable { + + @Override public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) + throws IOException { + + } + + @Override public @Nullable Map getUnknown() { + return null; + } + + @Override public void setUnknown(@Nullable Map unknown) { + + } + + // rrweb uses camelCase hence the json keys are in camelCase here + public static final class JsonKeys { + public static final String TAG = "tag"; + public static final String PAYLOAD = "payload"; + public static final String SEGMENT_ID = "segmentId"; + public static final String SIZE = "size"; + public static final String DURATION = "duration"; + public static final String ENCODING = "encoding"; + public static final String CONTAINER = "container"; + public static final String HEIGHT = "height"; + public static final String WIDTH = "width"; + public static final String FRAME_COUNT = "frameCount"; + public static final String FRAME_RATE_TYPE = "frameRateType"; + public static final String FRAME_RATE = "frameRate"; + public static final String LEFT = "left"; + public static final String TOP = "top"; + } + + +} diff --git a/sentry/src/main/java/io/sentry/util/MapObjectWriter.java b/sentry/src/main/java/io/sentry/util/MapObjectWriter.java index 26f80eddc29..7d25c7d9dda 100644 --- a/sentry/src/main/java/io/sentry/util/MapObjectWriter.java +++ b/sentry/src/main/java/io/sentry/util/MapObjectWriter.java @@ -151,6 +151,12 @@ public MapObjectWriter value(final @Nullable String value) throws IOException { return this; } + @Override + public ObjectWriter jsonValue(@Nullable String value) throws IOException { + // no-op + return this; + } + @Override public MapObjectWriter nullValue() throws IOException { postValue((Object) null); diff --git a/sentry/src/test/java/io/sentry/protocol/ReplayRecordingSerializationTest.kt b/sentry/src/test/java/io/sentry/protocol/ReplayRecordingSerializationTest.kt new file mode 100644 index 00000000000..b731cabdb4b --- /dev/null +++ b/sentry/src/test/java/io/sentry/protocol/ReplayRecordingSerializationTest.kt @@ -0,0 +1,22 @@ +package io.sentry.protocol + +import io.sentry.DateUtils +import io.sentry.ILogger +import io.sentry.ReplayRecording +import io.sentry.SentryEvent +import io.sentry.SentryLevel +import org.mockito.kotlin.mock + +class ReplayRecordingSerializationTest { + class Fixture { + val logger = mock() + + fun getSut() = ReplayRecording().apply { + segmentId = 0 + payload = listOf( + + ) + } + } + private val fixture = Fixture() +} diff --git a/sentry/src/test/java/io/sentry/rrweb/RRWebEventSerializationTest.kt b/sentry/src/test/java/io/sentry/rrweb/RRWebEventSerializationTest.kt new file mode 100644 index 00000000000..1223075c6ba --- /dev/null +++ b/sentry/src/test/java/io/sentry/rrweb/RRWebEventSerializationTest.kt @@ -0,0 +1,78 @@ +package io.sentry.rrweb + +import io.sentry.ILogger +import io.sentry.JsonDeserializer +import io.sentry.JsonObjectReader +import io.sentry.JsonSerializable +import io.sentry.ObjectWriter +import io.sentry.protocol.SerializationUtils.deserializeJson +import io.sentry.protocol.SerializationUtils.sanitizedFile +import io.sentry.protocol.SerializationUtils.serializeToString +import io.sentry.rrweb.RRWebEventType.Custom +import io.sentry.vendor.gson.stream.JsonToken +import org.junit.Test +import org.mockito.kotlin.mock +import kotlin.test.assertEquals + +class RRWebEventSerializationTest { + + /** + * Make subclass, as `RRWebEvent` initializers are protected. + */ + class Sut : RRWebEvent(), JsonSerializable { + override fun serialize(writer: ObjectWriter, logger: ILogger) { + writer.beginObject() + Serializer().serialize(this, writer, logger) + writer.endObject() + } + + class Deserializer : JsonDeserializer { + override fun deserialize(reader: JsonObjectReader, logger: ILogger): Sut { + val sut = Sut() + reader.beginObject() + + val baseEventDeserializer = RRWebEvent.Deserializer() + do { + val nextName = reader.nextName() + baseEventDeserializer.deserializeValue(sut, nextName, reader, logger) + } while (reader.hasNext() && reader.peek() == JsonToken.NAME) + reader.endObject() + return sut + } + } + } + + class Fixture { + val logger = mock() + + fun update(rrWebEvent: RRWebEvent) { + rrWebEvent.apply { + type = Custom + timestamp = 9999999 + } + } + } + private val fixture = Fixture() + + @Test + fun serialize() { + val expected = sanitizedFile("json/rrweb_event.json") + val sut = Sut().apply { fixture.update(this) } + val actual = serializeToString(sut, fixture.logger) + + assertEquals(expected, actual) + } + + @Test + fun deserialize() { + val expectedJson = sanitizedFile("json/rrweb_event.json") + val actual = deserializeJson( + expectedJson, + Sut.Deserializer(), + fixture.logger + ) + val actualJson = serializeToString(actual, fixture.logger) + + assertEquals(expectedJson, actualJson) + } +} diff --git a/sentry/src/test/java/io/sentry/rrweb/RRWebMetaEventSerializationTest.kt b/sentry/src/test/java/io/sentry/rrweb/RRWebMetaEventSerializationTest.kt new file mode 100644 index 00000000000..3ab8ad03cb7 --- /dev/null +++ b/sentry/src/test/java/io/sentry/rrweb/RRWebMetaEventSerializationTest.kt @@ -0,0 +1,42 @@ +package io.sentry.rrweb + +import io.sentry.ILogger +import io.sentry.protocol.SerializationUtils.deserializeJson +import io.sentry.protocol.SerializationUtils.sanitizedFile +import io.sentry.protocol.SerializationUtils.serializeToString +import io.sentry.rrweb.RRWebEventType.Meta +import org.junit.Test +import org.mockito.kotlin.mock +import kotlin.test.assertEquals + +class RRWebMetaEventSerializationTest { + + class Fixture { + val logger = mock() + + fun getSut() = RRWebMetaEvent().apply { + href = "https://sentry.io" + width = 1080 + height = 1920 + type = Meta + timestamp = 1234567890 + } + } + + private val fixture = Fixture() + + @Test + fun serialize() { + val expected = sanitizedFile("json/rrweb_meta_event.json") + val actual = serializeToString(fixture.getSut(), fixture.logger) + assertEquals(expected, actual) + } + + @Test + fun deserialize() { + val expectedJson = sanitizedFile("json/rrweb_meta_event.json") + val actual = deserializeJson(expectedJson, RRWebMetaEvent.Deserializer(), fixture.logger) + val actualJson = serializeToString(actual, fixture.logger) + assertEquals(expectedJson, actualJson) + } +} diff --git a/sentry/src/test/resources/json/rrweb_event.json b/sentry/src/test/resources/json/rrweb_event.json new file mode 100644 index 00000000000..d5610238e97 --- /dev/null +++ b/sentry/src/test/resources/json/rrweb_event.json @@ -0,0 +1,4 @@ +{ + "type": 5, + "timestamp": 9999999 +} diff --git a/sentry/src/test/resources/json/rrweb_meta_event.json b/sentry/src/test/resources/json/rrweb_meta_event.json new file mode 100644 index 00000000000..a1d9621f526 --- /dev/null +++ b/sentry/src/test/resources/json/rrweb_meta_event.json @@ -0,0 +1,9 @@ +{ + "type": 4, + "timestamp": 1234567890, + "data": { + "href": "https://sentry.io", + "width": 1080, + "height": 1920 + } +} From fa72057630fd5c81ec7c8c84eef7c442f84e5fc7 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 19 Feb 2024 14:51:00 +0100 Subject: [PATCH 12/89] Add replay envelopes --- .../core/DefaultAndroidEventProcessor.java | 5 +- sentry/src/main/java/io/sentry/Hint.java | 26 +- sentry/src/main/java/io/sentry/Hub.java | 10 +- .../src/main/java/io/sentry/HubAdapter.java | 2 +- .../main/java/io/sentry/ISentryClient.java | 3 +- .../main/java/io/sentry/JsonObjectWriter.java | 3 +- .../main/java/io/sentry/JsonSerializer.java | 2 + .../java/io/sentry/MainEventProcessor.java | 5 +- .../main/java/io/sentry/NoOpSentryClient.java | 4 +- .../src/main/java/io/sentry/ObjectWriter.java | 1 + .../main/java/io/sentry/ReplayRecording.java | 9 - sentry/src/main/java/io/sentry/Sentry.java | 2 +- .../src/main/java/io/sentry/SentryClient.java | 108 ++++-- .../java/io/sentry/SentryEnvelopeItem.java | 127 +++++-- .../main/java/io/sentry/SentryItemType.java | 2 - .../java/io/sentry/SentryReplayEvent.java | 192 +++++----- .../main/java/io/sentry/rrweb/RRWebEvent.java | 32 +- .../java/io/sentry/rrweb/RRWebEventType.java | 6 +- .../java/io/sentry/rrweb/RRWebMetaEvent.java | 47 ++- .../java/io/sentry/rrweb/RRWebVideoEvent.java | 329 +++++++++++++++++- .../ReplayRecordingSerializationTest.kt | 41 ++- .../SentryReplayEventSerializationTest.kt | 58 +++ .../rrweb/RRWebMetaEventSerializationTest.kt | 2 +- .../rrweb/RRWebVideoEventSerializationTest.kt | 47 +++ .../test/resources/json/replay_recording.json | 3 + .../json/replay_recording_payload.json | 32 ++ .../test/resources/json/rrweb_meta_event.json | 4 +- .../resources/json/rrweb_video_event.json | 21 ++ .../resources/json/sentry_replay_event.json | 258 ++++++++++++++ 29 files changed, 1127 insertions(+), 254 deletions(-) create mode 100644 sentry/src/test/java/io/sentry/protocol/SentryReplayEventSerializationTest.kt create mode 100644 sentry/src/test/java/io/sentry/rrweb/RRWebVideoEventSerializationTest.kt create mode 100644 sentry/src/test/resources/json/replay_recording.json create mode 100644 sentry/src/test/resources/json/replay_recording_payload.json create mode 100644 sentry/src/test/resources/json/rrweb_video_event.json create mode 100644 sentry/src/test/resources/json/sentry_replay_event.json diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java b/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java index 0e1ddc7a3a6..0fde2c33dbe 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/DefaultAndroidEventProcessor.java @@ -260,12 +260,9 @@ private void setSideLoadedInfo(final @NotNull SentryBaseEvent event) { @Override public @NotNull SentryReplayEvent process( - final @NotNull SentryReplayEvent event, final @NotNull Hint hint) { + final @NotNull SentryReplayEvent event, final @NotNull Hint hint) { final boolean applyScopeData = shouldApplyScopeData(event, hint); if (applyScopeData) { - // we only set memory data if it's not a hard crash, when it's a hard crash the event is - // enriched on restart, so non static data might be wrong, eg lowMemory or availMem will - // be different if the App. crashes because of OOM. processNonCachedEvent(event, hint); } diff --git a/sentry/src/main/java/io/sentry/Hint.java b/sentry/src/main/java/io/sentry/Hint.java index a638d240ab4..750017d00dd 100644 --- a/sentry/src/main/java/io/sentry/Hint.java +++ b/sentry/src/main/java/io/sentry/Hint.java @@ -27,11 +27,10 @@ public final class Hint { private final @NotNull Map internalStorage = new HashMap(); private final @NotNull List attachments = new ArrayList<>(); - private final @NotNull List replayRecordings = new ArrayList<>(); private @Nullable Attachment screenshot = null; private @Nullable Attachment viewHierarchy = null; - private @Nullable Attachment threadDump = null; + private @Nullable ReplayRecording replayRecording = null; public static @NotNull Hint withAttachment(@Nullable Attachment attachment) { @NotNull final Hint hint = new Hint(); @@ -71,12 +70,6 @@ public synchronized void remove(@NotNull String name) { internalStorage.remove(name); } - public void addReplayRecording(final @Nullable ReplayRecording recording) { - if (recording != null) { - replayRecordings.add(recording); - } - } - public void addAttachment(@Nullable Attachment attachment) { if (attachment != null) { attachments.add(attachment); @@ -93,10 +86,6 @@ public void addAttachments(@Nullable List attachments) { return new ArrayList<>(attachments); } - public @NotNull List getReplayRecordings() { - return new ArrayList<>(replayRecordings); - } - public void replaceAttachments(@Nullable List attachments) { clearAttachments(); addAttachments(attachments); @@ -106,10 +95,6 @@ public void clearAttachments() { attachments.clear(); } - public void clearReplayRecordings() { - replayRecordings.clear(); - } - /** * Clears all attributes added via {@link #set(String, Object)} Note: SDK internal attributes are * being kept. This is useful to avoid leaking any objects (e.g. Android activities) being @@ -151,6 +136,15 @@ public void setThreadDump(final @Nullable Attachment threadDump) { return threadDump; } + @Nullable + public ReplayRecording getReplayRecording() { + return replayRecording; + } + + public void setReplayRecording(final @Nullable ReplayRecording replayRecording) { + this.replayRecording = replayRecording; + } + private boolean isCastablePrimitive(@Nullable Object hintValue, @NotNull Class clazz) { Class nonPrimitiveClass = PRIMITIVE_MAPPINGS.get(clazz.getCanonicalName()); return hintValue != null diff --git a/sentry/src/main/java/io/sentry/Hub.java b/sentry/src/main/java/io/sentry/Hub.java index 25778256d68..b91b5517050 100644 --- a/sentry/src/main/java/io/sentry/Hub.java +++ b/sentry/src/main/java/io/sentry/Hub.java @@ -922,14 +922,14 @@ private IScope buildLocalScope( @Override public @NotNull SentryId captureReplay( - final @NotNull SentryReplayEvent replay, final @Nullable Hint hint) { + final @NotNull SentryReplayEvent replay, final @Nullable Hint hint) { SentryId sentryId = SentryId.EMPTY_ID; if (!isEnabled()) { options - .getLogger() - .log( - SentryLevel.WARNING, - "Instance is disabled and this 'captureReplay' call is a no-op."); + .getLogger() + .log( + SentryLevel.WARNING, + "Instance is disabled and this 'captureReplay' call is a no-op."); } else { try { StackItem item = stack.peek(); diff --git a/sentry/src/main/java/io/sentry/HubAdapter.java b/sentry/src/main/java/io/sentry/HubAdapter.java index ecae05cc98b..5d7796f1642 100644 --- a/sentry/src/main/java/io/sentry/HubAdapter.java +++ b/sentry/src/main/java/io/sentry/HubAdapter.java @@ -264,7 +264,7 @@ public void reportFullyDisplayed() { @Override public @NotNull SentryId captureReplay( - final @NotNull SentryReplayEvent replay, final @Nullable Hint hint) { + final @NotNull SentryReplayEvent replay, final @Nullable Hint hint) { return Sentry.getCurrentHub().captureReplay(replay, hint); } diff --git a/sentry/src/main/java/io/sentry/ISentryClient.java b/sentry/src/main/java/io/sentry/ISentryClient.java index 5ea2e5f847b..e568746e9a1 100644 --- a/sentry/src/main/java/io/sentry/ISentryClient.java +++ b/sentry/src/main/java/io/sentry/ISentryClient.java @@ -147,7 +147,8 @@ public interface ISentryClient { return captureException(throwable, scope, null); } - @NotNull SentryId captureReplayEvent( + @NotNull + SentryId captureReplayEvent( @NotNull SentryReplayEvent event, @Nullable IScope scope, @Nullable Hint hint); /** diff --git a/sentry/src/main/java/io/sentry/JsonObjectWriter.java b/sentry/src/main/java/io/sentry/JsonObjectWriter.java index 3c0a326a5db..ff5114606c1 100644 --- a/sentry/src/main/java/io/sentry/JsonObjectWriter.java +++ b/sentry/src/main/java/io/sentry/JsonObjectWriter.java @@ -52,7 +52,8 @@ public JsonObjectWriter value(final @Nullable String value) throws IOException { return this; } - @Override public ObjectWriter jsonValue(@Nullable String value) throws IOException { + @Override + public ObjectWriter jsonValue(@Nullable String value) throws IOException { jsonWriter.jsonValue(value); return this; } diff --git a/sentry/src/main/java/io/sentry/JsonSerializer.java b/sentry/src/main/java/io/sentry/JsonSerializer.java index 62d6ddcb9d1..95c0c538ac4 100644 --- a/sentry/src/main/java/io/sentry/JsonSerializer.java +++ b/sentry/src/main/java/io/sentry/JsonSerializer.java @@ -31,6 +31,7 @@ import io.sentry.protocol.ViewHierarchyNode; import io.sentry.rrweb.RRWebEventType; import io.sentry.rrweb.RRWebMetaEvent; +import io.sentry.rrweb.RRWebVideoEvent; import io.sentry.util.Objects; import java.io.BufferedOutputStream; import java.io.BufferedWriter; @@ -93,6 +94,7 @@ public JsonSerializer(@NotNull SentryOptions options) { deserializersByClass.put(Request.class, new Request.Deserializer()); deserializersByClass.put(RRWebEventType.class, new RRWebEventType.Deserializer()); deserializersByClass.put(RRWebMetaEvent.class, new RRWebMetaEvent.Deserializer()); + deserializersByClass.put(RRWebVideoEvent.class, new RRWebVideoEvent.Deserializer()); deserializersByClass.put(SdkInfo.class, new SdkInfo.Deserializer()); deserializersByClass.put(SdkVersion.class, new SdkVersion.Deserializer()); deserializersByClass.put(SentryEnvelopeHeader.class, new SentryEnvelopeHeader.Deserializer()); diff --git a/sentry/src/main/java/io/sentry/MainEventProcessor.java b/sentry/src/main/java/io/sentry/MainEventProcessor.java index 813d3aaf264..e79ebc37bd0 100644 --- a/sentry/src/main/java/io/sentry/MainEventProcessor.java +++ b/sentry/src/main/java/io/sentry/MainEventProcessor.java @@ -152,13 +152,16 @@ private void processNonCachedEvent(final @NotNull SentryBaseEvent event) { @Override public @NotNull SentryReplayEvent process(@NotNull SentryReplayEvent event, @NotNull Hint hint) { setCommons(event); - setDebugMeta(event); + // TODO: maybe later it's needed to deobfuscate something (e.g. view hierarchy), for now the + // TODO: protocol does not support it + // setDebugMeta(event); if (shouldApplyScopeData(event, hint)) { processNonCachedEvent(event); } return event; } + private void setCommons(final @NotNull SentryBaseEvent event) { setPlatform(event); } diff --git a/sentry/src/main/java/io/sentry/NoOpSentryClient.java b/sentry/src/main/java/io/sentry/NoOpSentryClient.java index 757b074f822..24b43f2b842 100644 --- a/sentry/src/main/java/io/sentry/NoOpSentryClient.java +++ b/sentry/src/main/java/io/sentry/NoOpSentryClient.java @@ -63,8 +63,8 @@ public SentryId captureEnvelope(@NotNull SentryEnvelope envelope, @Nullable Hint } @Override - public @NotNull SentryId captureReplayEvent(@NotNull SentryReplayEvent event, - @Nullable IScope scope, @Nullable Hint hint) { + public @NotNull SentryId captureReplayEvent( + @NotNull SentryReplayEvent event, @Nullable IScope scope, @Nullable Hint hint) { return SentryId.EMPTY_ID; } diff --git a/sentry/src/main/java/io/sentry/ObjectWriter.java b/sentry/src/main/java/io/sentry/ObjectWriter.java index 0c424461bd1..a5b8d12a4e0 100644 --- a/sentry/src/main/java/io/sentry/ObjectWriter.java +++ b/sentry/src/main/java/io/sentry/ObjectWriter.java @@ -16,6 +16,7 @@ public interface ObjectWriter { ObjectWriter name(final @NotNull String name) throws IOException; ObjectWriter value(final @Nullable String value) throws IOException; + ObjectWriter jsonValue(final @Nullable String value) throws IOException; ObjectWriter nullValue() throws IOException; diff --git a/sentry/src/main/java/io/sentry/ReplayRecording.java b/sentry/src/main/java/io/sentry/ReplayRecording.java index 64b7c31cda7..c110c007008 100644 --- a/sentry/src/main/java/io/sentry/ReplayRecording.java +++ b/sentry/src/main/java/io/sentry/ReplayRecording.java @@ -52,15 +52,6 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger } } writer.endObject(); - - // session replay recording format - // {"segment_id":0}\n{json-serialized-gzipped-rrweb-protocol} - - writer.jsonValue("\n"); - - if (payload != null) { - writer.value(logger, payload); - } } @Override diff --git a/sentry/src/main/java/io/sentry/Sentry.java b/sentry/src/main/java/io/sentry/Sentry.java index 392dd0cef24..6058199d622 100644 --- a/sentry/src/main/java/io/sentry/Sentry.java +++ b/sentry/src/main/java/io/sentry/Sentry.java @@ -1027,7 +1027,7 @@ public interface OptionsConfiguration { } public static void captureReplay( - final @NotNull SentryReplayEvent replay, final @Nullable Hint hint) { + final @NotNull SentryReplayEvent replay, final @Nullable Hint hint) { getCurrentHub().captureReplay(replay, hint); } } diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index 0b8da468b69..129d415a8ac 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -248,7 +248,7 @@ private boolean shouldApplyScopeData(final @NotNull CheckIn event, final @NotNul @Override public @NotNull SentryId captureReplayEvent( - @NotNull SentryReplayEvent event, final @Nullable IScope scope, @Nullable Hint hint) { + @NotNull SentryReplayEvent event, final @Nullable IScope scope, @Nullable Hint hint) { Objects.requireNonNull(event, "SessionReplay is required."); if (hint == null) { @@ -262,8 +262,8 @@ private boolean shouldApplyScopeData(final @NotNull CheckIn event, final @NotNul options.getLogger().log(SentryLevel.DEBUG, "Capturing session replay: %s", event.getEventId()); SentryId sentryId = SentryId.EMPTY_ID; - if (event.getReplayId() != null) { - sentryId = event.getReplayId(); + if (event.getEventId() != null) { + sentryId = event.getEventId(); } event = processReplayEvent(event, hint, options.getEventProcessors()); @@ -274,15 +274,22 @@ private boolean shouldApplyScopeData(final @NotNull CheckIn event, final @NotNul } try { - final SentryEnvelope envelope = - buildEnvelope(event, hint.getReplayRecordings()); + @Nullable TraceContext traceContext = null; + if (scope != null) { + final @Nullable ITransaction transaction = scope.getTransaction(); + if (transaction != null) { + traceContext = transaction.traceContext(); + } else { + final @NotNull PropagationContext propagationContext = + TracingUtils.maybeUpdateBaggage(scope, options); + traceContext = propagationContext.traceContext(); + } + } + + final SentryEnvelope envelope = buildEnvelope(event, hint.getReplayRecording(), traceContext); hint.clear(); - if (envelope != null) { - transport.send(envelope, hint); - } else { - sentryId = SentryId.EMPTY_ID; - } + transport.send(envelope, hint); } catch (IOException e) { options.getLogger().log(SentryLevel.WARNING, e, "Capturing event %s failed.", sentryId); @@ -566,38 +573,22 @@ public void captureUserFeedback(final @NotNull UserFeedback userFeedback) { return new SentryEnvelope(envelopeHeader, envelopeItems); } - private @Nullable SentryEnvelope buildEnvelope( - final @Nullable SentryReplayEvent event, - final @Nullable List replayRecordings - ) { - SentryId sentryId = null; + private @NotNull SentryEnvelope buildEnvelope( + final @NotNull SentryReplayEvent event, + final @Nullable ReplayRecording replayRecording, + final @Nullable TraceContext traceContext) { final List envelopeItems = new ArrayList<>(); - if (event != null) { - final SentryEnvelopeItem eventItem = - SentryEnvelopeItem.fromEvent(options.getSerializer(), event); - envelopeItems.add(eventItem); - sentryId = event.getEventId(); - } - - if (replayRecordings != null) { - for (final ReplayRecording replayRecording : replayRecordings) { - final SentryEnvelopeItem replayItem = - SentryEnvelopeItem.fromReplayRecording( - options.getSerializer(), options.getLogger(), replayRecording); - envelopeItems.add(replayItem); - } - } + final SentryEnvelopeItem replayItem = + SentryEnvelopeItem.fromReplay( + options.getSerializer(), options.getLogger(), event, replayRecording); + envelopeItems.add(replayItem); + final SentryId sentryId = event.getEventId(); + final SentryEnvelopeHeader envelopeHeader = + new SentryEnvelopeHeader(sentryId, options.getSdkVersion(), traceContext); - if (!envelopeItems.isEmpty()) { - final SentryEnvelopeHeader envelopeHeader = - new SentryEnvelopeHeader(sentryId, options.getSdkVersion()); - - return new SentryEnvelope(envelopeHeader, envelopeItems); - } - - return null; + return new SentryEnvelope(envelopeHeader, envelopeItems); } /** @@ -921,6 +912,47 @@ public void captureSession(final @NotNull Session session, final @Nullable Hint return checkIn; } + private @NotNull SentryReplayEvent applyScope( + final @NotNull SentryReplayEvent replayEvent, final @Nullable IScope scope) { + // no breadcrumbs and extras for replay events + if (scope != null) { + if (replayEvent.getRequest() == null) { + replayEvent.setRequest(scope.getRequest()); + } + if (replayEvent.getUser() == null) { + replayEvent.setUser(scope.getUser()); + } + if (replayEvent.getTags() == null) { + replayEvent.setTags(new HashMap<>(scope.getTags())); + } else { + for (Map.Entry item : scope.getTags().entrySet()) { + if (!replayEvent.getTags().containsKey(item.getKey())) { + replayEvent.getTags().put(item.getKey(), item.getValue()); + } + } + } + final Contexts contexts = replayEvent.getContexts(); + for (Map.Entry entry : new Contexts(scope.getContexts()).entrySet()) { + if (!contexts.containsKey(entry.getKey())) { + contexts.put(entry.getKey(), entry.getValue()); + } + } + + // Set trace data from active span to connect replays with transactions + final ISpan span = scope.getSpan(); + if (replayEvent.getContexts().getTrace() == null) { + if (span == null) { + replayEvent + .getContexts() + .setTrace(TransactionContext.fromPropagationContext(scope.getPropagationContext())); + } else { + replayEvent.getContexts().setTrace(span.getSpanContext()); + } + } + } + return replayEvent; + } + private @NotNull T applyScope( final @NotNull T sentryBaseEvent, final @Nullable IScope scope) { if (scope != null) { diff --git a/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java index 91a80a866ec..53b2d24ea59 100644 --- a/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java +++ b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java @@ -19,9 +19,11 @@ import java.io.InputStreamReader; import java.io.OutputStreamWriter; import java.io.Reader; -import java.io.StringWriter; import java.io.Writer; +import java.nio.ByteBuffer; import java.nio.charset.Charset; +import java.util.HashMap; +import java.util.Map; import java.util.concurrent.Callable; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; @@ -342,47 +344,70 @@ public ClientReport getClientReport(final @NotNull ISerializer serializer) throw } } - public static SentryEnvelopeItem fromReplayRecording( - final @NotNull ISerializer serializer, - final @NotNull ILogger logger, - final @NotNull ReplayRecording replayRecording) { + public static SentryEnvelopeItem fromReplay( + final @NotNull ISerializer serializer, + final @NotNull ILogger logger, + final @NotNull SentryReplayEvent replayEvent, + final @Nullable ReplayRecording replayRecording) { + + final File replayVideo = replayEvent.getVideoFile(); final CachedItem cachedItem = - new CachedItem( - () -> { - try { - try (final ByteArrayOutputStream stream = new ByteArrayOutputStream(); - final Writer writer = - new BufferedWriter(new OutputStreamWriter(stream, UTF_8))) { - serializer.serialize(replayRecording, writer); - return stream.toByteArray(); - } - } catch (Throwable t) { - logger.log(SentryLevel.ERROR, "Could not serialize replay recording", t); - return null; - } - }); + new CachedItem( + () -> { + try { + try (final ByteArrayOutputStream stream = new ByteArrayOutputStream(); + final Writer writer = + new BufferedWriter(new OutputStreamWriter(stream, UTF_8))) { + final Map replayPayload = new HashMap<>(); + // first serialize replay event json bytes + serializer.serialize(replayEvent, writer); + replayPayload.put(SentryItemType.ReplayEvent.getItemType(), stream.toByteArray()); + stream.reset(); + + // next serialize replay recording in the following format: + // {"segment_id":0}\n{json-serialized-rrweb-protocol} + if (replayRecording != null) { + serializer.serialize(replayRecording, writer); + writer.write("\n"); + writer.flush(); + if (replayRecording.getPayload() != null) { + serializer.serialize(replayRecording.getPayload(), writer); + } + replayPayload.put( + SentryItemType.ReplayRecording.getItemType(), stream.toByteArray()); + stream.reset(); + } + + // next serialize replay video bytes from given file + if (replayVideo.exists()) { + final byte[] videoBytes = + readBytesFromFile( + replayVideo.getPath(), SentryReplayEvent.REPLAY_VIDEO_MAX_SIZE); + if (videoBytes.length > 0) { + replayPayload.put(SentryItemType.ReplayVideo.getItemType(), videoBytes); + } + } + + return serializeToMsgpack(replayPayload); + } + } catch (Throwable t) { + logger.log(SentryLevel.ERROR, "Could not serialize replay recording", t); + return null; + } finally { + replayVideo.delete(); + } + }); final SentryEnvelopeItemHeader itemHeader = - new SentryEnvelopeItemHeader( - SentryItemType.ReplayRecording, () -> cachedItem.getBytes().length, null, null); + new SentryEnvelopeItemHeader( + SentryItemType.ReplayVideo, () -> cachedItem.getBytes().length, null, null); // avoid method refs on Android due to some issues with older AGP setups // noinspection Convert2MethodRef - SentryEnvelopeItem item = new SentryEnvelopeItem(itemHeader, () -> cachedItem.getBytes()); - - try { - StringWriter writer = new StringWriter(); - serializer.serialize(item.header, writer); - writer.flush(); - writer.flush(); - } catch (Exception e) { - logger.log(SentryLevel.ERROR, "f", e); - } - return item; + return new SentryEnvelopeItem(itemHeader, () -> cachedItem.getBytes()); } - private static class CachedItem { private @Nullable byte[] bytes; private final @Nullable Callable dataFactory; @@ -402,4 +427,40 @@ public CachedItem(final @Nullable Callable dataFactory) { return bytes != null ? bytes : new byte[] {}; } } + + @SuppressWarnings("CharsetObjectCanBeUsed") + private static byte[] serializeToMsgpack(Map map) throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + + // Write map header + baos.write((byte) (0x80 | map.size())); + + // Iterate over the map and serialize each key-value pair + for (Map.Entry entry : map.entrySet()) { + // Pack the key as a string + byte[] keyBytes = entry.getKey().getBytes(Charset.forName("UTF-8")); + int keyLength = keyBytes.length; + if (keyLength <= 31) { + baos.write((byte) (0xA0 | keyLength)); + } else { + baos.write((byte) (0xD9)); + baos.write((byte) (keyLength)); + } + baos.write(keyBytes); + + // Pack the value as a binary string + byte[] valueBytes = entry.getValue(); + int valueLength = valueBytes.length; + if (valueLength <= 255) { + baos.write((byte) (0xC4)); + baos.write((byte) (valueLength)); + } else { + baos.write((byte) (0xC5)); + baos.write(ByteBuffer.allocate(4).putInt(valueLength).array()); + } + baos.write(valueBytes); + } + + return baos.toByteArray(); + } } diff --git a/sentry/src/main/java/io/sentry/SentryItemType.java b/sentry/src/main/java/io/sentry/SentryItemType.java index 7cd58a9de28..79deda34c07 100644 --- a/sentry/src/main/java/io/sentry/SentryItemType.java +++ b/sentry/src/main/java/io/sentry/SentryItemType.java @@ -35,8 +35,6 @@ public static SentryItemType resolve(Object item) { return ClientReport; } else if (item instanceof SentryReplayEvent) { return ReplayEvent; - } else if (item instanceof ReplayRecording) { - return ReplayRecording; } else { return Attachment; } diff --git a/sentry/src/main/java/io/sentry/SentryReplayEvent.java b/sentry/src/main/java/io/sentry/SentryReplayEvent.java index 8de75994226..623986d8688 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayEvent.java +++ b/sentry/src/main/java/io/sentry/SentryReplayEvent.java @@ -2,36 +2,50 @@ import io.sentry.protocol.SentryId; import io.sentry.vendor.gson.stream.JsonToken; +import java.io.File; import java.io.IOException; +import java.math.BigDecimal; import java.util.ArrayList; import java.util.Date; import java.util.HashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; public final class SentryReplayEvent extends SentryBaseEvent - implements JsonUnknown, JsonSerializable { + implements JsonUnknown, JsonSerializable { - public static final class JsonKeys { - public static final String TYPE = "type"; - public static final String REPLAY_TYPE = "replay_type"; - public static final String REPLAY_ID = "replay_id"; - public static final String SEGMENT_ID = "segment_id"; - public static final String TIMESTAMP = "timestamp"; - public static final String REPLAY_START_TIMESTAMP = "replay_start_timestamp"; - public static final String URLS = "urls"; - public static final String ERROR_IDS = "error_ids"; - public static final String TRACE_IDS = "trace_ids"; + public enum ReplayType implements JsonSerializable { + SESSION, + BUFFER; + + @Override + public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) + throws IOException { + writer.value(name().toLowerCase(Locale.ROOT)); + } + + public static final class Deserializer implements JsonDeserializer { + @Override + public @NotNull ReplayType deserialize( + @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + return ReplayType.valueOf(reader.nextString().toUpperCase(Locale.ROOT)); + } + } } - private @Nullable String type; - private @Nullable String replayType; + public static final long REPLAY_VIDEO_MAX_SIZE = 10 * 1024 * 1024; + public static final String REPLAY_EVENT_TYPE = "replay_event"; + + private @NotNull File videoFile; + private @NotNull String type; + private @NotNull ReplayType replayType; private @Nullable SentryId replayId; - private @Nullable Integer segmentId; - private @Nullable Double timestamp; - private @Nullable Double replayStartTimestamp; + private int segmentId; + private @NotNull Date timestamp; + private @Nullable Date replayStartTimestamp; private @Nullable List urls; private @Nullable List errorIds; private @Nullable List traceIds; @@ -39,20 +53,30 @@ public static final class JsonKeys { public SentryReplayEvent() { super(); - this.replayId = this.getEventId(); - this.type = "replay_event"; - this.replayType = "session"; + this.replayId = new SentryId(); + this.type = REPLAY_EVENT_TYPE; + this.replayType = ReplayType.SESSION; this.errorIds = new ArrayList<>(); this.traceIds = new ArrayList<>(); this.urls = new ArrayList<>(); + timestamp = DateUtils.getCurrentDateTime(); } - @Nullable + @NotNull + public File getVideoFile() { + return videoFile; + } + + public void setVideoFile(final @NotNull File videoFile) { + this.videoFile = videoFile; + } + + @NotNull public String getType() { return type; } - public void setType(final @Nullable String type) { + public void setType(final @NotNull String type) { this.type = type; } @@ -65,30 +89,29 @@ public void setReplayId(final @Nullable SentryId replayId) { this.replayId = replayId; } - @Nullable - public Integer getSegmentId() { + public int getSegmentId() { return segmentId; } - public void setSegmentId(final @Nullable Integer segmentId) { + public void setSegmentId(final int segmentId) { this.segmentId = segmentId; } - @Nullable - public Double getTimestamp() { + @NotNull + public Date getTimestamp() { return timestamp; } - public void setTimestamp(final @Nullable Double timestamp) { + public void setTimestamp(final @NotNull Date timestamp) { this.timestamp = timestamp; } @Nullable - public Double getReplayStartTimestamp() { + public Date getReplayStartTimestamp() { return replayStartTimestamp; } - public void setReplayStartTimestamp(final @Nullable Double replayStartTimestamp) { + public void setReplayStartTimestamp(final @Nullable Date replayStartTimestamp) { this.replayStartTimestamp = replayStartTimestamp; } @@ -119,36 +142,46 @@ public void setTraceIds(final @Nullable List traceIds) { this.traceIds = traceIds; } - @Nullable - public String getReplayType() { + @NotNull + public ReplayType getReplayType() { return replayType; } - public void setReplayType(@Nullable String replayType) { + public void setReplayType(final @NotNull ReplayType replayType) { this.replayType = replayType; } + // region json + public static final class JsonKeys { + public static final String TYPE = "type"; + public static final String REPLAY_TYPE = "replay_type"; + public static final String REPLAY_ID = "replay_id"; + public static final String SEGMENT_ID = "segment_id"; + public static final String TIMESTAMP = "timestamp"; + public static final String REPLAY_START_TIMESTAMP = "replay_start_timestamp"; + public static final String URLS = "urls"; + public static final String ERROR_IDS = "error_ids"; + public static final String TRACE_IDS = "trace_ids"; + } + @Override + @SuppressWarnings("JdkObsolete") public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger logger) - throws IOException { + throws IOException { writer.beginObject(); - if (type != null) { - writer.name(JsonKeys.TYPE).value(type); - } - if (replayType != null) { - writer.name(JsonKeys.REPLAY_TYPE).value(replayType); - } + writer.name(JsonKeys.TYPE).value(type); + writer.name(JsonKeys.REPLAY_TYPE).value(logger, replayType); + writer.name(JsonKeys.SEGMENT_ID).value(segmentId); + writer + .name(JsonKeys.TIMESTAMP) + .value(logger, BigDecimal.valueOf(DateUtils.dateToSeconds(timestamp))); if (replayId != null) { writer.name(JsonKeys.REPLAY_ID).value(logger, replayId); } - if (segmentId != null) { - writer.name(JsonKeys.SEGMENT_ID).value(segmentId); - } - if (timestamp != null) { - writer.name(JsonKeys.TIMESTAMP).value(logger, timestamp); - } if (replayStartTimestamp != null) { - writer.name(JsonKeys.REPLAY_START_TIMESTAMP).value(logger, replayStartTimestamp); + writer + .name(JsonKeys.REPLAY_START_TIMESTAMP) + .value(logger, BigDecimal.valueOf(DateUtils.dateToSeconds(replayStartTimestamp))); } if (urls != null) { writer.name(JsonKeys.URLS).value(logger, urls); @@ -183,9 +216,10 @@ public void setUnknown(final @Nullable Map unknown) { public static final class Deserializer implements JsonDeserializer { + @SuppressWarnings("unchecked") @Override public @NotNull SentryReplayEvent deserialize( - final @NotNull JsonObjectReader reader, final @NotNull ILogger logger) throws Exception { + final @NotNull JsonObjectReader reader, final @NotNull ILogger logger) throws Exception { SentryBaseEvent.Deserializer baseEventDeserializer = new SentryBaseEvent.Deserializer(); @@ -193,11 +227,11 @@ public static final class Deserializer implements JsonDeserializer unknown = null; @Nullable String type = null; - @Nullable String replayType = null; + @Nullable ReplayType replayType = null; @Nullable SentryId replayId = null; @Nullable Integer segmentId = null; - @Nullable Double timestamp = null; - @Nullable Double replayStartTimestamp = null; + @Nullable Date timestamp = null; + @Nullable Date replayStartTimestamp = null; @Nullable List urls = null; @Nullable List errorIds = null; @Nullable List traceIds = null; @@ -210,7 +244,7 @@ public static final class Deserializer implements JsonDeserializer) reader.nextObjectOrNull(); break; case JsonKeys.ERROR_IDS: - errorIds = nextStringList(reader); + errorIds = (List) reader.nextObjectOrNull(); break; case JsonKeys.TRACE_IDS: - traceIds = nextStringList(reader); + traceIds = (List) reader.nextObjectOrNull(); break; default: if (!baseEventDeserializer.deserializeValue(replay, nextName, reader, logger)) { @@ -245,11 +279,19 @@ public static final class Deserializer implements JsonDeserializer nextStringList(final @NotNull JsonObjectReader reader) - throws IOException { - @Nullable List result = null; - final @Nullable Object data = reader.nextObjectOrNull(); - if (data instanceof List) { - result = new ArrayList<>(((List) data).size()); - for (Object item : (List) data) { - if (item instanceof String) { - result.add((String) item); - } - } - } - return result; - } } + // endregion json } diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebEvent.java index ba7f7227ab4..999149331cf 100644 --- a/sentry/src/main/java/io/sentry/rrweb/RRWebEvent.java +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebEvent.java @@ -1,25 +1,11 @@ package io.sentry.rrweb; -import io.sentry.Breadcrumb; import io.sentry.ILogger; import io.sentry.JsonObjectReader; -import io.sentry.JsonSerializable; -import io.sentry.JsonUnknown; import io.sentry.ObjectWriter; -import io.sentry.SentryBaseEvent; -import io.sentry.SentryLongDate; -import io.sentry.protocol.Contexts; -import io.sentry.protocol.DebugMeta; -import io.sentry.protocol.Request; -import io.sentry.protocol.SdkVersion; -import io.sentry.protocol.SentryId; -import io.sentry.protocol.User; -import io.sentry.util.CollectionUtils; import java.io.IOException; -import java.util.Map; import java.util.Objects; import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; public abstract class RRWebEvent { @@ -32,7 +18,7 @@ protected RRWebEvent(final @NotNull RRWebEventType type) { } protected RRWebEvent() { - this(RRWebEventType.Custom); + this(RRWebEventType.Custom); } @NotNull @@ -60,8 +46,8 @@ public static final class JsonKeys { public static final class Serializer { public void serialize( - @NotNull RRWebEvent baseEvent, @NotNull ObjectWriter writer, @NotNull ILogger logger) - throws IOException { + @NotNull RRWebEvent baseEvent, @NotNull ObjectWriter writer, @NotNull ILogger logger) + throws IOException { writer.name(JsonKeys.TYPE).value(logger, baseEvent.type); writer.name(JsonKeys.TIMESTAMP).value(baseEvent.timestamp); } @@ -70,15 +56,15 @@ public void serialize( public static final class Deserializer { @SuppressWarnings("unchecked") public boolean deserializeValue( - @NotNull RRWebEvent baseEvent, - @NotNull String nextName, - @NotNull JsonObjectReader reader, - @NotNull ILogger logger) - throws Exception { + @NotNull RRWebEvent baseEvent, + @NotNull String nextName, + @NotNull JsonObjectReader reader, + @NotNull ILogger logger) + throws Exception { switch (nextName) { case JsonKeys.TYPE: baseEvent.type = - Objects.requireNonNull(reader.nextOrNull(logger, new RRWebEventType.Deserializer())); + Objects.requireNonNull(reader.nextOrNull(logger, new RRWebEventType.Deserializer())); return true; case JsonKeys.TIMESTAMP: baseEvent.timestamp = reader.nextLong(); diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebEventType.java b/sentry/src/main/java/io/sentry/rrweb/RRWebEventType.java index 412ab234ab8..a81e5f30e8b 100644 --- a/sentry/src/main/java/io/sentry/rrweb/RRWebEventType.java +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebEventType.java @@ -17,15 +17,15 @@ public enum RRWebEventType implements JsonSerializable { Custom, Plugin; - @Override public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) - throws IOException { + @Override + public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) throws IOException { writer.value(ordinal()); } public static final class Deserializer implements JsonDeserializer { @Override public @NotNull RRWebEventType deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { return RRWebEventType.values()[reader.nextInt()]; } } diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebMetaEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebMetaEvent.java index 67f7a82e4da..d64400e3235 100644 --- a/sentry/src/main/java/io/sentry/rrweb/RRWebMetaEvent.java +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebMetaEvent.java @@ -58,14 +58,20 @@ public static final class JsonKeys { } @Override - public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) - throws IOException { + public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) throws IOException { + writer.beginObject(); + new RRWebEvent.Serializer().serialize(this, writer, logger); writer.name(JsonKeys.DATA); + serializeData(writer, logger); + writer.endObject(); + } + + private void serializeData(final @NotNull ObjectWriter writer, final @NotNull ILogger logger) + throws IOException { writer.beginObject(); writer.name(JsonKeys.HREF).value(href); writer.name(JsonKeys.HEIGHT).value(height); writer.name(JsonKeys.WIDTH).value(width); - new RRWebEvent.Serializer().serialize(this, writer, logger); if (unknown != null) { for (String key : unknown.keySet()) { Object value = unknown.get(key); @@ -91,18 +97,37 @@ public static final class Deserializer implements JsonDeserializer unknown = null; - RRWebEvent.Deserializer baseEventDeserializer = new RRWebEvent.Deserializer(); while (reader.peek() == JsonToken.NAME) { final String nextName = reader.nextName(); switch (nextName) { case JsonKeys.DATA: + deserializeData(event, reader, logger); + break; + default: + baseEventDeserializer.deserializeValue(event, nextName, reader, logger); break; + } + } + reader.endObject(); + return event; + } + + private void deserializeData( + final @NotNull RRWebMetaEvent event, + final @NotNull JsonObjectReader reader, + final @NotNull ILogger logger) + throws Exception { + Map unknown = null; + + reader.beginObject(); + while (reader.peek() == JsonToken.NAME) { + final String nextName = reader.nextName(); + switch (nextName) { case JsonKeys.HREF: final String href = reader.nextStringOrNull(); event.href = href == null ? "" : href; @@ -116,18 +141,14 @@ public static final class Deserializer implements JsonDeserializer(); - } - reader.nextUnknown(logger, unknown, nextName); + if (unknown == null) { + unknown = new ConcurrentHashMap<>(); } - break; + reader.nextUnknown(logger, unknown, nextName); } } event.setUnknown(unknown); reader.endObject(); - return event; } } } diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebVideoEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebVideoEvent.java index 17a54320b0e..a20c705e5af 100644 --- a/sentry/src/main/java/io/sentry/rrweb/RRWebVideoEvent.java +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebVideoEvent.java @@ -1,31 +1,178 @@ package io.sentry.rrweb; import io.sentry.ILogger; +import io.sentry.JsonDeserializer; +import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; import io.sentry.ObjectWriter; +import io.sentry.vendor.gson.stream.JsonToken; import java.io.IOException; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; public final class RRWebVideoEvent extends RRWebEvent implements JsonUnknown, JsonSerializable { - @Override public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) - throws IOException { + public static final String EVENT_TAG = "video"; + public static final String REPLAY_ENCODING = "h264"; + public static final String REPLAY_CONTAINER = "mp4"; + public static final String REPLAY_FRAME_RATE_TYPE_CONSTANT = "constant"; + public static final String REPLAY_FRAME_RATE_TYPE_VARIABLE = "variable"; + private @NotNull String tag; + private int segmentId; + private long size; + private int duration; + private @NotNull String encoding = REPLAY_ENCODING; + private @NotNull String container = REPLAY_CONTAINER; + private int height; + private int width; + private int frameCount; + private @NotNull String frameRateType = REPLAY_FRAME_RATE_TYPE_CONSTANT; + private int frameRate; + private int left; + private int top; + private @Nullable Map payloadUnknown; + private @Nullable Map dataUnknown; + + public RRWebVideoEvent() { + super(RRWebEventType.Custom); + tag = EVENT_TAG; + } + + @NotNull + public String getTag() { + return tag; + } + + public void setTag(final @NotNull String tag) { + this.tag = tag; + } + + public int getSegmentId() { + return segmentId; + } + + public void setSegmentId(final int segmentId) { + this.segmentId = segmentId; } - @Override public @Nullable Map getUnknown() { - return null; + public long getSize() { + return size; } - @Override public void setUnknown(@Nullable Map unknown) { + public void setSize(final long size) { + this.size = size; + } + + public int getDuration() { + return duration; + } + + public void setDuration(final int duration) { + this.duration = duration; + } + + @NotNull + public String getEncoding() { + return encoding; + } + + public void setEncoding(final @NotNull String encoding) { + this.encoding = encoding; + } + @NotNull + public String getContainer() { + return container; } + public void setContainer(final @NotNull String container) { + this.container = container; + } + + public int getHeight() { + return height; + } + + public void setHeight(final int height) { + this.height = height; + } + + public int getWidth() { + return width; + } + + public void setWidth(final int width) { + this.width = width; + } + + public int getFrameCount() { + return frameCount; + } + + public void setFrameCount(final int frameCount) { + this.frameCount = frameCount; + } + + @NotNull + public String getFrameRateType() { + return frameRateType; + } + + public void setFrameRateType(final @NotNull String frameRateType) { + this.frameRateType = frameRateType; + } + + public int getFrameRate() { + return frameRate; + } + + public void setFrameRate(final int frameRate) { + this.frameRate = frameRate; + } + + public int getLeft() { + return left; + } + + public void setLeft(final int left) { + this.left = left; + } + + public int getTop() { + return top; + } + + public void setTop(final int top) { + this.top = top; + } + + public @Nullable Map getPayloadUnknown() { + return payloadUnknown; + } + + public void setPayloadUnknown(final @Nullable Map payloadUnknown) { + this.payloadUnknown = payloadUnknown; + } + + @Override + public @Nullable Map getUnknown() { + return dataUnknown; + } + + @Override + public void setUnknown(final @Nullable Map unknown) { + this.dataUnknown = unknown; + } + + // region json + // rrweb uses camelCase hence the json keys are in camelCase here public static final class JsonKeys { + public static final String DATA = "data"; public static final String TAG = "tag"; public static final String PAYLOAD = "payload"; public static final String SEGMENT_ID = "segmentId"; @@ -42,5 +189,177 @@ public static final class JsonKeys { public static final String TOP = "top"; } + @Override + public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) throws IOException { + writer.beginObject(); + new RRWebEvent.Serializer().serialize(this, writer, logger); + writer.name(JsonKeys.DATA); + serializeData(writer, logger); + writer.endObject(); + } + + private void serializeData(final @NotNull ObjectWriter writer, final @NotNull ILogger logger) + throws IOException { + writer.beginObject(); + writer.name(JsonKeys.TAG).value(tag); + writer.name(JsonKeys.PAYLOAD); + serializePayload(writer, logger); + if (dataUnknown != null) { + for (String key : dataUnknown.keySet()) { + Object value = dataUnknown.get(key); + writer.name(key); + writer.value(logger, value); + } + } + writer.endObject(); + } + private void serializePayload(final @NotNull ObjectWriter writer, final @NotNull ILogger logger) + throws IOException { + writer.beginObject(); + writer.name(JsonKeys.SEGMENT_ID).value(segmentId); + writer.name(JsonKeys.SIZE).value(size); + writer.name(JsonKeys.DURATION).value(duration); + writer.name(JsonKeys.ENCODING).value(encoding); + writer.name(JsonKeys.CONTAINER).value(container); + writer.name(JsonKeys.HEIGHT).value(height); + writer.name(JsonKeys.WIDTH).value(width); + writer.name(JsonKeys.FRAME_COUNT).value(frameCount); + writer.name(JsonKeys.FRAME_RATE).value(frameRate); + writer.name(JsonKeys.FRAME_RATE_TYPE).value(frameRateType); + writer.name(JsonKeys.LEFT).value(left); + writer.name(JsonKeys.TOP).value(top); + if (payloadUnknown != null) { + for (String key : payloadUnknown.keySet()) { + Object value = payloadUnknown.get(key); + writer.name(key); + writer.value(logger, value); + } + } + writer.endObject(); + } + + public static final class Deserializer implements JsonDeserializer { + + @SuppressWarnings("unchecked") + @Override + public @NotNull RRWebVideoEvent deserialize( + @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + reader.beginObject(); + RRWebVideoEvent event = new RRWebVideoEvent(); + RRWebEvent.Deserializer baseEventDeserializer = new RRWebEvent.Deserializer(); + + while (reader.peek() == JsonToken.NAME) { + final String nextName = reader.nextName(); + switch (nextName) { + case RRWebMetaEvent.JsonKeys.DATA: + deserializeData(event, reader, logger); + break; + default: + baseEventDeserializer.deserializeValue(event, nextName, reader, logger); + break; + } + } + reader.endObject(); + return event; + } + + private void deserializeData( + final @NotNull RRWebVideoEvent event, + final @NotNull JsonObjectReader reader, + final @NotNull ILogger logger) + throws Exception { + Map dataUknown = null; + + reader.beginObject(); + while (reader.peek() == JsonToken.NAME) { + final String nextName = reader.nextName(); + switch (nextName) { + case JsonKeys.TAG: + final String tag = reader.nextStringOrNull(); + event.tag = tag == null ? "" : tag; + break; + case JsonKeys.PAYLOAD: + deserializePayload(event, reader, logger); + break; + default: + if (dataUknown == null) { + dataUknown = new ConcurrentHashMap<>(); + } + reader.nextUnknown(logger, dataUknown, nextName); + } + } + event.setUnknown(dataUknown); + reader.endObject(); + } + + private void deserializePayload( + final @NotNull RRWebVideoEvent event, + final @NotNull JsonObjectReader reader, + final @NotNull ILogger logger) + throws Exception { + Map payloadUnknown = null; + + reader.beginObject(); + while (reader.peek() == JsonToken.NAME) { + final String nextName = reader.nextName(); + switch (nextName) { + case JsonKeys.SEGMENT_ID: + event.segmentId = reader.nextInt(); + break; + case JsonKeys.SIZE: + final Long size = reader.nextLongOrNull(); + event.size = size == null ? 0 : size; + break; + case JsonKeys.DURATION: + event.duration = reader.nextInt(); + break; + case JsonKeys.CONTAINER: + final String container = reader.nextStringOrNull(); + event.container = container == null ? "" : container; + break; + case JsonKeys.ENCODING: + final String encoding = reader.nextStringOrNull(); + event.encoding = encoding == null ? "" : encoding; + break; + case JsonKeys.HEIGHT: + final Integer height = reader.nextIntegerOrNull(); + event.height = height == null ? 0 : height; + break; + case JsonKeys.WIDTH: + final Integer width = reader.nextIntegerOrNull(); + event.width = width == null ? 0 : width; + break; + case JsonKeys.FRAME_COUNT: + final Integer frameCount = reader.nextIntegerOrNull(); + event.frameCount = frameCount == null ? 0 : frameCount; + break; + case JsonKeys.FRAME_RATE: + final Integer frameRate = reader.nextIntegerOrNull(); + event.frameRate = frameRate == null ? 0 : frameRate; + break; + case JsonKeys.FRAME_RATE_TYPE: + final String frameRateType = reader.nextStringOrNull(); + event.frameRateType = frameRateType == null ? "" : frameRateType; + break; + case JsonKeys.LEFT: + final Integer left = reader.nextIntegerOrNull(); + event.left = left == null ? 0 : left; + break; + case JsonKeys.TOP: + final Integer top = reader.nextIntegerOrNull(); + event.top = top == null ? 0 : top; + break; + default: + if (payloadUnknown == null) { + payloadUnknown = new ConcurrentHashMap<>(); + } + reader.nextUnknown(logger, payloadUnknown, nextName); + } + } + event.setUnknown(payloadUnknown); + reader.endObject(); + } + } + // endregion json } diff --git a/sentry/src/test/java/io/sentry/protocol/ReplayRecordingSerializationTest.kt b/sentry/src/test/java/io/sentry/protocol/ReplayRecordingSerializationTest.kt index b731cabdb4b..68b45fd8741 100644 --- a/sentry/src/test/java/io/sentry/protocol/ReplayRecordingSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/protocol/ReplayRecordingSerializationTest.kt @@ -1,11 +1,18 @@ package io.sentry.protocol -import io.sentry.DateUtils import io.sentry.ILogger +import io.sentry.JsonSerializer import io.sentry.ReplayRecording -import io.sentry.SentryEvent -import io.sentry.SentryLevel +import io.sentry.SentryOptions +import io.sentry.protocol.SerializationUtils.deserializeJson +import io.sentry.protocol.SerializationUtils.sanitizedFile +import io.sentry.protocol.SerializationUtils.serializeToString +import io.sentry.rrweb.RRWebMetaEventSerializationTest +import io.sentry.rrweb.RRWebVideoEventSerializationTest +import org.junit.Test import org.mockito.kotlin.mock +import java.io.StringWriter +import kotlin.test.assertEquals class ReplayRecordingSerializationTest { class Fixture { @@ -14,9 +21,35 @@ class ReplayRecordingSerializationTest { fun getSut() = ReplayRecording().apply { segmentId = 0 payload = listOf( - + RRWebMetaEventSerializationTest.Fixture().getSut(), + RRWebVideoEventSerializationTest.Fixture().getSut() ) } } + private val fixture = Fixture() + + @Test + fun serialize() { + val expected = sanitizedFile("json/replay_recording.json") + val actual = serializeToString(fixture.getSut(), fixture.logger) + assertEquals(expected, actual) + } + + @Test + fun deserialize() { + val expectedJson = sanitizedFile("json/replay_recording.json") + val actual = deserializeJson(expectedJson, ReplayRecording.Deserializer(), fixture.logger) + val actualJson = serializeToString(actual, fixture.logger) + assertEquals(expectedJson, actualJson) + } + + @Test + fun serializePayload() { + val expected = sanitizedFile("json/replay_recording_payload.json") + val writer = StringWriter() + JsonSerializer(SentryOptions()).serialize(fixture.getSut().payload as Any, writer) + val actual = writer.toString() + assertEquals(expected, actual) + } } diff --git a/sentry/src/test/java/io/sentry/protocol/SentryReplayEventSerializationTest.kt b/sentry/src/test/java/io/sentry/protocol/SentryReplayEventSerializationTest.kt new file mode 100644 index 00000000000..82b32e96c76 --- /dev/null +++ b/sentry/src/test/java/io/sentry/protocol/SentryReplayEventSerializationTest.kt @@ -0,0 +1,58 @@ +package io.sentry.protocol + +import io.sentry.DateUtils +import io.sentry.ILogger +import io.sentry.SentryIntegrationPackageStorage +import io.sentry.SentryReplayEvent +import io.sentry.protocol.SerializationUtils.deserializeJson +import io.sentry.protocol.SerializationUtils.sanitizedFile +import io.sentry.protocol.SerializationUtils.serializeToString +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.mock +import kotlin.test.assertEquals + +class SentryReplayEventSerializationTest { + + class Fixture { + val logger = mock() + + fun getSut() = SentryReplayEvent().apply { + replayId = SentryId("f715e1d64ef64ea3ad7744b5230813c3") + segmentId = 0 + timestamp = DateUtils.getDateTimeWithMillisPrecision("987654321.123") + replayStartTimestamp = DateUtils.getDateTimeWithMillisPrecision("987654321.123") + urls = listOf("ScreenOne") + errorIds = listOf("ab3a347a4cc14fd4b4cf1dc56b670c5b") + traceIds = listOf("340cfef948204549ac07c3b353c81c50") + SentryBaseEventSerializationTest.Fixture().update(this) + } + } + private val fixture = Fixture() + + @Before + fun setup() { + SentryIntegrationPackageStorage.getInstance().clearStorage() + } + + @After + fun teardown() { + SentryIntegrationPackageStorage.getInstance().clearStorage() + } + + @Test + fun serialize() { + val expected = sanitizedFile("json/sentry_replay_event.json") + val actual = serializeToString(fixture.getSut(), fixture.logger) + assertEquals(expected, actual) + } + + @Test + fun deserialize() { + val expectedJson = sanitizedFile("json/sentry_replay_event.json") + val actual = deserializeJson(expectedJson, SentryReplayEvent.Deserializer(), fixture.logger) + val actualJson = serializeToString(actual, fixture.logger) + assertEquals(expectedJson, actualJson) + } +} diff --git a/sentry/src/test/java/io/sentry/rrweb/RRWebMetaEventSerializationTest.kt b/sentry/src/test/java/io/sentry/rrweb/RRWebMetaEventSerializationTest.kt index 3ab8ad03cb7..29ec354333e 100644 --- a/sentry/src/test/java/io/sentry/rrweb/RRWebMetaEventSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/rrweb/RRWebMetaEventSerializationTest.kt @@ -16,8 +16,8 @@ class RRWebMetaEventSerializationTest { fun getSut() = RRWebMetaEvent().apply { href = "https://sentry.io" - width = 1080 height = 1920 + width = 1080 type = Meta timestamp = 1234567890 } diff --git a/sentry/src/test/java/io/sentry/rrweb/RRWebVideoEventSerializationTest.kt b/sentry/src/test/java/io/sentry/rrweb/RRWebVideoEventSerializationTest.kt new file mode 100644 index 00000000000..79bfd024564 --- /dev/null +++ b/sentry/src/test/java/io/sentry/rrweb/RRWebVideoEventSerializationTest.kt @@ -0,0 +1,47 @@ +package io.sentry.rrweb + +import io.sentry.ILogger +import io.sentry.protocol.SerializationUtils +import io.sentry.rrweb.RRWebEventType.Custom +import org.junit.Test +import org.mockito.kotlin.mock +import kotlin.test.assertEquals + +class RRWebVideoEventSerializationTest { + class Fixture { + val logger = mock() + + fun getSut() = RRWebVideoEvent().apply { + type = Custom + timestamp = 12345678901 + tag = "video" + segmentId = 0 + size = 4_000_000L + duration = 5000 + height = 1920 + width = 1080 + frameCount = 5 + frameRate = 1 + left = 100 + top = 100 + } + } + + private val fixture = Fixture() + + @Test + fun serialize() { + val expected = SerializationUtils.sanitizedFile("json/rrweb_video_event.json") + val actual = SerializationUtils.serializeToString(fixture.getSut(), fixture.logger) + assertEquals(expected, actual) + } + + @Test + fun deserialize() { + val expectedJson = SerializationUtils.sanitizedFile("json/rrweb_video_event.json") + val actual = + SerializationUtils.deserializeJson(expectedJson, RRWebVideoEvent.Deserializer(), fixture.logger) + val actualJson = SerializationUtils.serializeToString(actual, fixture.logger) + assertEquals(expectedJson, actualJson) + } +} diff --git a/sentry/src/test/resources/json/replay_recording.json b/sentry/src/test/resources/json/replay_recording.json new file mode 100644 index 00000000000..b0de77bc872 --- /dev/null +++ b/sentry/src/test/resources/json/replay_recording.json @@ -0,0 +1,3 @@ +{ + "segment_id": 0 +} diff --git a/sentry/src/test/resources/json/replay_recording_payload.json b/sentry/src/test/resources/json/replay_recording_payload.json new file mode 100644 index 00000000000..fe790b52d6b --- /dev/null +++ b/sentry/src/test/resources/json/replay_recording_payload.json @@ -0,0 +1,32 @@ +[ + { + "type": 4, + "timestamp": 1234567890, + "data": { + "href": "https://sentry.io", + "height": 1920, + "width": 1080 + } + }, + { + "type": 5, + "timestamp": 12345678901, + "data": { + "tag": "video", + "payload": { + "segmentId": 0, + "size": 4000000, + "duration": 5000, + "encoding":"h264", + "container":"mp4", + "height": 1920, + "width": 1080, + "frameCount": 5, + "frameRate": 1, + "frameRateType": "constant", + "left": 100, + "top": 100 + } + } + } +] diff --git a/sentry/src/test/resources/json/rrweb_meta_event.json b/sentry/src/test/resources/json/rrweb_meta_event.json index a1d9621f526..5eb561a78d1 100644 --- a/sentry/src/test/resources/json/rrweb_meta_event.json +++ b/sentry/src/test/resources/json/rrweb_meta_event.json @@ -3,7 +3,7 @@ "timestamp": 1234567890, "data": { "href": "https://sentry.io", - "width": 1080, - "height": 1920 + "height": 1920, + "width": 1080 } } diff --git a/sentry/src/test/resources/json/rrweb_video_event.json b/sentry/src/test/resources/json/rrweb_video_event.json new file mode 100644 index 00000000000..692dafe879e --- /dev/null +++ b/sentry/src/test/resources/json/rrweb_video_event.json @@ -0,0 +1,21 @@ +{ + "type": 5, + "timestamp": 12345678901, + "data": { + "tag": "video", + "payload": { + "segmentId": 0, + "size": 4000000, + "duration": 5000, + "encoding":"h264", + "container":"mp4", + "height": 1920, + "width": 1080, + "frameCount": 5, + "frameRate": 1, + "frameRateType": "constant", + "left": 100, + "top": 100 + } + } +} diff --git a/sentry/src/test/resources/json/sentry_replay_event.json b/sentry/src/test/resources/json/sentry_replay_event.json new file mode 100644 index 00000000000..b3d1c9ddb50 --- /dev/null +++ b/sentry/src/test/resources/json/sentry_replay_event.json @@ -0,0 +1,258 @@ +{ + "type": "replay_event", + "replay_type": "session", + "segment_id": 0, + "timestamp": 987654321.123, + "replay_id": "f715e1d64ef64ea3ad7744b5230813c3", + "replay_start_timestamp": 987654321.123, + "urls": + [ + "ScreenOne" + ], + "error_ids": + [ + "ab3a347a4cc14fd4b4cf1dc56b670c5b" + ], + "trace_ids": + [ + "340cfef948204549ac07c3b353c81c50" + ], + "event_id": "afcb46b1140ade5187c4bbb5daa804df", + "contexts": + { + "app": + { + "app_identifier": "3b7a3313-53b4-43f4-a6a1-7a7c36a9b0db", + "app_start_time": "1918-11-17T07:46:04.000Z", + "device_app_hash": "3d1fcf36-2c25-4378-bdf8-1e65239f1df4", + "build_type": "d78c56cd-eb0f-4213-8899-cd10ddf20763", + "app_name": "873656fd-f620-4edf-bb7a-a0d13325dba0", + "app_version": "801aab22-ad4b-44fb-995c-bacb5387e20c", + "app_build": "660f0cde-eedb-49dc-a973-8aa1c04f4a28", + "permissions": + { + "WRITE_EXTERNAL_STORAGE": "not_granted", + "CAMERA": "granted" + }, + "in_foreground": true, + "view_names": ["MainActivity", "SidebarActivity"] + }, + "browser": + { + "name": "e1c723db-7408-4043-baa7-f4e96234e5dc", + "version": "724a48e9-2d35-416b-9f79-132beba2473a" + }, + "device": + { + "name": "83f1de77-fdb0-470e-8249-8f5c5d894ec4", + "manufacturer": "e21b2405-e378-4a0b-ad2c-4822d97cd38c", + "brand": "1abbd13e-d1ca-4d81-bd1b-24aa2c339cf9", + "family": "67a4b8ea-6c38-4c33-8579-7697f538685c", + "model": "d6ca2f35-bcc5-4dd3-ad64-7c3b585e02fd", + "model_id": "d3f133bd-b0a2-4aa4-9eed-875eba93652e", + "archs": + [ + "856e5da3-774c-4663-a830-d19f0b7dbb5b", + "b345bd5a-90a5-4301-a5a2-6c102d7589b6", + "fd7ed64e-a591-49e0-8dc1-578234356d23", + "8cec4101-0305-480b-91ee-f3c007f668c3", + "22583b9b-195e-49bf-bfe8-825ae3a346f2", + "8675b7aa-5b94-42d0-bc14-72ea1bb7112e" + ], + "battery_level": 0.45770407, + "charging": false, + "online": true, + "orientation": "portrait", + "simulator": true, + "memory_size": -6712323365568152393, + "free_memory": -953384122080236886, + "usable_memory": -8999512249221323968, + "low_memory": false, + "storage_size": -3227905175393990709, + "free_storage": -3749039933924297357, + "external_storage_size": -7739608324159255302, + "external_free_storage": -1562576688560812557, + "screen_width_pixels": 1101873181, + "screen_height_pixels": 1902392170, + "screen_density": 0.9829039, + "screen_dpi": -2092079070, + "boot_time": "2004-11-04T08:38:00.000Z", + "timezone": "Europe/Vienna", + "id": "e0fa5c8d-83f5-4e70-bc60-1e82ad30e196", + "language": "6dd45f60-111d-42d8-9204-0452cc836ad8", + "connection_type": "9ceb3a6c-5292-4ed9-8665-5732495e8ed4", + "battery_temperature": 0.14775127, + "processor_count": 4, + "processor_frequency": 800.0, + "cpu_description": "cpu0" + }, + "gpu": + { + "name": "d623a6b5-e1ab-4402-931b-c06f5a43a5ae", + "id": -596576280, + "vendor_id": "1874778041", + "vendor_name": "d732cf76-07dc-48e2-8920-96d6bfc2439d", + "memory_size": -1484004451, + "api_type": "95dfc8bc-88ae-4d66-b85f-6c88ad45b80f", + "multi_threaded_rendering": true, + "version": "3f3f73c3-83a2-423a-8a6f-bb3de0d4a6ae", + "npot_support": "e06b074a-463c-45de-a959-cbabd461d99d" + }, + "os": + { + "name": "686a11a8-eae7-4393-aa10-a1368d523cb2", + "version": "3033f32d-6a27-4715-80c8-b232ce84ca61", + "raw_description": "eb2d0c5e-f5d4-49c7-b876-d8a654ee87cf", + "build": "bd197b97-eb68-49c3-9d07-ef789caf3069", + "kernel_version": "1df24aec-3a6f-49a9-8b50-69ae5f9dde08", + "rooted": true + }, + "response": + { + "cookies": "PHPSESSID=298zf09hf012fh2; csrftoken=u32t4o3tb3gg43; _gat=1;", + "headers": { + "content-type": "text/html" + }, + "status_code": 500, + "body_size": 1000, + "data": + { + "d9d709db-b666-40cc-bcbb-093bb12aad26": "1631d0e6-96b7-4632-85f8-ef69e8bcfb16" + }, + "arbitrary_field": "arbitrary" + }, + "runtime": + { + "name": "4ed019c4-9af9-43e0-830e-bfde9fe4461c", + "version": "16534f6b-1670-4bb8-aec2-647a1b97669b", + "raw_description": "773b5b05-a0f9-4ee6-9f3b-13155c37ad6e" + }, + "trace": + { + "trace_id": "afcb46b1140ade5187c4bbb5daa804df", + "span_id": "bf6b582d-8ce3-412b-a334-f4c5539b9602", + "parent_span_id": "c7500f2a-d4e6-4f5f-a0f4-6bb67e98d5a2", + "op": "e481581d-35a4-4e97-8a1c-b554bf49f23e", + "description": "c204b6c7-9753-4d45-927d-b19789bfc9a5", + "status": "resource_exhausted", + "origin": "auto.test.unit.spancontext", + "tags": + { + "2a5fa3f5-7b87-487f-aaa5-84567aa73642": "4781d51a-c5af-47f2-a4ed-f030c9b3e194", + "29106d7d-7fa4-444f-9d34-b9d7510c69ab": "218c23ea-694a-497e-bf6d-e5f26f1ad7bd", + "ba9ce913-269f-4c03-882d-8ca5e6991b14": "35a74e90-8db8-4610-a411-872cbc1030ac" + } + } + }, + "sdk": + { + "name": "3e934135-3f2b-49bc-8756-9f025b55143e", + "version": "3e31738e-4106-42d0-8be2-4a3a1bc648d3", + "packages": + [ + { + "name": "b59a1949-9950-4203-b394-ddd8d02c9633", + "version": "3d7790f3-7f32-43f7-b82f-9f5bc85205a8" + } + ], + "integrations": + [ + "daec50ae-8729-49b5-82f7-991446745cd5", + "8fc94968-3499-4a2c-b4d7-ecc058d9c1b0" + ] + }, + "request": + { + "url": "67369bc9-64d3-4d31-bfba-37393b145682", + "method": "8185abc3-5411-4041-a0d9-374180081044", + "query_string": "e3dc7659-f42e-413c-a07c-52b24bf9d60d", + "data": + { + "d9d709db-b666-40cc-bcbb-093bb12aad26": "1631d0e6-96b7-4632-85f8-ef69e8bcfb16" + }, + "cookies": "d84f4cfc-5310-4818-ad4f-3f8d22ceaca8", + "headers": + { + "c4991f66-9af9-4914-ac5e-e4854a5a4822": "37714d22-25a7-469b-b762-289b456fbec3" + }, + "env": + { + "6d569c89-5d5e-40e0-a4fc-109b20a53778": "ccadf763-44e4-475c-830c-de6ba0dbd202" + }, + "other": + { + "669ff1c1-517b-46dc-a889-131555364a56": "89043294-f6e1-4e2e-b152-1fdf9b1102fc" + }, + "fragment": "fragment", + "body_size": 1000, + "api_target": "graphql" + }, + "tags": + { + "79ba41db-8dc6-4156-b53e-6cf6d742eb88": "690ce82f-4d5d-4d81-b467-461a41dd9419" + }, + "release": "be9b8133-72f5-497b-adeb-b0a245eebad6", + "environment": "89204175-e462-4628-8acb-3a7fa8d8da7d", + "platform": "38decc78-2711-4a6a-a0be-abb61bfa5a6e", + "user": + { + "email": "c4d61c1b-c144-431e-868f-37a46be5e5f2", + "id": "efb2084b-1871-4b59-8897-b4bd9f196a01", + "username": "60c05dff-7140-4d94-9a61-c9cdd9ca9b96", + "ip_address": "51d22b77-f663-4dbe-8103-8b749d1d9a48", + "name": "c8c60762-b1cf-11ed-afa1-0242ac120002", + "geo": { + "city": "0e6ed0b0-b1c5-11ed-afa1-0242ac120002", + "country_code": "JP", + "region": "273a3d0a-b1c5-11ed-afa1-0242ac120002" + }, + "data": + { + "dc2813d0-0f66-4a3f-a995-71268f61a8fa": "991659ad-7c59-4dd3-bb89-0bd5c74014bd" + } + }, + "server_name": "e6f0ae04-0f40-421b-aad1-f68c15117937", + "dist": "27022a08-aace-40c6-8d0a-358a27fcaa7a", + "breadcrumbs": + [ + { + "timestamp": "2009-11-16T01:08:47.000Z", + "message": "46f233c0-7c2d-488a-b05a-7be559173e16", + "type": "ace57e2e-305e-4048-abf0-6c8538ea7bf4", + "data": + { + "6607d106-d426-462b-af74-f29fce978e48": "149bb94a-1387-4484-90be-2df15d1322ab" + }, + "category": "b6eea851-5ae5-40ed-8fdd-5e1a655a879c", + "level": "debug" + } + ], + "debug_meta": + { + "sdk_info": + { + "sdk_name": "182c4407-c1e1-4427-9b5a-ad2e22b1046a", + "version_major": 2045114005, + "version_minor": 1436566288, + "version_patchlevel": 1637914973 + }, + "images": + [ + { + "uuid": "8994027e-1cd9-4be8-b611-88ce08cf16e6", + "type": "fd6e053b-a7fe-4754-916e-bfb3ab77177d", + "debug_id": "8c653f5a-3418-4823-ba91-29a84c9c1235", + "debug_file": "55cc15dd-51f3-4cad-803c-6fd90eac21f6", + "code_id": "01230ece-f729-4af4-8b48-df74700aa4bf", + "code_file": "415c8995-1cb4-4bed-ba5c-5b3d6ba1ad47", + "image_addr": "8a258c81-641d-4e54-b06e-a0f56b1ee2ef", + "image_size": -7905338721846826571, + "arch": "d00d5bea-fb5c-43c9-85f0-dc1350d957a4" + } + ] + }, + "extra": + { + "34a7d067-fad2-49d9-97b9-71eff243127b": "fe3dc1cf-4a99-4213-85bb-e0957b8349b8" + } +} From 6cfb511d03b0bb6068994b58ce51f1beaac0e05f Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 19 Feb 2024 14:52:56 +0100 Subject: [PATCH 13/89] Remove jsonValue --- sentry/src/main/java/io/sentry/JsonObjectWriter.java | 6 ------ sentry/src/main/java/io/sentry/ObjectWriter.java | 2 -- sentry/src/main/java/io/sentry/util/MapObjectWriter.java | 6 ------ 3 files changed, 14 deletions(-) diff --git a/sentry/src/main/java/io/sentry/JsonObjectWriter.java b/sentry/src/main/java/io/sentry/JsonObjectWriter.java index ff5114606c1..b174ddb4847 100644 --- a/sentry/src/main/java/io/sentry/JsonObjectWriter.java +++ b/sentry/src/main/java/io/sentry/JsonObjectWriter.java @@ -52,12 +52,6 @@ public JsonObjectWriter value(final @Nullable String value) throws IOException { return this; } - @Override - public ObjectWriter jsonValue(@Nullable String value) throws IOException { - jsonWriter.jsonValue(value); - return this; - } - @Override public JsonObjectWriter nullValue() throws IOException { jsonWriter.nullValue(); diff --git a/sentry/src/main/java/io/sentry/ObjectWriter.java b/sentry/src/main/java/io/sentry/ObjectWriter.java index a5b8d12a4e0..ea8d4e83eac 100644 --- a/sentry/src/main/java/io/sentry/ObjectWriter.java +++ b/sentry/src/main/java/io/sentry/ObjectWriter.java @@ -17,8 +17,6 @@ public interface ObjectWriter { ObjectWriter value(final @Nullable String value) throws IOException; - ObjectWriter jsonValue(final @Nullable String value) throws IOException; - ObjectWriter nullValue() throws IOException; ObjectWriter value(final boolean value) throws IOException; diff --git a/sentry/src/main/java/io/sentry/util/MapObjectWriter.java b/sentry/src/main/java/io/sentry/util/MapObjectWriter.java index 7d25c7d9dda..26f80eddc29 100644 --- a/sentry/src/main/java/io/sentry/util/MapObjectWriter.java +++ b/sentry/src/main/java/io/sentry/util/MapObjectWriter.java @@ -151,12 +151,6 @@ public MapObjectWriter value(final @Nullable String value) throws IOException { return this; } - @Override - public ObjectWriter jsonValue(@Nullable String value) throws IOException { - // no-op - return this; - } - @Override public MapObjectWriter nullValue() throws IOException { postValue((Object) null); From 0d031d770405212dd1d37af3d7c6dcbd90ec4e25 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 19 Feb 2024 14:55:40 +0100 Subject: [PATCH 14/89] Remove --- sentry/src/main/java/io/sentry/SentryItemType.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/sentry/src/main/java/io/sentry/SentryItemType.java b/sentry/src/main/java/io/sentry/SentryItemType.java index 79deda34c07..ab5fb3bc73b 100644 --- a/sentry/src/main/java/io/sentry/SentryItemType.java +++ b/sentry/src/main/java/io/sentry/SentryItemType.java @@ -33,8 +33,6 @@ public static SentryItemType resolve(Object item) { return Session; } else if (item instanceof ClientReport) { return ClientReport; - } else if (item instanceof SentryReplayEvent) { - return ReplayEvent; } else { return Attachment; } From 07e6b261803a095c7b4774c13ae73805d365bb35 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 19 Feb 2024 14:58:22 +0100 Subject: [PATCH 15/89] Fix json --- .../resources/json/sentry_replay_event.json | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/sentry/src/test/resources/json/sentry_replay_event.json b/sentry/src/test/resources/json/sentry_replay_event.json index b3d1c9ddb50..04c96968b91 100644 --- a/sentry/src/test/resources/json/sentry_replay_event.json +++ b/sentry/src/test/resources/json/sentry_replay_event.json @@ -211,22 +211,7 @@ "dc2813d0-0f66-4a3f-a995-71268f61a8fa": "991659ad-7c59-4dd3-bb89-0bd5c74014bd" } }, - "server_name": "e6f0ae04-0f40-421b-aad1-f68c15117937", "dist": "27022a08-aace-40c6-8d0a-358a27fcaa7a", - "breadcrumbs": - [ - { - "timestamp": "2009-11-16T01:08:47.000Z", - "message": "46f233c0-7c2d-488a-b05a-7be559173e16", - "type": "ace57e2e-305e-4048-abf0-6c8538ea7bf4", - "data": - { - "6607d106-d426-462b-af74-f29fce978e48": "149bb94a-1387-4484-90be-2df15d1322ab" - }, - "category": "b6eea851-5ae5-40ed-8fdd-5e1a655a879c", - "level": "debug" - } - ], "debug_meta": { "sdk_info": @@ -250,9 +235,5 @@ "arch": "d00d5bea-fb5c-43c9-85f0-dc1350d957a4" } ] - }, - "extra": - { - "34a7d067-fad2-49d9-97b9-71eff243127b": "fe3dc1cf-4a99-4213-85bb-e0957b8349b8" } } From 18af924efa5d49b0922703d3cf23b92699e5951f Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 21 Feb 2024 00:19:02 +0100 Subject: [PATCH 16/89] Finalize replay envelopes --- buildSrc/src/main/java/Config.kt | 1 + sentry/build.gradle.kts | 1 + .../main/java/io/sentry/JsonObjectWriter.java | 11 ++ .../main/java/io/sentry/JsonSerializer.java | 2 + .../src/main/java/io/sentry/ObjectWriter.java | 4 + .../main/java/io/sentry/ReplayRecording.java | 84 +++++++++++- .../java/io/sentry/SentryEnvelopeItem.java | 36 ++---- .../java/io/sentry/SentryReplayEvent.java | 32 ++++- .../main/java/io/sentry/rrweb/RRWebEvent.java | 23 +++- .../java/io/sentry/rrweb/RRWebEventType.java | 4 +- .../java/io/sentry/rrweb/RRWebMetaEvent.java | 45 ++++++- .../java/io/sentry/rrweb/RRWebVideoEvent.java | 89 +++++++++++-- .../test/java/io/sentry/SentryClientTest.kt | 120 +++++++++++++++++- .../ReplayRecordingSerializationTest.kt | 20 +-- .../rrweb/RRWebEventSerializationTest.kt | 4 +- .../test/resources/json/replay_recording.json | 5 +- 16 files changed, 407 insertions(+), 74 deletions(-) diff --git a/buildSrc/src/main/java/Config.kt b/buildSrc/src/main/java/Config.kt index 8a02ce0e655..408d36051a5 100644 --- a/buildSrc/src/main/java/Config.kt +++ b/buildSrc/src/main/java/Config.kt @@ -195,6 +195,7 @@ object Config { val jsonUnit = "net.javacrumbs.json-unit:json-unit:2.32.0" val hsqldb = "org.hsqldb:hsqldb:2.6.1" val javaFaker = "com.github.javafaker:javafaker:1.0.2" + val msgpack = "org.msgpack:msgpack-core:0.9.8" } object QualityPlugins { diff --git a/sentry/build.gradle.kts b/sentry/build.gradle.kts index 2f35cbd4f72..08efc550d5a 100644 --- a/sentry/build.gradle.kts +++ b/sentry/build.gradle.kts @@ -32,6 +32,7 @@ dependencies { testImplementation(Config.TestLibs.mockitoInline) testImplementation(Config.TestLibs.awaitility) testImplementation(Config.TestLibs.javaFaker) + testImplementation(Config.TestLibs.msgpack) testImplementation(projects.sentryTestSupport) } diff --git a/sentry/src/main/java/io/sentry/JsonObjectWriter.java b/sentry/src/main/java/io/sentry/JsonObjectWriter.java index b174ddb4847..f1e84e6d5a0 100644 --- a/sentry/src/main/java/io/sentry/JsonObjectWriter.java +++ b/sentry/src/main/java/io/sentry/JsonObjectWriter.java @@ -52,6 +52,12 @@ public JsonObjectWriter value(final @Nullable String value) throws IOException { return this; } + @Override + public ObjectWriter jsonValue(@Nullable String value) throws IOException { + jsonWriter.jsonValue(value); + return this; + } + @Override public JsonObjectWriter nullValue() throws IOException { jsonWriter.nullValue(); @@ -103,6 +109,11 @@ public JsonObjectWriter value(final @NotNull ILogger logger, final @Nullable Obj return this; } + @Override + public void setLenient(final boolean lenient) { + jsonWriter.setLenient(lenient); + } + public void setIndent(final @NotNull String indent) { jsonWriter.setIndent(indent); } diff --git a/sentry/src/main/java/io/sentry/JsonSerializer.java b/sentry/src/main/java/io/sentry/JsonSerializer.java index 95c0c538ac4..9f0e93f59a9 100644 --- a/sentry/src/main/java/io/sentry/JsonSerializer.java +++ b/sentry/src/main/java/io/sentry/JsonSerializer.java @@ -92,6 +92,7 @@ public JsonSerializer(@NotNull SentryOptions options) { deserializersByClass.put( ProfileMeasurementValue.class, new ProfileMeasurementValue.Deserializer()); deserializersByClass.put(Request.class, new Request.Deserializer()); + deserializersByClass.put(ReplayRecording.class, new ReplayRecording.Deserializer()); deserializersByClass.put(RRWebEventType.class, new RRWebEventType.Deserializer()); deserializersByClass.put(RRWebMetaEvent.class, new RRWebMetaEvent.Deserializer()); deserializersByClass.put(RRWebVideoEvent.class, new RRWebVideoEvent.Deserializer()); @@ -107,6 +108,7 @@ public JsonSerializer(@NotNull SentryOptions options) { deserializersByClass.put(SentryLockReason.class, new SentryLockReason.Deserializer()); deserializersByClass.put(SentryPackage.class, new SentryPackage.Deserializer()); deserializersByClass.put(SentryRuntime.class, new SentryRuntime.Deserializer()); + deserializersByClass.put(SentryReplayEvent.class, new SentryReplayEvent.Deserializer()); deserializersByClass.put(SentrySpan.class, new SentrySpan.Deserializer()); deserializersByClass.put(SentryStackFrame.class, new SentryStackFrame.Deserializer()); deserializersByClass.put(SentryStackTrace.class, new SentryStackTrace.Deserializer()); diff --git a/sentry/src/main/java/io/sentry/ObjectWriter.java b/sentry/src/main/java/io/sentry/ObjectWriter.java index ea8d4e83eac..91e64a0c8b5 100644 --- a/sentry/src/main/java/io/sentry/ObjectWriter.java +++ b/sentry/src/main/java/io/sentry/ObjectWriter.java @@ -17,6 +17,8 @@ public interface ObjectWriter { ObjectWriter value(final @Nullable String value) throws IOException; + ObjectWriter jsonValue(final @Nullable String value) throws IOException; + ObjectWriter nullValue() throws IOException; ObjectWriter value(final boolean value) throws IOException; @@ -31,4 +33,6 @@ public interface ObjectWriter { ObjectWriter value(final @NotNull ILogger logger, final @Nullable Object object) throws IOException; + + void setLenient(boolean lenient); } diff --git a/sentry/src/main/java/io/sentry/ReplayRecording.java b/sentry/src/main/java/io/sentry/ReplayRecording.java index c110c007008..d8892191f24 100644 --- a/sentry/src/main/java/io/sentry/ReplayRecording.java +++ b/sentry/src/main/java/io/sentry/ReplayRecording.java @@ -1,8 +1,15 @@ package io.sentry; import io.sentry.rrweb.RRWebEvent; +import io.sentry.rrweb.RRWebEventType; +import io.sentry.rrweb.RRWebMetaEvent; +import io.sentry.rrweb.RRWebVideoEvent; +import io.sentry.util.MapObjectReader; +import io.sentry.util.Objects; import io.sentry.vendor.gson.stream.JsonToken; import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -37,6 +44,19 @@ public void setPayload(@Nullable List payload) { this.payload = payload; } + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ReplayRecording that = (ReplayRecording) o; + return Objects.equals(segmentId, that.segmentId) && Objects.equals(payload, that.payload); + } + + @Override + public int hashCode() { + return Objects.hash(segmentId, payload); + } + @Override public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger logger) throws IOException { @@ -52,6 +72,15 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger } } writer.endObject(); + + // {"segment_id":0}\n{json-serialized-rrweb-protocol} + + writer.setLenient(true); + writer.jsonValue("\n"); + if (payload != null) { + writer.value(logger, payload); + } + writer.setLenient(false); } @Override @@ -66,14 +95,16 @@ public void setUnknown(@Nullable Map unknown) { public static final class Deserializer implements JsonDeserializer { + @SuppressWarnings("unchecked") @Override public @NotNull ReplayRecording deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + @NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { final ReplayRecording replay = new ReplayRecording(); @Nullable Map unknown = null; @Nullable Integer segmentId = null; + @Nullable List payload = null; reader.beginObject(); while (reader.peek() == JsonToken.NAME) { @@ -92,7 +123,58 @@ public static final class Deserializer implements JsonDeserializer events = (List) reader.nextObjectOrNull(); + reader.setLenient(false); + + // since we lose the type of an rrweb event at runtime, we have to recover it from a map + if (events != null) { + payload = new ArrayList<>(events.size()); + for (Object event : events) { + if (event instanceof Map) { + final Map eventMap = (Map) event; + final ObjectReader mapReader = new MapObjectReader(eventMap); + for (Map.Entry entry : eventMap.entrySet()) { + final String key = entry.getKey(); + final Object value = entry.getValue(); + if (key.equals("type")) { + RRWebEventType type = RRWebEventType.values()[(int) value]; + switch (type) { + case Meta: + final RRWebEvent metaEvent = + new RRWebMetaEvent.Deserializer().deserialize(mapReader, logger); + payload.add(metaEvent); + break; + case Custom: + final Map data = + (Map) eventMap.getOrDefault("data", Collections.emptyMap()); + final String tag = + (String) data.getOrDefault(RRWebEvent.JsonKeys.TAG, "default"); + switch (tag) { + case RRWebVideoEvent.EVENT_TAG: + final RRWebEvent videoEvent = + new RRWebVideoEvent.Deserializer().deserialize(mapReader, logger); + payload.add(videoEvent); + break; + default: + logger.log(SentryLevel.DEBUG, "Unsupported rrweb event type %s", type); + break; + } + break; + default: + logger.log(SentryLevel.DEBUG, "Unsupported rrweb event type %s", type); + break; + } + } + } + } + } + } + replay.setSegmentId(segmentId); + replay.setPayload(payload); replay.setUnknown(unknown); return replay; } diff --git a/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java index 53b2d24ea59..be6838670e4 100644 --- a/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java +++ b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java @@ -21,6 +21,7 @@ import java.io.Reader; import java.io.Writer; import java.nio.ByteBuffer; +import java.nio.ByteOrder; import java.nio.charset.Charset; import java.util.HashMap; import java.util.Map; @@ -365,22 +366,16 @@ public static SentryEnvelopeItem fromReplay( replayPayload.put(SentryItemType.ReplayEvent.getItemType(), stream.toByteArray()); stream.reset(); - // next serialize replay recording in the following format: - // {"segment_id":0}\n{json-serialized-rrweb-protocol} + // next serialize replay recording if (replayRecording != null) { serializer.serialize(replayRecording, writer); - writer.write("\n"); - writer.flush(); - if (replayRecording.getPayload() != null) { - serializer.serialize(replayRecording.getPayload(), writer); - } replayPayload.put( SentryItemType.ReplayRecording.getItemType(), stream.toByteArray()); stream.reset(); } // next serialize replay video bytes from given file - if (replayVideo.exists()) { + if (replayVideo != null && replayVideo.exists()) { final byte[] videoBytes = readBytesFromFile( replayVideo.getPath(), SentryReplayEvent.REPLAY_VIDEO_MAX_SIZE); @@ -395,7 +390,9 @@ public static SentryEnvelopeItem fromReplay( logger.log(SentryLevel.ERROR, "Could not serialize replay recording", t); return null; } finally { - replayVideo.delete(); + if (replayVideo != null) { + replayVideo.delete(); + } } }); @@ -428,7 +425,7 @@ public CachedItem(final @Nullable Callable dataFactory) { } } - @SuppressWarnings("CharsetObjectCanBeUsed") + @SuppressWarnings({"CharsetObjectCanBeUsed", "UnnecessaryParentheses"}) private static byte[] serializeToMsgpack(Map map) throws IOException { ByteArrayOutputStream baos = new ByteArrayOutputStream(); @@ -440,24 +437,17 @@ private static byte[] serializeToMsgpack(Map map) throws IOExcep // Pack the key as a string byte[] keyBytes = entry.getKey().getBytes(Charset.forName("UTF-8")); int keyLength = keyBytes.length; - if (keyLength <= 31) { - baos.write((byte) (0xA0 | keyLength)); - } else { - baos.write((byte) (0xD9)); - baos.write((byte) (keyLength)); - } + // string up to 255 chars + baos.write((byte) (0xd9)); + baos.write((byte) (keyLength)); baos.write(keyBytes); // Pack the value as a binary string byte[] valueBytes = entry.getValue(); int valueLength = valueBytes.length; - if (valueLength <= 255) { - baos.write((byte) (0xC4)); - baos.write((byte) (valueLength)); - } else { - baos.write((byte) (0xC5)); - baos.write(ByteBuffer.allocate(4).putInt(valueLength).array()); - } + // We will always use the 4 bytes data length for simplicity. + baos.write((byte) (0xc6)); + baos.write(ByteBuffer.allocate(4).order(ByteOrder.BIG_ENDIAN).putInt(valueLength).array()); baos.write(valueBytes); } diff --git a/sentry/src/main/java/io/sentry/SentryReplayEvent.java b/sentry/src/main/java/io/sentry/SentryReplayEvent.java index 623986d8688..a351a21d549 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayEvent.java +++ b/sentry/src/main/java/io/sentry/SentryReplayEvent.java @@ -1,6 +1,7 @@ package io.sentry; import io.sentry.protocol.SentryId; +import io.sentry.util.Objects; import io.sentry.vendor.gson.stream.JsonToken; import java.io.File; import java.io.IOException; @@ -29,8 +30,8 @@ public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) public static final class Deserializer implements JsonDeserializer { @Override - public @NotNull ReplayType deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + public @NotNull ReplayType deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) + throws Exception { return ReplayType.valueOf(reader.nextString().toUpperCase(Locale.ROOT)); } } @@ -39,7 +40,7 @@ public static final class Deserializer implements JsonDeserializer { public static final long REPLAY_VIDEO_MAX_SIZE = 10 * 1024 * 1024; public static final String REPLAY_EVENT_TYPE = "replay_event"; - private @NotNull File videoFile; + private @Nullable File videoFile; private @NotNull String type; private @NotNull ReplayType replayType; private @Nullable SentryId replayId; @@ -62,12 +63,12 @@ public SentryReplayEvent() { timestamp = DateUtils.getCurrentDateTime(); } - @NotNull + @Nullable public File getVideoFile() { return videoFile; } - public void setVideoFile(final @NotNull File videoFile) { + public void setVideoFile(final @Nullable File videoFile) { this.videoFile = videoFile; } @@ -151,6 +152,25 @@ public void setReplayType(final @NotNull ReplayType replayType) { this.replayType = replayType; } + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + SentryReplayEvent that = (SentryReplayEvent) o; + return segmentId == that.segmentId + && Objects.equals(type, that.type) + && replayType == that.replayType + && Objects.equals(replayId, that.replayId) + && Objects.equals(urls, that.urls) + && Objects.equals(errorIds, that.errorIds) + && Objects.equals(traceIds, that.traceIds); + } + + @Override + public int hashCode() { + return Objects.hash(type, replayType, replayId, segmentId, urls, errorIds, traceIds); + } + // region json public static final class JsonKeys { public static final String TYPE = "type"; @@ -219,7 +239,7 @@ public static final class Deserializer implements JsonDeserializer { @Override public @NotNull RRWebEventType deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + @NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { return RRWebEventType.values()[reader.nextInt()]; } } diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebMetaEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebMetaEvent.java index d64400e3235..0a1c914cb33 100644 --- a/sentry/src/main/java/io/sentry/rrweb/RRWebMetaEvent.java +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebMetaEvent.java @@ -2,12 +2,14 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; +import io.sentry.util.Objects; import io.sentry.vendor.gson.stream.JsonToken; import java.io.IOException; +import java.util.HashMap; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import org.jetbrains.annotations.NotNull; @@ -19,6 +21,7 @@ public final class RRWebMetaEvent extends RRWebEvent implements JsonUnknown, Jso private int height; private int width; private @Nullable Map unknown; + private @Nullable Map dataUnknown; public RRWebMetaEvent() { super(RRWebEventType.Meta); @@ -50,6 +53,31 @@ public void setWidth(final int width) { this.width = width; } + @Nullable + public Map getDataUnknown() { + return dataUnknown; + } + + public void setDataUnknown(final @Nullable Map dataUnknown) { + this.dataUnknown = dataUnknown; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + if (!super.equals(o)) return false; + RRWebMetaEvent metaEvent = (RRWebMetaEvent) o; + return height == metaEvent.height + && width == metaEvent.width + && Objects.equals(href, metaEvent.href); + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), href, height, width); + } + public static final class JsonKeys { public static final String DATA = "data"; public static final String HREF = "href"; @@ -97,8 +125,9 @@ public static final class Deserializer implements JsonDeserializer unknown = null; RRWebMetaEvent event = new RRWebMetaEvent(); RRWebEvent.Deserializer baseEventDeserializer = new RRWebEvent.Deserializer(); @@ -109,17 +138,23 @@ public static final class Deserializer implements JsonDeserializer(); + } + reader.nextUnknown(logger, unknown, nextName); + } break; } } + event.setUnknown(unknown); reader.endObject(); return event; } private void deserializeData( final @NotNull RRWebMetaEvent event, - final @NotNull JsonObjectReader reader, + final @NotNull ObjectReader reader, final @NotNull ILogger logger) throws Exception { Map unknown = null; @@ -147,7 +182,7 @@ private void deserializeData( reader.nextUnknown(logger, unknown, nextName); } } - event.setUnknown(unknown); + event.setDataUnknown(unknown); reader.endObject(); } } diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebVideoEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebVideoEvent.java index a20c705e5af..f9d61e591e5 100644 --- a/sentry/src/main/java/io/sentry/rrweb/RRWebVideoEvent.java +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebVideoEvent.java @@ -2,12 +2,14 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; +import io.sentry.util.Objects; import io.sentry.vendor.gson.stream.JsonToken; import java.io.IOException; +import java.util.HashMap; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import org.jetbrains.annotations.NotNull; @@ -34,6 +36,7 @@ public final class RRWebVideoEvent extends RRWebEvent implements JsonUnknown, Js private int frameRate; private int left; private int top; + private @Nullable Map unknown; private @Nullable Map payloadUnknown; private @Nullable Map dataUnknown; @@ -158,14 +161,62 @@ public void setPayloadUnknown(final @Nullable Map payloadUnknown this.payloadUnknown = payloadUnknown; } + public @Nullable Map getDataUnknown() { + return dataUnknown; + } + + public void setDataUnknown(final @Nullable Map dataUnknown) { + this.dataUnknown = dataUnknown; + } + @Override public @Nullable Map getUnknown() { - return dataUnknown; + return unknown; } @Override public void setUnknown(final @Nullable Map unknown) { - this.dataUnknown = unknown; + this.unknown = unknown; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + if (!super.equals(o)) return false; + RRWebVideoEvent that = (RRWebVideoEvent) o; + return segmentId == that.segmentId + && size == that.size + && duration == that.duration + && height == that.height + && width == that.width + && frameCount == that.frameCount + && frameRate == that.frameRate + && left == that.left + && top == that.top + && Objects.equals(tag, that.tag) + && Objects.equals(encoding, that.encoding) + && Objects.equals(container, that.container) + && Objects.equals(frameRateType, that.frameRateType); + } + + @Override + public int hashCode() { + return Objects.hash( + super.hashCode(), + tag, + segmentId, + size, + duration, + encoding, + container, + height, + width, + frameCount, + frameRateType, + frameRate, + left, + top); } // region json @@ -173,7 +224,6 @@ public void setUnknown(final @Nullable Map unknown) { // rrweb uses camelCase hence the json keys are in camelCase here public static final class JsonKeys { public static final String DATA = "data"; - public static final String TAG = "tag"; public static final String PAYLOAD = "payload"; public static final String SEGMENT_ID = "segmentId"; public static final String SIZE = "size"; @@ -195,13 +245,20 @@ public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) thr new RRWebEvent.Serializer().serialize(this, writer, logger); writer.name(JsonKeys.DATA); serializeData(writer, logger); + if (unknown != null) { + for (String key : unknown.keySet()) { + Object value = unknown.get(key); + writer.name(key); + writer.value(logger, value); + } + } writer.endObject(); } private void serializeData(final @NotNull ObjectWriter writer, final @NotNull ILogger logger) throws IOException { writer.beginObject(); - writer.name(JsonKeys.TAG).value(tag); + writer.name(RRWebEvent.JsonKeys.TAG).value(tag); writer.name(JsonKeys.PAYLOAD); serializePayload(writer, logger); if (dataUnknown != null) { @@ -244,8 +301,10 @@ public static final class Deserializer implements JsonDeserializer unknown = null; + RRWebVideoEvent event = new RRWebVideoEvent(); RRWebEvent.Deserializer baseEventDeserializer = new RRWebEvent.Deserializer(); @@ -256,17 +315,23 @@ public static final class Deserializer implements JsonDeserializer(); + } + reader.nextUnknown(logger, unknown, nextName); + } break; } } + event.setUnknown(unknown); reader.endObject(); return event; } private void deserializeData( final @NotNull RRWebVideoEvent event, - final @NotNull JsonObjectReader reader, + final @NotNull ObjectReader reader, final @NotNull ILogger logger) throws Exception { Map dataUknown = null; @@ -275,7 +340,7 @@ private void deserializeData( while (reader.peek() == JsonToken.NAME) { final String nextName = reader.nextName(); switch (nextName) { - case JsonKeys.TAG: + case RRWebEvent.JsonKeys.TAG: final String tag = reader.nextStringOrNull(); event.tag = tag == null ? "" : tag; break; @@ -289,13 +354,13 @@ private void deserializeData( reader.nextUnknown(logger, dataUknown, nextName); } } - event.setUnknown(dataUknown); + event.setDataUnknown(dataUknown); reader.endObject(); } private void deserializePayload( final @NotNull RRWebVideoEvent event, - final @NotNull JsonObjectReader reader, + final @NotNull ObjectReader reader, final @NotNull ILogger logger) throws Exception { Map payloadUnknown = null; @@ -357,7 +422,7 @@ private void deserializePayload( reader.nextUnknown(logger, payloadUnknown, nextName); } } - event.setUnknown(payloadUnknown); + event.setPayloadUnknown(payloadUnknown); reader.endObject(); } } diff --git a/sentry/src/test/java/io/sentry/SentryClientTest.kt b/sentry/src/test/java/io/sentry/SentryClientTest.kt index 8fab30790f2..0733e6ea45f 100644 --- a/sentry/src/test/java/io/sentry/SentryClientTest.kt +++ b/sentry/src/test/java/io/sentry/SentryClientTest.kt @@ -5,7 +5,6 @@ import io.sentry.Session.State.Crashed import io.sentry.clientreport.ClientReportTestHelper.Companion.assertClientReport import io.sentry.clientreport.DiscardReason import io.sentry.clientreport.DiscardedEvent -import io.sentry.clientreport.DropEverythingEventProcessor import io.sentry.exception.SentryEnvelopeException import io.sentry.hints.AbnormalExit import io.sentry.hints.ApplyScopeData @@ -41,6 +40,7 @@ import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoMoreInteractions import org.mockito.kotlin.whenever +import org.msgpack.core.MessagePack import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import java.io.File @@ -2480,6 +2480,105 @@ class SentryClientTest { ) } + @Test + fun `when captureReplayEvent, envelope is sent`() { + val sut = fixture.getSut() + val replayEvent = createReplayEvent() + + sut.captureReplayEvent(replayEvent, null, null) + + verify(fixture.transport).send( + check { actual -> + assertEquals(replayEvent.eventId, actual.header.eventId) + assertEquals(fixture.sentryOptions.sdkVersion, actual.header.sdkVersion) + + assertEquals(1, actual.items.count()) + val item = actual.items.first() + assertEquals(SentryItemType.ReplayVideo, item.header.type) + + val unpacker = MessagePack.newDefaultUnpacker(item.data) + val mapSize = unpacker.unpackMapHeader() + assertEquals(1, mapSize) + }, + any() + ) + } + + @Test + fun `when captureReplayEvent with recording, adds it to payload`() { + val sut = fixture.getSut() + val replayEvent = createReplayEvent() + + val hint = Hint().apply { replayRecording = createReplayRecording() } + sut.captureReplayEvent(replayEvent, null, hint) + + verify(fixture.transport).send( + check { actual -> + assertEquals(replayEvent.eventId, actual.header.eventId) + assertEquals(fixture.sentryOptions.sdkVersion, actual.header.sdkVersion) + + assertEquals(1, actual.items.count()) + val item = actual.items.first() + assertEquals(SentryItemType.ReplayVideo, item.header.type) + + val unpacker = MessagePack.newDefaultUnpacker(item.data) + val mapSize = unpacker.unpackMapHeader() + assertEquals(2, mapSize) + }, + any() + ) + } + + @Test + fun `when captureReplayEvent, omits breadcrumbs and extras from scope`() { + val sut = fixture.getSut() + val replayEvent = createReplayEvent() + + sut.captureReplayEvent(replayEvent, createScope(), null) + + verify(fixture.transport).send( + check { actual -> + val item = actual.items.first() + + val unpacker = MessagePack.newDefaultUnpacker(item.data) + val mapSize = unpacker.unpackMapHeader() + for (i in 0 until mapSize) { + val key = unpacker.unpackString() + when (key) { + SentryItemType.ReplayEvent.itemType -> { + val replayEventLength = unpacker.unpackBinaryHeader() + val replayEventBytes = unpacker.readPayload(replayEventLength) + val actualReplayEvent = fixture.sentryOptions.serializer.deserialize( + InputStreamReader(replayEventBytes.inputStream()), + SentryReplayEvent::class.java + ) + // sanity check + assertEquals("id", actualReplayEvent!!.user!!.id) + + assertNull(actualReplayEvent.breadcrumbs) + assertNull(actualReplayEvent.extras) + } + } + } + }, + any() + ) + } + + @Test + fun `when replay event is dropped, captures client report with datacategory replay`() { + fixture.sentryOptions.addEventProcessor(DropEverythingEventProcessor()) + val sut = fixture.getSut() + val replayEvent = createReplayEvent() + + sut.captureReplayEvent(replayEvent, createScope(), null) + + assertClientReport( + fixture.sentryOptions.clientReportRecorder, + listOf(DiscardedEvent(DiscardReason.EVENT_PROCESSOR.reason, DataCategory.Replay.category, 1)) + ) + } + private fun givenScopeWithStartedSession(errored: Boolean = false, crashed: Boolean = false): IScope { val scope = createScope(fixture.sentryOptions) scope.startSession() @@ -2538,6 +2637,21 @@ class SentryClientTest { } } + private fun createReplayEvent(): SentryReplayEvent = SentryReplayEvent().apply { + replayId = SentryId("f715e1d64ef64ea3ad7744b5230813c3") + segmentId = 0 + timestamp = DateUtils.getDateTimeWithMillisPrecision("987654321.123") + replayStartTimestamp = DateUtils.getDateTimeWithMillisPrecision("987654321.123") + urls = listOf("ScreenOne") + errorIds = listOf("ab3a347a4cc14fd4b4cf1dc56b670c5b") + traceIds = listOf("340cfef948204549ac07c3b353c81c50") + } + + private fun createReplayRecording(): ReplayRecording = ReplayRecording().apply { + segmentId = 0 + payload = emptyList() + } + private fun createScope(options: SentryOptions = SentryOptions()): IScope { return Scope(options).apply { addBreadcrumb( @@ -2721,4 +2835,8 @@ class DropEverythingEventProcessor : EventProcessor { ): SentryTransaction? { return null } + + override fun process(event: SentryReplayEvent, hint: Hint): SentryReplayEvent? { + return null + } } diff --git a/sentry/src/test/java/io/sentry/protocol/ReplayRecordingSerializationTest.kt b/sentry/src/test/java/io/sentry/protocol/ReplayRecordingSerializationTest.kt index 68b45fd8741..d8f93ddfb5d 100644 --- a/sentry/src/test/java/io/sentry/protocol/ReplayRecordingSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/protocol/ReplayRecordingSerializationTest.kt @@ -1,17 +1,14 @@ package io.sentry.protocol +import io.sentry.FileFromResources import io.sentry.ILogger -import io.sentry.JsonSerializer import io.sentry.ReplayRecording -import io.sentry.SentryOptions import io.sentry.protocol.SerializationUtils.deserializeJson -import io.sentry.protocol.SerializationUtils.sanitizedFile import io.sentry.protocol.SerializationUtils.serializeToString import io.sentry.rrweb.RRWebMetaEventSerializationTest import io.sentry.rrweb.RRWebVideoEventSerializationTest import org.junit.Test import org.mockito.kotlin.mock -import java.io.StringWriter import kotlin.test.assertEquals class ReplayRecordingSerializationTest { @@ -31,25 +28,18 @@ class ReplayRecordingSerializationTest { @Test fun serialize() { - val expected = sanitizedFile("json/replay_recording.json") + val expected = FileFromResources.invoke("json/replay_recording.json") + .substringBeforeLast("\n") val actual = serializeToString(fixture.getSut(), fixture.logger) assertEquals(expected, actual) } @Test fun deserialize() { - val expectedJson = sanitizedFile("json/replay_recording.json") + val expectedJson = FileFromResources.invoke("json/replay_recording.json") + .substringBeforeLast("\n") val actual = deserializeJson(expectedJson, ReplayRecording.Deserializer(), fixture.logger) val actualJson = serializeToString(actual, fixture.logger) assertEquals(expectedJson, actualJson) } - - @Test - fun serializePayload() { - val expected = sanitizedFile("json/replay_recording_payload.json") - val writer = StringWriter() - JsonSerializer(SentryOptions()).serialize(fixture.getSut().payload as Any, writer) - val actual = writer.toString() - assertEquals(expected, actual) - } } diff --git a/sentry/src/test/java/io/sentry/rrweb/RRWebEventSerializationTest.kt b/sentry/src/test/java/io/sentry/rrweb/RRWebEventSerializationTest.kt index 1223075c6ba..2c2b60cd28d 100644 --- a/sentry/src/test/java/io/sentry/rrweb/RRWebEventSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/rrweb/RRWebEventSerializationTest.kt @@ -2,8 +2,8 @@ package io.sentry.rrweb import io.sentry.ILogger import io.sentry.JsonDeserializer -import io.sentry.JsonObjectReader import io.sentry.JsonSerializable +import io.sentry.ObjectReader import io.sentry.ObjectWriter import io.sentry.protocol.SerializationUtils.deserializeJson import io.sentry.protocol.SerializationUtils.sanitizedFile @@ -27,7 +27,7 @@ class RRWebEventSerializationTest { } class Deserializer : JsonDeserializer { - override fun deserialize(reader: JsonObjectReader, logger: ILogger): Sut { + override fun deserialize(reader: ObjectReader, logger: ILogger): Sut { val sut = Sut() reader.beginObject() diff --git a/sentry/src/test/resources/json/replay_recording.json b/sentry/src/test/resources/json/replay_recording.json index b0de77bc872..287419e1ebf 100644 --- a/sentry/src/test/resources/json/replay_recording.json +++ b/sentry/src/test/resources/json/replay_recording.json @@ -1,3 +1,2 @@ -{ - "segment_id": 0 -} +{"segment_id":0} +[{"type":4,"timestamp":1234567890,"data":{"href":"https://sentry.io","height":1920,"width":1080}},{"type":5,"timestamp":12345678901,"data":{"tag":"video","payload":{"segmentId":0,"size":4000000,"duration":5000,"encoding":"h264","container":"mp4","height":1920,"width":1080,"frameCount":5,"frameRate":1,"frameRateType":"constant","left":100,"top":100}}}] From 64cedfa4f88599bb7ebb65f98f33e76d3fa69f97 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 21 Feb 2024 00:20:07 +0100 Subject: [PATCH 17/89] Introduce MapObjectReader --- sentry/api/sentry.api | 554 ++++++++++++++---- .../src/main/java/io/sentry/Breadcrumb.java | 7 +- sentry/src/main/java/io/sentry/CheckIn.java | 2 +- .../main/java/io/sentry/JsonDeserializer.java | 2 +- .../main/java/io/sentry/JsonObjectReader.java | 196 +++++-- .../main/java/io/sentry/MonitorConfig.java | 4 +- .../main/java/io/sentry/MonitorContexts.java | 2 +- .../main/java/io/sentry/MonitorSchedule.java | 2 +- .../src/main/java/io/sentry/ObjectReader.java | 101 ++++ .../java/io/sentry/ProfilingTraceData.java | 2 +- .../io/sentry/ProfilingTransactionData.java | 2 +- .../SentryAppStartProfilingOptions.java | 2 +- .../main/java/io/sentry/SentryBaseEvent.java | 2 +- .../java/io/sentry/SentryEnvelopeHeader.java | 2 +- .../io/sentry/SentryEnvelopeItemHeader.java | 2 +- .../src/main/java/io/sentry/SentryEvent.java | 4 +- .../main/java/io/sentry/SentryItemType.java | 2 +- .../src/main/java/io/sentry/SentryLevel.java | 4 +- .../main/java/io/sentry/SentryLockReason.java | 2 +- sentry/src/main/java/io/sentry/Session.java | 2 +- .../src/main/java/io/sentry/SpanContext.java | 4 +- sentry/src/main/java/io/sentry/SpanId.java | 2 +- .../src/main/java/io/sentry/SpanStatus.java | 4 +- .../src/main/java/io/sentry/TraceContext.java | 6 +- .../src/main/java/io/sentry/UserFeedback.java | 4 +- .../io/sentry/clientreport/ClientReport.java | 6 +- .../sentry/clientreport/DiscardedEvent.java | 4 +- .../ProfileMeasurement.java | 4 +- .../ProfileMeasurementValue.java | 4 +- .../src/main/java/io/sentry/protocol/App.java | 4 +- .../main/java/io/sentry/protocol/Browser.java | 4 +- .../java/io/sentry/protocol/Contexts.java | 4 +- .../java/io/sentry/protocol/DebugImage.java | 6 +- .../java/io/sentry/protocol/DebugMeta.java | 4 +- .../main/java/io/sentry/protocol/Device.java | 6 +- .../src/main/java/io/sentry/protocol/Geo.java | 4 +- .../src/main/java/io/sentry/protocol/Gpu.java | 4 +- .../io/sentry/protocol/MeasurementValue.java | 4 +- .../java/io/sentry/protocol/Mechanism.java | 4 +- .../main/java/io/sentry/protocol/Message.java | 4 +- .../io/sentry/protocol/OperatingSystem.java | 4 +- .../main/java/io/sentry/protocol/Request.java | 4 +- .../java/io/sentry/protocol/Response.java | 4 +- .../main/java/io/sentry/protocol/SdkInfo.java | 4 +- .../java/io/sentry/protocol/SdkVersion.java | 6 +- .../io/sentry/protocol/SentryException.java | 4 +- .../java/io/sentry/protocol/SentryId.java | 4 +- .../io/sentry/protocol/SentryPackage.java | 6 +- .../io/sentry/protocol/SentryRuntime.java | 6 +- .../java/io/sentry/protocol/SentrySpan.java | 6 +- .../io/sentry/protocol/SentryStackFrame.java | 4 +- .../io/sentry/protocol/SentryStackTrace.java | 4 +- .../java/io/sentry/protocol/SentryThread.java | 6 +- .../io/sentry/protocol/SentryTransaction.java | 4 +- .../io/sentry/protocol/TransactionInfo.java | 4 +- .../main/java/io/sentry/protocol/User.java | 4 +- .../io/sentry/protocol/ViewHierarchy.java | 6 +- .../io/sentry/protocol/ViewHierarchyNode.java | 4 +- .../java/io/sentry/util/MapObjectReader.java | 350 +++++++++++ .../java/io/sentry/util/MapObjectWriter.java | 11 + .../java/io/sentry/JsonObjectReaderTest.kt | 2 +- .../java/io/sentry/SentryEnvelopeItemTest.kt | 235 +++++++- .../SentryBaseEventSerializationTest.kt | 4 +- 63 files changed, 1369 insertions(+), 299 deletions(-) create mode 100644 sentry/src/main/java/io/sentry/ObjectReader.java create mode 100644 sentry/src/main/java/io/sentry/util/MapObjectReader.java diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 8dc0851098e..20e2d8b53bc 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -136,8 +136,8 @@ public final class io/sentry/Breadcrumb : io/sentry/JsonSerializable, io/sentry/ public final class io/sentry/Breadcrumb$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/Breadcrumb; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/Breadcrumb; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/Breadcrumb$JsonKeys { @@ -181,8 +181,8 @@ public final class io/sentry/CheckIn : io/sentry/JsonSerializable, io/sentry/Jso public final class io/sentry/CheckIn$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/CheckIn; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/CheckIn; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/CheckIn$JsonKeys { @@ -226,6 +226,7 @@ public final class io/sentry/DataCategory : java/lang/Enum { public static final field Error Lio/sentry/DataCategory; public static final field Monitor Lio/sentry/DataCategory; public static final field Profile Lio/sentry/DataCategory; + public static final field Replay Lio/sentry/DataCategory; public static final field Security Lio/sentry/DataCategory; public static final field Session Lio/sentry/DataCategory; public static final field Transaction Lio/sentry/DataCategory; @@ -300,6 +301,7 @@ public final class io/sentry/EnvelopeSender : io/sentry/IEnvelopeSender { public abstract interface class io/sentry/EventProcessor { public fun process (Lio/sentry/SentryEvent;Lio/sentry/Hint;)Lio/sentry/SentryEvent; + public fun process (Lio/sentry/SentryReplayEvent;Lio/sentry/Hint;)Lio/sentry/SentryReplayEvent; public fun process (Lio/sentry/protocol/SentryTransaction;Lio/sentry/Hint;)Lio/sentry/protocol/SentryTransaction; } @@ -387,12 +389,14 @@ public final class io/sentry/Hint { public fun get (Ljava/lang/String;)Ljava/lang/Object; public fun getAs (Ljava/lang/String;Ljava/lang/Class;)Ljava/lang/Object; public fun getAttachments ()Ljava/util/List; + public fun getReplayRecording ()Lio/sentry/ReplayRecording; public fun getScreenshot ()Lio/sentry/Attachment; public fun getThreadDump ()Lio/sentry/Attachment; public fun getViewHierarchy ()Lio/sentry/Attachment; public fun remove (Ljava/lang/String;)V public fun replaceAttachments (Ljava/util/List;)V public fun set (Ljava/lang/String;Ljava/lang/Object;)V + public fun setReplayRecording (Lio/sentry/ReplayRecording;)V public fun setScreenshot (Lio/sentry/Attachment;)V public fun setThreadDump (Lio/sentry/Attachment;)V public fun setViewHierarchy (Lio/sentry/Attachment;)V @@ -421,6 +425,7 @@ public final class io/sentry/Hub : io/sentry/IHub { public fun captureException (Ljava/lang/Throwable;Lio/sentry/Hint;Lio/sentry/ScopeCallback;)Lio/sentry/protocol/SentryId; public fun captureMessage (Ljava/lang/String;Lio/sentry/SentryLevel;)Lio/sentry/protocol/SentryId; public fun captureMessage (Ljava/lang/String;Lio/sentry/SentryLevel;Lio/sentry/ScopeCallback;)Lio/sentry/protocol/SentryId; + public fun captureReplay (Lio/sentry/SentryReplayEvent;Lio/sentry/Hint;)Lio/sentry/protocol/SentryId; public fun captureTransaction (Lio/sentry/protocol/SentryTransaction;Lio/sentry/TraceContext;Lio/sentry/Hint;Lio/sentry/ProfilingTraceData;)Lio/sentry/protocol/SentryId; public fun captureUserFeedback (Lio/sentry/UserFeedback;)V public fun clearBreadcrumbs ()V @@ -471,6 +476,7 @@ public final class io/sentry/HubAdapter : io/sentry/IHub { public fun captureException (Ljava/lang/Throwable;Lio/sentry/Hint;Lio/sentry/ScopeCallback;)Lio/sentry/protocol/SentryId; public fun captureMessage (Ljava/lang/String;Lio/sentry/SentryLevel;)Lio/sentry/protocol/SentryId; public fun captureMessage (Ljava/lang/String;Lio/sentry/SentryLevel;Lio/sentry/ScopeCallback;)Lio/sentry/protocol/SentryId; + public fun captureReplay (Lio/sentry/SentryReplayEvent;Lio/sentry/Hint;)Lio/sentry/protocol/SentryId; public fun captureTransaction (Lio/sentry/protocol/SentryTransaction;Lio/sentry/TraceContext;Lio/sentry/Hint;Lio/sentry/ProfilingTraceData;)Lio/sentry/protocol/SentryId; public fun captureUserFeedback (Lio/sentry/UserFeedback;)V public fun clearBreadcrumbs ()V @@ -559,6 +565,7 @@ public abstract interface class io/sentry/IHub { public fun captureMessage (Ljava/lang/String;Lio/sentry/ScopeCallback;)Lio/sentry/protocol/SentryId; public abstract fun captureMessage (Ljava/lang/String;Lio/sentry/SentryLevel;)Lio/sentry/protocol/SentryId; public abstract fun captureMessage (Ljava/lang/String;Lio/sentry/SentryLevel;Lio/sentry/ScopeCallback;)Lio/sentry/protocol/SentryId; + public abstract fun captureReplay (Lio/sentry/SentryReplayEvent;Lio/sentry/Hint;)Lio/sentry/protocol/SentryId; public fun captureTransaction (Lio/sentry/protocol/SentryTransaction;Lio/sentry/Hint;)Lio/sentry/protocol/SentryId; public fun captureTransaction (Lio/sentry/protocol/SentryTransaction;Lio/sentry/TraceContext;)Lio/sentry/protocol/SentryId; public fun captureTransaction (Lio/sentry/protocol/SentryTransaction;Lio/sentry/TraceContext;Lio/sentry/Hint;)Lio/sentry/protocol/SentryId; @@ -723,6 +730,7 @@ public abstract interface class io/sentry/ISentryClient { public fun captureException (Ljava/lang/Throwable;Lio/sentry/IScope;Lio/sentry/Hint;)Lio/sentry/protocol/SentryId; public fun captureMessage (Ljava/lang/String;Lio/sentry/SentryLevel;)Lio/sentry/protocol/SentryId; public fun captureMessage (Ljava/lang/String;Lio/sentry/SentryLevel;Lio/sentry/IScope;)Lio/sentry/protocol/SentryId; + public abstract fun captureReplayEvent (Lio/sentry/SentryReplayEvent;Lio/sentry/IScope;Lio/sentry/Hint;)Lio/sentry/protocol/SentryId; public fun captureSession (Lio/sentry/Session;)V public abstract fun captureSession (Lio/sentry/Session;Lio/sentry/Hint;)V public fun captureTransaction (Lio/sentry/protocol/SentryTransaction;)Lio/sentry/protocol/SentryId; @@ -843,7 +851,7 @@ public final class io/sentry/JavaMemoryCollector : io/sentry/IPerformanceSnapsho } public abstract interface class io/sentry/JsonDeserializer { - public abstract fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public abstract fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/JsonObjectDeserializer { @@ -851,23 +859,38 @@ public final class io/sentry/JsonObjectDeserializer { public fun deserialize (Lio/sentry/JsonObjectReader;)Ljava/lang/Object; } -public final class io/sentry/JsonObjectReader : io/sentry/vendor/gson/stream/JsonReader { +public final class io/sentry/JsonObjectReader : io/sentry/ObjectReader { public fun (Ljava/io/Reader;)V - public static fun dateOrNull (Ljava/lang/String;Lio/sentry/ILogger;)Ljava/util/Date; + public fun beginArray ()V + public fun beginObject ()V + public fun close ()V + public fun endArray ()V + public fun endObject ()V + public fun hasNext ()Z + public fun nextBoolean ()Z public fun nextBooleanOrNull ()Ljava/lang/Boolean; public fun nextDateOrNull (Lio/sentry/ILogger;)Ljava/util/Date; + public fun nextDouble ()D public fun nextDoubleOrNull ()Ljava/lang/Double; - public fun nextFloat ()Ljava/lang/Float; + public fun nextFloat ()F public fun nextFloatOrNull ()Ljava/lang/Float; + public fun nextInt ()I public fun nextIntegerOrNull ()Ljava/lang/Integer; public fun nextListOrNull (Lio/sentry/ILogger;Lio/sentry/JsonDeserializer;)Ljava/util/List; + public fun nextLong ()J public fun nextLongOrNull ()Ljava/lang/Long; public fun nextMapOrNull (Lio/sentry/ILogger;Lio/sentry/JsonDeserializer;)Ljava/util/Map; + public fun nextName ()Ljava/lang/String; + public fun nextNull ()V public fun nextObjectOrNull ()Ljava/lang/Object; public fun nextOrNull (Lio/sentry/ILogger;Lio/sentry/JsonDeserializer;)Ljava/lang/Object; + public fun nextString ()Ljava/lang/String; public fun nextStringOrNull ()Ljava/lang/String; public fun nextTimeZoneOrNull (Lio/sentry/ILogger;)Ljava/util/TimeZone; public fun nextUnknown (Lio/sentry/ILogger;Ljava/util/Map;Ljava/lang/String;)V + public fun peek ()Lio/sentry/vendor/gson/stream/JsonToken; + public fun setLenient (Z)V + public fun skipValue ()V } public final class io/sentry/JsonObjectSerializer { @@ -887,11 +910,13 @@ public final class io/sentry/JsonObjectWriter : io/sentry/ObjectWriter { public synthetic fun endArray ()Lio/sentry/ObjectWriter; public fun endObject ()Lio/sentry/JsonObjectWriter; public synthetic fun endObject ()Lio/sentry/ObjectWriter; + public fun jsonValue (Ljava/lang/String;)Lio/sentry/ObjectWriter; public fun name (Ljava/lang/String;)Lio/sentry/JsonObjectWriter; public synthetic fun name (Ljava/lang/String;)Lio/sentry/ObjectWriter; public fun nullValue ()Lio/sentry/JsonObjectWriter; public synthetic fun nullValue ()Lio/sentry/ObjectWriter; public fun setIndent (Ljava/lang/String;)V + public fun setLenient (Z)V public fun value (D)Lio/sentry/JsonObjectWriter; public synthetic fun value (D)Lio/sentry/ObjectWriter; public fun value (J)Lio/sentry/JsonObjectWriter; @@ -936,6 +961,7 @@ public final class io/sentry/MainEventProcessor : io/sentry/EventProcessor, java public fun (Lio/sentry/SentryOptions;)V public fun close ()V public fun process (Lio/sentry/SentryEvent;Lio/sentry/Hint;)Lio/sentry/SentryEvent; + public fun process (Lio/sentry/SentryReplayEvent;Lio/sentry/Hint;)Lio/sentry/SentryReplayEvent; public fun process (Lio/sentry/protocol/SentryTransaction;Lio/sentry/Hint;)Lio/sentry/protocol/SentryTransaction; } @@ -1018,8 +1044,8 @@ public final class io/sentry/MonitorConfig : io/sentry/JsonSerializable, io/sent public final class io/sentry/MonitorConfig$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/MonitorConfig; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/MonitorConfig; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/MonitorConfig$JsonKeys { @@ -1040,8 +1066,8 @@ public final class io/sentry/MonitorContexts : java/util/concurrent/ConcurrentHa public final class io/sentry/MonitorContexts$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/MonitorContexts; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/MonitorContexts; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/MonitorSchedule : io/sentry/JsonSerializable, io/sentry/JsonUnknown { @@ -1063,8 +1089,8 @@ public final class io/sentry/MonitorSchedule : io/sentry/JsonSerializable, io/se public final class io/sentry/MonitorSchedule$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/MonitorSchedule; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/MonitorSchedule; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/MonitorSchedule$JsonKeys { @@ -1119,6 +1145,7 @@ public final class io/sentry/NoOpHub : io/sentry/IHub { public fun captureException (Ljava/lang/Throwable;Lio/sentry/Hint;Lio/sentry/ScopeCallback;)Lio/sentry/protocol/SentryId; public fun captureMessage (Ljava/lang/String;Lio/sentry/SentryLevel;)Lio/sentry/protocol/SentryId; public fun captureMessage (Ljava/lang/String;Lio/sentry/SentryLevel;Lio/sentry/ScopeCallback;)Lio/sentry/protocol/SentryId; + public fun captureReplay (Lio/sentry/SentryReplayEvent;Lio/sentry/Hint;)Lio/sentry/protocol/SentryId; public fun captureTransaction (Lio/sentry/protocol/SentryTransaction;Lio/sentry/TraceContext;Lio/sentry/Hint;Lio/sentry/ProfilingTraceData;)Lio/sentry/protocol/SentryId; public fun captureUserFeedback (Lio/sentry/UserFeedback;)V public fun clearBreadcrumbs ()V @@ -1331,13 +1358,48 @@ public final class io/sentry/NoOpTransportFactory : io/sentry/ITransportFactory public static fun getInstance ()Lio/sentry/NoOpTransportFactory; } +public abstract interface class io/sentry/ObjectReader : java/io/Closeable { + public abstract fun beginArray ()V + public abstract fun beginObject ()V + public static fun dateOrNull (Ljava/lang/String;Lio/sentry/ILogger;)Ljava/util/Date; + public abstract fun endArray ()V + public abstract fun endObject ()V + public abstract fun hasNext ()Z + public abstract fun nextBoolean ()Z + public abstract fun nextBooleanOrNull ()Ljava/lang/Boolean; + public abstract fun nextDateOrNull (Lio/sentry/ILogger;)Ljava/util/Date; + public abstract fun nextDouble ()D + public abstract fun nextDoubleOrNull ()Ljava/lang/Double; + public abstract fun nextFloat ()F + public abstract fun nextFloatOrNull ()Ljava/lang/Float; + public abstract fun nextInt ()I + public abstract fun nextIntegerOrNull ()Ljava/lang/Integer; + public abstract fun nextListOrNull (Lio/sentry/ILogger;Lio/sentry/JsonDeserializer;)Ljava/util/List; + public abstract fun nextLong ()J + public abstract fun nextLongOrNull ()Ljava/lang/Long; + public abstract fun nextMapOrNull (Lio/sentry/ILogger;Lio/sentry/JsonDeserializer;)Ljava/util/Map; + public abstract fun nextName ()Ljava/lang/String; + public abstract fun nextNull ()V + public abstract fun nextObjectOrNull ()Ljava/lang/Object; + public abstract fun nextOrNull (Lio/sentry/ILogger;Lio/sentry/JsonDeserializer;)Ljava/lang/Object; + public abstract fun nextString ()Ljava/lang/String; + public abstract fun nextStringOrNull ()Ljava/lang/String; + public abstract fun nextTimeZoneOrNull (Lio/sentry/ILogger;)Ljava/util/TimeZone; + public abstract fun nextUnknown (Lio/sentry/ILogger;Ljava/util/Map;Ljava/lang/String;)V + public abstract fun peek ()Lio/sentry/vendor/gson/stream/JsonToken; + public abstract fun setLenient (Z)V + public abstract fun skipValue ()V +} + public abstract interface class io/sentry/ObjectWriter { public abstract fun beginArray ()Lio/sentry/ObjectWriter; public abstract fun beginObject ()Lio/sentry/ObjectWriter; public abstract fun endArray ()Lio/sentry/ObjectWriter; public abstract fun endObject ()Lio/sentry/ObjectWriter; + public abstract fun jsonValue (Ljava/lang/String;)Lio/sentry/ObjectWriter; public abstract fun name (Ljava/lang/String;)Lio/sentry/ObjectWriter; public abstract fun nullValue ()Lio/sentry/ObjectWriter; + public abstract fun setLenient (Z)V public abstract fun value (D)Lio/sentry/ObjectWriter; public abstract fun value (J)Lio/sentry/ObjectWriter; public abstract fun value (Lio/sentry/ILogger;Ljava/lang/Object;)Lio/sentry/ObjectWriter; @@ -1426,8 +1488,8 @@ public final class io/sentry/ProfilingTraceData : io/sentry/JsonSerializable, io public final class io/sentry/ProfilingTraceData$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/ProfilingTraceData; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/ProfilingTraceData; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/ProfilingTraceData$JsonKeys { @@ -1484,8 +1546,8 @@ public final class io/sentry/ProfilingTransactionData : io/sentry/JsonSerializab public final class io/sentry/ProfilingTransactionData$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/ProfilingTransactionData; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/ProfilingTransactionData; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/ProfilingTransactionData$JsonKeys { @@ -1519,6 +1581,30 @@ public final class io/sentry/PropagationContext { public fun traceContext ()Lio/sentry/TraceContext; } +public final class io/sentry/ReplayRecording : io/sentry/JsonSerializable, io/sentry/JsonUnknown { + public fun ()V + public fun equals (Ljava/lang/Object;)Z + public fun getPayload ()Ljava/util/List; + public fun getSegmentId ()Ljava/lang/Integer; + public fun getUnknown ()Ljava/util/Map; + public fun hashCode ()I + public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V + public fun setPayload (Ljava/util/List;)V + public fun setSegmentId (Ljava/lang/Integer;)V + public fun setUnknown (Ljava/util/Map;)V +} + +public final class io/sentry/ReplayRecording$Deserializer : io/sentry/JsonDeserializer { + public fun ()V + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/ReplayRecording; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; +} + +public final class io/sentry/ReplayRecording$JsonKeys { + public static final field SEGMENT_ID Ljava/lang/String; + public fun ()V +} + public final class io/sentry/RequestDetails { public fun (Ljava/lang/String;Ljava/util/Map;)V public fun getHeaders ()Ljava/util/Map; @@ -1669,6 +1755,7 @@ public final class io/sentry/Sentry { public static fun captureMessage (Ljava/lang/String;Lio/sentry/ScopeCallback;)Lio/sentry/protocol/SentryId; public static fun captureMessage (Ljava/lang/String;Lio/sentry/SentryLevel;)Lio/sentry/protocol/SentryId; public static fun captureMessage (Ljava/lang/String;Lio/sentry/SentryLevel;Lio/sentry/ScopeCallback;)Lio/sentry/protocol/SentryId; + public static fun captureReplay (Lio/sentry/SentryReplayEvent;Lio/sentry/Hint;)V public static fun captureUserFeedback (Lio/sentry/UserFeedback;)V public static fun clearBreadcrumbs ()V public static fun cloneMainHub ()Lio/sentry/IHub; @@ -1742,8 +1829,8 @@ public final class io/sentry/SentryAppStartProfilingOptions : io/sentry/JsonSeri public final class io/sentry/SentryAppStartProfilingOptions$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/SentryAppStartProfilingOptions; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/SentryAppStartProfilingOptions; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/SentryAppStartProfilingOptions$JsonKeys { @@ -1809,7 +1896,7 @@ public abstract class io/sentry/SentryBaseEvent { public final class io/sentry/SentryBaseEvent$Deserializer { public fun ()V - public fun deserializeValue (Lio/sentry/SentryBaseEvent;Ljava/lang/String;Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Z + public fun deserializeValue (Lio/sentry/SentryBaseEvent;Ljava/lang/String;Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Z } public final class io/sentry/SentryBaseEvent$JsonKeys { @@ -1839,6 +1926,7 @@ public final class io/sentry/SentryClient : io/sentry/ISentryClient { public fun captureCheckIn (Lio/sentry/CheckIn;Lio/sentry/IScope;Lio/sentry/Hint;)Lio/sentry/protocol/SentryId; public fun captureEnvelope (Lio/sentry/SentryEnvelope;Lio/sentry/Hint;)Lio/sentry/protocol/SentryId; public fun captureEvent (Lio/sentry/SentryEvent;Lio/sentry/IScope;Lio/sentry/Hint;)Lio/sentry/protocol/SentryId; + public fun captureReplayEvent (Lio/sentry/SentryReplayEvent;Lio/sentry/IScope;Lio/sentry/Hint;)Lio/sentry/protocol/SentryId; public fun captureSession (Lio/sentry/Session;Lio/sentry/Hint;)V public fun captureTransaction (Lio/sentry/protocol/SentryTransaction;Lio/sentry/TraceContext;Lio/sentry/IScope;Lio/sentry/Hint;Lio/sentry/ProfilingTraceData;)Lio/sentry/protocol/SentryId; public fun captureUserFeedback (Lio/sentry/UserFeedback;)V @@ -1899,8 +1987,8 @@ public final class io/sentry/SentryEnvelopeHeader : io/sentry/JsonSerializable, public final class io/sentry/SentryEnvelopeHeader$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/SentryEnvelopeHeader; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/SentryEnvelopeHeader; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/SentryEnvelopeHeader$JsonKeys { @@ -1917,6 +2005,7 @@ public final class io/sentry/SentryEnvelopeItem { public static fun fromClientReport (Lio/sentry/ISerializer;Lio/sentry/clientreport/ClientReport;)Lio/sentry/SentryEnvelopeItem; public static fun fromEvent (Lio/sentry/ISerializer;Lio/sentry/SentryBaseEvent;)Lio/sentry/SentryEnvelopeItem; public static fun fromProfilingTrace (Lio/sentry/ProfilingTraceData;JLio/sentry/ISerializer;)Lio/sentry/SentryEnvelopeItem; + public static fun fromReplay (Lio/sentry/ISerializer;Lio/sentry/ILogger;Lio/sentry/SentryReplayEvent;Lio/sentry/ReplayRecording;)Lio/sentry/SentryEnvelopeItem; public static fun fromSession (Lio/sentry/ISerializer;Lio/sentry/Session;)Lio/sentry/SentryEnvelopeItem; public static fun fromUserFeedback (Lio/sentry/ISerializer;Lio/sentry/UserFeedback;)Lio/sentry/SentryEnvelopeItem; public fun getClientReport (Lio/sentry/ISerializer;)Lio/sentry/clientreport/ClientReport; @@ -1940,8 +2029,8 @@ public final class io/sentry/SentryEnvelopeItemHeader : io/sentry/JsonSerializab public final class io/sentry/SentryEnvelopeItemHeader$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/SentryEnvelopeItemHeader; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/SentryEnvelopeItemHeader; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/SentryEnvelopeItemHeader$JsonKeys { @@ -1987,8 +2076,8 @@ public final class io/sentry/SentryEvent : io/sentry/SentryBaseEvent, io/sentry/ public final class io/sentry/SentryEvent$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/SentryEvent; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/SentryEvent; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/SentryEvent$JsonKeys { @@ -2047,6 +2136,7 @@ public final class io/sentry/SentryItemType : java/lang/Enum, io/sentry/JsonSeri public static final field Profile Lio/sentry/SentryItemType; public static final field ReplayEvent Lio/sentry/SentryItemType; public static final field ReplayRecording Lio/sentry/SentryItemType; + public static final field ReplayVideo Lio/sentry/SentryItemType; public static final field Session Lio/sentry/SentryItemType; public static final field Transaction Lio/sentry/SentryItemType; public static final field Unknown Lio/sentry/SentryItemType; @@ -2097,8 +2187,8 @@ public final class io/sentry/SentryLockReason : io/sentry/JsonSerializable, io/s public final class io/sentry/SentryLockReason$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/SentryLockReason; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/SentryLockReason; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/SentryLockReason$JsonKeys { @@ -2374,6 +2464,70 @@ public abstract interface class io/sentry/SentryOptions$TracesSamplerCallback { public abstract fun sample (Lio/sentry/SamplingContext;)Ljava/lang/Double; } +public final class io/sentry/SentryReplayEvent : io/sentry/SentryBaseEvent, io/sentry/JsonSerializable, io/sentry/JsonUnknown { + public static final field REPLAY_EVENT_TYPE Ljava/lang/String; + public static final field REPLAY_VIDEO_MAX_SIZE J + public fun ()V + public fun equals (Ljava/lang/Object;)Z + public fun getErrorIds ()Ljava/util/List; + public fun getReplayId ()Lio/sentry/protocol/SentryId; + public fun getReplayStartTimestamp ()Ljava/util/Date; + public fun getReplayType ()Lio/sentry/SentryReplayEvent$ReplayType; + public fun getSegmentId ()I + public fun getTimestamp ()Ljava/util/Date; + public fun getTraceIds ()Ljava/util/List; + public fun getType ()Ljava/lang/String; + public fun getUnknown ()Ljava/util/Map; + public fun getUrls ()Ljava/util/List; + public fun getVideoFile ()Ljava/io/File; + public fun hashCode ()I + public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V + public fun setErrorIds (Ljava/util/List;)V + public fun setReplayId (Lio/sentry/protocol/SentryId;)V + public fun setReplayStartTimestamp (Ljava/util/Date;)V + public fun setReplayType (Lio/sentry/SentryReplayEvent$ReplayType;)V + public fun setSegmentId (I)V + public fun setTimestamp (Ljava/util/Date;)V + public fun setTraceIds (Ljava/util/List;)V + public fun setType (Ljava/lang/String;)V + public fun setUnknown (Ljava/util/Map;)V + public fun setUrls (Ljava/util/List;)V + public fun setVideoFile (Ljava/io/File;)V +} + +public final class io/sentry/SentryReplayEvent$Deserializer : io/sentry/JsonDeserializer { + public fun ()V + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/SentryReplayEvent; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; +} + +public final class io/sentry/SentryReplayEvent$JsonKeys { + public static final field ERROR_IDS Ljava/lang/String; + public static final field REPLAY_ID Ljava/lang/String; + public static final field REPLAY_START_TIMESTAMP Ljava/lang/String; + public static final field REPLAY_TYPE Ljava/lang/String; + public static final field SEGMENT_ID Ljava/lang/String; + public static final field TIMESTAMP Ljava/lang/String; + public static final field TRACE_IDS Ljava/lang/String; + public static final field TYPE Ljava/lang/String; + public static final field URLS Ljava/lang/String; + public fun ()V +} + +public final class io/sentry/SentryReplayEvent$ReplayType : java/lang/Enum, io/sentry/JsonSerializable { + public static final field BUFFER Lio/sentry/SentryReplayEvent$ReplayType; + public static final field SESSION Lio/sentry/SentryReplayEvent$ReplayType; + public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V + public static fun valueOf (Ljava/lang/String;)Lio/sentry/SentryReplayEvent$ReplayType; + public static fun values ()[Lio/sentry/SentryReplayEvent$ReplayType; +} + +public final class io/sentry/SentryReplayEvent$ReplayType$Deserializer : io/sentry/JsonDeserializer { + public fun ()V + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/SentryReplayEvent$ReplayType; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; +} + public final class io/sentry/SentrySpanStorage { public fun get (Ljava/lang/String;)Lio/sentry/ISpan; public static fun getInstance ()Lio/sentry/SentrySpanStorage; @@ -2495,8 +2649,8 @@ public final class io/sentry/Session : io/sentry/JsonSerializable, io/sentry/Jso public final class io/sentry/Session$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/Session; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/Session; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/Session$JsonKeys { @@ -2617,8 +2771,8 @@ public class io/sentry/SpanContext : io/sentry/JsonSerializable, io/sentry/JsonU public final class io/sentry/SpanContext$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/SpanContext; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/SpanContext; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/SpanContext$JsonKeys { @@ -2662,8 +2816,8 @@ public final class io/sentry/SpanId : io/sentry/JsonSerializable { public final class io/sentry/SpanId$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/SpanId; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/SpanId; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public class io/sentry/SpanOptions { @@ -2704,8 +2858,8 @@ public final class io/sentry/SpanStatus : java/lang/Enum, io/sentry/JsonSerializ public final class io/sentry/SpanStatus$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/SpanStatus; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/SpanStatus; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/SystemOutLogger : io/sentry/ILogger { @@ -2733,8 +2887,8 @@ public final class io/sentry/TraceContext : io/sentry/JsonSerializable, io/sentr public final class io/sentry/TraceContext$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/TraceContext; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/TraceContext; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/TraceContext$JsonKeys { @@ -2893,8 +3047,8 @@ public final class io/sentry/UserFeedback : io/sentry/JsonSerializable, io/sentr public final class io/sentry/UserFeedback$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/UserFeedback; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/UserFeedback; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/UserFeedback$JsonKeys { @@ -3005,8 +3159,8 @@ public final class io/sentry/clientreport/ClientReport : io/sentry/JsonSerializa public final class io/sentry/clientreport/ClientReport$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/clientreport/ClientReport; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/clientreport/ClientReport; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/clientreport/ClientReport$JsonKeys { @@ -3050,8 +3204,8 @@ public final class io/sentry/clientreport/DiscardedEvent : io/sentry/JsonSeriali public final class io/sentry/clientreport/DiscardedEvent$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/clientreport/DiscardedEvent; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/clientreport/DiscardedEvent; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/clientreport/DiscardedEvent$JsonKeys { @@ -3344,8 +3498,8 @@ public final class io/sentry/profilemeasurements/ProfileMeasurement : io/sentry/ public final class io/sentry/profilemeasurements/ProfileMeasurement$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/profilemeasurements/ProfileMeasurement; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/profilemeasurements/ProfileMeasurement; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/profilemeasurements/ProfileMeasurement$JsonKeys { @@ -3368,8 +3522,8 @@ public final class io/sentry/profilemeasurements/ProfileMeasurementValue : io/se public final class io/sentry/profilemeasurements/ProfileMeasurementValue$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/profilemeasurements/ProfileMeasurementValue; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/profilemeasurements/ProfileMeasurementValue; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/profilemeasurements/ProfileMeasurementValue$JsonKeys { @@ -3410,8 +3564,8 @@ public final class io/sentry/protocol/App : io/sentry/JsonSerializable, io/sentr public final class io/sentry/protocol/App$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/App; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/App; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/App$JsonKeys { @@ -3444,8 +3598,8 @@ public final class io/sentry/protocol/Browser : io/sentry/JsonSerializable, io/s public final class io/sentry/protocol/Browser$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/Browser; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/Browser; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/Browser$JsonKeys { @@ -3479,8 +3633,8 @@ public final class io/sentry/protocol/Contexts : java/util/concurrent/Concurrent public final class io/sentry/protocol/Contexts$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/Contexts; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/Contexts; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/DebugImage : io/sentry/JsonSerializable, io/sentry/JsonUnknown { @@ -3513,8 +3667,8 @@ public final class io/sentry/protocol/DebugImage : io/sentry/JsonSerializable, i public final class io/sentry/protocol/DebugImage$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/DebugImage; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/DebugImage; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/DebugImage$JsonKeys { @@ -3543,8 +3697,8 @@ public final class io/sentry/protocol/DebugMeta : io/sentry/JsonSerializable, io public final class io/sentry/protocol/DebugMeta$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/DebugMeta; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/DebugMeta; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/DebugMeta$JsonKeys { @@ -3633,8 +3787,8 @@ public final class io/sentry/protocol/Device : io/sentry/JsonSerializable, io/se public final class io/sentry/protocol/Device$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/Device; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/Device; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/Device$DeviceOrientation : java/lang/Enum, io/sentry/JsonSerializable { @@ -3647,8 +3801,8 @@ public final class io/sentry/protocol/Device$DeviceOrientation : java/lang/Enum, public final class io/sentry/protocol/Device$DeviceOrientation$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/Device$DeviceOrientation; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/Device$DeviceOrientation; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/Device$JsonKeys { @@ -3706,8 +3860,8 @@ public final class io/sentry/protocol/Geo : io/sentry/JsonSerializable, io/sentr public final class io/sentry/protocol/Geo$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/Geo; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/Geo; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/Geo$JsonKeys { @@ -3747,8 +3901,8 @@ public final class io/sentry/protocol/Gpu : io/sentry/JsonSerializable, io/sentr public final class io/sentry/protocol/Gpu$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/Gpu; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/Gpu; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/Gpu$JsonKeys { @@ -3783,8 +3937,8 @@ public final class io/sentry/protocol/MeasurementValue : io/sentry/JsonSerializa public final class io/sentry/protocol/MeasurementValue$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/MeasurementValue; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/MeasurementValue; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/MeasurementValue$JsonKeys { @@ -3817,8 +3971,8 @@ public final class io/sentry/protocol/Mechanism : io/sentry/JsonSerializable, io public final class io/sentry/protocol/Mechanism$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/Mechanism; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/Mechanism; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/Mechanism$JsonKeys { @@ -3847,8 +4001,8 @@ public final class io/sentry/protocol/Message : io/sentry/JsonSerializable, io/s public final class io/sentry/protocol/Message$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/Message; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/Message; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/Message$JsonKeys { @@ -3882,8 +4036,8 @@ public final class io/sentry/protocol/OperatingSystem : io/sentry/JsonSerializab public final class io/sentry/protocol/OperatingSystem$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/OperatingSystem; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/OperatingSystem; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/OperatingSystem$JsonKeys { @@ -3930,8 +4084,8 @@ public final class io/sentry/protocol/Request : io/sentry/JsonSerializable, io/s public final class io/sentry/protocol/Request$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/Request; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/Request; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/Request$JsonKeys { @@ -3970,8 +4124,8 @@ public final class io/sentry/protocol/Response : io/sentry/JsonSerializable, io/ public final class io/sentry/protocol/Response$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/Response; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/Response; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/Response$JsonKeys { @@ -4000,8 +4154,8 @@ public final class io/sentry/protocol/SdkInfo : io/sentry/JsonSerializable, io/s public final class io/sentry/protocol/SdkInfo$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/SdkInfo; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/SdkInfo; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/SdkInfo$JsonKeys { @@ -4034,8 +4188,8 @@ public final class io/sentry/protocol/SdkVersion : io/sentry/JsonSerializable, i public final class io/sentry/protocol/SdkVersion$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/SdkVersion; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/SdkVersion; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/SdkVersion$JsonKeys { @@ -4067,8 +4221,8 @@ public final class io/sentry/protocol/SentryException : io/sentry/JsonSerializab public final class io/sentry/protocol/SentryException$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/SentryException; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/SentryException; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/SentryException$JsonKeys { @@ -4094,8 +4248,8 @@ public final class io/sentry/protocol/SentryId : io/sentry/JsonSerializable { public final class io/sentry/protocol/SentryId$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/SentryId; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/SentryId; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/SentryPackage : io/sentry/JsonSerializable, io/sentry/JsonUnknown { @@ -4113,8 +4267,8 @@ public final class io/sentry/protocol/SentryPackage : io/sentry/JsonSerializable public final class io/sentry/protocol/SentryPackage$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/SentryPackage; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/SentryPackage; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/SentryPackage$JsonKeys { @@ -4139,8 +4293,8 @@ public final class io/sentry/protocol/SentryRuntime : io/sentry/JsonSerializable public final class io/sentry/protocol/SentryRuntime$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/SentryRuntime; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/SentryRuntime; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/SentryRuntime$JsonKeys { @@ -4173,8 +4327,8 @@ public final class io/sentry/protocol/SentrySpan : io/sentry/JsonSerializable, i public final class io/sentry/protocol/SentrySpan$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/SentrySpan; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/SentrySpan; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/SentrySpan$JsonKeys { @@ -4243,8 +4397,8 @@ public final class io/sentry/protocol/SentryStackFrame : io/sentry/JsonSerializa public final class io/sentry/protocol/SentryStackFrame$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/SentryStackFrame; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/SentryStackFrame; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/SentryStackFrame$JsonKeys { @@ -4284,8 +4438,8 @@ public final class io/sentry/protocol/SentryStackTrace : io/sentry/JsonSerializa public final class io/sentry/protocol/SentryStackTrace$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/SentryStackTrace; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/SentryStackTrace; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/SentryStackTrace$JsonKeys { @@ -4324,8 +4478,8 @@ public final class io/sentry/protocol/SentryThread : io/sentry/JsonSerializable, public final class io/sentry/protocol/SentryThread$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/SentryThread; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/SentryThread; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/SentryThread$JsonKeys { @@ -4362,8 +4516,8 @@ public final class io/sentry/protocol/SentryTransaction : io/sentry/SentryBaseEv public final class io/sentry/protocol/SentryTransaction$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/SentryTransaction; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/SentryTransaction; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/SentryTransaction$JsonKeys { @@ -4386,8 +4540,8 @@ public final class io/sentry/protocol/TransactionInfo : io/sentry/JsonSerializab public final class io/sentry/protocol/TransactionInfo$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/TransactionInfo; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/TransactionInfo; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/TransactionInfo$JsonKeys { @@ -4438,8 +4592,8 @@ public final class io/sentry/protocol/User : io/sentry/JsonSerializable, io/sent public final class io/sentry/protocol/User$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/User; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/User; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/User$JsonKeys { @@ -4466,8 +4620,8 @@ public final class io/sentry/protocol/ViewHierarchy : io/sentry/JsonSerializable public final class io/sentry/protocol/ViewHierarchy$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/ViewHierarchy; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/ViewHierarchy; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/ViewHierarchy$JsonKeys { @@ -4507,8 +4661,8 @@ public final class io/sentry/protocol/ViewHierarchyNode : io/sentry/JsonSerializ public final class io/sentry/protocol/ViewHierarchyNode$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/ViewHierarchyNode; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/ViewHierarchyNode; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/ViewHierarchyNode$JsonKeys { @@ -4526,6 +4680,152 @@ public final class io/sentry/protocol/ViewHierarchyNode$JsonKeys { public fun ()V } +public abstract class io/sentry/rrweb/RRWebEvent { + protected fun ()V + protected fun (Lio/sentry/rrweb/RRWebEventType;)V + public fun equals (Ljava/lang/Object;)Z + public fun getTimestamp ()J + public fun getType ()Lio/sentry/rrweb/RRWebEventType; + public fun hashCode ()I + public fun setTimestamp (J)V + public fun setType (Lio/sentry/rrweb/RRWebEventType;)V +} + +public final class io/sentry/rrweb/RRWebEvent$Deserializer { + public fun ()V + public fun deserializeValue (Lio/sentry/rrweb/RRWebEvent;Ljava/lang/String;Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Z +} + +public final class io/sentry/rrweb/RRWebEvent$JsonKeys { + public static final field TAG Ljava/lang/String; + public static final field TIMESTAMP Ljava/lang/String; + public static final field TYPE Ljava/lang/String; + public fun ()V +} + +public final class io/sentry/rrweb/RRWebEvent$Serializer { + public fun ()V + public fun serialize (Lio/sentry/rrweb/RRWebEvent;Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V +} + +public final class io/sentry/rrweb/RRWebEventType : java/lang/Enum, io/sentry/JsonSerializable { + public static final field Custom Lio/sentry/rrweb/RRWebEventType; + public static final field DomContentLoaded Lio/sentry/rrweb/RRWebEventType; + public static final field FullSnapshot Lio/sentry/rrweb/RRWebEventType; + public static final field IncrementalSnapshot Lio/sentry/rrweb/RRWebEventType; + public static final field Load Lio/sentry/rrweb/RRWebEventType; + public static final field Meta Lio/sentry/rrweb/RRWebEventType; + public static final field Plugin Lio/sentry/rrweb/RRWebEventType; + public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V + public static fun valueOf (Ljava/lang/String;)Lio/sentry/rrweb/RRWebEventType; + public static fun values ()[Lio/sentry/rrweb/RRWebEventType; +} + +public final class io/sentry/rrweb/RRWebEventType$Deserializer : io/sentry/JsonDeserializer { + public fun ()V + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/rrweb/RRWebEventType; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; +} + +public final class io/sentry/rrweb/RRWebMetaEvent : io/sentry/rrweb/RRWebEvent, io/sentry/JsonSerializable, io/sentry/JsonUnknown { + public fun ()V + public fun equals (Ljava/lang/Object;)Z + public fun getDataUnknown ()Ljava/util/Map; + public fun getHeight ()I + public fun getHref ()Ljava/lang/String; + public fun getUnknown ()Ljava/util/Map; + public fun getWidth ()I + public fun hashCode ()I + public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V + public fun setDataUnknown (Ljava/util/Map;)V + public fun setHeight (I)V + public fun setHref (Ljava/lang/String;)V + public fun setUnknown (Ljava/util/Map;)V + public fun setWidth (I)V +} + +public final class io/sentry/rrweb/RRWebMetaEvent$Deserializer : io/sentry/JsonDeserializer { + public fun ()V + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/rrweb/RRWebMetaEvent; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; +} + +public final class io/sentry/rrweb/RRWebMetaEvent$JsonKeys { + public static final field DATA Ljava/lang/String; + public static final field HEIGHT Ljava/lang/String; + public static final field HREF Ljava/lang/String; + public static final field WIDTH Ljava/lang/String; + public fun ()V +} + +public final class io/sentry/rrweb/RRWebVideoEvent : io/sentry/rrweb/RRWebEvent, io/sentry/JsonSerializable, io/sentry/JsonUnknown { + public static final field EVENT_TAG Ljava/lang/String; + public static final field REPLAY_CONTAINER Ljava/lang/String; + public static final field REPLAY_ENCODING Ljava/lang/String; + public static final field REPLAY_FRAME_RATE_TYPE_CONSTANT Ljava/lang/String; + public static final field REPLAY_FRAME_RATE_TYPE_VARIABLE Ljava/lang/String; + public fun ()V + public fun equals (Ljava/lang/Object;)Z + public fun getContainer ()Ljava/lang/String; + public fun getDataUnknown ()Ljava/util/Map; + public fun getDuration ()I + public fun getEncoding ()Ljava/lang/String; + public fun getFrameCount ()I + public fun getFrameRate ()I + public fun getFrameRateType ()Ljava/lang/String; + public fun getHeight ()I + public fun getLeft ()I + public fun getPayloadUnknown ()Ljava/util/Map; + public fun getSegmentId ()I + public fun getSize ()J + public fun getTag ()Ljava/lang/String; + public fun getTop ()I + public fun getUnknown ()Ljava/util/Map; + public fun getWidth ()I + public fun hashCode ()I + public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V + public fun setContainer (Ljava/lang/String;)V + public fun setDataUnknown (Ljava/util/Map;)V + public fun setDuration (I)V + public fun setEncoding (Ljava/lang/String;)V + public fun setFrameCount (I)V + public fun setFrameRate (I)V + public fun setFrameRateType (Ljava/lang/String;)V + public fun setHeight (I)V + public fun setLeft (I)V + public fun setPayloadUnknown (Ljava/util/Map;)V + public fun setSegmentId (I)V + public fun setSize (J)V + public fun setTag (Ljava/lang/String;)V + public fun setTop (I)V + public fun setUnknown (Ljava/util/Map;)V + public fun setWidth (I)V +} + +public final class io/sentry/rrweb/RRWebVideoEvent$Deserializer : io/sentry/JsonDeserializer { + public fun ()V + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/rrweb/RRWebVideoEvent; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; +} + +public final class io/sentry/rrweb/RRWebVideoEvent$JsonKeys { + public static final field CONTAINER Ljava/lang/String; + public static final field DATA Ljava/lang/String; + public static final field DURATION Ljava/lang/String; + public static final field ENCODING Ljava/lang/String; + public static final field FRAME_COUNT Ljava/lang/String; + public static final field FRAME_RATE Ljava/lang/String; + public static final field FRAME_RATE_TYPE Ljava/lang/String; + public static final field HEIGHT Ljava/lang/String; + public static final field LEFT Ljava/lang/String; + public static final field PAYLOAD Ljava/lang/String; + public static final field SEGMENT_ID Ljava/lang/String; + public static final field SIZE Ljava/lang/String; + public static final field TOP Ljava/lang/String; + public static final field WIDTH Ljava/lang/String; + public fun ()V +} + public final class io/sentry/transport/AsyncHttpTransport : io/sentry/transport/ITransport { public fun (Lio/sentry/SentryOptions;Lio/sentry/transport/RateLimiter;Lio/sentry/transport/ITransportGate;Lio/sentry/RequestDetails;)V public fun (Lio/sentry/transport/QueuedThreadPoolExecutor;Lio/sentry/SentryOptions;Lio/sentry/transport/RateLimiter;Lio/sentry/transport/ITransportGate;Lio/sentry/transport/HttpConnection;)V @@ -4727,6 +5027,40 @@ public final class io/sentry/util/LogUtils { public static fun logNotInstanceOf (Ljava/lang/Class;Ljava/lang/Object;Lio/sentry/ILogger;)V } +public final class io/sentry/util/MapObjectReader : io/sentry/ObjectReader { + public fun (Ljava/util/Map;)V + public fun beginArray ()V + public fun beginObject ()V + public fun close ()V + public fun endArray ()V + public fun endObject ()V + public fun hasNext ()Z + public fun nextBoolean ()Z + public fun nextBooleanOrNull ()Ljava/lang/Boolean; + public fun nextDateOrNull (Lio/sentry/ILogger;)Ljava/util/Date; + public fun nextDouble ()D + public fun nextDoubleOrNull ()Ljava/lang/Double; + public fun nextFloat ()F + public fun nextFloatOrNull ()Ljava/lang/Float; + public fun nextInt ()I + public fun nextIntegerOrNull ()Ljava/lang/Integer; + public fun nextListOrNull (Lio/sentry/ILogger;Lio/sentry/JsonDeserializer;)Ljava/util/List; + public fun nextLong ()J + public fun nextLongOrNull ()Ljava/lang/Long; + public fun nextMapOrNull (Lio/sentry/ILogger;Lio/sentry/JsonDeserializer;)Ljava/util/Map; + public fun nextName ()Ljava/lang/String; + public fun nextNull ()V + public fun nextObjectOrNull ()Ljava/lang/Object; + public fun nextOrNull (Lio/sentry/ILogger;Lio/sentry/JsonDeserializer;)Ljava/lang/Object; + public fun nextString ()Ljava/lang/String; + public fun nextStringOrNull ()Ljava/lang/String; + public fun nextTimeZoneOrNull (Lio/sentry/ILogger;)Ljava/util/TimeZone; + public fun nextUnknown (Lio/sentry/ILogger;Ljava/util/Map;Ljava/lang/String;)V + public fun peek ()Lio/sentry/vendor/gson/stream/JsonToken; + public fun setLenient (Z)V + public fun skipValue ()V +} + public final class io/sentry/util/MapObjectWriter : io/sentry/ObjectWriter { public fun (Ljava/util/Map;)V public synthetic fun beginArray ()Lio/sentry/ObjectWriter; @@ -4737,10 +5071,12 @@ public final class io/sentry/util/MapObjectWriter : io/sentry/ObjectWriter { public fun endArray ()Lio/sentry/util/MapObjectWriter; public synthetic fun endObject ()Lio/sentry/ObjectWriter; public fun endObject ()Lio/sentry/util/MapObjectWriter; + public fun jsonValue (Ljava/lang/String;)Lio/sentry/ObjectWriter; public synthetic fun name (Ljava/lang/String;)Lio/sentry/ObjectWriter; public fun name (Ljava/lang/String;)Lio/sentry/util/MapObjectWriter; public synthetic fun nullValue ()Lio/sentry/ObjectWriter; public fun nullValue ()Lio/sentry/util/MapObjectWriter; + public fun setLenient (Z)V public synthetic fun value (D)Lio/sentry/ObjectWriter; public fun value (D)Lio/sentry/util/MapObjectWriter; public synthetic fun value (J)Lio/sentry/ObjectWriter; diff --git a/sentry/src/main/java/io/sentry/Breadcrumb.java b/sentry/src/main/java/io/sentry/Breadcrumb.java index fe2055c336c..da1453bc68b 100644 --- a/sentry/src/main/java/io/sentry/Breadcrumb.java +++ b/sentry/src/main/java/io/sentry/Breadcrumb.java @@ -86,8 +86,7 @@ public static Breadcrumb fromMap( switch (entry.getKey()) { case JsonKeys.TIMESTAMP: if (value instanceof String) { - Date deserializedDate = - JsonObjectReader.dateOrNull((String) value, options.getLogger()); + Date deserializedDate = ObjectReader.dateOrNull((String) value, options.getLogger()); if (deserializedDate != null) { timestamp = deserializedDate; } @@ -700,8 +699,8 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @SuppressWarnings("unchecked") @Override - public @NotNull Breadcrumb deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + public @NotNull Breadcrumb deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) + throws Exception { reader.beginObject(); @NotNull Date timestamp = DateUtils.getCurrentDateTime(); String message = null; diff --git a/sentry/src/main/java/io/sentry/CheckIn.java b/sentry/src/main/java/io/sentry/CheckIn.java index 4c83771324f..e7c6abef3e8 100644 --- a/sentry/src/main/java/io/sentry/CheckIn.java +++ b/sentry/src/main/java/io/sentry/CheckIn.java @@ -170,7 +170,7 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @Override - public @NotNull CheckIn deserialize(@NotNull JsonObjectReader reader, @NotNull ILogger logger) + public @NotNull CheckIn deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { SentryId sentryId = null; MonitorConfig monitorConfig = null; diff --git a/sentry/src/main/java/io/sentry/JsonDeserializer.java b/sentry/src/main/java/io/sentry/JsonDeserializer.java index 7e62814fe64..390328231b6 100644 --- a/sentry/src/main/java/io/sentry/JsonDeserializer.java +++ b/sentry/src/main/java/io/sentry/JsonDeserializer.java @@ -6,5 +6,5 @@ @ApiStatus.Internal public interface JsonDeserializer { @NotNull - T deserialize(@NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception; + T deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception; } diff --git a/sentry/src/main/java/io/sentry/JsonObjectReader.java b/sentry/src/main/java/io/sentry/JsonObjectReader.java index 79c9a35420c..c069a9e26d8 100644 --- a/sentry/src/main/java/io/sentry/JsonObjectReader.java +++ b/sentry/src/main/java/io/sentry/JsonObjectReader.java @@ -15,64 +15,74 @@ import org.jetbrains.annotations.Nullable; @ApiStatus.Internal -public final class JsonObjectReader extends JsonReader { +public final class JsonObjectReader implements ObjectReader { + + private final @NotNull JsonReader jsonReader; public JsonObjectReader(Reader in) { - super(in); + this.jsonReader = new JsonReader(in); } + @Override public @Nullable String nextStringOrNull() throws IOException { - if (peek() == JsonToken.NULL) { - nextNull(); + if (jsonReader.peek() == JsonToken.NULL) { + jsonReader.nextNull(); return null; } - return nextString(); + return jsonReader.nextString(); } + @Override public @Nullable Double nextDoubleOrNull() throws IOException { - if (peek() == JsonToken.NULL) { - nextNull(); + if (jsonReader.peek() == JsonToken.NULL) { + jsonReader.nextNull(); return null; } - return nextDouble(); + return jsonReader.nextDouble(); } + @Override public @Nullable Float nextFloatOrNull() throws IOException { - if (peek() == JsonToken.NULL) { - nextNull(); + if (jsonReader.peek() == JsonToken.NULL) { + jsonReader.nextNull(); return null; } return nextFloat(); } - public @NotNull Float nextFloat() throws IOException { - return (float) nextDouble(); + @Override + public float nextFloat() throws IOException { + return (float) jsonReader.nextDouble(); } + @Override public @Nullable Long nextLongOrNull() throws IOException { - if (peek() == JsonToken.NULL) { - nextNull(); + if (jsonReader.peek() == JsonToken.NULL) { + jsonReader.nextNull(); return null; } - return nextLong(); + return jsonReader.nextLong(); } + @Override public @Nullable Integer nextIntegerOrNull() throws IOException { - if (peek() == JsonToken.NULL) { - nextNull(); + if (jsonReader.peek() == JsonToken.NULL) { + jsonReader.nextNull(); return null; } - return nextInt(); + return jsonReader.nextInt(); } + @Override public @Nullable Boolean nextBooleanOrNull() throws IOException { - if (peek() == JsonToken.NULL) { - nextNull(); + if (jsonReader.peek() == JsonToken.NULL) { + jsonReader.nextNull(); return null; } - return nextBoolean(); + return jsonReader.nextBoolean(); } + @Override public void nextUnknown(ILogger logger, Map unknown, String name) { try { unknown.put(name, nextObjectOrNull()); @@ -81,90 +91,79 @@ public void nextUnknown(ILogger logger, Map unknown, String name } } + @Override public @Nullable List nextListOrNull( @NotNull ILogger logger, @NotNull JsonDeserializer deserializer) throws IOException { - if (peek() == JsonToken.NULL) { - nextNull(); + if (jsonReader.peek() == JsonToken.NULL) { + jsonReader.nextNull(); return null; } - beginArray(); + jsonReader.beginArray(); List list = new ArrayList<>(); - if (hasNext()) { + if (jsonReader.hasNext()) { do { try { list.add(deserializer.deserialize(this, logger)); } catch (Exception e) { logger.log(SentryLevel.WARNING, "Failed to deserialize object in list.", e); } - } while (peek() == JsonToken.BEGIN_OBJECT); + } while (jsonReader.peek() == JsonToken.BEGIN_OBJECT); } - endArray(); + jsonReader.endArray(); return list; } + @Override public @Nullable Map nextMapOrNull( @NotNull ILogger logger, @NotNull JsonDeserializer deserializer) throws IOException { - if (peek() == JsonToken.NULL) { - nextNull(); + if (jsonReader.peek() == JsonToken.NULL) { + jsonReader.nextNull(); return null; } - beginObject(); + jsonReader.beginObject(); Map map = new HashMap<>(); - if (hasNext()) { + if (jsonReader.hasNext()) { do { try { - String key = nextName(); + String key = jsonReader.nextName(); map.put(key, deserializer.deserialize(this, logger)); } catch (Exception e) { logger.log(SentryLevel.WARNING, "Failed to deserialize object in map.", e); } - } while (peek() == JsonToken.BEGIN_OBJECT || peek() == JsonToken.NAME); + } while (jsonReader.peek() == JsonToken.BEGIN_OBJECT || jsonReader.peek() == JsonToken.NAME); } - endObject(); + jsonReader.endObject(); return map; } + @Override public @Nullable T nextOrNull( @NotNull ILogger logger, @NotNull JsonDeserializer deserializer) throws Exception { - if (peek() == JsonToken.NULL) { - nextNull(); + if (jsonReader.peek() == JsonToken.NULL) { + jsonReader.nextNull(); return null; } return deserializer.deserialize(this, logger); } + @Override public @Nullable Date nextDateOrNull(ILogger logger) throws IOException { - if (peek() == JsonToken.NULL) { - nextNull(); - return null; - } - return JsonObjectReader.dateOrNull(nextString(), logger); - } - - public static @Nullable Date dateOrNull(@Nullable String dateString, ILogger logger) { - if (dateString == null) { + if (jsonReader.peek() == JsonToken.NULL) { + jsonReader.nextNull(); return null; } - try { - return DateUtils.getDateTime(dateString); - } catch (Exception ignored) { - try { - return DateUtils.getDateTimeWithMillisPrecision(dateString); - } catch (Exception e) { - logger.log(SentryLevel.ERROR, "Error when deserializing millis timestamp format.", e); - } - } - return null; + return ObjectReader.dateOrNull(jsonReader.nextString(), logger); } + @Override public @Nullable TimeZone nextTimeZoneOrNull(ILogger logger) throws IOException { - if (peek() == JsonToken.NULL) { - nextNull(); + if (jsonReader.peek() == JsonToken.NULL) { + jsonReader.nextNull(); return null; } try { - return TimeZone.getTimeZone(nextString()); + return TimeZone.getTimeZone(jsonReader.nextString()); } catch (Exception e) { logger.log(SentryLevel.ERROR, "Error when deserializing TimeZone", e); } @@ -177,7 +176,88 @@ public void nextUnknown(ILogger logger, Map unknown, String name * * @return The deserialized object from json. */ + @Override public @Nullable Object nextObjectOrNull() throws IOException { return new JsonObjectDeserializer().deserialize(this); } + + @Override + public @NotNull JsonToken peek() throws IOException { + return jsonReader.peek(); + } + + @Override + public @NotNull String nextName() throws IOException { + return jsonReader.nextName(); + } + + @Override + public void beginObject() throws IOException { + jsonReader.beginObject(); + } + + @Override + public void endObject() throws IOException { + jsonReader.endObject(); + } + + @Override + public void beginArray() throws IOException { + jsonReader.beginArray(); + } + + @Override + public void endArray() throws IOException { + jsonReader.endArray(); + } + + @Override + public boolean hasNext() throws IOException { + return jsonReader.hasNext(); + } + + @Override + public int nextInt() throws IOException { + return jsonReader.nextInt(); + } + + @Override + public long nextLong() throws IOException { + return jsonReader.nextLong(); + } + + @Override + public String nextString() throws IOException { + return jsonReader.nextString(); + } + + @Override + public boolean nextBoolean() throws IOException { + return jsonReader.nextBoolean(); + } + + @Override + public double nextDouble() throws IOException { + return jsonReader.nextDouble(); + } + + @Override + public void nextNull() throws IOException { + jsonReader.nextNull(); + } + + @Override + public void setLenient(boolean lenient) { + jsonReader.setLenient(lenient); + } + + @Override + public void skipValue() throws IOException { + jsonReader.skipValue(); + } + + @Override + public void close() throws IOException { + jsonReader.close(); + } } diff --git a/sentry/src/main/java/io/sentry/MonitorConfig.java b/sentry/src/main/java/io/sentry/MonitorConfig.java index c25884c2bde..7d15eb26d38 100644 --- a/sentry/src/main/java/io/sentry/MonitorConfig.java +++ b/sentry/src/main/java/io/sentry/MonitorConfig.java @@ -105,8 +105,8 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @Override - public @NotNull MonitorConfig deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + public @NotNull MonitorConfig deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) + throws Exception { MonitorSchedule schedule = null; Long checkinMargin = null; Long maxRuntime = null; diff --git a/sentry/src/main/java/io/sentry/MonitorContexts.java b/sentry/src/main/java/io/sentry/MonitorContexts.java index 3a15aa4113c..00ccb680fc3 100644 --- a/sentry/src/main/java/io/sentry/MonitorContexts.java +++ b/sentry/src/main/java/io/sentry/MonitorContexts.java @@ -66,7 +66,7 @@ public static final class Deserializer implements JsonDeserializer { @Override public @NotNull MonitorSchedule deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + @NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { String type = null; String value = null; String unit = null; diff --git a/sentry/src/main/java/io/sentry/ObjectReader.java b/sentry/src/main/java/io/sentry/ObjectReader.java new file mode 100644 index 00000000000..0449379f7d5 --- /dev/null +++ b/sentry/src/main/java/io/sentry/ObjectReader.java @@ -0,0 +1,101 @@ +package io.sentry; + +import io.sentry.vendor.gson.stream.JsonToken; +import java.io.Closeable; +import java.io.IOException; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.TimeZone; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public interface ObjectReader extends Closeable { + static @Nullable Date dateOrNull(@Nullable String dateString, final @NotNull ILogger logger) { + if (dateString == null) { + return null; + } + try { + return DateUtils.getDateTime(dateString); + } catch (Exception ignored) { + try { + return DateUtils.getDateTimeWithMillisPrecision(dateString); + } catch (Exception e) { + logger.log(SentryLevel.ERROR, "Error when deserializing millis timestamp format.", e); + } + } + return null; + } + + void nextUnknown(ILogger logger, Map unknown, String name); + + @Nullable List nextListOrNull( + @NotNull ILogger logger, @NotNull JsonDeserializer deserializer) throws IOException; + + @Nullable Map nextMapOrNull( + @NotNull ILogger logger, @NotNull JsonDeserializer deserializer) throws IOException; + + @Nullable T nextOrNull(@NotNull ILogger logger, @NotNull JsonDeserializer deserializer) + throws Exception; + + @Nullable + Date nextDateOrNull(ILogger logger) throws IOException; + + @Nullable + TimeZone nextTimeZoneOrNull(ILogger logger) throws IOException; + + @Nullable + Object nextObjectOrNull() throws IOException; + + @NotNull + JsonToken peek() throws IOException; + + @NotNull + String nextName() throws IOException; + + void beginObject() throws IOException; + + void endObject() throws IOException; + + void beginArray() throws IOException; + + void endArray() throws IOException; + + boolean hasNext() throws IOException; + + int nextInt() throws IOException; + + @Nullable + Integer nextIntegerOrNull() throws IOException; + + long nextLong() throws IOException; + + @Nullable + Long nextLongOrNull() throws IOException; + + String nextString() throws IOException; + + @Nullable + String nextStringOrNull() throws IOException; + + boolean nextBoolean() throws IOException; + + @Nullable + Boolean nextBooleanOrNull() throws IOException; + + double nextDouble() throws IOException; + + @Nullable + Double nextDoubleOrNull() throws IOException; + + float nextFloat() throws IOException; + + @Nullable + Float nextFloatOrNull() throws IOException; + + void nextNull() throws IOException; + + void setLenient(boolean lenient); + + void skipValue() throws IOException; +} diff --git a/sentry/src/main/java/io/sentry/ProfilingTraceData.java b/sentry/src/main/java/io/sentry/ProfilingTraceData.java index 35902f5f04c..9d54a7ee0e6 100644 --- a/sentry/src/main/java/io/sentry/ProfilingTraceData.java +++ b/sentry/src/main/java/io/sentry/ProfilingTraceData.java @@ -448,7 +448,7 @@ public static final class Deserializer implements JsonDeserializer unknown = null; diff --git a/sentry/src/main/java/io/sentry/ProfilingTransactionData.java b/sentry/src/main/java/io/sentry/ProfilingTransactionData.java index 46ba9bba444..045b859f05f 100644 --- a/sentry/src/main/java/io/sentry/ProfilingTransactionData.java +++ b/sentry/src/main/java/io/sentry/ProfilingTransactionData.java @@ -179,7 +179,7 @@ public static final class Deserializer implements JsonDeserializer unknown = null; diff --git a/sentry/src/main/java/io/sentry/SentryAppStartProfilingOptions.java b/sentry/src/main/java/io/sentry/SentryAppStartProfilingOptions.java index d98ec2c32ff..a9828792d77 100644 --- a/sentry/src/main/java/io/sentry/SentryAppStartProfilingOptions.java +++ b/sentry/src/main/java/io/sentry/SentryAppStartProfilingOptions.java @@ -151,7 +151,7 @@ public static final class Deserializer @Override public @NotNull SentryAppStartProfilingOptions deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + @NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { reader.beginObject(); SentryAppStartProfilingOptions options = new SentryAppStartProfilingOptions(); Map unknown = null; diff --git a/sentry/src/main/java/io/sentry/SentryBaseEvent.java b/sentry/src/main/java/io/sentry/SentryBaseEvent.java index c247342cc28..58435194a7b 100644 --- a/sentry/src/main/java/io/sentry/SentryBaseEvent.java +++ b/sentry/src/main/java/io/sentry/SentryBaseEvent.java @@ -395,7 +395,7 @@ public static final class Deserializer { public boolean deserializeValue( @NotNull SentryBaseEvent baseEvent, @NotNull String nextName, - @NotNull JsonObjectReader reader, + @NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { switch (nextName) { diff --git a/sentry/src/main/java/io/sentry/SentryEnvelopeHeader.java b/sentry/src/main/java/io/sentry/SentryEnvelopeHeader.java index ceb7e7bdd55..3e9525d3072 100644 --- a/sentry/src/main/java/io/sentry/SentryEnvelopeHeader.java +++ b/sentry/src/main/java/io/sentry/SentryEnvelopeHeader.java @@ -117,7 +117,7 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @Override public @NotNull SentryEnvelopeHeader deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + @NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { reader.beginObject(); SentryId eventId = null; diff --git a/sentry/src/main/java/io/sentry/SentryEnvelopeItemHeader.java b/sentry/src/main/java/io/sentry/SentryEnvelopeItemHeader.java index 1ca9a1c8c2f..6903d9b1bb9 100644 --- a/sentry/src/main/java/io/sentry/SentryEnvelopeItemHeader.java +++ b/sentry/src/main/java/io/sentry/SentryEnvelopeItemHeader.java @@ -130,7 +130,7 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @Override public @NotNull SentryEnvelopeItemHeader deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + @NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { reader.beginObject(); String contentType = null; diff --git a/sentry/src/main/java/io/sentry/SentryEvent.java b/sentry/src/main/java/io/sentry/SentryEvent.java index 5bd1cf3877f..d370458acbf 100644 --- a/sentry/src/main/java/io/sentry/SentryEvent.java +++ b/sentry/src/main/java/io/sentry/SentryEvent.java @@ -311,8 +311,8 @@ public static final class Deserializer implements JsonDeserializer @SuppressWarnings("unchecked") @Override - public @NotNull SentryEvent deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + public @NotNull SentryEvent deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) + throws Exception { reader.beginObject(); SentryEvent event = new SentryEvent(); Map unknown = null; diff --git a/sentry/src/main/java/io/sentry/SentryItemType.java b/sentry/src/main/java/io/sentry/SentryItemType.java index ab5fb3bc73b..b0d9c62e905 100644 --- a/sentry/src/main/java/io/sentry/SentryItemType.java +++ b/sentry/src/main/java/io/sentry/SentryItemType.java @@ -65,7 +65,7 @@ static final class Deserializer implements JsonDeserializer { @Override public @NotNull SentryItemType deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + @NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { return SentryItemType.valueOfLabel(reader.nextString().toLowerCase(Locale.ROOT)); } } diff --git a/sentry/src/main/java/io/sentry/SentryLevel.java b/sentry/src/main/java/io/sentry/SentryLevel.java index ac179c9831b..f1c6e04cb87 100644 --- a/sentry/src/main/java/io/sentry/SentryLevel.java +++ b/sentry/src/main/java/io/sentry/SentryLevel.java @@ -21,8 +21,8 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger static final class Deserializer implements JsonDeserializer { @Override - public @NotNull SentryLevel deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + public @NotNull SentryLevel deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) + throws Exception { return SentryLevel.valueOf(reader.nextString().toUpperCase(Locale.ROOT)); } } diff --git a/sentry/src/main/java/io/sentry/SentryLockReason.java b/sentry/src/main/java/io/sentry/SentryLockReason.java index f376317f6b5..bd04f48ab0c 100644 --- a/sentry/src/main/java/io/sentry/SentryLockReason.java +++ b/sentry/src/main/java/io/sentry/SentryLockReason.java @@ -147,7 +147,7 @@ public static final class Deserializer implements JsonDeserializer unknown = null; reader.beginObject(); diff --git a/sentry/src/main/java/io/sentry/Session.java b/sentry/src/main/java/io/sentry/Session.java index 500da919fe2..482b055b676 100644 --- a/sentry/src/main/java/io/sentry/Session.java +++ b/sentry/src/main/java/io/sentry/Session.java @@ -426,7 +426,7 @@ public static final class Deserializer implements JsonDeserializer { @SuppressWarnings("unchecked") @Override - public @NotNull Session deserialize(@NotNull JsonObjectReader reader, @NotNull ILogger logger) + public @NotNull Session deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { reader.beginObject(); diff --git a/sentry/src/main/java/io/sentry/SpanContext.java b/sentry/src/main/java/io/sentry/SpanContext.java index be428708cb1..5a43ff845e0 100644 --- a/sentry/src/main/java/io/sentry/SpanContext.java +++ b/sentry/src/main/java/io/sentry/SpanContext.java @@ -292,8 +292,8 @@ public void setUnknown(@Nullable Map unknown) { public static final class Deserializer implements JsonDeserializer { @SuppressWarnings("unchecked") @Override - public @NotNull SpanContext deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + public @NotNull SpanContext deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) + throws Exception { reader.beginObject(); SentryId traceId = null; SpanId spanId = null; diff --git a/sentry/src/main/java/io/sentry/SpanId.java b/sentry/src/main/java/io/sentry/SpanId.java index 7e221775ced..70608fb7cbb 100644 --- a/sentry/src/main/java/io/sentry/SpanId.java +++ b/sentry/src/main/java/io/sentry/SpanId.java @@ -53,7 +53,7 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @Override - public @NotNull SpanId deserialize(@NotNull JsonObjectReader reader, @NotNull ILogger logger) + public @NotNull SpanId deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { return new SpanId(reader.nextString()); } diff --git a/sentry/src/main/java/io/sentry/SpanStatus.java b/sentry/src/main/java/io/sentry/SpanStatus.java index b0b1bf78c8c..5185d27e058 100644 --- a/sentry/src/main/java/io/sentry/SpanStatus.java +++ b/sentry/src/main/java/io/sentry/SpanStatus.java @@ -114,8 +114,8 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @Override - public @NotNull SpanStatus deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + public @NotNull SpanStatus deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) + throws Exception { return SpanStatus.valueOf(reader.nextString().toUpperCase(Locale.ROOT)); } } diff --git a/sentry/src/main/java/io/sentry/TraceContext.java b/sentry/src/main/java/io/sentry/TraceContext.java index ef2944a9e9c..df799aaa07b 100644 --- a/sentry/src/main/java/io/sentry/TraceContext.java +++ b/sentry/src/main/java/io/sentry/TraceContext.java @@ -141,7 +141,7 @@ public static final class JsonKeys { public static final class Deserializer implements JsonDeserializer { @Override public @NotNull TraceContextUser deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + @NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { reader.beginObject(); String id = null; @@ -239,8 +239,8 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @Override - public @NotNull TraceContext deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + public @NotNull TraceContext deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) + throws Exception { reader.beginObject(); SentryId traceId = null; diff --git a/sentry/src/main/java/io/sentry/UserFeedback.java b/sentry/src/main/java/io/sentry/UserFeedback.java index 27086188fe9..b580744ee77 100644 --- a/sentry/src/main/java/io/sentry/UserFeedback.java +++ b/sentry/src/main/java/io/sentry/UserFeedback.java @@ -174,8 +174,8 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @Override - public @NotNull UserFeedback deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + public @NotNull UserFeedback deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) + throws Exception { SentryId sentryId = null; String name = null; String email = null; diff --git a/sentry/src/main/java/io/sentry/clientreport/ClientReport.java b/sentry/src/main/java/io/sentry/clientreport/ClientReport.java index 66c3188116a..e1b8abcaea3 100644 --- a/sentry/src/main/java/io/sentry/clientreport/ClientReport.java +++ b/sentry/src/main/java/io/sentry/clientreport/ClientReport.java @@ -3,9 +3,9 @@ import io.sentry.DateUtils; import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.SentryLevel; import io.sentry.vendor.gson.stream.JsonToken; @@ -74,8 +74,8 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @Override - public @NotNull ClientReport deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + public @NotNull ClientReport deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) + throws Exception { Date timestamp = null; List discardedEvents = new ArrayList<>(); Map unknown = null; diff --git a/sentry/src/main/java/io/sentry/clientreport/DiscardedEvent.java b/sentry/src/main/java/io/sentry/clientreport/DiscardedEvent.java index 8fb5da3165f..10b12b0fed5 100644 --- a/sentry/src/main/java/io/sentry/clientreport/DiscardedEvent.java +++ b/sentry/src/main/java/io/sentry/clientreport/DiscardedEvent.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.SentryLevel; import io.sentry.vendor.gson.stream.JsonToken; @@ -93,7 +93,7 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @Override public @NotNull DiscardedEvent deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + @NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { String reason = null; String category = null; Long quanity = null; diff --git a/sentry/src/main/java/io/sentry/profilemeasurements/ProfileMeasurement.java b/sentry/src/main/java/io/sentry/profilemeasurements/ProfileMeasurement.java index 94e77edbfb7..1e6ff5fb41c 100644 --- a/sentry/src/main/java/io/sentry/profilemeasurements/ProfileMeasurement.java +++ b/sentry/src/main/java/io/sentry/profilemeasurements/ProfileMeasurement.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.util.Objects; import io.sentry.vendor.gson.stream.JsonToken; @@ -118,7 +118,7 @@ public static final class Deserializer implements JsonDeserializer unknown = null; diff --git a/sentry/src/main/java/io/sentry/profilemeasurements/ProfileMeasurementValue.java b/sentry/src/main/java/io/sentry/profilemeasurements/ProfileMeasurementValue.java index 9639ba892ff..b0cebf5439d 100644 --- a/sentry/src/main/java/io/sentry/profilemeasurements/ProfileMeasurementValue.java +++ b/sentry/src/main/java/io/sentry/profilemeasurements/ProfileMeasurementValue.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.util.Objects; import io.sentry.vendor.gson.stream.JsonToken; @@ -92,7 +92,7 @@ public static final class Deserializer implements JsonDeserializer unknown = null; diff --git a/sentry/src/main/java/io/sentry/protocol/App.java b/sentry/src/main/java/io/sentry/protocol/App.java index b7b41638db2..90d7d7aff6b 100644 --- a/sentry/src/main/java/io/sentry/protocol/App.java +++ b/sentry/src/main/java/io/sentry/protocol/App.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.util.CollectionUtils; import io.sentry.util.Objects; @@ -255,7 +255,7 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @SuppressWarnings("unchecked") @Override - public @NotNull App deserialize(@NotNull JsonObjectReader reader, @NotNull ILogger logger) + public @NotNull App deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { reader.beginObject(); App app = new App(); diff --git a/sentry/src/main/java/io/sentry/protocol/Browser.java b/sentry/src/main/java/io/sentry/protocol/Browser.java index 99fe427c278..ed32be5ea2e 100644 --- a/sentry/src/main/java/io/sentry/protocol/Browser.java +++ b/sentry/src/main/java/io/sentry/protocol/Browser.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.util.CollectionUtils; import io.sentry.util.Objects; @@ -102,7 +102,7 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @Override - public @NotNull Browser deserialize(@NotNull JsonObjectReader reader, @NotNull ILogger logger) + public @NotNull Browser deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { reader.beginObject(); Browser browser = new Browser(); diff --git a/sentry/src/main/java/io/sentry/protocol/Contexts.java b/sentry/src/main/java/io/sentry/protocol/Contexts.java index 21be9fd8a58..28d2e8d2a44 100644 --- a/sentry/src/main/java/io/sentry/protocol/Contexts.java +++ b/sentry/src/main/java/io/sentry/protocol/Contexts.java @@ -2,8 +2,8 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.SpanContext; import io.sentry.util.HintUtils; @@ -160,7 +160,7 @@ public static final class Deserializer implements JsonDeserializer { @Override public @NotNull Contexts deserialize( - final @NotNull JsonObjectReader reader, final @NotNull ILogger logger) throws Exception { + final @NotNull ObjectReader reader, final @NotNull ILogger logger) throws Exception { final Contexts contexts = new Contexts(); reader.beginObject(); while (reader.peek() == JsonToken.NAME) { diff --git a/sentry/src/main/java/io/sentry/protocol/DebugImage.java b/sentry/src/main/java/io/sentry/protocol/DebugImage.java index d26432033e4..e769e2c2ca3 100644 --- a/sentry/src/main/java/io/sentry/protocol/DebugImage.java +++ b/sentry/src/main/java/io/sentry/protocol/DebugImage.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.vendor.gson.stream.JsonToken; import java.io.IOException; @@ -314,8 +314,8 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @Override - public @NotNull DebugImage deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + public @NotNull DebugImage deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) + throws Exception { DebugImage debugImage = new DebugImage(); Map unknown = null; diff --git a/sentry/src/main/java/io/sentry/protocol/DebugMeta.java b/sentry/src/main/java/io/sentry/protocol/DebugMeta.java index 134947507ae..458c4de6311 100644 --- a/sentry/src/main/java/io/sentry/protocol/DebugMeta.java +++ b/sentry/src/main/java/io/sentry/protocol/DebugMeta.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.vendor.gson.stream.JsonToken; import java.io.IOException; @@ -95,7 +95,7 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @Override - public @NotNull DebugMeta deserialize(@NotNull JsonObjectReader reader, @NotNull ILogger logger) + public @NotNull DebugMeta deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { DebugMeta debugMeta = new DebugMeta(); diff --git a/sentry/src/main/java/io/sentry/protocol/Device.java b/sentry/src/main/java/io/sentry/protocol/Device.java index 4f06f749953..25cfa41fd13 100644 --- a/sentry/src/main/java/io/sentry/protocol/Device.java +++ b/sentry/src/main/java/io/sentry/protocol/Device.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.util.CollectionUtils; import io.sentry.util.Objects; @@ -544,7 +544,7 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @Override public @NotNull DeviceOrientation deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + @NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { return DeviceOrientation.valueOf(reader.nextString().toUpperCase(Locale.ROOT)); } } @@ -726,7 +726,7 @@ public void setUnknown(@Nullable Map unknown) { public static final class Deserializer implements JsonDeserializer { @Override - public @NotNull Device deserialize(@NotNull JsonObjectReader reader, @NotNull ILogger logger) + public @NotNull Device deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { reader.beginObject(); Device device = new Device(); diff --git a/sentry/src/main/java/io/sentry/protocol/Geo.java b/sentry/src/main/java/io/sentry/protocol/Geo.java index fefc340e1be..6042b72d1d9 100644 --- a/sentry/src/main/java/io/sentry/protocol/Geo.java +++ b/sentry/src/main/java/io/sentry/protocol/Geo.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.vendor.gson.stream.JsonToken; import java.io.IOException; @@ -161,7 +161,7 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @Override - public Geo deserialize(JsonObjectReader reader, ILogger logger) throws Exception { + public Geo deserialize(@NotNull ObjectReader reader, ILogger logger) throws Exception { reader.beginObject(); final Geo geo = new Geo(); Map unknown = null; diff --git a/sentry/src/main/java/io/sentry/protocol/Gpu.java b/sentry/src/main/java/io/sentry/protocol/Gpu.java index 0dfe85f68f9..b4a8344e2d7 100644 --- a/sentry/src/main/java/io/sentry/protocol/Gpu.java +++ b/sentry/src/main/java/io/sentry/protocol/Gpu.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.util.CollectionUtils; import io.sentry.util.Objects; @@ -229,7 +229,7 @@ public void setUnknown(@Nullable Map unknown) { public static final class Deserializer implements JsonDeserializer { @Override - public @NotNull Gpu deserialize(@NotNull JsonObjectReader reader, @NotNull ILogger logger) + public @NotNull Gpu deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { reader.beginObject(); Gpu gpu = new Gpu(); diff --git a/sentry/src/main/java/io/sentry/protocol/MeasurementValue.java b/sentry/src/main/java/io/sentry/protocol/MeasurementValue.java index 5ba28f41268..85cf6cf40f7 100644 --- a/sentry/src/main/java/io/sentry/protocol/MeasurementValue.java +++ b/sentry/src/main/java/io/sentry/protocol/MeasurementValue.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.SentryLevel; import io.sentry.vendor.gson.stream.JsonToken; @@ -101,7 +101,7 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @Override public @NotNull MeasurementValue deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + @NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { reader.beginObject(); String unit = null; diff --git a/sentry/src/main/java/io/sentry/protocol/Mechanism.java b/sentry/src/main/java/io/sentry/protocol/Mechanism.java index 648aed39c2b..fac8808f2db 100644 --- a/sentry/src/main/java/io/sentry/protocol/Mechanism.java +++ b/sentry/src/main/java/io/sentry/protocol/Mechanism.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.util.CollectionUtils; import io.sentry.vendor.gson.stream.JsonToken; @@ -205,7 +205,7 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @SuppressWarnings("unchecked") @Override - public @NotNull Mechanism deserialize(@NotNull JsonObjectReader reader, @NotNull ILogger logger) + public @NotNull Mechanism deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { Mechanism mechanism = new Mechanism(); Map unknown = null; diff --git a/sentry/src/main/java/io/sentry/protocol/Message.java b/sentry/src/main/java/io/sentry/protocol/Message.java index a1c79e21986..9aceea56a65 100644 --- a/sentry/src/main/java/io/sentry/protocol/Message.java +++ b/sentry/src/main/java/io/sentry/protocol/Message.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.util.CollectionUtils; import io.sentry.vendor.gson.stream.JsonToken; @@ -131,7 +131,7 @@ public static final class Deserializer implements JsonDeserializer { @SuppressWarnings("unchecked") @Override - public @NotNull Message deserialize(@NotNull JsonObjectReader reader, @NotNull ILogger logger) + public @NotNull Message deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { reader.beginObject(); Message message = new Message(); diff --git a/sentry/src/main/java/io/sentry/protocol/OperatingSystem.java b/sentry/src/main/java/io/sentry/protocol/OperatingSystem.java index 796a4ea1a0f..ecfb59542b3 100644 --- a/sentry/src/main/java/io/sentry/protocol/OperatingSystem.java +++ b/sentry/src/main/java/io/sentry/protocol/OperatingSystem.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.util.CollectionUtils; import io.sentry.util.Objects; @@ -180,7 +180,7 @@ public static final class Deserializer implements JsonDeserializer unknown = null; diff --git a/sentry/src/main/java/io/sentry/protocol/Request.java b/sentry/src/main/java/io/sentry/protocol/Request.java index 14f54038444..44e205a3901 100644 --- a/sentry/src/main/java/io/sentry/protocol/Request.java +++ b/sentry/src/main/java/io/sentry/protocol/Request.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.util.CollectionUtils; import io.sentry.util.Objects; @@ -326,7 +326,7 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger @SuppressWarnings("unchecked") public static final class Deserializer implements JsonDeserializer { @Override - public @NotNull Request deserialize(@NotNull JsonObjectReader reader, @NotNull ILogger logger) + public @NotNull Request deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { reader.beginObject(); Request request = new Request(); diff --git a/sentry/src/main/java/io/sentry/protocol/Response.java b/sentry/src/main/java/io/sentry/protocol/Response.java index 23a16c78f8c..f1a93037109 100644 --- a/sentry/src/main/java/io/sentry/protocol/Response.java +++ b/sentry/src/main/java/io/sentry/protocol/Response.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.util.CollectionUtils; import io.sentry.vendor.gson.stream.JsonToken; @@ -154,7 +154,7 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @Override public @NotNull Response deserialize( - final @NotNull JsonObjectReader reader, final @NotNull ILogger logger) throws Exception { + final @NotNull ObjectReader reader, final @NotNull ILogger logger) throws Exception { reader.beginObject(); final Response response = new Response(); Map unknown = null; diff --git a/sentry/src/main/java/io/sentry/protocol/SdkInfo.java b/sentry/src/main/java/io/sentry/protocol/SdkInfo.java index ee3ac1eb165..928a8b522dc 100644 --- a/sentry/src/main/java/io/sentry/protocol/SdkInfo.java +++ b/sentry/src/main/java/io/sentry/protocol/SdkInfo.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.vendor.gson.stream.JsonToken; import java.io.IOException; @@ -116,7 +116,7 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @Override - public @NotNull SdkInfo deserialize(@NotNull JsonObjectReader reader, @NotNull ILogger logger) + public @NotNull SdkInfo deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { SdkInfo sdkInfo = new SdkInfo(); diff --git a/sentry/src/main/java/io/sentry/protocol/SdkVersion.java b/sentry/src/main/java/io/sentry/protocol/SdkVersion.java index f7ba230463b..aa997910be7 100644 --- a/sentry/src/main/java/io/sentry/protocol/SdkVersion.java +++ b/sentry/src/main/java/io/sentry/protocol/SdkVersion.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.SentryIntegrationPackageStorage; import io.sentry.SentryLevel; @@ -224,8 +224,8 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger @SuppressWarnings("unchecked") public static final class Deserializer implements JsonDeserializer { @Override - public @NotNull SdkVersion deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + public @NotNull SdkVersion deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) + throws Exception { String name = null; String version = null; diff --git a/sentry/src/main/java/io/sentry/protocol/SentryException.java b/sentry/src/main/java/io/sentry/protocol/SentryException.java index 5ee9464a3c4..4d56e127474 100644 --- a/sentry/src/main/java/io/sentry/protocol/SentryException.java +++ b/sentry/src/main/java/io/sentry/protocol/SentryException.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.vendor.gson.stream.JsonToken; import java.io.IOException; @@ -223,7 +223,7 @@ public static final class Deserializer implements JsonDeserializer unknown = null; reader.beginObject(); diff --git a/sentry/src/main/java/io/sentry/protocol/SentryId.java b/sentry/src/main/java/io/sentry/protocol/SentryId.java index c1e5ea1819e..109655fdf2b 100644 --- a/sentry/src/main/java/io/sentry/protocol/SentryId.java +++ b/sentry/src/main/java/io/sentry/protocol/SentryId.java @@ -2,8 +2,8 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.util.StringUtils; import java.io.IOException; @@ -82,7 +82,7 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @Override - public @NotNull SentryId deserialize(@NotNull JsonObjectReader reader, @NotNull ILogger logger) + public @NotNull SentryId deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { return new SentryId(reader.nextString()); } diff --git a/sentry/src/main/java/io/sentry/protocol/SentryPackage.java b/sentry/src/main/java/io/sentry/protocol/SentryPackage.java index cea6bb84974..aa2358d8dfb 100644 --- a/sentry/src/main/java/io/sentry/protocol/SentryPackage.java +++ b/sentry/src/main/java/io/sentry/protocol/SentryPackage.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.SentryLevel; import io.sentry.util.Objects; @@ -100,8 +100,8 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @Override - public @NotNull SentryPackage deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + public @NotNull SentryPackage deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) + throws Exception { String name = null; String version = null; diff --git a/sentry/src/main/java/io/sentry/protocol/SentryRuntime.java b/sentry/src/main/java/io/sentry/protocol/SentryRuntime.java index 751e664ae64..7d2ed8fa1ef 100644 --- a/sentry/src/main/java/io/sentry/protocol/SentryRuntime.java +++ b/sentry/src/main/java/io/sentry/protocol/SentryRuntime.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.util.CollectionUtils; import io.sentry.vendor.gson.stream.JsonToken; @@ -110,8 +110,8 @@ public void setUnknown(@Nullable Map unknown) { public static final class Deserializer implements JsonDeserializer { @Override - public @NotNull SentryRuntime deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + public @NotNull SentryRuntime deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) + throws Exception { reader.beginObject(); SentryRuntime runtime = new SentryRuntime(); Map unknown = null; diff --git a/sentry/src/main/java/io/sentry/protocol/SentrySpan.java b/sentry/src/main/java/io/sentry/protocol/SentrySpan.java index b627aa4c9f5..8e4396ac444 100644 --- a/sentry/src/main/java/io/sentry/protocol/SentrySpan.java +++ b/sentry/src/main/java/io/sentry/protocol/SentrySpan.java @@ -3,9 +3,9 @@ import io.sentry.DateUtils; import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.SentryLevel; import io.sentry.Span; @@ -218,8 +218,8 @@ public static final class Deserializer implements JsonDeserializer { @SuppressWarnings("unchecked") @Override - public @NotNull SentrySpan deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + public @NotNull SentrySpan deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) + throws Exception { reader.beginObject(); Double startTimestamp = null; diff --git a/sentry/src/main/java/io/sentry/protocol/SentryStackFrame.java b/sentry/src/main/java/io/sentry/protocol/SentryStackFrame.java index fcb93eb2e8f..03d64e2172f 100644 --- a/sentry/src/main/java/io/sentry/protocol/SentryStackFrame.java +++ b/sentry/src/main/java/io/sentry/protocol/SentryStackFrame.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.SentryLockReason; import io.sentry.vendor.gson.stream.JsonToken; @@ -398,7 +398,7 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @Override public @NotNull SentryStackFrame deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + @NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { SentryStackFrame sentryStackFrame = new SentryStackFrame(); Map unknown = null; reader.beginObject(); diff --git a/sentry/src/main/java/io/sentry/protocol/SentryStackTrace.java b/sentry/src/main/java/io/sentry/protocol/SentryStackTrace.java index 90b42666c8f..e79e8e7ec05 100644 --- a/sentry/src/main/java/io/sentry/protocol/SentryStackTrace.java +++ b/sentry/src/main/java/io/sentry/protocol/SentryStackTrace.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.util.CollectionUtils; import io.sentry.vendor.gson.stream.JsonToken; @@ -154,7 +154,7 @@ public static final class Deserializer implements JsonDeserializer unknown = null; reader.beginObject(); diff --git a/sentry/src/main/java/io/sentry/protocol/SentryThread.java b/sentry/src/main/java/io/sentry/protocol/SentryThread.java index 1d57e35b10d..accb05968e1 100644 --- a/sentry/src/main/java/io/sentry/protocol/SentryThread.java +++ b/sentry/src/main/java/io/sentry/protocol/SentryThread.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.SentryLockReason; import io.sentry.vendor.gson.stream.JsonToken; @@ -303,8 +303,8 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @SuppressWarnings("unchecked") @Override - public @NotNull SentryThread deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + public @NotNull SentryThread deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) + throws Exception { SentryThread sentryThread = new SentryThread(); Map unknown = null; reader.beginObject(); diff --git a/sentry/src/main/java/io/sentry/protocol/SentryTransaction.java b/sentry/src/main/java/io/sentry/protocol/SentryTransaction.java index d0c9271a26e..791fda17999 100644 --- a/sentry/src/main/java/io/sentry/protocol/SentryTransaction.java +++ b/sentry/src/main/java/io/sentry/protocol/SentryTransaction.java @@ -3,9 +3,9 @@ import io.sentry.DateUtils; import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.SentryBaseEvent; import io.sentry.SentryTracer; @@ -231,7 +231,7 @@ public static final class Deserializer implements JsonDeserializer { @SuppressWarnings("unchecked") @Override - public @NotNull User deserialize(@NotNull JsonObjectReader reader, @NotNull ILogger logger) + public @NotNull User deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { reader.beginObject(); User user = new User(); diff --git a/sentry/src/main/java/io/sentry/protocol/ViewHierarchy.java b/sentry/src/main/java/io/sentry/protocol/ViewHierarchy.java index 69e51560402..791c9bbbd69 100644 --- a/sentry/src/main/java/io/sentry/protocol/ViewHierarchy.java +++ b/sentry/src/main/java/io/sentry/protocol/ViewHierarchy.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.vendor.gson.stream.JsonToken; import java.io.IOException; @@ -73,8 +73,8 @@ public void setUnknown(@Nullable Map unknown) { public static final class Deserializer implements JsonDeserializer { @Override - public @NotNull ViewHierarchy deserialize( - @NotNull JsonObjectReader reader, @NotNull ILogger logger) throws Exception { + public @NotNull ViewHierarchy deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) + throws Exception { @Nullable String renderingSystem = null; @Nullable List windows = null; diff --git a/sentry/src/main/java/io/sentry/protocol/ViewHierarchyNode.java b/sentry/src/main/java/io/sentry/protocol/ViewHierarchyNode.java index 923eb95877e..525d644fdc5 100644 --- a/sentry/src/main/java/io/sentry/protocol/ViewHierarchyNode.java +++ b/sentry/src/main/java/io/sentry/protocol/ViewHierarchyNode.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.vendor.gson.stream.JsonToken; import java.io.IOException; @@ -205,7 +205,7 @@ public static final class Deserializer implements JsonDeserializer unknown = null; @NotNull final ViewHierarchyNode node = new ViewHierarchyNode(); diff --git a/sentry/src/main/java/io/sentry/util/MapObjectReader.java b/sentry/src/main/java/io/sentry/util/MapObjectReader.java new file mode 100644 index 00000000000..ba2cb83ed3c --- /dev/null +++ b/sentry/src/main/java/io/sentry/util/MapObjectReader.java @@ -0,0 +1,350 @@ +package io.sentry.util; + +import io.sentry.ILogger; +import io.sentry.JsonDeserializer; +import io.sentry.ObjectReader; +import io.sentry.SentryLevel; +import io.sentry.vendor.gson.stream.JsonToken; +import java.io.IOException; +import java.util.AbstractMap; +import java.util.ArrayDeque; +import java.util.Date; +import java.util.Deque; +import java.util.List; +import java.util.Map; +import java.util.TimeZone; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@SuppressWarnings("unchecked") +public final class MapObjectReader implements ObjectReader { + + private final Deque> stack; + + public MapObjectReader(final Map root) { + stack = new ArrayDeque<>(); + stack.addLast(new AbstractMap.SimpleEntry<>(null, root)); + } + + @Override + public void nextUnknown(final @NotNull ILogger logger, Map unknown, String name) { + try { + unknown.put(name, nextObjectOrNull()); + } catch (Exception exception) { + logger.log(SentryLevel.ERROR, exception, "Error deserializing unknown key: %s", name); + } + } + + @Nullable + @Override + public List nextListOrNull( + final @NotNull ILogger logger, final @NotNull JsonDeserializer deserializer) + throws IOException { + return nextValueOrNull(); + } + + @Nullable + @Override + public Map nextMapOrNull( + final @NotNull ILogger logger, final @NotNull JsonDeserializer deserializer) + throws IOException { + return nextValueOrNull(); + } + + @Nullable + @Override + public T nextOrNull( + final @NotNull ILogger logger, final @NotNull JsonDeserializer deserializer) + throws Exception { + return nextValueOrNull(logger, deserializer); + } + + @Nullable + @Override + public Date nextDateOrNull(final @NotNull ILogger logger) throws IOException { + String dateString = nextStringOrNull(); + return ObjectReader.dateOrNull(dateString, logger); + } + + @Nullable + @Override + public TimeZone nextTimeZoneOrNull(final @NotNull ILogger logger) throws IOException { + String timeZoneId = nextStringOrNull(); + return timeZoneId != null ? TimeZone.getTimeZone(timeZoneId) : null; + } + + @Nullable + @Override + public Object nextObjectOrNull() throws IOException { + return nextValueOrNull(); + } + + @NotNull + @Override + public JsonToken peek() throws IOException { + if (stack.isEmpty()) { + return JsonToken.END_DOCUMENT; + } + + Map.Entry currentEntry = stack.peekLast(); + if (currentEntry == null) { + return JsonToken.END_DOCUMENT; + } + + if (currentEntry.getKey() != null) { + return JsonToken.NAME; + } + + Object value = currentEntry.getValue(); + + if (value instanceof Map) { + return JsonToken.BEGIN_OBJECT; + } else if (value instanceof List) { + return JsonToken.BEGIN_ARRAY; + } else if (value instanceof String) { + return JsonToken.STRING; + } else if (value instanceof Number) { + return JsonToken.NUMBER; + } else if (value instanceof Boolean) { + return JsonToken.BOOLEAN; + } else if (value instanceof JsonToken) { + return (JsonToken) value; + } else { + return JsonToken.END_DOCUMENT; + } + } + + @NotNull + @Override + public String nextName() throws IOException { + Map.Entry currentEntry = stack.peekLast(); + if (currentEntry != null && currentEntry.getKey() != null) { + return currentEntry.getKey(); + } + throw new IOException("Expected a name but was " + peek()); + } + + @Override + public void beginObject() throws IOException { + Map.Entry currentEntry = stack.removeLast(); + if (currentEntry == null) { + throw new IOException("No more entries"); + } + Object value = currentEntry.getValue(); + if (value instanceof Map) { + // insert a dummy entry to indicate end of an object + stack.addLast(new AbstractMap.SimpleEntry<>(null, JsonToken.END_OBJECT)); + // extract map entries onto the stack + for (Map.Entry entry : ((Map) value).entrySet()) { + stack.addLast(entry); + } + } else { + throw new IOException("Current token is not an object"); + } + } + + @Override + public void endObject() throws IOException { + if (stack.size() > 1) { + stack.removeLast(); // Pop the current map from stack + } + } + + @Override + public void beginArray() throws IOException { + Map.Entry currentEntry = stack.removeLast(); + if (currentEntry == null) { + throw new IOException("No more entries"); + } + Object value = currentEntry.getValue(); + if (value instanceof List) { + // insert a dummy entry to indicate end of an object + stack.addLast(new AbstractMap.SimpleEntry<>(null, JsonToken.END_ARRAY)); + // extract map entries onto the stack + for (Object entry : (List) value) { + stack.addLast(new AbstractMap.SimpleEntry<>(null, entry)); + } + } else { + throw new IOException("Current token is not an object"); + } + } + + @Override + public void endArray() throws IOException { + if (stack.size() > 1) { + stack.removeLast(); // Pop the current array from stack + } + } + + @Override + public boolean hasNext() throws IOException { + return !stack.isEmpty(); + } + + @Override + public int nextInt() throws IOException { + Object value = nextValueOrNull(); + if (value instanceof Number) { + return ((Number) value).intValue(); + } else { + throw new IOException("Expected int"); + } + } + + @Nullable + @Override + public Integer nextIntegerOrNull() throws IOException { + Object value = nextValueOrNull(); + if (value instanceof Number) { + return ((Number) value).intValue(); + } + return null; + } + + @Override + public long nextLong() throws IOException { + Object value = nextValueOrNull(); + if (value instanceof Number) { + return ((Number) value).longValue(); + } else { + throw new IOException("Expected long"); + } + } + + @Nullable + @Override + public Long nextLongOrNull() throws IOException { + Object value = nextValueOrNull(); + if (value instanceof Number) { + return ((Number) value).longValue(); + } + return null; + } + + @Override + public String nextString() throws IOException { + String value = nextValueOrNull(); + if (value != null) { + return value; + } else { + throw new IOException("Expected string"); + } + } + + @Nullable + @Override + public String nextStringOrNull() throws IOException { + return nextValueOrNull(); + } + + @Override + public boolean nextBoolean() throws IOException { + Boolean value = nextValueOrNull(); + if (value != null) { + return value; + } else { + throw new IOException("Expected boolean"); + } + } + + @Nullable + @Override + public Boolean nextBooleanOrNull() throws IOException { + return nextValueOrNull(); + } + + @Override + public double nextDouble() throws IOException { + Object value = nextValueOrNull(); + if (value instanceof Number) { + return ((Number) value).doubleValue(); + } else { + throw new IOException("Expected double"); + } + } + + @Nullable + @Override + public Double nextDoubleOrNull() throws IOException { + Object value = nextValueOrNull(); + if (value instanceof Number) { + return ((Number) value).doubleValue(); + } + return null; + } + + @Nullable + @Override + public Float nextFloatOrNull() throws IOException { + Object value = nextValueOrNull(); + if (value instanceof Number) { + return ((Number) value).floatValue(); + } + return null; + } + + @Override + public float nextFloat() throws IOException { + Object value = nextValueOrNull(); + if (value instanceof Number) { + return ((Number) value).floatValue(); + } else { + throw new IOException("Expected float"); + } + } + + @Override + public void nextNull() throws IOException { + nextValueOrNull(); + } + + @Override + public void setLenient(boolean lenient) {} + + @Override + public void skipValue() throws IOException {} + + @SuppressWarnings("TypeParameterUnusedInFormals") + @Nullable + private T nextValueOrNull() throws IOException { + try { + return nextValueOrNull(null, null); + } catch (Exception e) { + throw new IOException(e); + } + } + + @SuppressWarnings("TypeParameterUnusedInFormals") + @Nullable + private T nextValueOrNull( + final @Nullable ILogger logger, final @Nullable JsonDeserializer deserializer) + throws Exception { + Map.Entry currentEntry = stack.peekLast(); + if (currentEntry == null) { + return null; + } + T value = (T) currentEntry.getValue(); + if (deserializer != null && logger != null) { + return deserializer.deserialize(this, logger); + } else if (value instanceof List) { + List list = (List) value; + if (!list.isEmpty()) { + T next = (T) list.remove(0); + if (next instanceof Map) { + stack.addLast(new AbstractMap.SimpleEntry<>(null, next)); + } + return next; + } + } else if (value instanceof Map) { + stack.addLast(new AbstractMap.SimpleEntry<>(null, value)); + return value; + } + stack.removeLast(); + return value; + } + + @Override + public void close() throws IOException { + stack.clear(); + } +} diff --git a/sentry/src/main/java/io/sentry/util/MapObjectWriter.java b/sentry/src/main/java/io/sentry/util/MapObjectWriter.java index 26f80eddc29..0bbc70a779d 100644 --- a/sentry/src/main/java/io/sentry/util/MapObjectWriter.java +++ b/sentry/src/main/java/io/sentry/util/MapObjectWriter.java @@ -120,6 +120,11 @@ public MapObjectWriter value(final @NotNull ILogger logger, final @Nullable Obje return this; } + @Override + public void setLenient(boolean lenient) { + // no-op + } + @Override public MapObjectWriter beginArray() throws IOException { stack.add(new ArrayList<>()); @@ -151,6 +156,12 @@ public MapObjectWriter value(final @Nullable String value) throws IOException { return this; } + @Override + public ObjectWriter jsonValue(@Nullable String value) throws IOException { + // no-op + return this; + } + @Override public MapObjectWriter nullValue() throws IOException { postValue((Object) null); diff --git a/sentry/src/test/java/io/sentry/JsonObjectReaderTest.kt b/sentry/src/test/java/io/sentry/JsonObjectReaderTest.kt index 976c88a1cd0..ec18b4ed5c2 100644 --- a/sentry/src/test/java/io/sentry/JsonObjectReaderTest.kt +++ b/sentry/src/test/java/io/sentry/JsonObjectReaderTest.kt @@ -285,7 +285,7 @@ class JsonObjectReaderTest { var bar: String? = null ) { class Deserializer : JsonDeserializer { - override fun deserialize(reader: JsonObjectReader, logger: ILogger): Deserializable { + override fun deserialize(reader: ObjectReader, logger: ILogger): Deserializable { return Deserializable().apply { reader.beginObject() reader.nextName() diff --git a/sentry/src/test/java/io/sentry/SentryEnvelopeItemTest.kt b/sentry/src/test/java/io/sentry/SentryEnvelopeItemTest.kt index 98178976510..efc5e5cadfe 100644 --- a/sentry/src/test/java/io/sentry/SentryEnvelopeItemTest.kt +++ b/sentry/src/test/java/io/sentry/SentryEnvelopeItemTest.kt @@ -1,6 +1,8 @@ package io.sentry import io.sentry.exception.SentryEnvelopeException +import io.sentry.protocol.ReplayRecordingSerializationTest +import io.sentry.protocol.SentryReplayEventSerializationTest import io.sentry.protocol.User import io.sentry.protocol.ViewHierarchy import io.sentry.test.injectForField @@ -10,12 +12,15 @@ import org.mockito.kotlin.any import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +import org.msgpack.core.MessagePack import java.io.BufferedWriter import java.io.ByteArrayOutputStream import java.io.File import java.io.IOException +import java.io.InputStreamReader import java.io.OutputStreamWriter import java.nio.charset.Charset +import java.nio.file.Files import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals @@ -66,7 +71,12 @@ class SentryEnvelopeItemTest { fun `fromAttachment with bytes`() { val attachment = Attachment(fixture.bytesAllowed, fixture.filename) - val item = SentryEnvelopeItem.fromAttachment(fixture.serializer, fixture.options.logger, attachment, fixture.maxAttachmentSize) + val item = SentryEnvelopeItem.fromAttachment( + fixture.serializer, + fixture.options.logger, + attachment, + fixture.maxAttachmentSize + ) assertAttachment(attachment, fixture.bytesAllowed, item) } @@ -78,7 +88,12 @@ class SentryEnvelopeItemTest { val attachment = Attachment(viewHierarchy, fixture.filename, "text/plain", null, false) - val item = SentryEnvelopeItem.fromAttachment(fixture.serializer, fixture.options.logger, attachment, fixture.maxAttachmentSize) + val item = SentryEnvelopeItem.fromAttachment( + fixture.serializer, + fixture.options.logger, + attachment, + fixture.maxAttachmentSize + ) assertAttachment(attachment, viewHierarchySerialized, item) } @@ -87,7 +102,12 @@ class SentryEnvelopeItemTest { fun `fromAttachment with attachmentType`() { val attachment = Attachment(fixture.pathname, fixture.filename, "", true, "event.minidump") - val item = SentryEnvelopeItem.fromAttachment(fixture.serializer, fixture.options.logger, attachment, fixture.maxAttachmentSize) + val item = SentryEnvelopeItem.fromAttachment( + fixture.serializer, + fixture.options.logger, + attachment, + fixture.maxAttachmentSize + ) assertEquals("event.minidump", item.header.attachmentType) } @@ -98,7 +118,12 @@ class SentryEnvelopeItemTest { file.writeBytes(fixture.bytesAllowed) val attachment = Attachment(file.path) - val item = SentryEnvelopeItem.fromAttachment(fixture.serializer, fixture.options.logger, attachment, fixture.maxAttachmentSize) + val item = SentryEnvelopeItem.fromAttachment( + fixture.serializer, + fixture.options.logger, + attachment, + fixture.maxAttachmentSize + ) assertAttachment(attachment, fixture.bytesAllowed, item) } @@ -110,7 +135,12 @@ class SentryEnvelopeItemTest { file.writeBytes(twoMB) val attachment = Attachment(file.absolutePath) - val item = SentryEnvelopeItem.fromAttachment(fixture.serializer, fixture.options.logger, attachment, fixture.maxAttachmentSize) + val item = SentryEnvelopeItem.fromAttachment( + fixture.serializer, + fixture.options.logger, + attachment, + fixture.maxAttachmentSize + ) assertAttachment(attachment, twoMB, item) } @@ -119,7 +149,12 @@ class SentryEnvelopeItemTest { fun `fromAttachment with non existent file`() { val attachment = Attachment("I don't exist", "file.txt") - val item = SentryEnvelopeItem.fromAttachment(fixture.serializer, fixture.options.logger, attachment, fixture.maxAttachmentSize) + val item = SentryEnvelopeItem.fromAttachment( + fixture.serializer, + fixture.options.logger, + attachment, + fixture.maxAttachmentSize + ) assertFailsWith( "Reading the attachment ${attachment.pathname} failed, because the file located at " + @@ -139,7 +174,12 @@ class SentryEnvelopeItemTest { if (changedFileReadPermission) { val attachment = Attachment(file.path, "file.txt") - val item = SentryEnvelopeItem.fromAttachment(fixture.serializer, fixture.options.logger, attachment, fixture.maxAttachmentSize) + val item = SentryEnvelopeItem.fromAttachment( + fixture.serializer, + fixture.options.logger, + attachment, + fixture.maxAttachmentSize + ) assertFailsWith( "Reading the attachment ${attachment.pathname} failed, " + @@ -162,7 +202,12 @@ class SentryEnvelopeItemTest { val securityManager = DenyReadFileSecurityManager(fixture.pathname) System.setSecurityManager(securityManager) - val item = SentryEnvelopeItem.fromAttachment(fixture.serializer, fixture.options.logger, attachment, fixture.maxAttachmentSize) + val item = SentryEnvelopeItem.fromAttachment( + fixture.serializer, + fixture.options.logger, + attachment, + fixture.maxAttachmentSize + ) assertFailsWith("Reading the attachment ${attachment.pathname} failed.") { item.data @@ -181,7 +226,12 @@ class SentryEnvelopeItemTest { // reflection instead. attachment.injectForField("pathname", null) - val item = SentryEnvelopeItem.fromAttachment(fixture.serializer, fixture.options.logger, attachment, fixture.maxAttachmentSize) + val item = SentryEnvelopeItem.fromAttachment( + fixture.serializer, + fixture.options.logger, + attachment, + fixture.maxAttachmentSize + ) assertFailsWith( "Couldn't attach the attachment ${attachment.filename}.\n" + @@ -196,7 +246,12 @@ class SentryEnvelopeItemTest { val image = this::class.java.classLoader.getResource("Tongariro.jpg")!! val attachment = Attachment(image.path) - val item = SentryEnvelopeItem.fromAttachment(fixture.serializer, fixture.options.logger, attachment, fixture.maxAttachmentSize) + val item = SentryEnvelopeItem.fromAttachment( + fixture.serializer, + fixture.options.logger, + attachment, + fixture.maxAttachmentSize + ) assertAttachment(attachment, image.readBytes(), item) } @@ -204,7 +259,12 @@ class SentryEnvelopeItemTest { fun `fromAttachment with bytes too big`() { val attachment = Attachment(fixture.bytesTooBig, fixture.filename) val exception = assertFailsWith { - SentryEnvelopeItem.fromAttachment(fixture.serializer, fixture.options.logger, attachment, fixture.maxAttachmentSize).data + SentryEnvelopeItem.fromAttachment( + fixture.serializer, + fixture.options.logger, + attachment, + fixture.maxAttachmentSize + ).data } assertEquals( @@ -227,7 +287,12 @@ class SentryEnvelopeItemTest { val attachment = Attachment(serializable, fixture.filename, "text/plain", null, false) val exception = assertFailsWith { - SentryEnvelopeItem.fromAttachment(fixture.serializer, fixture.options.logger, attachment, fixture.maxAttachmentSize).data + SentryEnvelopeItem.fromAttachment( + fixture.serializer, + fixture.options.logger, + attachment, + fixture.maxAttachmentSize + ).data } assertEquals( @@ -246,7 +311,12 @@ class SentryEnvelopeItemTest { val attachment = Attachment(file.path) val exception = assertFailsWith { - SentryEnvelopeItem.fromAttachment(fixture.serializer, fixture.options.logger, attachment, fixture.maxAttachmentSize).data + SentryEnvelopeItem.fromAttachment( + fixture.serializer, + fixture.options.logger, + attachment, + fixture.maxAttachmentSize + ).data } assertEquals( @@ -261,7 +331,12 @@ class SentryEnvelopeItemTest { fun `fromAttachment with bytesFrom serializable are null`() { val attachment = Attachment(mock(), "mock-file-name", null, null, false) - val item = SentryEnvelopeItem.fromAttachment(fixture.errorSerializer, fixture.options.logger, attachment, fixture.maxAttachmentSize) + val item = SentryEnvelopeItem.fromAttachment( + fixture.errorSerializer, + fixture.options.logger, + attachment, + fixture.maxAttachmentSize + ) assertFailsWith( "Couldn't attach the attachment ${attachment.filename}.\n" + @@ -279,8 +354,13 @@ class SentryEnvelopeItemTest { } file.writeBytes(fixture.bytes) - SentryEnvelopeItem.fromProfilingTrace(profilingTraceData, fixture.maxAttachmentSize, fixture.serializer).data - verify(profilingTraceData).sampledProfile = Base64.encodeToString(fixture.bytes, Base64.NO_WRAP or Base64.NO_PADDING) + SentryEnvelopeItem.fromProfilingTrace( + profilingTraceData, + fixture.maxAttachmentSize, + fixture.serializer + ).data + verify(profilingTraceData).sampledProfile = + Base64.encodeToString(fixture.bytes, Base64.NO_WRAP or Base64.NO_PADDING) } @Test @@ -292,7 +372,11 @@ class SentryEnvelopeItemTest { file.writeBytes(fixture.bytes) assert(file.exists()) - val traceData = SentryEnvelopeItem.fromProfilingTrace(profilingTraceData, fixture.maxAttachmentSize, mock()) + val traceData = SentryEnvelopeItem.fromProfilingTrace( + profilingTraceData, + fixture.maxAttachmentSize, + mock() + ) assert(file.exists()) traceData.data assertFalse(file.exists()) @@ -306,7 +390,11 @@ class SentryEnvelopeItemTest { } assertFailsWith("Dropping profiling trace data, because the file ${file.path} doesn't exists") { - SentryEnvelopeItem.fromProfilingTrace(profilingTraceData, fixture.maxAttachmentSize, mock()).data + SentryEnvelopeItem.fromProfilingTrace( + profilingTraceData, + fixture.maxAttachmentSize, + mock() + ).data } } @@ -319,7 +407,11 @@ class SentryEnvelopeItemTest { file.writeBytes(fixture.bytes) file.setReadable(false) assertFailsWith("Dropping profiling trace data, because the file ${file.path} doesn't exists") { - SentryEnvelopeItem.fromProfilingTrace(profilingTraceData, fixture.maxAttachmentSize, mock()).data + SentryEnvelopeItem.fromProfilingTrace( + profilingTraceData, + fixture.maxAttachmentSize, + mock() + ).data } } @@ -331,7 +423,11 @@ class SentryEnvelopeItemTest { whenever(it.traceFile).thenReturn(file) } - val traceData = SentryEnvelopeItem.fromProfilingTrace(profilingTraceData, fixture.maxAttachmentSize, mock()) + val traceData = SentryEnvelopeItem.fromProfilingTrace( + profilingTraceData, + fixture.maxAttachmentSize, + mock() + ) assertFailsWith("Profiling trace file is empty") { traceData.data } @@ -346,7 +442,11 @@ class SentryEnvelopeItemTest { } val exception = assertFailsWith { - SentryEnvelopeItem.fromProfilingTrace(profilingTraceData, fixture.maxAttachmentSize, mock()).data + SentryEnvelopeItem.fromProfilingTrace( + profilingTraceData, + fixture.maxAttachmentSize, + mock() + ).data } assertEquals( @@ -357,6 +457,58 @@ class SentryEnvelopeItemTest { ) } + @Test + fun `fromReplay encodes payload into msgpack`() { + val file = Files.createTempFile("replay", "").toFile() + val videoBytes = + this::class.java.classLoader.getResource("Tongariro.jpg")!!.readBytes() + file.writeBytes(videoBytes) + + val replayEvent = SentryReplayEventSerializationTest.Fixture().getSut().apply { + videoFile = file + } + val replayRecording = ReplayRecordingSerializationTest.Fixture().getSut() + val replayItem = SentryEnvelopeItem + .fromReplay(fixture.serializer, fixture.options.logger, replayEvent, replayRecording) + + assertEquals(SentryItemType.ReplayVideo, replayItem.header.type) + + assertPayload(replayItem, replayEvent, replayRecording, videoBytes) + } + + @Test + fun `fromReplay does not add video item when no bytes`() { + val file = File(fixture.pathname) + file.writeBytes(ByteArray(0)) + + val replayEvent = SentryReplayEventSerializationTest.Fixture().getSut().apply { + videoFile = file + } + + val replayItem = SentryEnvelopeItem + .fromReplay(fixture.serializer, fixture.options.logger, replayEvent, null) + replayItem.data + assertPayload(replayItem, replayEvent, null, ByteArray(0)) { mapSize -> + assertEquals(1, mapSize) + } + } + + @Test + fun `fromReplay deletes file only after reading data`() { + val file = File(fixture.pathname) + val replayEvent = SentryReplayEventSerializationTest.Fixture().getSut().apply { + videoFile = file + } + + file.writeBytes(fixture.bytes) + assert(file.exists()) + val replayItem = SentryEnvelopeItem + .fromReplay(fixture.serializer, fixture.options.logger, replayEvent, null) + assert(file.exists()) + replayItem.data + assertFalse(file.exists()) + } + private fun createSession(): Session { return Session("dis", User(), "env", "rel") } @@ -379,4 +531,45 @@ class SentryEnvelopeItemTest { } } } + + private fun assertPayload( + replayItem: SentryEnvelopeItem, + replayEvent: SentryReplayEvent, + replayRecording: ReplayRecording?, + videoBytes: ByteArray, + mapSizeAsserter: (mapSize: Int) -> Unit = {} + ) { + val unpacker = MessagePack.newDefaultUnpacker(replayItem.data) + val mapSize = unpacker.unpackMapHeader() + mapSizeAsserter(mapSize) + for (i in 0 until mapSize) { + val key = unpacker.unpackString() + when (key) { + SentryItemType.ReplayEvent.itemType -> { + val replayEventLength = unpacker.unpackBinaryHeader() + val replayEventBytes = unpacker.readPayload(replayEventLength) + val actualReplayEvent = fixture.serializer.deserialize( + InputStreamReader(replayEventBytes.inputStream()), + SentryReplayEvent::class.java + ) + assertEquals(replayEvent, actualReplayEvent) + } + SentryItemType.ReplayRecording.itemType -> { + val replayRecordingLength = unpacker.unpackBinaryHeader() + val replayRecordingBytes = unpacker.readPayload(replayRecordingLength) + val actualReplayRecording = fixture.serializer.deserialize( + InputStreamReader(replayRecordingBytes.inputStream()), + ReplayRecording::class.java + ) + assertEquals(replayRecording, actualReplayRecording) + } + SentryItemType.ReplayVideo.itemType -> { + val videoLength = unpacker.unpackBinaryHeader() + val actualBytes = unpacker.readPayload(videoLength) + assertArrayEquals(videoBytes, actualBytes) + } + } + } + unpacker.close() + } } diff --git a/sentry/src/test/java/io/sentry/protocol/SentryBaseEventSerializationTest.kt b/sentry/src/test/java/io/sentry/protocol/SentryBaseEventSerializationTest.kt index 4bc13559dae..3da517ef56f 100644 --- a/sentry/src/test/java/io/sentry/protocol/SentryBaseEventSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/protocol/SentryBaseEventSerializationTest.kt @@ -2,8 +2,8 @@ package io.sentry.protocol import io.sentry.ILogger import io.sentry.JsonDeserializer -import io.sentry.JsonObjectReader import io.sentry.JsonSerializable +import io.sentry.ObjectReader import io.sentry.ObjectWriter import io.sentry.SentryBaseEvent import io.sentry.SentryIntegrationPackageStorage @@ -27,7 +27,7 @@ class SentryBaseEventSerializationTest { } class Deserializer : JsonDeserializer { - override fun deserialize(reader: JsonObjectReader, logger: ILogger): Sut { + override fun deserialize(reader: ObjectReader, logger: ILogger): Sut { val sut = Sut() reader.beginObject() From b8cb924594c5b56ee7f35d207bb7511b7ae729ba Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 21 Feb 2024 00:44:03 +0100 Subject: [PATCH 18/89] Add missing test --- .../java/io/sentry/SentryEnvelopeItemTest.kt | 235 ++++++++++++++++-- 1 file changed, 214 insertions(+), 21 deletions(-) diff --git a/sentry/src/test/java/io/sentry/SentryEnvelopeItemTest.kt b/sentry/src/test/java/io/sentry/SentryEnvelopeItemTest.kt index 98178976510..efc5e5cadfe 100644 --- a/sentry/src/test/java/io/sentry/SentryEnvelopeItemTest.kt +++ b/sentry/src/test/java/io/sentry/SentryEnvelopeItemTest.kt @@ -1,6 +1,8 @@ package io.sentry import io.sentry.exception.SentryEnvelopeException +import io.sentry.protocol.ReplayRecordingSerializationTest +import io.sentry.protocol.SentryReplayEventSerializationTest import io.sentry.protocol.User import io.sentry.protocol.ViewHierarchy import io.sentry.test.injectForField @@ -10,12 +12,15 @@ import org.mockito.kotlin.any import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +import org.msgpack.core.MessagePack import java.io.BufferedWriter import java.io.ByteArrayOutputStream import java.io.File import java.io.IOException +import java.io.InputStreamReader import java.io.OutputStreamWriter import java.nio.charset.Charset +import java.nio.file.Files import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals @@ -66,7 +71,12 @@ class SentryEnvelopeItemTest { fun `fromAttachment with bytes`() { val attachment = Attachment(fixture.bytesAllowed, fixture.filename) - val item = SentryEnvelopeItem.fromAttachment(fixture.serializer, fixture.options.logger, attachment, fixture.maxAttachmentSize) + val item = SentryEnvelopeItem.fromAttachment( + fixture.serializer, + fixture.options.logger, + attachment, + fixture.maxAttachmentSize + ) assertAttachment(attachment, fixture.bytesAllowed, item) } @@ -78,7 +88,12 @@ class SentryEnvelopeItemTest { val attachment = Attachment(viewHierarchy, fixture.filename, "text/plain", null, false) - val item = SentryEnvelopeItem.fromAttachment(fixture.serializer, fixture.options.logger, attachment, fixture.maxAttachmentSize) + val item = SentryEnvelopeItem.fromAttachment( + fixture.serializer, + fixture.options.logger, + attachment, + fixture.maxAttachmentSize + ) assertAttachment(attachment, viewHierarchySerialized, item) } @@ -87,7 +102,12 @@ class SentryEnvelopeItemTest { fun `fromAttachment with attachmentType`() { val attachment = Attachment(fixture.pathname, fixture.filename, "", true, "event.minidump") - val item = SentryEnvelopeItem.fromAttachment(fixture.serializer, fixture.options.logger, attachment, fixture.maxAttachmentSize) + val item = SentryEnvelopeItem.fromAttachment( + fixture.serializer, + fixture.options.logger, + attachment, + fixture.maxAttachmentSize + ) assertEquals("event.minidump", item.header.attachmentType) } @@ -98,7 +118,12 @@ class SentryEnvelopeItemTest { file.writeBytes(fixture.bytesAllowed) val attachment = Attachment(file.path) - val item = SentryEnvelopeItem.fromAttachment(fixture.serializer, fixture.options.logger, attachment, fixture.maxAttachmentSize) + val item = SentryEnvelopeItem.fromAttachment( + fixture.serializer, + fixture.options.logger, + attachment, + fixture.maxAttachmentSize + ) assertAttachment(attachment, fixture.bytesAllowed, item) } @@ -110,7 +135,12 @@ class SentryEnvelopeItemTest { file.writeBytes(twoMB) val attachment = Attachment(file.absolutePath) - val item = SentryEnvelopeItem.fromAttachment(fixture.serializer, fixture.options.logger, attachment, fixture.maxAttachmentSize) + val item = SentryEnvelopeItem.fromAttachment( + fixture.serializer, + fixture.options.logger, + attachment, + fixture.maxAttachmentSize + ) assertAttachment(attachment, twoMB, item) } @@ -119,7 +149,12 @@ class SentryEnvelopeItemTest { fun `fromAttachment with non existent file`() { val attachment = Attachment("I don't exist", "file.txt") - val item = SentryEnvelopeItem.fromAttachment(fixture.serializer, fixture.options.logger, attachment, fixture.maxAttachmentSize) + val item = SentryEnvelopeItem.fromAttachment( + fixture.serializer, + fixture.options.logger, + attachment, + fixture.maxAttachmentSize + ) assertFailsWith( "Reading the attachment ${attachment.pathname} failed, because the file located at " + @@ -139,7 +174,12 @@ class SentryEnvelopeItemTest { if (changedFileReadPermission) { val attachment = Attachment(file.path, "file.txt") - val item = SentryEnvelopeItem.fromAttachment(fixture.serializer, fixture.options.logger, attachment, fixture.maxAttachmentSize) + val item = SentryEnvelopeItem.fromAttachment( + fixture.serializer, + fixture.options.logger, + attachment, + fixture.maxAttachmentSize + ) assertFailsWith( "Reading the attachment ${attachment.pathname} failed, " + @@ -162,7 +202,12 @@ class SentryEnvelopeItemTest { val securityManager = DenyReadFileSecurityManager(fixture.pathname) System.setSecurityManager(securityManager) - val item = SentryEnvelopeItem.fromAttachment(fixture.serializer, fixture.options.logger, attachment, fixture.maxAttachmentSize) + val item = SentryEnvelopeItem.fromAttachment( + fixture.serializer, + fixture.options.logger, + attachment, + fixture.maxAttachmentSize + ) assertFailsWith("Reading the attachment ${attachment.pathname} failed.") { item.data @@ -181,7 +226,12 @@ class SentryEnvelopeItemTest { // reflection instead. attachment.injectForField("pathname", null) - val item = SentryEnvelopeItem.fromAttachment(fixture.serializer, fixture.options.logger, attachment, fixture.maxAttachmentSize) + val item = SentryEnvelopeItem.fromAttachment( + fixture.serializer, + fixture.options.logger, + attachment, + fixture.maxAttachmentSize + ) assertFailsWith( "Couldn't attach the attachment ${attachment.filename}.\n" + @@ -196,7 +246,12 @@ class SentryEnvelopeItemTest { val image = this::class.java.classLoader.getResource("Tongariro.jpg")!! val attachment = Attachment(image.path) - val item = SentryEnvelopeItem.fromAttachment(fixture.serializer, fixture.options.logger, attachment, fixture.maxAttachmentSize) + val item = SentryEnvelopeItem.fromAttachment( + fixture.serializer, + fixture.options.logger, + attachment, + fixture.maxAttachmentSize + ) assertAttachment(attachment, image.readBytes(), item) } @@ -204,7 +259,12 @@ class SentryEnvelopeItemTest { fun `fromAttachment with bytes too big`() { val attachment = Attachment(fixture.bytesTooBig, fixture.filename) val exception = assertFailsWith { - SentryEnvelopeItem.fromAttachment(fixture.serializer, fixture.options.logger, attachment, fixture.maxAttachmentSize).data + SentryEnvelopeItem.fromAttachment( + fixture.serializer, + fixture.options.logger, + attachment, + fixture.maxAttachmentSize + ).data } assertEquals( @@ -227,7 +287,12 @@ class SentryEnvelopeItemTest { val attachment = Attachment(serializable, fixture.filename, "text/plain", null, false) val exception = assertFailsWith { - SentryEnvelopeItem.fromAttachment(fixture.serializer, fixture.options.logger, attachment, fixture.maxAttachmentSize).data + SentryEnvelopeItem.fromAttachment( + fixture.serializer, + fixture.options.logger, + attachment, + fixture.maxAttachmentSize + ).data } assertEquals( @@ -246,7 +311,12 @@ class SentryEnvelopeItemTest { val attachment = Attachment(file.path) val exception = assertFailsWith { - SentryEnvelopeItem.fromAttachment(fixture.serializer, fixture.options.logger, attachment, fixture.maxAttachmentSize).data + SentryEnvelopeItem.fromAttachment( + fixture.serializer, + fixture.options.logger, + attachment, + fixture.maxAttachmentSize + ).data } assertEquals( @@ -261,7 +331,12 @@ class SentryEnvelopeItemTest { fun `fromAttachment with bytesFrom serializable are null`() { val attachment = Attachment(mock(), "mock-file-name", null, null, false) - val item = SentryEnvelopeItem.fromAttachment(fixture.errorSerializer, fixture.options.logger, attachment, fixture.maxAttachmentSize) + val item = SentryEnvelopeItem.fromAttachment( + fixture.errorSerializer, + fixture.options.logger, + attachment, + fixture.maxAttachmentSize + ) assertFailsWith( "Couldn't attach the attachment ${attachment.filename}.\n" + @@ -279,8 +354,13 @@ class SentryEnvelopeItemTest { } file.writeBytes(fixture.bytes) - SentryEnvelopeItem.fromProfilingTrace(profilingTraceData, fixture.maxAttachmentSize, fixture.serializer).data - verify(profilingTraceData).sampledProfile = Base64.encodeToString(fixture.bytes, Base64.NO_WRAP or Base64.NO_PADDING) + SentryEnvelopeItem.fromProfilingTrace( + profilingTraceData, + fixture.maxAttachmentSize, + fixture.serializer + ).data + verify(profilingTraceData).sampledProfile = + Base64.encodeToString(fixture.bytes, Base64.NO_WRAP or Base64.NO_PADDING) } @Test @@ -292,7 +372,11 @@ class SentryEnvelopeItemTest { file.writeBytes(fixture.bytes) assert(file.exists()) - val traceData = SentryEnvelopeItem.fromProfilingTrace(profilingTraceData, fixture.maxAttachmentSize, mock()) + val traceData = SentryEnvelopeItem.fromProfilingTrace( + profilingTraceData, + fixture.maxAttachmentSize, + mock() + ) assert(file.exists()) traceData.data assertFalse(file.exists()) @@ -306,7 +390,11 @@ class SentryEnvelopeItemTest { } assertFailsWith("Dropping profiling trace data, because the file ${file.path} doesn't exists") { - SentryEnvelopeItem.fromProfilingTrace(profilingTraceData, fixture.maxAttachmentSize, mock()).data + SentryEnvelopeItem.fromProfilingTrace( + profilingTraceData, + fixture.maxAttachmentSize, + mock() + ).data } } @@ -319,7 +407,11 @@ class SentryEnvelopeItemTest { file.writeBytes(fixture.bytes) file.setReadable(false) assertFailsWith("Dropping profiling trace data, because the file ${file.path} doesn't exists") { - SentryEnvelopeItem.fromProfilingTrace(profilingTraceData, fixture.maxAttachmentSize, mock()).data + SentryEnvelopeItem.fromProfilingTrace( + profilingTraceData, + fixture.maxAttachmentSize, + mock() + ).data } } @@ -331,7 +423,11 @@ class SentryEnvelopeItemTest { whenever(it.traceFile).thenReturn(file) } - val traceData = SentryEnvelopeItem.fromProfilingTrace(profilingTraceData, fixture.maxAttachmentSize, mock()) + val traceData = SentryEnvelopeItem.fromProfilingTrace( + profilingTraceData, + fixture.maxAttachmentSize, + mock() + ) assertFailsWith("Profiling trace file is empty") { traceData.data } @@ -346,7 +442,11 @@ class SentryEnvelopeItemTest { } val exception = assertFailsWith { - SentryEnvelopeItem.fromProfilingTrace(profilingTraceData, fixture.maxAttachmentSize, mock()).data + SentryEnvelopeItem.fromProfilingTrace( + profilingTraceData, + fixture.maxAttachmentSize, + mock() + ).data } assertEquals( @@ -357,6 +457,58 @@ class SentryEnvelopeItemTest { ) } + @Test + fun `fromReplay encodes payload into msgpack`() { + val file = Files.createTempFile("replay", "").toFile() + val videoBytes = + this::class.java.classLoader.getResource("Tongariro.jpg")!!.readBytes() + file.writeBytes(videoBytes) + + val replayEvent = SentryReplayEventSerializationTest.Fixture().getSut().apply { + videoFile = file + } + val replayRecording = ReplayRecordingSerializationTest.Fixture().getSut() + val replayItem = SentryEnvelopeItem + .fromReplay(fixture.serializer, fixture.options.logger, replayEvent, replayRecording) + + assertEquals(SentryItemType.ReplayVideo, replayItem.header.type) + + assertPayload(replayItem, replayEvent, replayRecording, videoBytes) + } + + @Test + fun `fromReplay does not add video item when no bytes`() { + val file = File(fixture.pathname) + file.writeBytes(ByteArray(0)) + + val replayEvent = SentryReplayEventSerializationTest.Fixture().getSut().apply { + videoFile = file + } + + val replayItem = SentryEnvelopeItem + .fromReplay(fixture.serializer, fixture.options.logger, replayEvent, null) + replayItem.data + assertPayload(replayItem, replayEvent, null, ByteArray(0)) { mapSize -> + assertEquals(1, mapSize) + } + } + + @Test + fun `fromReplay deletes file only after reading data`() { + val file = File(fixture.pathname) + val replayEvent = SentryReplayEventSerializationTest.Fixture().getSut().apply { + videoFile = file + } + + file.writeBytes(fixture.bytes) + assert(file.exists()) + val replayItem = SentryEnvelopeItem + .fromReplay(fixture.serializer, fixture.options.logger, replayEvent, null) + assert(file.exists()) + replayItem.data + assertFalse(file.exists()) + } + private fun createSession(): Session { return Session("dis", User(), "env", "rel") } @@ -379,4 +531,45 @@ class SentryEnvelopeItemTest { } } } + + private fun assertPayload( + replayItem: SentryEnvelopeItem, + replayEvent: SentryReplayEvent, + replayRecording: ReplayRecording?, + videoBytes: ByteArray, + mapSizeAsserter: (mapSize: Int) -> Unit = {} + ) { + val unpacker = MessagePack.newDefaultUnpacker(replayItem.data) + val mapSize = unpacker.unpackMapHeader() + mapSizeAsserter(mapSize) + for (i in 0 until mapSize) { + val key = unpacker.unpackString() + when (key) { + SentryItemType.ReplayEvent.itemType -> { + val replayEventLength = unpacker.unpackBinaryHeader() + val replayEventBytes = unpacker.readPayload(replayEventLength) + val actualReplayEvent = fixture.serializer.deserialize( + InputStreamReader(replayEventBytes.inputStream()), + SentryReplayEvent::class.java + ) + assertEquals(replayEvent, actualReplayEvent) + } + SentryItemType.ReplayRecording.itemType -> { + val replayRecordingLength = unpacker.unpackBinaryHeader() + val replayRecordingBytes = unpacker.readPayload(replayRecordingLength) + val actualReplayRecording = fixture.serializer.deserialize( + InputStreamReader(replayRecordingBytes.inputStream()), + ReplayRecording::class.java + ) + assertEquals(replayRecording, actualReplayRecording) + } + SentryItemType.ReplayVideo.itemType -> { + val videoLength = unpacker.unpackBinaryHeader() + val actualBytes = unpacker.readPayload(videoLength) + assertArrayEquals(videoBytes, actualBytes) + } + } + } + unpacker.close() + } } From 1e76fc7499e8a1f4747c67c3aed4d1547c83759e Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 22 Feb 2024 10:21:35 +0100 Subject: [PATCH 19/89] Add test for MapObjectReader --- .../java/io/sentry/util/MapObjectReader.java | 11 +- .../io/sentry/util/MapObjectReaderTest.kt | 131 ++++++++++++++++++ 2 files changed, 139 insertions(+), 3 deletions(-) create mode 100644 sentry/src/test/java/io/sentry/util/MapObjectReaderTest.kt diff --git a/sentry/src/main/java/io/sentry/util/MapObjectReader.java b/sentry/src/main/java/io/sentry/util/MapObjectReader.java index ba2cb83ed3c..e4dff5f4917 100644 --- a/sentry/src/main/java/io/sentry/util/MapObjectReader.java +++ b/sentry/src/main/java/io/sentry/util/MapObjectReader.java @@ -8,6 +8,7 @@ import java.io.IOException; import java.util.AbstractMap; import java.util.ArrayDeque; +import java.util.ArrayList; import java.util.Date; import java.util.Deque; import java.util.List; @@ -161,7 +162,8 @@ public void beginArray() throws IOException { // insert a dummy entry to indicate end of an object stack.addLast(new AbstractMap.SimpleEntry<>(null, JsonToken.END_ARRAY)); // extract map entries onto the stack - for (Object entry : (List) value) { + for (int i = ((List) value).size() - 1; i >= 0; i--) { + Object entry = ((List) value).get(i); stack.addLast(new AbstractMap.SimpleEntry<>(null, entry)); } } else { @@ -295,7 +297,10 @@ public float nextFloat() throws IOException { @Override public void nextNull() throws IOException { - nextValueOrNull(); + Object value = nextValueOrNull(); + if (value != null) { + throw new IOException("Expected null but was " + peek()); + } } @Override @@ -327,7 +332,7 @@ private T nextValueOrNull( if (deserializer != null && logger != null) { return deserializer.deserialize(this, logger); } else if (value instanceof List) { - List list = (List) value; + List list = new ArrayList<>((List) value); if (!list.isEmpty()) { T next = (T) list.remove(0); if (next instanceof Map) { diff --git a/sentry/src/test/java/io/sentry/util/MapObjectReaderTest.kt b/sentry/src/test/java/io/sentry/util/MapObjectReaderTest.kt new file mode 100644 index 00000000000..ab52919c438 --- /dev/null +++ b/sentry/src/test/java/io/sentry/util/MapObjectReaderTest.kt @@ -0,0 +1,131 @@ +package io.sentry.util + +import io.sentry.ILogger +import io.sentry.JsonDeserializer +import io.sentry.JsonSerializable +import io.sentry.NoOpLogger +import io.sentry.ObjectReader +import io.sentry.ObjectWriter +import io.sentry.vendor.gson.stream.JsonToken +import java.math.BigDecimal +import java.net.URI +import java.util.Currency +import java.util.Date +import java.util.Locale +import java.util.TimeZone +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals + +class MapObjectReaderTest { + + enum class BasicEnum { + A + } + + data class BasicSerializable(var test: String = "string") : JsonSerializable { + + override fun serialize(writer: ObjectWriter, logger: ILogger) { + writer.beginObject() + .name("test") + .value(test) + .endObject() + } + + class Deserializer : JsonDeserializer { + override fun deserialize(reader: ObjectReader, logger: ILogger): BasicSerializable { + val basicSerializable = BasicSerializable() + reader.beginObject() + if (reader.nextName() == "test") { + basicSerializable.test = reader.nextString() + } + reader.endObject() + return basicSerializable + } + } + } + + @Test + fun `deserializes data correctly`() { + val logger = NoOpLogger.getInstance() + val data = mutableMapOf() + val writer = MapObjectWriter(data) + + writer.name("null").nullValue() + writer.name("int").value(1) + writer.name("boolean").value(true) + writer.name("long").value(Long.MAX_VALUE) + writer.name("double").value(Double.MAX_VALUE) + writer.name("number").value(BigDecimal(123)) + writer.name("date").value(logger, Date(0)) + writer.name("string").value("string") + + writer.name("TimeZone").value(logger, TimeZone.getTimeZone("Vienna")) + writer.name("JsonSerializable").value( + logger, + BasicSerializable() + ) + writer.name("Collection").value(logger, listOf("a", "b")) + writer.name("Arrays").value(logger, arrayOf("b", "c")) + writer.name("Map").value(logger, mapOf(kotlin.Pair("key", "value"))) + writer.name("Locale").value(logger, Locale.US) + writer.name("URI").value(logger, URI.create("http://www.example.com")) + writer.name("UUID").value(logger, UUID.fromString("00000000-1111-2222-3333-444444444444")) + writer.name("Currency").value(logger, Currency.getInstance("EUR")) + writer.name("Enum").value(logger, MapObjectWriterTest.BasicEnum.A) + + val reader = MapObjectReader(data) + reader.beginObject() + assertEquals(JsonToken.NAME, reader.peek()) + assertEquals("Enum", reader.nextName()) + assertEquals(BasicEnum.A, BasicEnum.valueOf(reader.nextString())) + assertEquals("Currency", reader.nextName()) + assertEquals(Currency.getInstance("EUR"), Currency.getInstance(reader.nextString())) + assertEquals("UUID", reader.nextName()) + assertEquals( + UUID.fromString("00000000-1111-2222-3333-444444444444"), + UUID.fromString(reader.nextString()) + ) + assertEquals("URI", reader.nextName()) + assertEquals(URI.create("http://www.example.com"), URI.create(reader.nextString())) + assertEquals("Locale", reader.nextName()) + assertEquals(Locale.US.toString(), reader.nextString()) + assertEquals("Map", reader.nextName()) + // nested object + reader.beginObject() + assertEquals("key", reader.nextName()) + assertEquals("value", reader.nextStringOrNull()) + reader.endObject() + assertEquals("Arrays", reader.nextName()) + reader.beginArray() + assertEquals("b", reader.nextString()) + assertEquals("c", reader.nextString()) + reader.endArray() + assertEquals("Collection", reader.nextName()) + reader.beginArray() + assertEquals("a", reader.nextString()) + assertEquals("b", reader.nextString()) + reader.endArray() + assertEquals("JsonSerializable", reader.nextName()) + assertEquals(BasicSerializable(), reader.nextOrNull(logger, BasicSerializable.Deserializer())) + assertEquals("TimeZone", reader.nextName()) + assertEquals(TimeZone.getTimeZone("Vienna"), reader.nextTimeZoneOrNull(logger)) + assertEquals("string", reader.nextName()) + assertEquals("string", reader.nextString()) + assertEquals("date", reader.nextName()) + assertEquals(Date(0), reader.nextDateOrNull(logger)) + assertEquals("number", reader.nextName()) + assertEquals(BigDecimal(123), reader.nextObjectOrNull()) + assertEquals("double", reader.nextName()) + assertEquals(Double.MAX_VALUE, reader.nextDoubleOrNull()) + assertEquals("long", reader.nextName()) + assertEquals(Long.MAX_VALUE, reader.nextLongOrNull()) + assertEquals("boolean", reader.nextName()) + assertEquals(true, reader.nextBoolean()) + assertEquals("int", reader.nextName()) + assertEquals(1, reader.nextInt()) + assertEquals("null", reader.nextName()) + reader.nextNull() + reader.endObject() + } +} From 13c19718d27874b59115661e7f2dbf915685c2d1 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 22 Feb 2024 10:24:02 +0100 Subject: [PATCH 20/89] Add MapObjectWriter change --- .../src/main/java/io/sentry/util/MapObjectWriter.java | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/sentry/src/main/java/io/sentry/util/MapObjectWriter.java b/sentry/src/main/java/io/sentry/util/MapObjectWriter.java index 26f80eddc29..0bbc70a779d 100644 --- a/sentry/src/main/java/io/sentry/util/MapObjectWriter.java +++ b/sentry/src/main/java/io/sentry/util/MapObjectWriter.java @@ -120,6 +120,11 @@ public MapObjectWriter value(final @NotNull ILogger logger, final @Nullable Obje return this; } + @Override + public void setLenient(boolean lenient) { + // no-op + } + @Override public MapObjectWriter beginArray() throws IOException { stack.add(new ArrayList<>()); @@ -151,6 +156,12 @@ public MapObjectWriter value(final @Nullable String value) throws IOException { return this; } + @Override + public ObjectWriter jsonValue(@Nullable String value) throws IOException { + // no-op + return this; + } + @Override public MapObjectWriter nullValue() throws IOException { postValue((Object) null); From 86baf7fc968a8ccc7dc7e8bdce22bd37e60bf8a6 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 22 Feb 2024 10:36:42 +0100 Subject: [PATCH 21/89] Add finals --- .../src/main/java/io/sentry/ObjectReader.java | 3 +- .../java/io/sentry/util/MapObjectReader.java | 53 ++++++++++--------- 2 files changed, 29 insertions(+), 27 deletions(-) diff --git a/sentry/src/main/java/io/sentry/ObjectReader.java b/sentry/src/main/java/io/sentry/ObjectReader.java index 0449379f7d5..6c2210897e0 100644 --- a/sentry/src/main/java/io/sentry/ObjectReader.java +++ b/sentry/src/main/java/io/sentry/ObjectReader.java @@ -11,7 +11,8 @@ import org.jetbrains.annotations.Nullable; public interface ObjectReader extends Closeable { - static @Nullable Date dateOrNull(@Nullable String dateString, final @NotNull ILogger logger) { + static @Nullable Date dateOrNull( + final @Nullable String dateString, final @NotNull ILogger logger) { if (dateString == null) { return null; } diff --git a/sentry/src/main/java/io/sentry/util/MapObjectReader.java b/sentry/src/main/java/io/sentry/util/MapObjectReader.java index e4dff5f4917..6cb05989b25 100644 --- a/sentry/src/main/java/io/sentry/util/MapObjectReader.java +++ b/sentry/src/main/java/io/sentry/util/MapObjectReader.java @@ -28,7 +28,8 @@ public MapObjectReader(final Map root) { } @Override - public void nextUnknown(final @NotNull ILogger logger, Map unknown, String name) { + public void nextUnknown( + final @NotNull ILogger logger, final Map unknown, final String name) { try { unknown.put(name, nextObjectOrNull()); } catch (Exception exception) { @@ -63,14 +64,14 @@ public T nextOrNull( @Nullable @Override public Date nextDateOrNull(final @NotNull ILogger logger) throws IOException { - String dateString = nextStringOrNull(); + final String dateString = nextStringOrNull(); return ObjectReader.dateOrNull(dateString, logger); } @Nullable @Override public TimeZone nextTimeZoneOrNull(final @NotNull ILogger logger) throws IOException { - String timeZoneId = nextStringOrNull(); + final String timeZoneId = nextStringOrNull(); return timeZoneId != null ? TimeZone.getTimeZone(timeZoneId) : null; } @@ -87,7 +88,7 @@ public JsonToken peek() throws IOException { return JsonToken.END_DOCUMENT; } - Map.Entry currentEntry = stack.peekLast(); + final Map.Entry currentEntry = stack.peekLast(); if (currentEntry == null) { return JsonToken.END_DOCUMENT; } @@ -96,7 +97,7 @@ public JsonToken peek() throws IOException { return JsonToken.NAME; } - Object value = currentEntry.getValue(); + final Object value = currentEntry.getValue(); if (value instanceof Map) { return JsonToken.BEGIN_OBJECT; @@ -118,7 +119,7 @@ public JsonToken peek() throws IOException { @NotNull @Override public String nextName() throws IOException { - Map.Entry currentEntry = stack.peekLast(); + final Map.Entry currentEntry = stack.peekLast(); if (currentEntry != null && currentEntry.getKey() != null) { return currentEntry.getKey(); } @@ -127,11 +128,11 @@ public String nextName() throws IOException { @Override public void beginObject() throws IOException { - Map.Entry currentEntry = stack.removeLast(); + final Map.Entry currentEntry = stack.removeLast(); if (currentEntry == null) { throw new IOException("No more entries"); } - Object value = currentEntry.getValue(); + final Object value = currentEntry.getValue(); if (value instanceof Map) { // insert a dummy entry to indicate end of an object stack.addLast(new AbstractMap.SimpleEntry<>(null, JsonToken.END_OBJECT)); @@ -153,17 +154,17 @@ public void endObject() throws IOException { @Override public void beginArray() throws IOException { - Map.Entry currentEntry = stack.removeLast(); + final Map.Entry currentEntry = stack.removeLast(); if (currentEntry == null) { throw new IOException("No more entries"); } - Object value = currentEntry.getValue(); + final Object value = currentEntry.getValue(); if (value instanceof List) { // insert a dummy entry to indicate end of an object stack.addLast(new AbstractMap.SimpleEntry<>(null, JsonToken.END_ARRAY)); // extract map entries onto the stack for (int i = ((List) value).size() - 1; i >= 0; i--) { - Object entry = ((List) value).get(i); + final Object entry = ((List) value).get(i); stack.addLast(new AbstractMap.SimpleEntry<>(null, entry)); } } else { @@ -185,7 +186,7 @@ public boolean hasNext() throws IOException { @Override public int nextInt() throws IOException { - Object value = nextValueOrNull(); + final Object value = nextValueOrNull(); if (value instanceof Number) { return ((Number) value).intValue(); } else { @@ -196,7 +197,7 @@ public int nextInt() throws IOException { @Nullable @Override public Integer nextIntegerOrNull() throws IOException { - Object value = nextValueOrNull(); + final Object value = nextValueOrNull(); if (value instanceof Number) { return ((Number) value).intValue(); } @@ -205,7 +206,7 @@ public Integer nextIntegerOrNull() throws IOException { @Override public long nextLong() throws IOException { - Object value = nextValueOrNull(); + final Object value = nextValueOrNull(); if (value instanceof Number) { return ((Number) value).longValue(); } else { @@ -216,7 +217,7 @@ public long nextLong() throws IOException { @Nullable @Override public Long nextLongOrNull() throws IOException { - Object value = nextValueOrNull(); + final Object value = nextValueOrNull(); if (value instanceof Number) { return ((Number) value).longValue(); } @@ -225,7 +226,7 @@ public Long nextLongOrNull() throws IOException { @Override public String nextString() throws IOException { - String value = nextValueOrNull(); + final String value = nextValueOrNull(); if (value != null) { return value; } else { @@ -241,7 +242,7 @@ public String nextStringOrNull() throws IOException { @Override public boolean nextBoolean() throws IOException { - Boolean value = nextValueOrNull(); + final Boolean value = nextValueOrNull(); if (value != null) { return value; } else { @@ -257,7 +258,7 @@ public Boolean nextBooleanOrNull() throws IOException { @Override public double nextDouble() throws IOException { - Object value = nextValueOrNull(); + final Object value = nextValueOrNull(); if (value instanceof Number) { return ((Number) value).doubleValue(); } else { @@ -268,7 +269,7 @@ public double nextDouble() throws IOException { @Nullable @Override public Double nextDoubleOrNull() throws IOException { - Object value = nextValueOrNull(); + final Object value = nextValueOrNull(); if (value instanceof Number) { return ((Number) value).doubleValue(); } @@ -278,7 +279,7 @@ public Double nextDoubleOrNull() throws IOException { @Nullable @Override public Float nextFloatOrNull() throws IOException { - Object value = nextValueOrNull(); + final Object value = nextValueOrNull(); if (value instanceof Number) { return ((Number) value).floatValue(); } @@ -287,7 +288,7 @@ public Float nextFloatOrNull() throws IOException { @Override public float nextFloat() throws IOException { - Object value = nextValueOrNull(); + final Object value = nextValueOrNull(); if (value instanceof Number) { return ((Number) value).floatValue(); } else { @@ -297,14 +298,14 @@ public float nextFloat() throws IOException { @Override public void nextNull() throws IOException { - Object value = nextValueOrNull(); + final Object value = nextValueOrNull(); if (value != null) { throw new IOException("Expected null but was " + peek()); } } @Override - public void setLenient(boolean lenient) {} + public void setLenient(final boolean lenient) {} @Override public void skipValue() throws IOException {} @@ -324,17 +325,17 @@ private T nextValueOrNull() throws IOException { private T nextValueOrNull( final @Nullable ILogger logger, final @Nullable JsonDeserializer deserializer) throws Exception { - Map.Entry currentEntry = stack.peekLast(); + final Map.Entry currentEntry = stack.peekLast(); if (currentEntry == null) { return null; } - T value = (T) currentEntry.getValue(); + final T value = (T) currentEntry.getValue(); if (deserializer != null && logger != null) { return deserializer.deserialize(this, logger); } else if (value instanceof List) { List list = new ArrayList<>((List) value); if (!list.isEmpty()) { - T next = (T) list.remove(0); + final T next = (T) list.remove(0); if (next instanceof Map) { stack.addLast(new AbstractMap.SimpleEntry<>(null, next)); } From f1ca9f68bb0444b43eb705811a9176bf869fc89e Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 22 Feb 2024 10:40:33 +0100 Subject: [PATCH 22/89] Fix test --- .../io/sentry/protocol/SentryReplayEventSerializationTest.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/sentry/src/test/java/io/sentry/protocol/SentryReplayEventSerializationTest.kt b/sentry/src/test/java/io/sentry/protocol/SentryReplayEventSerializationTest.kt index 82b32e96c76..7be66d31e77 100644 --- a/sentry/src/test/java/io/sentry/protocol/SentryReplayEventSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/protocol/SentryReplayEventSerializationTest.kt @@ -27,6 +27,10 @@ class SentryReplayEventSerializationTest { errorIds = listOf("ab3a347a4cc14fd4b4cf1dc56b670c5b") traceIds = listOf("340cfef948204549ac07c3b353c81c50") SentryBaseEventSerializationTest.Fixture().update(this) + // irrelevant for replay + serverName = null + breadcrumbs = null + extras = null } } private val fixture = Fixture() From fbbe0d97b431de445abf0f8184751ab7652f54db Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 22 Feb 2024 10:56:52 +0100 Subject: [PATCH 23/89] Fix test --- .../android/core/SessionTrackingIntegrationTest.kt | 9 +++++++++ .../protocol/SentryReplayEventSerializationTest.kt | 4 ++++ 2 files changed, 13 insertions(+) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SessionTrackingIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SessionTrackingIntegrationTest.kt index af32fa3714f..341d15e6081 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SessionTrackingIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SessionTrackingIntegrationTest.kt @@ -15,6 +15,7 @@ import io.sentry.Sentry import io.sentry.SentryEnvelope import io.sentry.SentryEvent import io.sentry.SentryOptions +import io.sentry.SentryReplayEvent import io.sentry.Session import io.sentry.TraceContext import io.sentry.UserFeedback @@ -141,6 +142,14 @@ class SessionTrackingIntegrationTest { TODO("Not yet implemented") } + override fun captureReplayEvent( + event: SentryReplayEvent, + scope: IScope?, + hint: Hint? + ): SentryId { + TODO("Not yet implemented") + } + override fun captureUserFeedback(userFeedback: UserFeedback) { TODO("Not yet implemented") } diff --git a/sentry/src/test/java/io/sentry/protocol/SentryReplayEventSerializationTest.kt b/sentry/src/test/java/io/sentry/protocol/SentryReplayEventSerializationTest.kt index 82b32e96c76..7be66d31e77 100644 --- a/sentry/src/test/java/io/sentry/protocol/SentryReplayEventSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/protocol/SentryReplayEventSerializationTest.kt @@ -27,6 +27,10 @@ class SentryReplayEventSerializationTest { errorIds = listOf("ab3a347a4cc14fd4b4cf1dc56b670c5b") traceIds = listOf("340cfef948204549ac07c3b353c81c50") SentryBaseEventSerializationTest.Fixture().update(this) + // irrelevant for replay + serverName = null + breadcrumbs = null + extras = null } } private val fixture = Fixture() From fd6396040f50ce68a397782961ee4478de485506 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 28 Feb 2024 11:54:19 +0100 Subject: [PATCH 24/89] Address review --- .../src/main/java/io/sentry/SentryEnvelopeItem.java | 2 +- .../src/main/java/io/sentry/SentryReplayEvent.java | 9 ++------- .../main/java/io/sentry/rrweb/RRWebMetaEvent.java | 2 ++ .../main/java/io/sentry/rrweb/RRWebVideoEvent.java | 12 +++++++----- 4 files changed, 12 insertions(+), 13 deletions(-) diff --git a/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java index be6838670e4..25128451293 100644 --- a/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java +++ b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java @@ -435,7 +435,7 @@ private static byte[] serializeToMsgpack(Map map) throws IOExcep // Iterate over the map and serialize each key-value pair for (Map.Entry entry : map.entrySet()) { // Pack the key as a string - byte[] keyBytes = entry.getKey().getBytes(Charset.forName("UTF-8")); + byte[] keyBytes = entry.getKey().getBytes(UTF_8); int keyLength = keyBytes.length; // string up to 255 chars baos.write((byte) (0xd9)); diff --git a/sentry/src/main/java/io/sentry/SentryReplayEvent.java b/sentry/src/main/java/io/sentry/SentryReplayEvent.java index a351a21d549..eaab8d0ae32 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayEvent.java +++ b/sentry/src/main/java/io/sentry/SentryReplayEvent.java @@ -5,7 +5,6 @@ import io.sentry.vendor.gson.stream.JsonToken; import java.io.File; import java.io.IOException; -import java.math.BigDecimal; import java.util.ArrayList; import java.util.Date; import java.util.HashMap; @@ -192,16 +191,12 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger writer.name(JsonKeys.TYPE).value(type); writer.name(JsonKeys.REPLAY_TYPE).value(logger, replayType); writer.name(JsonKeys.SEGMENT_ID).value(segmentId); - writer - .name(JsonKeys.TIMESTAMP) - .value(logger, BigDecimal.valueOf(DateUtils.dateToSeconds(timestamp))); + writer.name(JsonKeys.TIMESTAMP).value(logger, timestamp); if (replayId != null) { writer.name(JsonKeys.REPLAY_ID).value(logger, replayId); } if (replayStartTimestamp != null) { - writer - .name(JsonKeys.REPLAY_START_TIMESTAMP) - .value(logger, BigDecimal.valueOf(DateUtils.dateToSeconds(replayStartTimestamp))); + writer.name(JsonKeys.REPLAY_START_TIMESTAMP).value(logger, replayStartTimestamp); } if (urls != null) { writer.name(JsonKeys.URLS).value(logger, urls); diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebMetaEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebMetaEvent.java index 0a1c914cb33..dee4fb2ef32 100644 --- a/sentry/src/main/java/io/sentry/rrweb/RRWebMetaEvent.java +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebMetaEvent.java @@ -20,6 +20,8 @@ public final class RRWebMetaEvent extends RRWebEvent implements JsonUnknown, Jso private @NotNull String href; private int height; private int width; + // to support unknown json attributes with nesting, we have to have unknown map for each of the + // nested object in json: { ..., "data": { ... } } private @Nullable Map unknown; private @Nullable Map dataUnknown; diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebVideoEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebVideoEvent.java index f9d61e591e5..c55e055cca5 100644 --- a/sentry/src/main/java/io/sentry/rrweb/RRWebVideoEvent.java +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebVideoEvent.java @@ -36,6 +36,8 @@ public final class RRWebVideoEvent extends RRWebEvent implements JsonUnknown, Js private int frameRate; private int left; private int top; + // to support unknown json attributes with nesting, we have to have unknown map for each of the + // nested object in json: { ..., "data": { ..., "payload": { ... } } } private @Nullable Map unknown; private @Nullable Map payloadUnknown; private @Nullable Map dataUnknown; @@ -334,7 +336,7 @@ private void deserializeData( final @NotNull ObjectReader reader, final @NotNull ILogger logger) throws Exception { - Map dataUknown = null; + Map dataUnknown = null; reader.beginObject(); while (reader.peek() == JsonToken.NAME) { @@ -348,13 +350,13 @@ private void deserializeData( deserializePayload(event, reader, logger); break; default: - if (dataUknown == null) { - dataUknown = new ConcurrentHashMap<>(); + if (dataUnknown == null) { + dataUnknown = new ConcurrentHashMap<>(); } - reader.nextUnknown(logger, dataUknown, nextName); + reader.nextUnknown(logger, dataUnknown, nextName); } } - event.setDataUnknown(dataUknown); + event.setDataUnknown(dataUnknown); reader.endObject(); } From 93785cc282c0c2a32b1dcd3e8a57a32ff2aa7658 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 28 Feb 2024 12:10:30 +0100 Subject: [PATCH 25/89] Add finals and annotations --- sentry/src/main/java/io/sentry/Hub.java | 2 +- .../java/io/sentry/MainEventProcessor.java | 3 +- .../main/java/io/sentry/ReplayRecording.java | 8 +-- .../java/io/sentry/SentryEnvelopeItem.java | 54 ++++++++++--------- .../java/io/sentry/SentryReplayEvent.java | 8 +-- .../main/java/io/sentry/rrweb/RRWebEvent.java | 12 +++-- .../java/io/sentry/rrweb/RRWebEventType.java | 5 +- .../java/io/sentry/rrweb/RRWebMetaEvent.java | 10 ++-- .../java/io/sentry/rrweb/RRWebVideoEvent.java | 23 ++++---- 9 files changed, 66 insertions(+), 59 deletions(-) diff --git a/sentry/src/main/java/io/sentry/Hub.java b/sentry/src/main/java/io/sentry/Hub.java index b91b5517050..8861c1c67fb 100644 --- a/sentry/src/main/java/io/sentry/Hub.java +++ b/sentry/src/main/java/io/sentry/Hub.java @@ -932,7 +932,7 @@ private IScope buildLocalScope( "Instance is disabled and this 'captureReplay' call is a no-op."); } else { try { - StackItem item = stack.peek(); + final @NotNull StackItem item = stack.peek(); sentryId = item.getClient().captureReplayEvent(replay, item.getScope(), hint); } catch (Throwable e) { options.getLogger().log(SentryLevel.ERROR, "Error while capturing replay", e); diff --git a/sentry/src/main/java/io/sentry/MainEventProcessor.java b/sentry/src/main/java/io/sentry/MainEventProcessor.java index e79ebc37bd0..d6445e3a56d 100644 --- a/sentry/src/main/java/io/sentry/MainEventProcessor.java +++ b/sentry/src/main/java/io/sentry/MainEventProcessor.java @@ -150,7 +150,8 @@ private void processNonCachedEvent(final @NotNull SentryBaseEvent event) { } @Override - public @NotNull SentryReplayEvent process(@NotNull SentryReplayEvent event, @NotNull Hint hint) { + public @NotNull SentryReplayEvent process( + final @NotNull SentryReplayEvent event, final @NotNull Hint hint) { setCommons(event); // TODO: maybe later it's needed to deobfuscate something (e.g. view hierarchy), for now the // TODO: protocol does not support it diff --git a/sentry/src/main/java/io/sentry/ReplayRecording.java b/sentry/src/main/java/io/sentry/ReplayRecording.java index d8892191f24..4e6aecdb2bf 100644 --- a/sentry/src/main/java/io/sentry/ReplayRecording.java +++ b/sentry/src/main/java/io/sentry/ReplayRecording.java @@ -31,7 +31,7 @@ public Integer getSegmentId() { return segmentId; } - public void setSegmentId(@Nullable Integer segmentId) { + public void setSegmentId(final @Nullable Integer segmentId) { this.segmentId = segmentId; } @@ -40,7 +40,7 @@ public List getPayload() { return payload; } - public void setPayload(@Nullable List payload) { + public void setPayload(final @Nullable List payload) { this.payload = payload; } @@ -136,11 +136,11 @@ public static final class Deserializer implements JsonDeserializer eventMap = (Map) event; final ObjectReader mapReader = new MapObjectReader(eventMap); - for (Map.Entry entry : eventMap.entrySet()) { + for (final Map.Entry entry : eventMap.entrySet()) { final String key = entry.getKey(); final Object value = entry.getValue(); if (key.equals("type")) { - RRWebEventType type = RRWebEventType.values()[(int) value]; + final RRWebEventType type = RRWebEventType.values()[(int) value]; switch (type) { case Meta: final RRWebEvent metaEvent = diff --git a/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java index 25128451293..ff162f44642 100644 --- a/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java +++ b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java @@ -425,32 +425,34 @@ public CachedItem(final @Nullable Callable dataFactory) { } } - @SuppressWarnings({"CharsetObjectCanBeUsed", "UnnecessaryParentheses"}) - private static byte[] serializeToMsgpack(Map map) throws IOException { - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - - // Write map header - baos.write((byte) (0x80 | map.size())); - - // Iterate over the map and serialize each key-value pair - for (Map.Entry entry : map.entrySet()) { - // Pack the key as a string - byte[] keyBytes = entry.getKey().getBytes(UTF_8); - int keyLength = keyBytes.length; - // string up to 255 chars - baos.write((byte) (0xd9)); - baos.write((byte) (keyLength)); - baos.write(keyBytes); - - // Pack the value as a binary string - byte[] valueBytes = entry.getValue(); - int valueLength = valueBytes.length; - // We will always use the 4 bytes data length for simplicity. - baos.write((byte) (0xc6)); - baos.write(ByteBuffer.allocate(4).order(ByteOrder.BIG_ENDIAN).putInt(valueLength).array()); - baos.write(valueBytes); - } + @SuppressWarnings({"UnnecessaryParentheses"}) + private static byte[] serializeToMsgpack(final @NotNull Map map) + throws IOException { + try (final ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + + // Write map header + baos.write((byte) (0x80 | map.size())); + + // Iterate over the map and serialize each key-value pair + for (final Map.Entry entry : map.entrySet()) { + // Pack the key as a string + final byte[] keyBytes = entry.getKey().getBytes(UTF_8); + final int keyLength = keyBytes.length; + // string up to 255 chars + baos.write((byte) (0xd9)); + baos.write((byte) (keyLength)); + baos.write(keyBytes); + + // Pack the value as a binary string + final byte[] valueBytes = entry.getValue(); + final int valueLength = valueBytes.length; + // We will always use the 4 bytes data length for simplicity. + baos.write((byte) (0xc6)); + baos.write(ByteBuffer.allocate(4).order(ByteOrder.BIG_ENDIAN).putInt(valueLength).array()); + baos.write(valueBytes); + } - return baos.toByteArray(); + return baos.toByteArray(); + } } } diff --git a/sentry/src/main/java/io/sentry/SentryReplayEvent.java b/sentry/src/main/java/io/sentry/SentryReplayEvent.java index eaab8d0ae32..95623d2ff62 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayEvent.java +++ b/sentry/src/main/java/io/sentry/SentryReplayEvent.java @@ -22,15 +22,15 @@ public enum ReplayType implements JsonSerializable { BUFFER; @Override - public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) + public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger logger) throws IOException { writer.value(name().toLowerCase(Locale.ROOT)); } public static final class Deserializer implements JsonDeserializer { @Override - public @NotNull ReplayType deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) - throws Exception { + public @NotNull ReplayType deserialize( + final @NotNull ObjectReader reader, final @NotNull ILogger logger) throws Exception { return ReplayType.valueOf(reader.nextString().toUpperCase(Locale.ROOT)); } } @@ -236,7 +236,7 @@ public static final class Deserializer implements JsonDeserializer { @Override public @NotNull RRWebEventType deserialize( - @NotNull ObjectReader reader, @NotNull ILogger logger) throws Exception { + final @NotNull ObjectReader reader, final @NotNull ILogger logger) throws Exception { return RRWebEventType.values()[reader.nextInt()]; } } diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebMetaEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebMetaEvent.java index dee4fb2ef32..b0aca2f3374 100644 --- a/sentry/src/main/java/io/sentry/rrweb/RRWebMetaEvent.java +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebMetaEvent.java @@ -127,11 +127,11 @@ public static final class Deserializer implements JsonDeserializer unknown = null; - RRWebMetaEvent event = new RRWebMetaEvent(); - RRWebEvent.Deserializer baseEventDeserializer = new RRWebEvent.Deserializer(); + @Nullable Map unknown = null; + final RRWebMetaEvent event = new RRWebMetaEvent(); + final RRWebEvent.Deserializer baseEventDeserializer = new RRWebEvent.Deserializer(); while (reader.peek() == JsonToken.NAME) { final String nextName = reader.nextName(); @@ -159,7 +159,7 @@ private void deserializeData( final @NotNull ObjectReader reader, final @NotNull ILogger logger) throws Exception { - Map unknown = null; + @Nullable Map unknown = null; reader.beginObject(); while (reader.peek() == JsonToken.NAME) { diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebVideoEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebVideoEvent.java index c55e055cca5..5bea9e3c471 100644 --- a/sentry/src/main/java/io/sentry/rrweb/RRWebVideoEvent.java +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebVideoEvent.java @@ -242,14 +242,15 @@ public static final class JsonKeys { } @Override - public void serialize(@NotNull ObjectWriter writer, @NotNull ILogger logger) throws IOException { + public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger logger) + throws IOException { writer.beginObject(); new RRWebEvent.Serializer().serialize(this, writer, logger); writer.name(JsonKeys.DATA); serializeData(writer, logger); if (unknown != null) { - for (String key : unknown.keySet()) { - Object value = unknown.get(key); + for (final String key : unknown.keySet()) { + final Object value = unknown.get(key); writer.name(key); writer.value(logger, value); } @@ -289,8 +290,8 @@ private void serializePayload(final @NotNull ObjectWriter writer, final @NotNull writer.name(JsonKeys.LEFT).value(left); writer.name(JsonKeys.TOP).value(top); if (payloadUnknown != null) { - for (String key : payloadUnknown.keySet()) { - Object value = payloadUnknown.get(key); + for (final String key : payloadUnknown.keySet()) { + final Object value = payloadUnknown.get(key); writer.name(key); writer.value(logger, value); } @@ -303,12 +304,12 @@ public static final class Deserializer implements JsonDeserializer unknown = null; + @Nullable Map unknown = null; - RRWebVideoEvent event = new RRWebVideoEvent(); - RRWebEvent.Deserializer baseEventDeserializer = new RRWebEvent.Deserializer(); + final RRWebVideoEvent event = new RRWebVideoEvent(); + final RRWebEvent.Deserializer baseEventDeserializer = new RRWebEvent.Deserializer(); while (reader.peek() == JsonToken.NAME) { final String nextName = reader.nextName(); @@ -336,7 +337,7 @@ private void deserializeData( final @NotNull ObjectReader reader, final @NotNull ILogger logger) throws Exception { - Map dataUnknown = null; + @Nullable Map dataUnknown = null; reader.beginObject(); while (reader.peek() == JsonToken.NAME) { @@ -365,7 +366,7 @@ private void deserializePayload( final @NotNull ObjectReader reader, final @NotNull ILogger logger) throws Exception { - Map payloadUnknown = null; + @Nullable Map payloadUnknown = null; reader.beginObject(); while (reader.peek() == JsonToken.NAME) { From 4e55ec0fbaeea6ee01bd23344b6cf31a6330e0bf Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 28 Feb 2024 12:28:28 +0100 Subject: [PATCH 26/89] Specify SHA for license headers --- .../src/main/java/io/sentry/android/replay/Windows.kt | 2 +- .../java/io/sentry/android/replay/video/SimpleFrameMuxer.kt | 2 +- .../java/io/sentry/android/replay/video/SimpleMp4FrameMuxer.kt | 2 +- .../java/io/sentry/android/replay/video/SimpleVideoEncoder.kt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt index 7238c8cc2dd..86ff440d02c 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt @@ -1,5 +1,5 @@ /** - * Adapted from https://github.com/square/curtains + * Adapted from https://github.com/square/curtains/tree/v1.2.5 * * Copyright 2021 Square Inc. * diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleFrameMuxer.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleFrameMuxer.kt index 70bb8cff462..17f454967bb 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleFrameMuxer.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleFrameMuxer.kt @@ -1,5 +1,5 @@ /** - * Adapted from https://github.com/fzyzcjy/flutter_screen_recorder/blob/master/packages/fast_screen_recorder/android/src/main/kotlin/com/cjy/fast_screen_recorder/SimpleFrameMuxer.kt + * Adapted from https://github.com/fzyzcjy/flutter_screen_recorder/blob/dce41cec25c66baf42c6bac4198e95874ce3eb9d/packages/fast_screen_recorder/android/src/main/kotlin/com/cjy/fast_screen_recorder/SimpleFrameMuxer.kt * * Copyright (c) 2021 fzyzcjy * diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleMp4FrameMuxer.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleMp4FrameMuxer.kt index 69f92701d35..bdedb888cdd 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleMp4FrameMuxer.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleMp4FrameMuxer.kt @@ -1,5 +1,5 @@ /** - * Adapted from https://github.com/fzyzcjy/flutter_screen_recorder/blob/master/packages/fast_screen_recorder/android/src/main/kotlin/com/cjy/fast_screen_recorder/SimpleMp4FrameMuxer.kt + * Adapted from https://github.com/fzyzcjy/flutter_screen_recorder/blob/dce41cec25c66baf42c6bac4198e95874ce3eb9d/packages/fast_screen_recorder/android/src/main/kotlin/com/cjy/fast_screen_recorder/SimpleMp4FrameMuxer.kt * * Copyright (c) 2021 fzyzcjy * diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt index 70fe81d7a41..eeafc61a368 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt @@ -1,5 +1,5 @@ /** - * Adapted from https://github.com/fzyzcjy/flutter_screen_recorder/blob/master/packages/fast_screen_recorder/android/src/main/kotlin/com/cjy/fast_screen_recorder/SimpleVideoEncoder.kt + * Adapted from https://github.com/fzyzcjy/flutter_screen_recorder/blob/dce41cec25c66baf42c6bac4198e95874ce3eb9d/packages/fast_screen_recorder/android/src/main/kotlin/com/cjy/fast_screen_recorder/SimpleFrameMuxer.kt * * Copyright (c) 2021 fzyzcjy * From 9603672ca97f2d03654aca55e79edd4cab9f34a7 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 28 Feb 2024 14:24:29 +0100 Subject: [PATCH 27/89] Address review from Dhiogo --- .../main/java/io/sentry/android/replay/ScreenshotRecorder.kt | 2 +- .../sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt index a37561b6492..8fd4d61f0b3 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -43,7 +43,7 @@ internal class ScreenshotRecorder( lastCapturedAtMs = now val root = rootView.get() - if (root == null || root.width <= 0 || root.height <= 0) { + if (root == null || root.width <= 0 || root.height <= 0 || !root.isShown) { return } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt index f044ef08502..77ef0c8c971 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt @@ -128,9 +128,11 @@ data class ViewHierarchyNode( } private fun Drawable.isRedactable(): Boolean { + // TODO: maybe find a way how to check if the drawable is coming from the apk or loaded from network + // TODO: otherwise maybe check for the bitmap size and don't redact those that take a lot of height (e.g. a background of a whatsapp chat) return when (this) { is InsetDrawable, is ColorDrawable, is VectorDrawable, is GradientDrawable -> false - is BitmapDrawable -> !bitmap.isRecycled && bitmap.height > 0 && bitmap.width > 0 + is BitmapDrawable -> !bitmap.isRecycled && bitmap.height > 10 && bitmap.width > 10 else -> true } } From 1ce57cbd24afa08a583d28e0ab6e2365a0592203 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 1 Mar 2024 14:17:55 +0100 Subject: [PATCH 28/89] Address review from Markus --- buildSrc/src/main/java/Config.kt | 2 +- .../android/replay/ScreenshotRecorder.kt | 81 ++++++++++++++----- .../sentry/android/replay/WindowRecorder.kt | 53 ++++++------ .../replay/video/SimpleVideoEncoder.kt | 4 + .../replay/viewhierarchy/ViewHierarchyNode.kt | 50 ++---------- 5 files changed, 97 insertions(+), 93 deletions(-) diff --git a/buildSrc/src/main/java/Config.kt b/buildSrc/src/main/java/Config.kt index 8a02ce0e655..f06d9bc5d7a 100644 --- a/buildSrc/src/main/java/Config.kt +++ b/buildSrc/src/main/java/Config.kt @@ -34,7 +34,7 @@ object Config { val minSdkVersion = 19 val minSdkVersionOkHttp = 21 - val minSdkVersionReplay = 26 + val minSdkVersionReplay = 19 val minSdkVersionNdk = 19 val minSdkVersionCompose = 21 val targetSdkVersion = sdkVersion diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt index 8fd4d61f0b3..ff36495f2f2 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -1,9 +1,11 @@ package io.sentry.android.replay +import android.annotation.TargetApi import android.graphics.Bitmap import android.graphics.Canvas -import android.graphics.Color +import android.graphics.Matrix import android.graphics.Paint +import android.graphics.Rect import android.graphics.RectF import android.os.Handler import android.os.HandlerThread @@ -20,14 +22,26 @@ import java.lang.ref.WeakReference import java.util.WeakHashMap import kotlin.system.measureTimeMillis +// TODO: use ILogger of Sentry and change level +@TargetApi(26) internal class ScreenshotRecorder( - val rootView: WeakReference, val encoder: SimpleVideoEncoder ) : ViewTreeObserver.OnDrawListener { + private var rootView: WeakReference? = null private val thread = HandlerThread("SentryReplay").also { it.start() } private val handler = Handler(thread.looper) private val bitmapToVH = WeakHashMap() + private val maskingPaint = Paint() + private val singlePixelBitmap: Bitmap = Bitmap.createBitmap( + 1, + 1, + Bitmap.Config.ARGB_8888 + ) + private val singlePixelBitmapCanvas: Canvas = Canvas(singlePixelBitmap) + private val prescaledMatrix = Matrix().apply { + preScale(encoder.muxerConfig.scaleFactor, encoder.muxerConfig.scaleFactor) + } companion object { const val TAG = "ScreenshotRecorder" @@ -37,12 +51,12 @@ internal class ScreenshotRecorder( override fun onDraw() { // TODO: replace with Debouncer from sentry-core val now = SystemClock.uptimeMillis() - if (lastCapturedAtMs != null && (now - lastCapturedAtMs!!) < 1000L) { + if (lastCapturedAtMs != null && (now - lastCapturedAtMs!!) < 500L) { return } lastCapturedAtMs = now - val root = rootView.get() + val root = rootView?.get() if (root == null || root.width <= 0 || root.height <= 0 || !root.isShown) { return } @@ -53,7 +67,6 @@ internal class ScreenshotRecorder( root.height, Bitmap.Config.ARGB_8888 ) - Log.e("BITMAP CREATED", bitmap.toString()) val time = measureTimeMillis { val rootNode = ViewHierarchyNode.fromView(root) @@ -62,8 +75,7 @@ internal class ScreenshotRecorder( } Log.e("TIME", time.toString()) -// val latch = CountDownLatch(1) - + // postAtFrontOfQueue to ensure the view hierarchy and bitmap are ase close in-sync as possible Handler(Looper.getMainLooper()).postAtFrontOfQueue { PixelCopy.request( window, @@ -78,26 +90,37 @@ internal class ScreenshotRecorder( Log.e("BITMAP CAPTURED", bitmap.toString()) val viewHierarchy = bitmapToVH[bitmap] - if (viewHierarchy != null) { - val canvas = Canvas(bitmap) + val scaledBitmap = Bitmap.createScaledBitmap( + bitmap, + encoder.muxerConfig.videoWidth, + encoder.muxerConfig.videoHeight, + true + ) + + if (viewHierarchy == null) { + Log.e(TAG, "Failed to determine view hierarchy, not capturing") + return@request + } else { + val canvas = Canvas(scaledBitmap) + canvas.setMatrix(prescaledMatrix) viewHierarchy.traverse { if (it.shouldRedact && (it.width > 0 && it.height > 0)) { it.visibleRect ?: return@traverse - val paint = Paint().apply { - color = it.dominantColor ?: Color.BLACK + // TODO: check for view type rather than rely on absence of dominantColor here + val color = if (it.dominantColor == null) { + singlePixelBitmapCanvas.drawBitmap(bitmap, it.visibleRect, Rect(0, 0, 1, 1), null) + singlePixelBitmap.getPixel(0, 0) + } else { + it.dominantColor } - canvas.drawRoundRect(RectF(it.visibleRect), 10f, 10f, paint) + + maskingPaint.setColor(color) + canvas.drawRoundRect(RectF(it.visibleRect), 10f, 10f, maskingPaint) } } } - val scaledBitmap = Bitmap.createScaledBitmap( - bitmap, - encoder.muxerConfig.videoWidth, - encoder.muxerConfig.videoHeight, - true - ) // val baos = ByteArrayOutputStream() // scaledBitmap.compress(Bitmap.CompressFormat.JPEG, 75, baos) // val bmp = BitmapFactory.decodeByteArray(baos.toByteArray(), 0, baos.size()) @@ -106,14 +129,30 @@ internal class ScreenshotRecorder( scaledBitmap.recycle() bitmap.recycle() Log.i(TAG, "Captured a screenshot") -// latch.countDown() }, handler ) } + } + + fun bind(root: View) { + // first unbind the current root + unbind(rootView?.get()) + rootView?.clear() + + // next bind the new root + rootView = WeakReference(root) + root.viewTreeObserver?.addOnDrawListener(this) + } + + fun unbind(root: View?) { + root?.viewTreeObserver?.removeOnDrawListener(this) + } -// val success = latch.await(200, MILLISECONDS) -// Log.i(TAG, "Captured a screenshot: $success") + fun close() { + unbind(rootView?.get()) + rootView?.clear() + thread.quitSafely() } private fun ViewHierarchyNode.traverse(callback: (ViewHierarchyNode) -> Unit) { diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt index 5e24a0af244..1a60d686b40 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt @@ -1,21 +1,21 @@ package io.sentry.android.replay +import android.annotation.TargetApi import android.content.Context import android.graphics.Point import android.os.Build.VERSION import android.os.Build.VERSION_CODES import android.view.View -import android.view.ViewTreeObserver import android.view.WindowManager import io.sentry.android.replay.video.MuxerConfig import io.sentry.android.replay.video.SimpleVideoEncoder import java.io.File import java.lang.ref.WeakReference -import java.util.WeakHashMap import java.util.concurrent.atomic.AtomicBoolean import kotlin.LazyThreadSafetyMode.NONE import kotlin.math.roundToInt +@TargetApi(26) class WindowRecorder { private val rootViewsSpy by lazy(NONE) { @@ -24,28 +24,20 @@ class WindowRecorder { private var encoder: SimpleVideoEncoder? = null private val isRecording = AtomicBoolean(false) - private val recorders: WeakHashMap = WeakHashMap() + private val rootViews = ArrayList>() + private var recorder: ScreenshotRecorder? = null private val onRootViewsChangedListener = OnRootViewsChangedListener { root, added -> if (added) { - if (recorders.containsKey(root)) { - // TODO: log - return@OnRootViewsChangedListener - } - // stop tracking other windows so they don't interfere in the recording like a 25th frame effect - recorders.entries.forEach { - it.key.viewTreeObserver.removeOnDrawListener(it.value) - } - - val recorder = ScreenshotRecorder(WeakReference(root), encoder!!) - recorders[root] = recorder - root.viewTreeObserver?.addOnDrawListener(recorder) + rootViews.add(WeakReference(root)) + recorder?.bind(root) } else { - root.viewTreeObserver?.removeOnDrawListener(recorders[root]) - recorders.remove(root) + recorder?.unbind(root) + rootViews.removeAll { it.get() == root } - recorders.entries.forEach { - it.key.viewTreeObserver.addOnDrawListener(it.value) + val newRoot = rootViews.lastOrNull()?.get() + if (newRoot != null && root != newRoot) { + recorder?.bind(newRoot) } } } @@ -62,13 +54,16 @@ class WindowRecorder { // context.resources.displayMetrics.density).roundToInt() // TODO: API level check // PixelCopy takes screenshots including system bars, so we have to get the real size here + val height: Int val aspectRatio = if (VERSION.SDK_INT >= VERSION_CODES.R) { - wm.currentWindowMetrics.bounds.bottom.toFloat() / wm.currentWindowMetrics.bounds.right.toFloat() + height = wm.currentWindowMetrics.bounds.bottom + height.toFloat() / wm.currentWindowMetrics.bounds.right.toFloat() } else { val screenResolution = Point() @Suppress("DEPRECATION") wm.defaultDisplay.getRealSize(screenResolution) - screenResolution.y.toFloat() / screenResolution.x.toFloat() + height = screenResolution.y + height.toFloat() / screenResolution.x.toFloat() } val videoFile = File(context.cacheDir, "sentry-sr.mp4") @@ -77,21 +72,23 @@ class WindowRecorder { videoFile, videoWidth = (720 / aspectRatio).roundToInt(), videoHeight = 720, - frameRate = 1f, + scaleFactor = 720f / height, + frameRate = 2f, bitrate = 500 * 1000 ) - ) - encoder?.start() + ).also { it.start() } + recorder = ScreenshotRecorder(encoder!!) rootViewsSpy.listeners += onRootViewsChangedListener } fun stopRecording() { rootViewsSpy.listeners -= onRootViewsChangedListener - recorders.entries.forEach { - it.key.viewTreeObserver.removeOnDrawListener(it.value) - } - recorders.clear() + rootViews.forEach { recorder?.unbind(it.get()) } + recorder?.close() + rootViews.clear() + recorder = null encoder?.startRelease() encoder = null + isRecording.set(false) } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt index eeafc61a368..4046eec37bc 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt @@ -29,6 +29,7 @@ */ package io.sentry.android.replay.video +import android.annotation.TargetApi import android.graphics.Bitmap import android.media.MediaCodec import android.media.MediaCodecInfo @@ -38,6 +39,7 @@ import android.os.Looper import android.view.Surface import java.io.File +@TargetApi(26) internal class SimpleVideoEncoder( val muxerConfig: MuxerConfig ) { @@ -156,10 +158,12 @@ internal class SimpleVideoEncoder( } } +@TargetApi(24) internal data class MuxerConfig( val file: File, val videoWidth: Int, val videoHeight: Int, + val scaleFactor: Float, val mimeType: String = MediaFormat.MIMETYPE_VIDEO_AVC, val frameRate: Float, val bitrate: Int, diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt index 77ef0c8c971..ad9c6dfda8d 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt @@ -1,7 +1,6 @@ package io.sentry.android.replay.viewhierarchy -import android.graphics.Bitmap -import android.graphics.Canvas +import android.annotation.TargetApi import android.graphics.Color import android.graphics.Rect import android.graphics.drawable.BitmapDrawable @@ -18,6 +17,7 @@ import android.widget.ImageView import android.widget.TextView // TODO: merge with ViewHierarchyNode from sentry-core maybe? +@TargetApi(26) data class ViewHierarchyNode( val x: Float, val y: Float, @@ -47,11 +47,11 @@ data class ViewHierarchyNode( return actualPosition.intersects(screen.left, screen.top, screen.right, screen.bottom) } - fun adjustAlpha(color: Int): Int { + fun Int.toOpaque(): Int { val alpha = 255 - val red = Color.red(color) - val green = Color.green(color) - val blue = Color.blue(color) + val red = Color.red(this) + val green = Color.green(this) + val blue = Color.blue(this) return Color.argb(alpha, red, green, blue) } @@ -77,7 +77,7 @@ data class ViewHierarchyNode( val bounds = Rect() val text = view.text.toString() view.paint.getTextBounds(text, 0, text.length, bounds) - dominantColor = adjustAlpha(view.currentTextColor) + dominantColor = view.currentTextColor.toOpaque() rect = Rect() view.getGlobalVisibleRect(rect) @@ -110,7 +110,6 @@ data class ViewHierarchyNode( is ImageView -> { shouldRedact = isVisible(view) && (view.drawable?.isRedactable() ?: false) if (shouldRedact) { - dominantColor = adjustAlpha((view.drawable?.pickDominantColor() ?: Color.BLACK)) rect = Rect() view.getGlobalVisibleRect(rect) } @@ -136,40 +135,5 @@ data class ViewHierarchyNode( else -> true } } - - private fun Drawable.pickDominantColor(): Int { - // TODO: pick default color based on dark/light default theme - return when (this) { - is BitmapDrawable -> { - val newBitmap = Bitmap.createScaledBitmap(bitmap, 1, 1, true) - val color = newBitmap.getPixel(0, 0) - newBitmap.recycle() - color - } - - else -> { - if (intrinsicHeight > 0 && intrinsicWidth > 0) { - // this is needed to pick a dominant color when there's a drawable from a 3rd-party image-loading lib, e.g. Coil - // we request the bitmap to draw onto the canvas and then downscale it to 1x1 pixels to get the dominant color - // TODO: maybe we should provide an option to disable this and just use black color for rectangles to save cpu time - val bmp = - Bitmap.createBitmap(this.intrinsicWidth, intrinsicHeight, Bitmap.Config.ARGB_8888) - val canvas = Canvas(bmp) - try { - draw(canvas) - val newBitmap = Bitmap.createScaledBitmap(bmp, 1, 1, true) - val color = newBitmap.getPixel(0, 0) - newBitmap.recycle() - bmp.recycle() - color - } catch (e: Throwable) { - Color.BLACK - } - } else { - Color.BLACK - } - } - } - } } } From 62477b47e3d8ffed2c24e0786717af4b70f6fa63 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 1 Mar 2024 14:30:30 +0100 Subject: [PATCH 29/89] Remove public captureReplay method --- sentry/src/main/java/io/sentry/Sentry.java | 5 ----- 1 file changed, 5 deletions(-) diff --git a/sentry/src/main/java/io/sentry/Sentry.java b/sentry/src/main/java/io/sentry/Sentry.java index 6058199d622..0aff89c0d06 100644 --- a/sentry/src/main/java/io/sentry/Sentry.java +++ b/sentry/src/main/java/io/sentry/Sentry.java @@ -1025,9 +1025,4 @@ public interface OptionsConfiguration { public static @NotNull SentryId captureCheckIn(final @NotNull CheckIn checkIn) { return getCurrentHub().captureCheckIn(checkIn); } - - public static void captureReplay( - final @NotNull SentryReplayEvent replay, final @Nullable Hint hint) { - getCurrentHub().captureReplay(replay, hint); - } } From af42fb3578733c1af687b48133fd9272c54c2603 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 1 Mar 2024 16:21:01 +0100 Subject: [PATCH 30/89] Fix test --- .../io/sentry/protocol/SentryReplayEventSerializationTest.kt | 4 ++-- sentry/src/test/resources/json/sentry_replay_event.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/sentry/src/test/java/io/sentry/protocol/SentryReplayEventSerializationTest.kt b/sentry/src/test/java/io/sentry/protocol/SentryReplayEventSerializationTest.kt index 7be66d31e77..6ecd6800767 100644 --- a/sentry/src/test/java/io/sentry/protocol/SentryReplayEventSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/protocol/SentryReplayEventSerializationTest.kt @@ -21,8 +21,8 @@ class SentryReplayEventSerializationTest { fun getSut() = SentryReplayEvent().apply { replayId = SentryId("f715e1d64ef64ea3ad7744b5230813c3") segmentId = 0 - timestamp = DateUtils.getDateTimeWithMillisPrecision("987654321.123") - replayStartTimestamp = DateUtils.getDateTimeWithMillisPrecision("987654321.123") + timestamp = DateUtils.getDateTime("1942-07-09T12:55:34.000Z") + replayStartTimestamp = DateUtils.getDateTime("1942-07-09T12:55:34.000Z") urls = listOf("ScreenOne") errorIds = listOf("ab3a347a4cc14fd4b4cf1dc56b670c5b") traceIds = listOf("340cfef948204549ac07c3b353c81c50") diff --git a/sentry/src/test/resources/json/sentry_replay_event.json b/sentry/src/test/resources/json/sentry_replay_event.json index 04c96968b91..dea5a99e59c 100644 --- a/sentry/src/test/resources/json/sentry_replay_event.json +++ b/sentry/src/test/resources/json/sentry_replay_event.json @@ -2,9 +2,9 @@ "type": "replay_event", "replay_type": "session", "segment_id": 0, - "timestamp": 987654321.123, + "timestamp": "1942-07-09T12:55:34.000Z", "replay_id": "f715e1d64ef64ea3ad7744b5230813c3", - "replay_start_timestamp": 987654321.123, + "replay_start_timestamp": "1942-07-09T12:55:34.000Z", "urls": [ "ScreenOne" From 1951891a845cc1bf167605183e5fe1384540df7e Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 1 Mar 2024 16:26:25 +0100 Subject: [PATCH 31/89] api dump --- sentry-android-replay/api/sentry-android-replay.api | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index b6029a33702..8f334554f5f 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -57,7 +57,7 @@ public final class io/sentry/android/replay/viewhierarchy/ViewHierarchyNode { } public final class io/sentry/android/replay/viewhierarchy/ViewHierarchyNode$Companion { - public final fun adjustAlpha (I)I public final fun fromView (Landroid/view/View;)Lio/sentry/android/replay/viewhierarchy/ViewHierarchyNode; + public final fun toOpaque (I)I } From 4e54c77b149347b3f44c5479f7976fc8417575a0 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Sat, 2 Mar 2024 00:04:28 +0100 Subject: [PATCH 32/89] api dump --- sentry/api/sentry.api | 1 - 1 file changed, 1 deletion(-) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 20e2d8b53bc..b6acae0cdee 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -1755,7 +1755,6 @@ public final class io/sentry/Sentry { public static fun captureMessage (Ljava/lang/String;Lio/sentry/ScopeCallback;)Lio/sentry/protocol/SentryId; public static fun captureMessage (Ljava/lang/String;Lio/sentry/SentryLevel;)Lio/sentry/protocol/SentryId; public static fun captureMessage (Ljava/lang/String;Lio/sentry/SentryLevel;Lio/sentry/ScopeCallback;)Lio/sentry/protocol/SentryId; - public static fun captureReplay (Lio/sentry/SentryReplayEvent;Lio/sentry/Hint;)V public static fun captureUserFeedback (Lio/sentry/UserFeedback;)V public static fun clearBreadcrumbs ()V public static fun cloneMainHub ()Lio/sentry/IHub; From b2940c424147399abc28e81e0bb441780bc932c6 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 4 Mar 2024 12:00:40 +0100 Subject: [PATCH 33/89] Address review from Markus --- .../android/replay/ScreenshotRecorder.kt | 19 +++++++++++-------- .../replay/viewhierarchy/ViewHierarchyNode.kt | 10 ++-------- 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt index ff36495f2f2..3b3a6758fc5 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -90,17 +90,18 @@ internal class ScreenshotRecorder( Log.e("BITMAP CAPTURED", bitmap.toString()) val viewHierarchy = bitmapToVH[bitmap] - val scaledBitmap = Bitmap.createScaledBitmap( - bitmap, - encoder.muxerConfig.videoWidth, - encoder.muxerConfig.videoHeight, - true - ) + var scaledBitmap: Bitmap? = null if (viewHierarchy == null) { Log.e(TAG, "Failed to determine view hierarchy, not capturing") return@request } else { + scaledBitmap = Bitmap.createScaledBitmap( + bitmap, + encoder.muxerConfig.videoWidth, + encoder.muxerConfig.videoHeight, + true + ) val canvas = Canvas(scaledBitmap) canvas.setMatrix(prescaledMatrix) viewHierarchy.traverse { @@ -124,9 +125,11 @@ internal class ScreenshotRecorder( // val baos = ByteArrayOutputStream() // scaledBitmap.compress(Bitmap.CompressFormat.JPEG, 75, baos) // val bmp = BitmapFactory.decodeByteArray(baos.toByteArray(), 0, baos.size()) - encoder.encode(scaledBitmap) + scaledBitmap?.let { + encoder.encode(it) + it.recycle() + } // bmp.recycle() - scaledBitmap.recycle() bitmap.recycle() Log.i(TAG, "Captured a screenshot") }, diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt index ad9c6dfda8d..9b6a068f059 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt @@ -1,7 +1,6 @@ package io.sentry.android.replay.viewhierarchy import android.annotation.TargetApi -import android.graphics.Color import android.graphics.Rect import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.ColorDrawable @@ -47,13 +46,8 @@ data class ViewHierarchyNode( return actualPosition.intersects(screen.left, screen.top, screen.right, screen.bottom) } - fun Int.toOpaque(): Int { - val alpha = 255 - val red = Color.red(this) - val green = Color.green(this) - val blue = Color.blue(this) - return Color.argb(alpha, red, green, blue) - } + // TODO: check if this works on RN + private fun Int.toOpaque() = this or 0xFF000000.toInt() fun fromView(view: View): ViewHierarchyNode { // TODO: Extract redacting into its own class/function From 002a0f35f58d06784caad862666d965bd7ce8f0e Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 4 Mar 2024 12:24:43 +0100 Subject: [PATCH 34/89] Api dump --- sentry-android-replay/api/sentry-android-replay.api | 1 - 1 file changed, 1 deletion(-) diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index 8f334554f5f..e81c5840eac 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -58,6 +58,5 @@ public final class io/sentry/android/replay/viewhierarchy/ViewHierarchyNode { public final class io/sentry/android/replay/viewhierarchy/ViewHierarchyNode$Companion { public final fun fromView (Landroid/view/View;)Lio/sentry/android/replay/viewhierarchy/ViewHierarchyNode; - public final fun toOpaque (I)I } From 9e87fe8ce0ff8d95c761fa1ec2fb110b98816048 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 19 Mar 2024 15:43:22 +0100 Subject: [PATCH 35/89] Add replay integration --- .../api/sentry-android-core.api | 4 + sentry-android-core/build.gradle.kts | 1 + .../core/AndroidOptionsInitializer.java | 8 +- .../sentry/android/core/LifecycleWatcher.java | 62 ++-- .../io/sentry/android/core/SentryAndroid.java | 80 ++++- .../api/sentry-android-replay.api | 28 +- sentry-android-replay/build.gradle.kts | 4 + .../io/sentry/android/replay/ReplayCache.kt | 171 ++++++++++ .../android/replay/ReplayIntegration.kt | 313 ++++++++++++++++++ .../android/replay/ScreenshotRecorder.kt | 145 ++++---- .../sentry/android/replay/WindowRecorder.kt | 83 ++--- .../java/io/sentry/android/replay/Windows.kt | 10 +- .../replay/video/SimpleMp4FrameMuxer.kt | 7 +- .../replay/video/SimpleVideoEncoder.kt | 143 ++++---- .../sentry/android/replay/ReplayCacheTest.kt | 86 +++++ sentry-android/build.gradle.kts | 1 + sentry/api/sentry.api | 10 +- sentry/src/main/java/io/sentry/IScope.java | 18 + sentry/src/main/java/io/sentry/NoOpScope.java | 9 + sentry/src/main/java/io/sentry/Scope.java | 17 + .../java/io/sentry/SentryEnvelopeItem.java | 5 +- .../java/io/sentry/rrweb/RRWebVideoEvent.java | 8 +- 22 files changed, 1005 insertions(+), 208 deletions(-) create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt create mode 100644 sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 51eb48f1b22..55278c63563 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -245,6 +245,10 @@ public final class io/sentry/android/core/SentryAndroid { public static fun init (Landroid/content/Context;Lio/sentry/ILogger;)V public static fun init (Landroid/content/Context;Lio/sentry/ILogger;Lio/sentry/Sentry$OptionsConfiguration;)V public static fun init (Landroid/content/Context;Lio/sentry/Sentry$OptionsConfiguration;)V + public static fun pauseReplay ()V + public static fun resumeReplay ()V + public static fun startReplay ()V + public static fun stopReplay ()V } public final class io/sentry/android/core/SentryAndroidDateProvider : io/sentry/SentryDateProvider { diff --git a/sentry-android-core/build.gradle.kts b/sentry-android-core/build.gradle.kts index 4ab0aec4231..da4851c92a9 100644 --- a/sentry-android-core/build.gradle.kts +++ b/sentry-android-core/build.gradle.kts @@ -76,6 +76,7 @@ dependencies { api(projects.sentry) compileOnly(projects.sentryAndroidFragment) compileOnly(projects.sentryAndroidTimber) + compileOnly(projects.sentryAndroidReplay) compileOnly(projects.sentryCompose) // lifecycle processor, session tracking diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java index 41d0dec6b2b..b58051cee71 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java @@ -22,6 +22,7 @@ import io.sentry.android.core.internal.util.SentryFrameMetricsCollector; import io.sentry.android.core.performance.AppStartMetrics; import io.sentry.android.fragment.FragmentLifecycleIntegration; +import io.sentry.android.replay.ReplayIntegration; import io.sentry.android.timber.SentryTimberIntegration; import io.sentry.cache.PersistingOptionsObserver; import io.sentry.cache.PersistingScopeObserver; @@ -29,6 +30,7 @@ import io.sentry.compose.viewhierarchy.ComposeViewHierarchyExporter; import io.sentry.internal.gestures.GestureTargetLocator; import io.sentry.internal.viewhierarchy.ViewHierarchyExporter; +import io.sentry.transport.CurrentDateProvider; import io.sentry.transport.NoOpEnvelopeCache; import io.sentry.util.LazyEvaluator; import io.sentry.util.Objects; @@ -230,7 +232,8 @@ static void installDefaultIntegrations( final @NotNull LoadClass loadClass, final @NotNull ActivityFramesTracker activityFramesTracker, final boolean isFragmentAvailable, - final boolean isTimberAvailable) { + final boolean isTimberAvailable, + final boolean isReplayAvailable) { // Integration MUST NOT cache option values in ctor, as they will be configured later by the // user @@ -295,6 +298,9 @@ static void installDefaultIntegrations( new NetworkBreadcrumbsIntegration(context, buildInfoProvider, options.getLogger())); options.addIntegration(new TempSensorBreadcrumbsIntegration(context)); options.addIntegration(new PhoneStateBreadcrumbsIntegration(context)); + if (isReplayAvailable) { + options.addIntegration(new ReplayIntegration(context, CurrentDateProvider.getInstance())); + } } /** diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java b/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java index 7b38bcd9c2f..ff281d2beba 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java @@ -11,6 +11,7 @@ import io.sentry.transport.ICurrentDateProvider; import java.util.Timer; import java.util.TimerTask; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicLong; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -19,11 +20,12 @@ final class LifecycleWatcher implements DefaultLifecycleObserver { private final AtomicLong lastUpdatedSession = new AtomicLong(0L); + private final AtomicBoolean isFreshSession = new AtomicBoolean(false); private final long sessionIntervalMillis; private @Nullable TimerTask timerTask; - private final @Nullable Timer timer; + private final @NotNull Timer timer = new Timer(true); private final @NotNull Object timerLock = new Object(); private final @NotNull IHub hub; private final boolean enableSessionTracking; @@ -55,11 +57,6 @@ final class LifecycleWatcher implements DefaultLifecycleObserver { this.enableAppLifecycleBreadcrumbs = enableAppLifecycleBreadcrumbs; this.hub = hub; this.currentDateProvider = currentDateProvider; - if (enableSessionTracking) { - timer = new Timer(true); - } else { - timer = null; - } } // App goes to foreground @@ -74,41 +71,45 @@ public void onStart(final @NotNull LifecycleOwner owner) { } private void startSession() { - if (enableSessionTracking) { - cancelTask(); + cancelTask(); - final long currentTimeMillis = currentDateProvider.getCurrentTimeMillis(); + final long currentTimeMillis = currentDateProvider.getCurrentTimeMillis(); - hub.configureScope( - scope -> { - if (lastUpdatedSession.get() == 0L) { - final @Nullable Session currentSession = scope.getSession(); - if (currentSession != null && currentSession.getStarted() != null) { - lastUpdatedSession.set(currentSession.getStarted().getTime()); - } + hub.configureScope( + scope -> { + if (lastUpdatedSession.get() == 0L) { + final @Nullable Session currentSession = scope.getSession(); + if (currentSession != null && currentSession.getStarted() != null) { + lastUpdatedSession.set(currentSession.getStarted().getTime()); + isFreshSession.set(true); } - }); + } + }); - final long lastUpdatedSession = this.lastUpdatedSession.get(); - if (lastUpdatedSession == 0L - || (lastUpdatedSession + sessionIntervalMillis) <= currentTimeMillis) { + final long lastUpdatedSession = this.lastUpdatedSession.get(); + if (lastUpdatedSession == 0L + || (lastUpdatedSession + sessionIntervalMillis) <= currentTimeMillis) { + if (enableSessionTracking) { addSessionBreadcrumb("start"); hub.startSession(); } - this.lastUpdatedSession.set(currentTimeMillis); + SentryAndroid.startReplay(); + } else if (!isFreshSession.getAndSet(false)) { + // only resume if it's not a fresh session, which has been started in SentryAndroid.init + SentryAndroid.resumeReplay(); } + this.lastUpdatedSession.set(currentTimeMillis); } // App went to background and triggered this callback after 700ms // as no new screen was shown @Override public void onStop(final @NotNull LifecycleOwner owner) { - if (enableSessionTracking) { - final long currentTimeMillis = currentDateProvider.getCurrentTimeMillis(); - this.lastUpdatedSession.set(currentTimeMillis); + final long currentTimeMillis = currentDateProvider.getCurrentTimeMillis(); + this.lastUpdatedSession.set(currentTimeMillis); - scheduleEndSession(); - } + SentryAndroid.pauseReplay(); + scheduleEndSession(); AppState.getInstance().setInBackground(true); addAppBreadcrumb("background"); @@ -122,8 +123,11 @@ private void scheduleEndSession() { new TimerTask() { @Override public void run() { - addSessionBreadcrumb("end"); - hub.endSession(); + if (enableSessionTracking) { + addSessionBreadcrumb("end"); + hub.endSession(); + } + SentryAndroid.stopReplay(); } }; @@ -164,7 +168,7 @@ TimerTask getTimerTask() { } @TestOnly - @Nullable + @NotNull Timer getTimer() { return timer; } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java index af68a026fbb..39a2019d1de 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java @@ -15,6 +15,8 @@ import io.sentry.android.core.performance.AppStartMetrics; import io.sentry.android.core.performance.TimeSpan; import io.sentry.android.fragment.FragmentLifecycleIntegration; +import io.sentry.android.replay.ReplayIntegration; +import io.sentry.android.replay.ReplayIntegrationKt; import io.sentry.android.timber.SentryTimberIntegration; import java.lang.reflect.InvocationTargetException; import java.util.ArrayList; @@ -33,6 +35,11 @@ public final class SentryAndroid { static final String SENTRY_TIMBER_INTEGRATION_CLASS_NAME = "io.sentry.android.timber.SentryTimberIntegration"; + static final String SENTRY_REPLAY_INTEGRATION_CLASS_NAME = + "io.sentry.android.replay.ReplayIntegration"; + + private static boolean isReplayAvailable = false; + private static final String TIMBER_CLASS_NAME = "timber.log.Timber"; private static final String FRAGMENT_CLASS_NAME = "androidx.fragment.app.FragmentManager$FragmentLifecycleCallbacks"; @@ -99,6 +106,8 @@ public static synchronized void init( final boolean isTimberAvailable = (isTimberUpstreamAvailable && classLoader.isClassAvailable(SENTRY_TIMBER_INTEGRATION_CLASS_NAME, options)); + isReplayAvailable = + classLoader.isClassAvailable(SENTRY_REPLAY_INTEGRATION_CLASS_NAME, options); final BuildInfoProvider buildInfoProvider = new BuildInfoProvider(logger); final LoadClass loadClass = new LoadClass(); @@ -118,7 +127,8 @@ public static synchronized void init( loadClass, activityFramesTracker, isFragmentAvailable, - isTimberAvailable); + isTimberAvailable, + isReplayAvailable); configuration.configure(options); @@ -145,9 +155,12 @@ public static synchronized void init( true); final @NotNull IHub hub = Sentry.getCurrentHub(); - if (hub.getOptions().isEnableAutoSessionTracking() && ContextUtils.isForegroundImportance()) { - hub.addBreadcrumb(BreadcrumbFactory.forSession("session.start")); - hub.startSession(); + if (ContextUtils.isForegroundImportance()) { + if (hub.getOptions().isEnableAutoSessionTracking()) { + hub.addBreadcrumb(BreadcrumbFactory.forSession("session.start")); + hub.startSession(); + } + startReplay(); } } catch (IllegalAccessException e) { logger.log(SentryLevel.FATAL, "Fatal error during SentryAndroid.init(...)", e); @@ -212,4 +225,63 @@ private static void deduplicateIntegrations( } } } + + public static synchronized void startReplay() { + performReplayAction( + "starting", + (replay) -> { + replay.start(); + }); + } + + public static synchronized void stopReplay() { + performReplayAction( + "stopping", + (replay) -> { + replay.stop(); + }); + } + + public static synchronized void resumeReplay() { + performReplayAction( + "resuming", + (replay) -> { + replay.resume(); + }); + } + + public static synchronized void pauseReplay() { + performReplayAction( + "pausing", + (replay) -> { + replay.pause(); + }); + } + + private static void performReplayAction( + final @NotNull String actionName, final @NotNull ReplayCallable action) { + final @NotNull IHub hub = Sentry.getCurrentHub(); + if (isReplayAvailable) { + final ReplayIntegration replay = ReplayIntegrationKt.getReplayIntegration(hub); + if (replay != null) { + action.call(replay); + } else { + hub.getOptions() + .getLogger() + .log( + SentryLevel.INFO, + "Session Replay wasn't registered yet, not " + actionName + " the replay"); + } + } else { + hub.getOptions() + .getLogger() + .log( + SentryLevel.INFO, + "Session Replay wasn't found on classpath, not " + actionName + " the replay"); + } + } + + private interface ReplayCallable { + void call(final @NotNull ReplayIntegration replay); + } } diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index e81c5840eac..5af2ca943ba 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -6,10 +6,30 @@ public final class io/sentry/android/replay/BuildConfig { public fun ()V } -public final class io/sentry/android/replay/WindowRecorder { - public fun ()V - public final fun startRecording (Landroid/content/Context;)V - public final fun stopRecording ()V +public final class io/sentry/android/replay/ReplayIntegration : io/sentry/Integration, io/sentry/android/replay/ScreenshotRecorderCallback, java/io/Closeable { + public static final field Companion Lio/sentry/android/replay/ReplayIntegration$Companion; + public static final field VIDEO_BUFFER_DURATION J + public static final field VIDEO_SEGMENT_DURATION J + public fun (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;)V + public fun close ()V + public fun onScreenshotRecorded (Landroid/graphics/Bitmap;)V + public final fun pause ()V + public fun register (Lio/sentry/IHub;Lio/sentry/SentryOptions;)V + public final fun resume ()V + public final fun start ()V + public final fun stop ()V +} + +public final class io/sentry/android/replay/ReplayIntegration$Companion { +} + +public final class io/sentry/android/replay/ReplayIntegrationKt { + public static final fun getReplayIntegration (Lio/sentry/IHub;)Lio/sentry/android/replay/ReplayIntegration; + public static final fun gracefullyShutdown (Ljava/util/concurrent/ExecutorService;Lio/sentry/SentryOptions;)V +} + +public abstract interface class io/sentry/android/replay/ScreenshotRecorderCallback { + public abstract fun onScreenshotRecorded (Landroid/graphics/Bitmap;)V } public abstract interface class io/sentry/android/replay/video/SimpleFrameMuxer { diff --git a/sentry-android-replay/build.gradle.kts b/sentry-android-replay/build.gradle.kts index 2314960f5e2..319386ee2b7 100644 --- a/sentry-android-replay/build.gradle.kts +++ b/sentry-android-replay/build.gradle.kts @@ -19,6 +19,8 @@ android { targetSdk = Config.Android.targetSdkVersion minSdk = Config.Android.minSdkVersionReplay + testInstrumentationRunner = Config.TestLibs.androidJUnitRunner + // for AGP 4.1 buildConfigField("String", "VERSION_NAME", "\"${project.version}\"") } @@ -67,7 +69,9 @@ dependencies { // tests testImplementation(projects.sentryTestSupport) + testImplementation(Config.TestLibs.robolectric) testImplementation(Config.TestLibs.kotlinTestJunit) + testImplementation(Config.TestLibs.androidxRunner) testImplementation(Config.TestLibs.androidxJunit) testImplementation(Config.TestLibs.mockitoKotlin) testImplementation(Config.TestLibs.mockitoInline) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt new file mode 100644 index 00000000000..cf6d6a93aaa --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt @@ -0,0 +1,171 @@ +package io.sentry.android.replay + +import android.graphics.Bitmap +import android.graphics.Bitmap.CompressFormat.JPEG +import android.graphics.BitmapFactory +import io.sentry.SentryLevel.DEBUG +import io.sentry.SentryLevel.ERROR +import io.sentry.SentryLevel.WARNING +import io.sentry.SentryOptions +import io.sentry.android.replay.video.MuxerConfig +import io.sentry.android.replay.video.SimpleVideoEncoder +import io.sentry.protocol.SentryId +import io.sentry.util.FileUtils +import java.io.Closeable +import java.io.File + +internal class ReplayCache( + private val options: SentryOptions, + private val replayId: SentryId, + private val recorderConfig: ScreenshotRecorderConfig +) : Closeable { + + private val encoderLock = Any() + private var encoder: SimpleVideoEncoder? = null + + private val replayCacheDir: File? by lazy { + if (options.cacheDirPath.isNullOrEmpty()) { + options.logger.log( + WARNING, + "SentryOptions.cacheDirPath is not set, session replay is no-op" + ) + null + } else { + File(options.cacheDirPath!!, "replay_$replayId").also { it.mkdirs() } + } + } + + // TODO: maybe account for multi-threaded access + private val frames = mutableListOf() + + fun addFrame(bitmap: Bitmap, frameTimestamp: Long) { + if (replayCacheDir == null) { + return + } + + val screenshot = File(replayCacheDir, "$frameTimestamp.jpg").also { + it.createNewFile() + } + screenshot.outputStream().use { + bitmap.compress(JPEG, 80, it) + it.flush() + } + + val frame = ReplayFrame(screenshot, frameTimestamp) + frames += frame + } + + fun createVideoOf(duration: Long, from: Long, segmentId: Int): GeneratedVideo? { + if (frames.isEmpty()) { + options.logger.log( + DEBUG, + "No captured frames, skipping generating a video segment" + ) + return null + } + + // TODO: reuse instance of encoder and just change file path to create a different muxer + val videoFile = File(replayCacheDir, "$segmentId.mp4") + encoder = synchronized(encoderLock) { + SimpleVideoEncoder( + options, + MuxerConfig( + file = videoFile, + recorderConfig = recorderConfig, + frameRate = recorderConfig.frameRate.toFloat(), + bitrate = 20 * 1000 + ) + ) + }.also { it.start() } + + val step = 1000 / recorderConfig.frameRate.toLong() + var frameCount = 0 + var lastFrame: ReplayFrame = frames.first() + for (timestamp in from until (from + (duration)) step step) { + val iter = frames.iterator() + val frameCountBefore = frameCount + while (iter.hasNext()) { + val frame = iter.next() + if (frame.timestamp in (timestamp..timestamp + step)) { + frameCount++ + encode(frame) + lastFrame = frame + break // we only support 1 frame per given interval + } + } + + // if the frame count hasn't changed we just replicate the last known frame to respect + // the video duration. + if (frameCountBefore == frameCount) { + frameCount++ + encode(lastFrame) + } + } + + if (frameCount == 0) { + options.logger.log( + DEBUG, + "Generated a video with no frames, not capturing a replay segment" + ) + deleteFile(videoFile) + return null + } + + var videoDuration: Long + synchronized(encoderLock) { + encoder?.release() + videoDuration = encoder?.duration ?: 0 + encoder = null + } + + frames.removeAll { + if (it.timestamp < (from + duration)) { + deleteFile(it.screenshot) + return@removeAll true + } + return@removeAll false + } + + return GeneratedVideo(videoFile, frameCount, videoDuration) + } + + private fun encode(frame: ReplayFrame) { + val bitmap = BitmapFactory.decodeFile(frame.screenshot.absolutePath) + synchronized(encoderLock) { + encoder?.encode(bitmap) + } + bitmap.recycle() + } + + private fun deleteFile(file: File) { + try { + if (!file.delete()) { + options.logger.log(ERROR, "Failed to delete replay frame: %s", file.absolutePath) + } + } catch (e: Throwable) { + options.logger.log(ERROR, e, "Failed to delete replay frame: %s", file.absolutePath) + } + } + + fun cleanup() { + FileUtils.deleteRecursively(replayCacheDir) + } + + override fun close() { + synchronized(encoderLock) { + encoder?.release() + encoder = null + } + } +} + +internal data class ReplayFrame( + val screenshot: File, + val timestamp: Long +) + +internal data class GeneratedVideo( + val video: File, + val frameCount: Int, + val duration: Long +) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt new file mode 100644 index 00000000000..26b35f9a15b --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -0,0 +1,313 @@ +package io.sentry.android.replay + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Point +import android.graphics.Rect +import android.os.Build +import android.os.Build.VERSION +import android.os.Build.VERSION_CODES +import android.view.WindowManager +import io.sentry.DateUtils +import io.sentry.Hint +import io.sentry.IHub +import io.sentry.Integration +import io.sentry.ReplayRecording +import io.sentry.SentryLevel.DEBUG +import io.sentry.SentryLevel.INFO +import io.sentry.SentryOptions +import io.sentry.SentryReplayEvent +import io.sentry.protocol.SentryId +import io.sentry.rrweb.RRWebMetaEvent +import io.sentry.rrweb.RRWebVideoEvent +import io.sentry.transport.ICurrentDateProvider +import java.io.Closeable +import java.io.File +import java.util.Date +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.ThreadFactory +import java.util.concurrent.TimeUnit.MILLISECONDS +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference +import kotlin.LazyThreadSafetyMode.NONE +import kotlin.math.roundToInt + +class ReplayIntegration( + private val context: Context, + private val dateProvider: ICurrentDateProvider +) : Integration, Closeable, ScreenshotRecorderCallback { + + companion object { + const val VIDEO_SEGMENT_DURATION = 5_000L + const val VIDEO_BUFFER_DURATION = 30_000L + } + + private lateinit var options: SentryOptions + private var hub: IHub? = null + private var recorder: WindowRecorder? = null + private var cache: ReplayCache? = null + + // TODO: probably not everything has to be thread-safe here + private val isEnabled = AtomicBoolean(false) + private val isRecording = AtomicBoolean(false) + private val currentReplayId = AtomicReference() + private val segmentTimestamp = AtomicReference() + private val currentSegment = AtomicInteger(0) + private val saver = + Executors.newSingleThreadScheduledExecutor(ReplayExecutorServiceThreadFactory()) + + private val screenBounds by lazy(NONE) { + // PixelCopy takes screenshots including system bars, so we have to get the real size here + val wm = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager + if (VERSION.SDK_INT >= VERSION_CODES.R) { + wm.currentWindowMetrics.bounds + } else { + val screenBounds = Point() + @Suppress("DEPRECATION") + wm.defaultDisplay.getRealSize(screenBounds) + Rect(0, 0, screenBounds.x, screenBounds.y) + } + } + + private val aspectRatio by lazy(NONE) { + screenBounds.bottom.toFloat() / screenBounds.right.toFloat() + } + + private val recorderConfig by lazy(NONE) { + ScreenshotRecorderConfig( + recordingWidth = (720 / aspectRatio).roundToInt(), + recordingHeight = 720, + scaleFactor = 720f / screenBounds.bottom + ) + } + + override fun register(hub: IHub, options: SentryOptions) { + this.options = options + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + options.logger.log(INFO, "Session replay is only supported on API 26 and above") + return + } + + // TODO: check for replaysSessionSampleRate and replaysOnErrorSampleRate + + this.hub = hub + recorder = WindowRecorder(options, recorderConfig, this) + isEnabled.set(true) + } + + fun start() { + if (!isEnabled.get()) { + options.logger.log( + DEBUG, + "Session replay is disabled due to conditions not met in Integration.register" + ) + return + } + + if (isRecording.getAndSet(true)) { + options.logger.log( + DEBUG, + "Session replay is already being recorded, not starting a new one" + ) + return + } + + currentSegment.set(0) + currentReplayId.set(SentryId()) + hub?.configureScope { it.replayId = currentReplayId.get() } + cache = ReplayCache(options, currentReplayId.get(), recorderConfig) + + recorder?.startRecording() + segmentTimestamp.set(DateUtils.getCurrentDateTime()) + // TODO: finalize old recording if there's some left on disk and send it using the replayId from persisted scope (e.g. for ANRs) + } + + fun resume() { + segmentTimestamp.set(DateUtils.getCurrentDateTime()) + recorder?.resume() + } + + fun pause() { + val now = dateProvider.currentTimeMillis + recorder?.pause() + + val currentSegmentTimestamp = segmentTimestamp.get() + val segmentId = currentSegment.get() + val duration = now - currentSegmentTimestamp.time + val replayId = currentReplayId.get() + saver.submit { + val videoDuration = + createAndCaptureSegment(duration, currentSegmentTimestamp, replayId, segmentId) + if (videoDuration != null) { + currentSegment.getAndIncrement() + } + } + } + + fun stop() { + if (!isEnabled.get()) { + options.logger.log( + DEBUG, + "Session replay is disabled due to conditions not met in Integration.register" + ) + return + } + + val now = dateProvider.currentTimeMillis + val currentSegmentTimestamp = segmentTimestamp.get() + val segmentId = currentSegment.get() + val duration = now - currentSegmentTimestamp.time + val replayId = currentReplayId.get() + saver.submit { + createAndCaptureSegment(duration, currentSegmentTimestamp, replayId, segmentId) + cache?.cleanup() + } + + recorder?.stopRecording() + cache?.close() + currentSegment.set(0) + segmentTimestamp.set(null) + currentReplayId.set(null) + hub?.configureScope { it.replayId = null } + isRecording.set(false) + } + + override fun onScreenshotRecorded(bitmap: Bitmap) { + // have to do it before submitting, otherwise if the queue is busy, the timestamp won't be + // reflecting the exact time of when it was captured + val frameTimestamp = dateProvider.currentTimeMillis + saver.submit { + cache?.addFrame(bitmap, frameTimestamp) + + val now = dateProvider.currentTimeMillis + if (now - segmentTimestamp.get().time >= VIDEO_SEGMENT_DURATION) { + val currentSegmentTimestamp = segmentTimestamp.get() + val segmentId = currentSegment.get() + val replayId = currentReplayId.get() + + val videoDuration = + createAndCaptureSegment( + VIDEO_SEGMENT_DURATION, + currentSegmentTimestamp, + replayId, + segmentId + ) + if (videoDuration != null) { + currentSegment.getAndIncrement() + // set next segment timestamp as close to the previous one as possible to avoid gaps + segmentTimestamp.set(DateUtils.getDateTime(currentSegmentTimestamp.time + videoDuration)) + } + } + } + } + + private fun createAndCaptureSegment( + duration: Long, + currentSegmentTimestamp: Date, + replayId: SentryId, + segmentId: Int + ): Long? { + val generatedVideo = cache?.createVideoOf( + duration, + currentSegmentTimestamp.time, + segmentId + ) ?: return null + + val (video, frameCount, videoDuration) = generatedVideo + captureReplay( + video, + replayId, + currentSegmentTimestamp, + segmentId, + frameCount, + videoDuration + ) + return videoDuration + } + + private fun captureReplay( + video: File, + currentReplayId: SentryId, + segmentTimestamp: Date, + segmentId: Int, + frameCount: Int, + duration: Long + ) { + val replay = SentryReplayEvent().apply { + eventId = currentReplayId + replayId = currentReplayId + this.segmentId = segmentId + this.timestamp = DateUtils.getDateTime(segmentTimestamp.time + duration) + if (segmentId == 0) { + replayStartTimestamp = segmentTimestamp + } + videoFile = video + } + + val recording = ReplayRecording().apply { + this.segmentId = segmentId + payload = listOf( + RRWebMetaEvent().apply { + this.timestamp = segmentTimestamp.time + height = recorderConfig.recordingHeight + width = recorderConfig.recordingWidth + }, + RRWebVideoEvent().apply { + this.timestamp = segmentTimestamp.time + this.segmentId = segmentId + this.duration = duration + this.frameCount = frameCount + size = video.length() + frameRate = recorderConfig.frameRate + height = recorderConfig.recordingHeight + width = recorderConfig.recordingWidth + // TODO: support non-fullscreen windows later + left = 0 + top = 0 + } + ) + } + + val hint = Hint().apply { replayRecording = recording } + hub?.captureReplay(replay, hint) + } + + override fun close() { + stop() + saver.gracefullyShutdown(options) + } + + private class ReplayExecutorServiceThreadFactory : ThreadFactory { + private var cnt = 0 + override fun newThread(r: Runnable): Thread { + val ret = Thread(r, "SentryReplayIntegration-" + cnt++) + ret.setDaemon(true) + return ret + } + } +} + +/** + * Retrieves the [ReplayIntegration] from the list of integrations in [SentryOptions] + */ +fun IHub.getReplayIntegration(): ReplayIntegration? = + options.integrations.find { it is ReplayIntegration } as? ReplayIntegration + +fun ExecutorService.gracefullyShutdown(options: SentryOptions) { + synchronized(this) { + if (!isShutdown) { + shutdown() + } + try { + if (!awaitTermination(options.shutdownTimeoutMillis, MILLISECONDS)) { + shutdownNow() + } + } catch (e: InterruptedException) { + shutdownNow() + Thread.currentThread().interrupt() + } + } +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt index 3b3a6758fc5..767c3614ba7 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -2,34 +2,35 @@ package io.sentry.android.replay import android.annotation.TargetApi import android.graphics.Bitmap +import android.graphics.Bitmap.Config.ARGB_8888 import android.graphics.Canvas import android.graphics.Matrix import android.graphics.Paint -import android.graphics.Rect -import android.graphics.RectF import android.os.Handler import android.os.HandlerThread import android.os.Looper -import android.os.SystemClock -import android.util.Log import android.view.PixelCopy import android.view.View import android.view.ViewGroup import android.view.ViewTreeObserver -import io.sentry.android.replay.video.SimpleVideoEncoder +import io.sentry.SentryLevel.DEBUG +import io.sentry.SentryLevel.INFO +import io.sentry.SentryOptions import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode import java.lang.ref.WeakReference import java.util.WeakHashMap +import java.util.concurrent.atomic.AtomicBoolean import kotlin.system.measureTimeMillis -// TODO: use ILogger of Sentry and change level @TargetApi(26) internal class ScreenshotRecorder( - val encoder: SimpleVideoEncoder + val config: ScreenshotRecorderConfig, + val options: SentryOptions, + private val screenshotRecorderCallback: ScreenshotRecorderCallback ) : ViewTreeObserver.OnDrawListener { private var rootView: WeakReference? = null - private val thread = HandlerThread("SentryReplay").also { it.start() } + private val thread = HandlerThread("SentryReplayRecorder").also { it.start() } private val handler = Handler(thread.looper) private val bitmapToVH = WeakHashMap() private val maskingPaint = Paint() @@ -40,24 +41,32 @@ internal class ScreenshotRecorder( ) private val singlePixelBitmapCanvas: Canvas = Canvas(singlePixelBitmap) private val prescaledMatrix = Matrix().apply { - preScale(encoder.muxerConfig.scaleFactor, encoder.muxerConfig.scaleFactor) + preScale(config.scaleFactor, config.scaleFactor) } + private val contentChanged = AtomicBoolean(false) + private val isCapturing = AtomicBoolean(true) + private var lastScreenshot: Bitmap? = null - companion object { - const val TAG = "ScreenshotRecorder" - } + fun capture() { + if (!isCapturing.get()) { + options.logger.log(DEBUG, "ScreenshotRecorder is paused, not capturing screenshot") + return + } - private var lastCapturedAtMs: Long? = null - override fun onDraw() { - // TODO: replace with Debouncer from sentry-core - val now = SystemClock.uptimeMillis() - if (lastCapturedAtMs != null && (now - lastCapturedAtMs!!) < 500L) { + if (!contentChanged.get() && lastScreenshot != null) { + options.logger.log(DEBUG, "Content hasn't changed, repeating last known frame") + + lastScreenshot?.let { + screenshotRecorderCallback.onScreenshotRecorded( + it.copy(ARGB_8888, false) + ) + } return } - lastCapturedAtMs = now val root = rootView?.get() if (root == null || root.width <= 0 || root.height <= 0 || !root.isShown) { + options.logger.log(DEBUG, "Root view is invalid, not capturing screenshot") return } @@ -68,76 +77,75 @@ internal class ScreenshotRecorder( Bitmap.Config.ARGB_8888 ) - val time = measureTimeMillis { - val rootNode = ViewHierarchyNode.fromView(root) - root.traverse(rootNode) - bitmapToVH[bitmap] = rootNode - } - Log.e("TIME", time.toString()) - // postAtFrontOfQueue to ensure the view hierarchy and bitmap are ase close in-sync as possible Handler(Looper.getMainLooper()).postAtFrontOfQueue { + val time = measureTimeMillis { + val rootNode = ViewHierarchyNode.fromView(root) + root.traverse(rootNode) + bitmapToVH[bitmap] = rootNode + } + options.logger.log(DEBUG, "Took %d ms to capture view hierarchy", time) + PixelCopy.request( window, bitmap, { copyResult: Int -> - Log.d(TAG, "PixelCopy result: $copyResult") if (copyResult != PixelCopy.SUCCESS) { - Log.e(TAG, "Failed to capture screenshot") + options.logger.log(INFO, "Failed to capture replay recording: %d", copyResult) return@request } - Log.e("BITMAP CAPTURED", bitmap.toString()) val viewHierarchy = bitmapToVH[bitmap] - - var scaledBitmap: Bitmap? = null + val scaledBitmap: Bitmap if (viewHierarchy == null) { - Log.e(TAG, "Failed to determine view hierarchy, not capturing") + options.logger.log(INFO, "Failed to determine view hierarchy, not capturing") return@request } else { scaledBitmap = Bitmap.createScaledBitmap( bitmap, - encoder.muxerConfig.videoWidth, - encoder.muxerConfig.videoHeight, + config.recordingWidth, + config.recordingHeight, true ) val canvas = Canvas(scaledBitmap) canvas.setMatrix(prescaledMatrix) viewHierarchy.traverse { - if (it.shouldRedact && (it.width > 0 && it.height > 0)) { - it.visibleRect ?: return@traverse - - // TODO: check for view type rather than rely on absence of dominantColor here - val color = if (it.dominantColor == null) { - singlePixelBitmapCanvas.drawBitmap(bitmap, it.visibleRect, Rect(0, 0, 1, 1), null) - singlePixelBitmap.getPixel(0, 0) - } else { - it.dominantColor - } - - maskingPaint.setColor(color) - canvas.drawRoundRect(RectF(it.visibleRect), 10f, 10f, maskingPaint) - } +// if (it.shouldRedact && (it.width > 0 && it.height > 0)) { +// it.visibleRect ?: return@traverse +// +// // TODO: check for view type rather than rely on absence of dominantColor here +// val color = if (it.dominantColor == null) { +// singlePixelBitmapCanvas.drawBitmap(bitmap, it.visibleRect, Rect(0, 0, 1, 1), null) +// singlePixelBitmap.getPixel(0, 0) +// } else { +// it.dominantColor +// } +// +// maskingPaint.setColor(color) +// canvas.drawRoundRect(RectF(it.visibleRect), 10f, 10f, maskingPaint) +// } } } -// val baos = ByteArrayOutputStream() -// scaledBitmap.compress(Bitmap.CompressFormat.JPEG, 75, baos) -// val bmp = BitmapFactory.decodeByteArray(baos.toByteArray(), 0, baos.size()) - scaledBitmap?.let { - encoder.encode(it) - it.recycle() - } -// bmp.recycle() + val screenshot = scaledBitmap.copy(ARGB_8888, false) + screenshotRecorderCallback.onScreenshotRecorded(screenshot) + lastScreenshot = screenshot + contentChanged.set(false) + + scaledBitmap.recycle() bitmap.recycle() - Log.i(TAG, "Captured a screenshot") + bitmapToVH.remove(bitmap) }, handler ) } } + override fun onDraw() { + contentChanged.set(true) + } + fun bind(root: View) { // first unbind the current root unbind(rootView?.get()) @@ -152,9 +160,23 @@ internal class ScreenshotRecorder( root?.viewTreeObserver?.removeOnDrawListener(this) } + fun pause() { + isCapturing.set(false) + unbind(rootView?.get()) + } + + fun resume() { + // can't use bind() as it will invalidate the weakref + rootView?.get()?.viewTreeObserver?.addOnDrawListener(this) + isCapturing.set(true) + } + fun close() { unbind(rootView?.get()) rootView?.clear() + lastScreenshot?.recycle() + bitmapToVH.clear() + isCapturing.set(false) thread.quitSafely() } @@ -188,3 +210,14 @@ internal class ScreenshotRecorder( parentNode.children = childNodes } } + +internal data class ScreenshotRecorderConfig( + val recordingWidth: Int, + val recordingHeight: Int, + val scaleFactor: Float, + val frameRate: Int = 2 +) + +interface ScreenshotRecorderCallback { + fun onScreenshotRecorded(bitmap: Bitmap) +} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt index 1a60d686b40..d23222368fe 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt @@ -1,31 +1,34 @@ package io.sentry.android.replay import android.annotation.TargetApi -import android.content.Context -import android.graphics.Point -import android.os.Build.VERSION -import android.os.Build.VERSION_CODES import android.view.View -import android.view.WindowManager -import io.sentry.android.replay.video.MuxerConfig -import io.sentry.android.replay.video.SimpleVideoEncoder -import java.io.File +import io.sentry.SentryLevel.ERROR +import io.sentry.SentryOptions +import java.io.Closeable import java.lang.ref.WeakReference +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.ThreadFactory +import java.util.concurrent.TimeUnit.MILLISECONDS import java.util.concurrent.atomic.AtomicBoolean import kotlin.LazyThreadSafetyMode.NONE -import kotlin.math.roundToInt @TargetApi(26) -class WindowRecorder { +internal class WindowRecorder( + private val options: SentryOptions, + private val recorderConfig: ScreenshotRecorderConfig, + private val screenshotRecorderCallback: ScreenshotRecorderCallback +) : Closeable { private val rootViewsSpy by lazy(NONE) { RootViewsSpy.install() } - private var encoder: SimpleVideoEncoder? = null private val isRecording = AtomicBoolean(false) private val rootViews = ArrayList>() private var recorder: ScreenshotRecorder? = null + private var capturingTask: ScheduledFuture<*>? = null + private val capturer = Executors.newSingleThreadScheduledExecutor(RecorderExecutorServiceThreadFactory()) private val onRootViewsChangedListener = OnRootViewsChangedListener { root, added -> if (added) { @@ -42,53 +45,53 @@ class WindowRecorder { } } - fun startRecording(context: Context) { + fun startRecording() { if (isRecording.getAndSet(true)) { return } - val wm = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager // val (height, width) = (wm.currentWindowMetrics.bounds.bottom / // context.resources.displayMetrics.density).roundToInt() to // (wm.currentWindowMetrics.bounds.right / // context.resources.displayMetrics.density).roundToInt() - // TODO: API level check - // PixelCopy takes screenshots including system bars, so we have to get the real size here - val height: Int - val aspectRatio = if (VERSION.SDK_INT >= VERSION_CODES.R) { - height = wm.currentWindowMetrics.bounds.bottom - height.toFloat() / wm.currentWindowMetrics.bounds.right.toFloat() - } else { - val screenResolution = Point() - @Suppress("DEPRECATION") - wm.defaultDisplay.getRealSize(screenResolution) - height = screenResolution.y - height.toFloat() / screenResolution.x.toFloat() - } - val videoFile = File(context.cacheDir, "sentry-sr.mp4") - encoder = SimpleVideoEncoder( - MuxerConfig( - videoFile, - videoWidth = (720 / aspectRatio).roundToInt(), - videoHeight = 720, - scaleFactor = 720f / height, - frameRate = 2f, - bitrate = 500 * 1000 - ) - ).also { it.start() } - recorder = ScreenshotRecorder(encoder!!) + recorder = ScreenshotRecorder(recorderConfig, options, screenshotRecorderCallback) rootViewsSpy.listeners += onRootViewsChangedListener + capturingTask = capturer.scheduleAtFixedRate({ + try { + recorder?.capture() + } catch (e: Throwable) { + options.logger.log(ERROR, "Failed to capture a screenshot with exception:", e) + // TODO: I guess schedule the capturer again, cause it will stop executing the runnable? + } + }, 0L, 1000L / recorderConfig.frameRate, MILLISECONDS) } + fun resume() = recorder?.resume() + fun pause() = recorder?.pause() + fun stopRecording() { rootViewsSpy.listeners -= onRootViewsChangedListener rootViews.forEach { recorder?.unbind(it.get()) } recorder?.close() rootViews.clear() recorder = null - encoder?.startRelease() - encoder = null + capturingTask?.cancel(false) + capturingTask = null isRecording.set(false) } + + private class RecorderExecutorServiceThreadFactory : ThreadFactory { + private var cnt = 0 + override fun newThread(r: Runnable): Thread { + val ret = Thread(r, "SentryWindowRecorder-" + cnt++) + ret.setDaemon(true) + return ret + } + } + + override fun close() { + stopRecording() + capturer.gracefullyShutdown(options) + } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt index 86ff440d02c..ece684c46e0 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt @@ -20,6 +20,8 @@ package io.sentry.android.replay import android.annotation.SuppressLint import android.os.Build.VERSION.SDK_INT +import android.os.Handler +import android.os.Looper import android.util.Log import android.view.View import android.view.Window @@ -152,8 +154,12 @@ internal class RootViewsSpy private constructor() { companion object { fun install(): RootViewsSpy { return RootViewsSpy().apply { - WindowManagerSpy.swapWindowManagerGlobalMViews { mViews -> - delegatingViewList.apply { addAll(mViews) } + // had to do this as a first message of the main thread queue, otherwise if this is + // called from ContentProvider, it might be too early and the listener won't be installed + Handler(Looper.getMainLooper()).postAtFrontOfQueue { + WindowManagerSpy.swapWindowManagerGlobalMViews { mViews -> + delegatingViewList.apply { addAll(mViews) } + } } } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleMp4FrameMuxer.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleMp4FrameMuxer.kt index bdedb888cdd..db34b2dbeac 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleMp4FrameMuxer.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleMp4FrameMuxer.kt @@ -32,9 +32,10 @@ package io.sentry.android.replay.video import android.media.MediaCodec import android.media.MediaFormat import android.media.MediaMuxer -import android.util.Log import java.nio.ByteBuffer import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeUnit.MICROSECONDS +import java.util.concurrent.TimeUnit.MILLISECONDS class SimpleMp4FrameMuxer(path: String, private val fps: Float) : SimpleFrameMuxer { private val frameUsec: Long = (TimeUnit.SECONDS.toMicros(1L) / fps).toLong() @@ -50,7 +51,6 @@ class SimpleMp4FrameMuxer(path: String, private val fps: Float) : SimpleFrameMux override fun start(videoFormat: MediaFormat) { videoTrackIndex = muxer.addTrack(videoFormat) - Log.i("SimpleMp4FrameMuxer", "start() videoFormat=$videoFormat videoTrackIndex=$videoTrackIndex") muxer.start() started = true } @@ -74,6 +74,7 @@ class SimpleMp4FrameMuxer(path: String, private val fps: Float) : SimpleFrameMux } override fun getVideoTime(): Long { - return finalVideoTime + // have to add one sec as we calculate it 0-based above + return MILLISECONDS.convert(finalVideoTime + frameUsec, MICROSECONDS) } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt index 4046eec37bc..cdb15fc11f2 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt @@ -34,20 +34,25 @@ import android.graphics.Bitmap import android.media.MediaCodec import android.media.MediaCodecInfo import android.media.MediaFormat -import android.os.Handler -import android.os.Looper import android.view.Surface +import io.sentry.SentryLevel.DEBUG +import io.sentry.SentryOptions +import io.sentry.android.replay.ScreenshotRecorderConfig import java.io.File +import java.nio.ByteBuffer + +private const val TIMEOUT_USEC = 100_000L @TargetApi(26) internal class SimpleVideoEncoder( + val options: SentryOptions, val muxerConfig: MuxerConfig ) { private val mediaFormat: MediaFormat = run { val format = MediaFormat.createVideoFormat( muxerConfig.mimeType, - muxerConfig.videoWidth, - muxerConfig.videoHeight + muxerConfig.recorderConfig.recordingWidth, + muxerConfig.recorderConfig.recordingHeight ) // Set some properties. Failing to specify some of these can cause the MediaCodec @@ -72,84 +77,102 @@ internal class SimpleVideoEncoder( codec } + private val bufferInfo: MediaCodec.BufferInfo = MediaCodec.BufferInfo() private val frameMuxer = muxerConfig.frameMuxer + val duration get() = frameMuxer.getVideoTime() private var surface: Surface? = null fun start() { - mediaCodec.setCallback(createMediaCodecCallback(), Handler(Looper.getMainLooper())) - mediaCodec.configure(mediaFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) surface = mediaCodec.createInputSurface() mediaCodec.start() + drainCodec(false) } - private fun createMediaCodecCallback(): MediaCodec.Callback { - return object : MediaCodec.Callback() { - override fun onInputBufferAvailable(codec: MediaCodec, index: Int) { - } - - override fun onOutputBufferAvailable( - codec: MediaCodec, - index: Int, - info: MediaCodec.BufferInfo - ) { - val encodedData = codec.getOutputBuffer(index)!! + fun encode(image: Bitmap) { + // NOTE do not use `lockCanvas` like what is done in bitmap2video + // This is because https://developer.android.com/reference/android/media/MediaCodec#createInputSurface() + // says that, "Surface.lockCanvas(android.graphics.Rect) may fail or produce unexpected results." + val canvas = surface?.lockHardwareCanvas() + canvas?.drawBitmap(image, 0f, 0f, null) + surface?.unlockCanvasAndPost(canvas) + drainCodec(false) + } - var effectiveSize = info.size + /** + * Extracts all pending data from the encoder. + * + * + * If endOfStream is not set, this returns when there is no more data to drain. If it + * is set, we send EOS to the encoder, and then iterate until we see EOS on the output. + * Calling this with endOfStream set should be done once, right before stopping the muxer. + * + * Borrows heavily from https://bigflake.com/mediacodec/EncodeAndMuxTest.java.txt + */ + private fun drainCodec(endOfStream: Boolean) { + options.logger.log(DEBUG, "[Encoder]: drainCodec($endOfStream)") + if (endOfStream) { + options.logger.log(DEBUG, "[Encoder]: sending EOS to encoder") + mediaCodec.signalEndOfInputStream() + } + var encoderOutputBuffers: Array? = mediaCodec.outputBuffers + while (true) { + val encoderStatus: Int = mediaCodec.dequeueOutputBuffer(bufferInfo, TIMEOUT_USEC) + if (encoderStatus == MediaCodec.INFO_TRY_AGAIN_LATER) { + // no output available yet + if (!endOfStream) { + break // out of while + } else { + options.logger.log(DEBUG, "[Encoder]: no output available, spinning to await EOS") + } + } else if (encoderStatus == MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED) { + // not expected for an encoder + encoderOutputBuffers = mediaCodec.outputBuffers + } else if (encoderStatus == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) { + // should happen before receiving buffers, and should only happen once + if (frameMuxer.isStarted()) { + throw RuntimeException("format changed twice") + } + val newFormat: MediaFormat = mediaCodec.outputFormat + options.logger.log(DEBUG, "[Encoder]: encoder output format changed: $newFormat") - if (info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0) { + // now that we have the Magic Goodies, start the muxer + frameMuxer.start(newFormat) + } else if (encoderStatus < 0) { + options.logger.log(DEBUG, "[Encoder]: unexpected result from encoder.dequeueOutputBuffer: $encoderStatus") + // let's ignore it + } else { + val encodedData = encoderOutputBuffers?.get(encoderStatus) + ?: throw RuntimeException("encoderOutputBuffer $encoderStatus was null") + if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0) { // The codec config data was pulled out and fed to the muxer when we got // the INFO_OUTPUT_FORMAT_CHANGED status. Ignore it. - effectiveSize = 0 + options.logger.log(DEBUG, "[Encoder]: ignoring BUFFER_FLAG_CODEC_CONFIG") + bufferInfo.size = 0 } - - if (effectiveSize != 0) { + if (bufferInfo.size != 0) { if (!frameMuxer.isStarted()) { throw RuntimeException("muxer hasn't started") } - frameMuxer.muxVideoFrame(encodedData, info) + frameMuxer.muxVideoFrame(encodedData, bufferInfo) + options.logger.log(DEBUG, "[Encoder]: sent ${bufferInfo.size} bytes to muxer") } - - mediaCodec.releaseOutputBuffer(index, false) - - if (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) { - actualRelease() - } - } - - override fun onError(codec: MediaCodec, e: MediaCodec.CodecException) { - } - - override fun onOutputFormatChanged(codec: MediaCodec, format: MediaFormat) { - // should happen before receiving buffers, and should only happen once - if (frameMuxer.isStarted()) { - throw RuntimeException("format changed twice") + mediaCodec.releaseOutputBuffer(encoderStatus, false) + if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) { + if (!endOfStream) { + options.logger.log(DEBUG, "[Encoder]: reached end of stream unexpectedly") + } else { + options.logger.log(DEBUG, "[Encoder]: end of stream reached") + } + break // out of while } - val newFormat: MediaFormat = mediaCodec.outputFormat - // now that we have the Magic Goodies, start the muxer - frameMuxer.start(newFormat) } } } - fun encode(image: Bitmap) { - // NOTE do not use `lockCanvas` like what is done in bitmap2video - // This is because https://developer.android.com/reference/android/media/MediaCodec#createInputSurface() - // says that, "Surface.lockCanvas(android.graphics.Rect) may fail or produce unexpected results." - val canvas = surface?.lockHardwareCanvas() - canvas?.drawBitmap(image, 0f, 0f, null) - surface?.unlockCanvasAndPost(canvas) - } - - /** - * can only *start* releasing, since it is asynchronous - */ - fun startRelease() { - mediaCodec.signalEndOfInputStream() - } - - private fun actualRelease() { + fun release() { + drainCodec(true) mediaCodec.stop() mediaCodec.release() surface?.release() @@ -161,9 +184,7 @@ internal class SimpleVideoEncoder( @TargetApi(24) internal data class MuxerConfig( val file: File, - val videoWidth: Int, - val videoHeight: Int, - val scaleFactor: Float, + val recorderConfig: ScreenshotRecorderConfig, val mimeType: String = MediaFormat.MIMETYPE_VIDEO_AVC, val frameRate: Float, val bitrate: Int, diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt new file mode 100644 index 00000000000..6a032911056 --- /dev/null +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt @@ -0,0 +1,86 @@ +package io.sentry.android.replay + +import android.graphics.Bitmap +import android.graphics.Bitmap.Config.ARGB_8888 +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.SentryOptions +import io.sentry.protocol.SentryId +import io.sentry.transport.ICurrentDateProvider +import org.junit.Rule +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import kotlin.test.Test + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [26]) +class ReplayCacheTest { + + @get:Rule + val tmpDir = TemporaryFolder() + + internal class Fixture { + val options = SentryOptions() + fun getSut( + dir: TemporaryFolder?, + replayId: SentryId, + frameRate: Int, + dateProvider: ICurrentDateProvider + ): ReplayCache { + val recorderConfig = ScreenshotRecorderConfig(100, 200, 1f, frameRate) + options.run { + cacheDirPath = dir?.newFolder()?.absolutePath + } + return ReplayCache(options, replayId, recorderConfig, dateProvider) + } + } + + private val fixture = Fixture() + + @Test + fun `test`() { + val replayId = SentryId() + val replayCache = fixture.getSut( + tmpDir, + replayId, + frameRate = 1, + dateProvider = object : ICurrentDateProvider { + override fun getCurrentTimeMillis(): Long { + return 1 + } + } + ) + + val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) + replayCache.addFrame(bitmap) + + replayCache.createVideoOf(5000L, 0, 0) + replayCache.createVideoOf(5000L, 5000L, 1) + } + + @Test + fun `test2`() { + val replayId = SentryId() + val replayCache = fixture.getSut( + tmpDir, + replayId, + frameRate = 1, + dateProvider = object : ICurrentDateProvider { + var counter = 0 + override fun getCurrentTimeMillis(): Long { + return when (counter++) { + 0 -> 1 + 1 -> 1001 + else -> 1001 + } + } + } + ) + + val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) + replayCache.addFrame(bitmap) + replayCache.addFrame(bitmap) + + replayCache.createVideoOf(5000L, 0, 0) + } +} diff --git a/sentry-android/build.gradle.kts b/sentry-android/build.gradle.kts index 47b873ac490..81619b736f2 100644 --- a/sentry-android/build.gradle.kts +++ b/sentry-android/build.gradle.kts @@ -35,4 +35,5 @@ android { dependencies { api(projects.sentryAndroidCore) api(projects.sentryAndroidNdk) + api(projects.sentryAndroidReplay) } diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index b6acae0cdee..9e3a90946fb 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -664,6 +664,7 @@ public abstract interface class io/sentry/IScope { public abstract fun getLevel ()Lio/sentry/SentryLevel; public abstract fun getOptions ()Lio/sentry/SentryOptions; public abstract fun getPropagationContext ()Lio/sentry/PropagationContext; + public abstract fun getReplayId ()Lio/sentry/protocol/SentryId; public abstract fun getRequest ()Lio/sentry/protocol/Request; public abstract fun getScreen ()Ljava/lang/String; public abstract fun getSession ()Lio/sentry/Session; @@ -686,6 +687,7 @@ public abstract interface class io/sentry/IScope { public abstract fun setFingerprint (Ljava/util/List;)V public abstract fun setLevel (Lio/sentry/SentryLevel;)V public abstract fun setPropagationContext (Lio/sentry/PropagationContext;)V + public abstract fun setReplayId (Lio/sentry/protocol/SentryId;)V public abstract fun setRequest (Lio/sentry/protocol/Request;)V public abstract fun setScreen (Ljava/lang/String;)V public abstract fun setTag (Ljava/lang/String;Ljava/lang/String;)V @@ -1215,6 +1217,7 @@ public final class io/sentry/NoOpScope : io/sentry/IScope { public fun getLevel ()Lio/sentry/SentryLevel; public fun getOptions ()Lio/sentry/SentryOptions; public fun getPropagationContext ()Lio/sentry/PropagationContext; + public fun getReplayId ()Lio/sentry/protocol/SentryId; public fun getRequest ()Lio/sentry/protocol/Request; public fun getScreen ()Ljava/lang/String; public fun getSession ()Lio/sentry/Session; @@ -1237,6 +1240,7 @@ public final class io/sentry/NoOpScope : io/sentry/IScope { public fun setFingerprint (Ljava/util/List;)V public fun setLevel (Lio/sentry/SentryLevel;)V public fun setPropagationContext (Lio/sentry/PropagationContext;)V + public fun setReplayId (Lio/sentry/protocol/SentryId;)V public fun setRequest (Lio/sentry/protocol/Request;)V public fun setScreen (Ljava/lang/String;)V public fun setTag (Ljava/lang/String;Ljava/lang/String;)V @@ -1639,6 +1643,7 @@ public final class io/sentry/Scope : io/sentry/IScope { public fun getLevel ()Lio/sentry/SentryLevel; public fun getOptions ()Lio/sentry/SentryOptions; public fun getPropagationContext ()Lio/sentry/PropagationContext; + public fun getReplayId ()Lio/sentry/protocol/SentryId; public fun getRequest ()Lio/sentry/protocol/Request; public fun getScreen ()Ljava/lang/String; public fun getSession ()Lio/sentry/Session; @@ -1661,6 +1666,7 @@ public final class io/sentry/Scope : io/sentry/IScope { public fun setFingerprint (Ljava/util/List;)V public fun setLevel (Lio/sentry/SentryLevel;)V public fun setPropagationContext (Lio/sentry/PropagationContext;)V + public fun setReplayId (Lio/sentry/protocol/SentryId;)V public fun setRequest (Lio/sentry/protocol/Request;)V public fun setScreen (Ljava/lang/String;)V public fun setTag (Ljava/lang/String;Ljava/lang/String;)V @@ -4767,7 +4773,7 @@ public final class io/sentry/rrweb/RRWebVideoEvent : io/sentry/rrweb/RRWebEvent, public fun equals (Ljava/lang/Object;)Z public fun getContainer ()Ljava/lang/String; public fun getDataUnknown ()Ljava/util/Map; - public fun getDuration ()I + public fun getDuration ()J public fun getEncoding ()Ljava/lang/String; public fun getFrameCount ()I public fun getFrameRate ()I @@ -4785,7 +4791,7 @@ public final class io/sentry/rrweb/RRWebVideoEvent : io/sentry/rrweb/RRWebEvent, public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V public fun setContainer (Ljava/lang/String;)V public fun setDataUnknown (Ljava/util/Map;)V - public fun setDuration (I)V + public fun setDuration (J)V public fun setEncoding (Ljava/lang/String;)V public fun setFrameCount (I)V public fun setFrameRate (I)V diff --git a/sentry/src/main/java/io/sentry/IScope.java b/sentry/src/main/java/io/sentry/IScope.java index 3842fb2c3a8..2d38371eadd 100644 --- a/sentry/src/main/java/io/sentry/IScope.java +++ b/sentry/src/main/java/io/sentry/IScope.java @@ -2,6 +2,7 @@ import io.sentry.protocol.Contexts; import io.sentry.protocol.Request; +import io.sentry.protocol.SentryId; import io.sentry.protocol.User; import java.util.Collection; import java.util.List; @@ -84,6 +85,23 @@ public interface IScope { @ApiStatus.Internal void setScreen(final @Nullable String screen); + /** + * Returns the Scope's current replay_id, previously set by {@link IScope#setReplayId(SentryId)} + * + * @return the id of the current session replay + */ + @ApiStatus.Internal + @Nullable + SentryId getReplayId(); + + /** + * Sets the Scope's current replay_id + * + * @param replayId the id of the current session replay + */ + @ApiStatus.Internal + void setReplayId(final @Nullable SentryId replayId); + /** * Returns the Scope's request * diff --git a/sentry/src/main/java/io/sentry/NoOpScope.java b/sentry/src/main/java/io/sentry/NoOpScope.java index c756fb49a39..660dca0b69b 100644 --- a/sentry/src/main/java/io/sentry/NoOpScope.java +++ b/sentry/src/main/java/io/sentry/NoOpScope.java @@ -2,6 +2,7 @@ import io.sentry.protocol.Contexts; import io.sentry.protocol.Request; +import io.sentry.protocol.SentryId; import io.sentry.protocol.User; import java.util.ArrayDeque; import java.util.ArrayList; @@ -68,6 +69,14 @@ public void setUser(@Nullable User user) {} @Override public void setScreen(@Nullable String screen) {} + @Override + public @Nullable SentryId getReplayId() { + return null; + } + + @Override + public void setReplayId(@Nullable SentryId replayId) {} + @Override public @Nullable Request getRequest() { return null; diff --git a/sentry/src/main/java/io/sentry/Scope.java b/sentry/src/main/java/io/sentry/Scope.java index 91c9fcd8cfe..164d52dc2b1 100644 --- a/sentry/src/main/java/io/sentry/Scope.java +++ b/sentry/src/main/java/io/sentry/Scope.java @@ -3,6 +3,7 @@ import io.sentry.protocol.App; import io.sentry.protocol.Contexts; import io.sentry.protocol.Request; +import io.sentry.protocol.SentryId; import io.sentry.protocol.TransactionNameSource; import io.sentry.protocol.User; import io.sentry.util.CollectionUtils; @@ -80,6 +81,9 @@ public final class Scope implements IScope { private @NotNull PropagationContext propagationContext; + /** Scope's session replay id */ + private @Nullable SentryId replayId; + /** * Scope's ctor * @@ -101,6 +105,7 @@ private Scope(final @NotNull Scope scope) { final User userRef = scope.user; this.user = userRef != null ? new User(userRef) : null; this.screen = scope.screen; + this.replayId = scope.replayId; final Request requestRef = scope.request; this.request = requestRef != null ? new Request(requestRef) : null; @@ -312,6 +317,18 @@ public void setScreen(final @Nullable String screen) { } } + @Override + public @Nullable SentryId getReplayId() { + return replayId; + } + + @Override + public void setReplayId(final @Nullable SentryId replayId) { + this.replayId = replayId; + + // TODO: set to contexts and notify observers to persist this as well + } + /** * Returns the Scope's request * diff --git a/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java index ff162f44642..728b28d0e2d 100644 --- a/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java +++ b/sentry/src/main/java/io/sentry/SentryEnvelopeItem.java @@ -23,7 +23,7 @@ import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.nio.charset.Charset; -import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.Map; import java.util.concurrent.Callable; import org.jetbrains.annotations.ApiStatus; @@ -360,7 +360,8 @@ public static SentryEnvelopeItem fromReplay( try (final ByteArrayOutputStream stream = new ByteArrayOutputStream(); final Writer writer = new BufferedWriter(new OutputStreamWriter(stream, UTF_8))) { - final Map replayPayload = new HashMap<>(); + // relay expects the payload to be in this exact order: [event,rrweb,video] + final Map replayPayload = new LinkedHashMap<>(); // first serialize replay event json bytes serializer.serialize(replayEvent, writer); replayPayload.put(SentryItemType.ReplayEvent.getItemType(), stream.toByteArray()); diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebVideoEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebVideoEvent.java index 5bea9e3c471..532177ff9f2 100644 --- a/sentry/src/main/java/io/sentry/rrweb/RRWebVideoEvent.java +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebVideoEvent.java @@ -26,7 +26,7 @@ public final class RRWebVideoEvent extends RRWebEvent implements JsonUnknown, Js private @NotNull String tag; private int segmentId; private long size; - private int duration; + private long duration; private @NotNull String encoding = REPLAY_ENCODING; private @NotNull String container = REPLAY_CONTAINER; private int height; @@ -72,11 +72,11 @@ public void setSize(final long size) { this.size = size; } - public int getDuration() { + public long getDuration() { return duration; } - public void setDuration(final int duration) { + public void setDuration(final long duration) { this.duration = duration; } @@ -380,7 +380,7 @@ private void deserializePayload( event.size = size == null ? 0 : size; break; case JsonKeys.DURATION: - event.duration = reader.nextInt(); + event.duration = reader.nextLong(); break; case JsonKeys.CONTAINER: final String container = reader.nextStringOrNull(); From 2e954f700be7c161ca4a47c45d27d72f045c8d6f Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 19 Mar 2024 16:23:56 +0100 Subject: [PATCH 36/89] Uncomment redacting --- .../android/replay/ScreenshotRecorder.kt | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt index 767c3614ba7..87915f8505a 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -6,6 +6,8 @@ import android.graphics.Bitmap.Config.ARGB_8888 import android.graphics.Canvas import android.graphics.Matrix import android.graphics.Paint +import android.graphics.Rect +import android.graphics.RectF import android.os.Handler import android.os.HandlerThread import android.os.Looper @@ -111,20 +113,20 @@ internal class ScreenshotRecorder( val canvas = Canvas(scaledBitmap) canvas.setMatrix(prescaledMatrix) viewHierarchy.traverse { -// if (it.shouldRedact && (it.width > 0 && it.height > 0)) { -// it.visibleRect ?: return@traverse -// -// // TODO: check for view type rather than rely on absence of dominantColor here -// val color = if (it.dominantColor == null) { -// singlePixelBitmapCanvas.drawBitmap(bitmap, it.visibleRect, Rect(0, 0, 1, 1), null) -// singlePixelBitmap.getPixel(0, 0) -// } else { -// it.dominantColor -// } -// -// maskingPaint.setColor(color) -// canvas.drawRoundRect(RectF(it.visibleRect), 10f, 10f, maskingPaint) -// } + if (it.shouldRedact && (it.width > 0 && it.height > 0)) { + it.visibleRect ?: return@traverse + + // TODO: check for view type rather than rely on absence of dominantColor here + val color = if (it.dominantColor == null) { + singlePixelBitmapCanvas.drawBitmap(bitmap, it.visibleRect, Rect(0, 0, 1, 1), null) + singlePixelBitmap.getPixel(0, 0) + } else { + it.dominantColor + } + + maskingPaint.setColor(color) + canvas.drawRoundRect(RectF(it.visibleRect), 10f, 10f, maskingPaint) + } } } From 8bc6219d87c40673be0e210a2932b1865bd82e8e Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 20 Mar 2024 11:37:43 +0100 Subject: [PATCH 37/89] Update proguard rules --- sentry-android-core/proguard-rules.pro | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/sentry-android-core/proguard-rules.pro b/sentry-android-core/proguard-rules.pro index 67d7e7691d5..a78a5a14a19 100644 --- a/sentry-android-core/proguard-rules.pro +++ b/sentry-android-core/proguard-rules.pro @@ -72,3 +72,9 @@ -keepnames class io.sentry.exception.SentryHttpClientException ##---------------End: proguard configuration for sentry-okhttp ---------- + +##---------------Begin: proguard configuration for sentry-android-replay ---------- +-dontwarn io.sentry.android.replay.ReplayIntegration +-dontwarn io.sentry.android.replay.ReplayIntegrationKt +-keepnames class io.sentry.android.replay.ReplayIntegration +##---------------End: proguard configuration for sentry-android-replay ---------- From a5aa4bee6954015ce440d0b9ec1a7ca64e9ca7ed Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 20 Mar 2024 12:07:26 +0100 Subject: [PATCH 38/89] Add missing rule for AndroidTest --- .../sentry-uitest-android/proguard-rules.pro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry-android-integration-tests/sentry-uitest-android/proguard-rules.pro b/sentry-android-integration-tests/sentry-uitest-android/proguard-rules.pro index 49f7f0749d8..02f5e80ba30 100644 --- a/sentry-android-integration-tests/sentry-uitest-android/proguard-rules.pro +++ b/sentry-android-integration-tests/sentry-uitest-android/proguard-rules.pro @@ -39,4 +39,4 @@ -dontwarn org.opentest4j.AssertionFailedError -dontwarn org.mockito.internal.** -dontwarn org.jetbrains.annotations.** - +-dontwarn io.sentry.android.replay.ReplayIntegration From f72e45ff601ad9ba8221c496c5ae079362fbb6bf Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 21 Mar 2024 22:38:59 +0100 Subject: [PATCH 39/89] Add ReplayCache tests --- .../io/sentry/android/replay/ReplayCache.kt | 36 ++-- .../android/replay/ReplayIntegration.kt | 4 +- .../replay/video/SimpleMp4FrameMuxer.kt | 5 +- .../replay/video/SimpleVideoEncoder.kt | 10 +- .../sentry/android/replay/ReplayCacheTest.kt | 180 +++++++++++++++--- 5 files changed, 180 insertions(+), 55 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt index cf6d6a93aaa..da62dfb6b18 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt @@ -10,20 +10,30 @@ import io.sentry.SentryOptions import io.sentry.android.replay.video.MuxerConfig import io.sentry.android.replay.video.SimpleVideoEncoder import io.sentry.protocol.SentryId -import io.sentry.util.FileUtils import java.io.Closeable import java.io.File internal class ReplayCache( private val options: SentryOptions, private val replayId: SentryId, - private val recorderConfig: ScreenshotRecorderConfig + private val recorderConfig: ScreenshotRecorderConfig, + private val encoderCreator: (File) -> SimpleVideoEncoder = { videoFile -> + SimpleVideoEncoder( + options, + MuxerConfig( + file = videoFile, + recorderConfig = recorderConfig, + frameRate = recorderConfig.frameRate.toFloat(), + bitrate = 20 * 1000 + ) + ).also { it.start() } + } ) : Closeable { private val encoderLock = Any() - private var encoder: SimpleVideoEncoder? = null + internal var encoder: SimpleVideoEncoder? = null - private val replayCacheDir: File? by lazy { + internal val replayCacheDir: File? by lazy { if (options.cacheDirPath.isNullOrEmpty()) { options.logger.log( WARNING, @@ -36,7 +46,7 @@ internal class ReplayCache( } // TODO: maybe account for multi-threaded access - private val frames = mutableListOf() + internal val frames = mutableListOf() fun addFrame(bitmap: Bitmap, frameTimestamp: Long) { if (replayCacheDir == null) { @@ -66,17 +76,7 @@ internal class ReplayCache( // TODO: reuse instance of encoder and just change file path to create a different muxer val videoFile = File(replayCacheDir, "$segmentId.mp4") - encoder = synchronized(encoderLock) { - SimpleVideoEncoder( - options, - MuxerConfig( - file = videoFile, - recorderConfig = recorderConfig, - frameRate = recorderConfig.frameRate.toFloat(), - bitrate = 20 * 1000 - ) - ) - }.also { it.start() } + encoder = synchronized(encoderLock) { encoderCreator(videoFile) } val step = 1000 / recorderConfig.frameRate.toLong() var frameCount = 0 @@ -147,10 +147,6 @@ internal class ReplayCache( } } - fun cleanup() { - FileUtils.deleteRecursively(replayCacheDir) - } - override fun close() { synchronized(encoderLock) { encoder?.release() diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index 26b35f9a15b..756b8638d04 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -21,6 +21,7 @@ import io.sentry.protocol.SentryId import io.sentry.rrweb.RRWebMetaEvent import io.sentry.rrweb.RRWebVideoEvent import io.sentry.transport.ICurrentDateProvider +import io.sentry.util.FileUtils import java.io.Closeable import java.io.File import java.util.Date @@ -161,9 +162,10 @@ class ReplayIntegration( val segmentId = currentSegment.get() val duration = now - currentSegmentTimestamp.time val replayId = currentReplayId.get() + val replayCacheDir = cache?.replayCacheDir saver.submit { createAndCaptureSegment(duration, currentSegmentTimestamp, replayId, segmentId) - cache?.cleanup() + FileUtils.deleteRecursively(replayCacheDir) } recorder?.stopRecording() diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleMp4FrameMuxer.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleMp4FrameMuxer.kt index db34b2dbeac..8a21b0bec0a 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleMp4FrameMuxer.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleMp4FrameMuxer.kt @@ -37,7 +37,7 @@ import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit.MICROSECONDS import java.util.concurrent.TimeUnit.MILLISECONDS -class SimpleMp4FrameMuxer(path: String, private val fps: Float) : SimpleFrameMuxer { +class SimpleMp4FrameMuxer(path: String, fps: Float) : SimpleFrameMuxer { private val frameUsec: Long = (TimeUnit.SECONDS.toMicros(1L) / fps).toLong() private val muxer: MediaMuxer = MediaMuxer(path, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4) @@ -74,6 +74,9 @@ class SimpleMp4FrameMuxer(path: String, private val fps: Float) : SimpleFrameMux } override fun getVideoTime(): Long { + if (videoFrames == 0) { + return 0 + } // have to add one sec as we calculate it 0-based above return MILLISECONDS.convert(finalVideoTime + frameUsec, MICROSECONDS) } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt index cdb15fc11f2..e2561faa1bb 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt @@ -46,7 +46,8 @@ private const val TIMEOUT_USEC = 100_000L @TargetApi(26) internal class SimpleVideoEncoder( val options: SentryOptions, - val muxerConfig: MuxerConfig + val muxerConfig: MuxerConfig, + val onClose: (() -> Unit)? = null ) { private val mediaFormat: MediaFormat = run { val format = MediaFormat.createVideoFormat( @@ -68,7 +69,7 @@ internal class SimpleVideoEncoder( format } - private val mediaCodec: MediaCodec = run { + internal val mediaCodec: MediaCodec = run { // val codecs = MediaCodecList(REGULAR_CODECS) // val codecName = codecs.findEncoderForFormat(mediaFormat) // val codec = MediaCodec.createByCodecName(codecName) @@ -172,6 +173,7 @@ internal class SimpleVideoEncoder( } fun release() { + onClose?.invoke() drainCodec(true) mediaCodec.stop() mediaCodec.release() @@ -185,8 +187,8 @@ internal class SimpleVideoEncoder( internal data class MuxerConfig( val file: File, val recorderConfig: ScreenshotRecorderConfig, + val bitrate: Int = 20_000, + val frameRate: Float = recorderConfig.frameRate.toFloat(), val mimeType: String = MediaFormat.MIMETYPE_VIDEO_AVC, - val frameRate: Float, - val bitrate: Int, val frameMuxer: SimpleFrameMuxer = SimpleMp4FrameMuxer(file.absolutePath, frameRate) ) diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt index 6a032911056..88d410fab1a 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt @@ -2,15 +2,23 @@ package io.sentry.android.replay import android.graphics.Bitmap import android.graphics.Bitmap.Config.ARGB_8888 +import android.media.MediaCodec import androidx.test.ext.junit.runners.AndroidJUnit4 import io.sentry.SentryOptions +import io.sentry.android.replay.video.MuxerConfig +import io.sentry.android.replay.video.SimpleVideoEncoder import io.sentry.protocol.SentryId -import io.sentry.transport.ICurrentDateProvider import org.junit.Rule import org.junit.rules.TemporaryFolder import org.junit.runner.RunWith import org.robolectric.annotation.Config +import java.io.File +import java.util.concurrent.TimeUnit.MICROSECONDS +import java.util.concurrent.TimeUnit.MILLISECONDS import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue @RunWith(AndroidJUnit4::class) @Config(sdk = [26]) @@ -21,66 +29,180 @@ class ReplayCacheTest { internal class Fixture { val options = SentryOptions() + var encoder: SimpleVideoEncoder? = null fun getSut( dir: TemporaryFolder?, - replayId: SentryId, + replayId: SentryId = SentryId(), frameRate: Int, - dateProvider: ICurrentDateProvider + framesToEncode: Int = 0 ): ReplayCache { val recorderConfig = ScreenshotRecorderConfig(100, 200, 1f, frameRate) options.run { cacheDirPath = dir?.newFolder()?.absolutePath } - return ReplayCache(options, replayId, recorderConfig, dateProvider) + return ReplayCache(options, replayId, recorderConfig, encoderCreator = { videoFile -> + encoder = SimpleVideoEncoder( + options, + MuxerConfig( + file = videoFile, + recorderConfig = recorderConfig, + frameRate = recorderConfig.frameRate.toFloat(), + bitrate = 20 * 1000 + ), + onClose = { + encodeFrame(framesToEncode, frameRate, size = 0, flags = MediaCodec.BUFFER_FLAG_END_OF_STREAM) + } + ).also { it.start() } + repeat(framesToEncode) { encodeFrame(it, frameRate) } + + encoder!! + }) + } + + fun encodeFrame(index: Int, frameRate: Int, size: Int = 10, flags: Int = 0) { + val presentationTime = MICROSECONDS.convert(index * (1000L / frameRate), MILLISECONDS) + encoder!!.mediaCodec.dequeueInputBuffer(0) + encoder!!.mediaCodec.queueInputBuffer(index, index * size, size, presentationTime, flags) } } private val fixture = Fixture() @Test - fun `test`() { + fun `when no cacheDirPath specified, does not store screenshots`() { val replayId = SentryId() val replayCache = fixture.getSut( - tmpDir, + null, replayId, - frameRate = 1, - dateProvider = object : ICurrentDateProvider { - override fun getCurrentTimeMillis(): Long { - return 1 - } - } + frameRate = 1 ) val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) - replayCache.addFrame(bitmap) + replayCache.addFrame(bitmap, 1) - replayCache.createVideoOf(5000L, 0, 0) - replayCache.createVideoOf(5000L, 5000L, 1) + assertTrue(replayCache.frames.isEmpty()) } @Test - fun `test2`() { + fun `stores screenshots with timestamp as name`() { val replayId = SentryId() val replayCache = fixture.getSut( tmpDir, replayId, + frameRate = 1 + ) + + val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) + replayCache.addFrame(bitmap, 1) + + val expectedScreenshotFile = File(replayCache.replayCacheDir, "1.jpg") + assertTrue(expectedScreenshotFile.exists()) + assertEquals(replayCache.frames.first().timestamp, 1) + assertEquals(replayCache.frames.first().screenshot, expectedScreenshotFile) + } + + @Test + fun `when no frames are provided, returns nothing`() { + val replayCache = fixture.getSut( + tmpDir, + frameRate = 1 + ) + + val video = replayCache.createVideoOf(5000L, 0, 0) + + assertNull(video) + } + + @Test + fun `deletes frames after creating a video`() { + val replayCache = fixture.getSut( + tmpDir, frameRate = 1, - dateProvider = object : ICurrentDateProvider { - var counter = 0 - override fun getCurrentTimeMillis(): Long { - return when (counter++) { - 0 -> 1 - 1 -> 1001 - else -> 1001 - } - } - } + framesToEncode = 3 + ) + + val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) + replayCache.addFrame(bitmap, 1) + + replayCache.createVideoOf(3000L, 0, 0) + assertTrue(replayCache.frames.isEmpty()) + assertTrue(replayCache.replayCacheDir!!.listFiles()!!.none { it.extension == "jpg" }) + } + + @Test + fun `repeats last known frame for the segment duration`() { + val replayCache = fixture.getSut( + tmpDir, + frameRate = 1, + framesToEncode = 5 + ) + + val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) + replayCache.addFrame(bitmap, 1) + + val segment0 = replayCache.createVideoOf(5000L, 0, 0) + assertEquals(5, segment0!!.frameCount) + assertEquals(5000, segment0.duration) + assertEquals(File(replayCache.replayCacheDir, "0.mp4"), segment0.video) + } + + @Test + fun `repeats last known frame for the segment duration for each timespan`() { + val replayCache = fixture.getSut( + tmpDir, + frameRate = 1, + framesToEncode = 5 + ) + + val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) + replayCache.addFrame(bitmap, 1) + replayCache.addFrame(bitmap, 3001) + + val segment0 = replayCache.createVideoOf(5000L, 0, 0) + assertEquals(5, segment0!!.frameCount) + assertEquals(5000, segment0.duration) + assertEquals(File(replayCache.replayCacheDir, "0.mp4"), segment0.video) + } + + @Test + fun `repeats last known frame for each segment`() { + val replayCache = fixture.getSut( + tmpDir, + frameRate = 1, + framesToEncode = 5 + ) + + val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) + replayCache.addFrame(bitmap, 1) + replayCache.addFrame(bitmap, 5001) + + val segment0 = replayCache.createVideoOf(5000L, 0, 0) + assertEquals(5, segment0!!.frameCount) + assertEquals(5000, segment0.duration) + assertEquals(File(replayCache.replayCacheDir, "0.mp4"), segment0.video) + + val segment1 = replayCache.createVideoOf(5000L, 5000L, 1) + assertEquals(5, segment1!!.frameCount) + assertEquals(5000, segment1.duration) + assertEquals(File(replayCache.replayCacheDir, "1.mp4"), segment1.video) + } + + @Test + fun `respects frameRate`() { + val replayCache = fixture.getSut( + tmpDir, + frameRate = 2, + framesToEncode = 6 ) val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) - replayCache.addFrame(bitmap) - replayCache.addFrame(bitmap) + replayCache.addFrame(bitmap, 1) + replayCache.addFrame(bitmap, 1001) + replayCache.addFrame(bitmap, 1501) - replayCache.createVideoOf(5000L, 0, 0) + val segment0 = replayCache.createVideoOf(3000L, 0, 0) + assertEquals(6, segment0!!.frameCount) + assertEquals(3000, segment0.duration) + assertEquals(File(replayCache.replayCacheDir, "0.mp4"), segment0.video) } } From 545712ca5d298eca2e265f6a711bc95068e80846 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 22 Mar 2024 00:28:02 +0100 Subject: [PATCH 40/89] Add tests --- sentry-android-core/build.gradle.kts | 1 + .../io/sentry/android/core/SentryAndroid.java | 50 +++++++++---------- .../core/AndroidOptionsInitializerTest.kt | 27 +++++++++- .../android/core/AndroidProfilerTest.kt | 1 + .../core/AndroidTransactionProfilerTest.kt | 1 + .../android/core/LifecycleWatcherTest.kt | 8 --- .../sentry/android/core/SentryAndroidTest.kt | 25 ++++++++-- .../android/core/SentryInitProviderTest.kt | 1 + .../api/sentry-android-replay.api | 1 + .../android/replay/ReplayIntegration.kt | 2 + 10 files changed, 76 insertions(+), 41 deletions(-) diff --git a/sentry-android-core/build.gradle.kts b/sentry-android-core/build.gradle.kts index da4851c92a9..6ea33c7b74e 100644 --- a/sentry-android-core/build.gradle.kts +++ b/sentry-android-core/build.gradle.kts @@ -104,6 +104,7 @@ dependencies { testImplementation(projects.sentryTestSupport) testImplementation(projects.sentryAndroidFragment) testImplementation(projects.sentryAndroidTimber) + testImplementation(projects.sentryAndroidReplay) testImplementation(projects.sentryComposeHelper) testImplementation(projects.sentryAndroidNdk) testRuntimeOnly(Config.Libs.composeUi) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java index 39a2019d1de..d6e11d15f54 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java @@ -227,44 +227,43 @@ private static void deduplicateIntegrations( } public static synchronized void startReplay() { - performReplayAction( - "starting", - (replay) -> { - replay.start(); - }); + if (!ensureReplayIntegration("starting")) { + return; + } + final @NotNull IHub hub = Sentry.getCurrentHub(); + ReplayIntegrationKt.getReplayIntegration(hub).start(); } public static synchronized void stopReplay() { - performReplayAction( - "stopping", - (replay) -> { - replay.stop(); - }); + if (!ensureReplayIntegration("stopping")) { + return; + } + final @NotNull IHub hub = Sentry.getCurrentHub(); + ReplayIntegrationKt.getReplayIntegration(hub).stop(); } public static synchronized void resumeReplay() { - performReplayAction( - "resuming", - (replay) -> { - replay.resume(); - }); + if (!ensureReplayIntegration("resuming")) { + return; + } + final @NotNull IHub hub = Sentry.getCurrentHub(); + ReplayIntegrationKt.getReplayIntegration(hub).resume(); } public static synchronized void pauseReplay() { - performReplayAction( - "pausing", - (replay) -> { - replay.pause(); - }); + if (!ensureReplayIntegration("pausing")) { + return; + } + final @NotNull IHub hub = Sentry.getCurrentHub(); + ReplayIntegrationKt.getReplayIntegration(hub).pause(); } - private static void performReplayAction( - final @NotNull String actionName, final @NotNull ReplayCallable action) { + private static boolean ensureReplayIntegration(final @NotNull String actionName) { final @NotNull IHub hub = Sentry.getCurrentHub(); if (isReplayAvailable) { final ReplayIntegration replay = ReplayIntegrationKt.getReplayIntegration(hub); if (replay != null) { - action.call(replay); + return true; } else { hub.getOptions() .getLogger() @@ -279,9 +278,6 @@ private static void performReplayAction( SentryLevel.INFO, "Session Replay wasn't found on classpath, not " + actionName + " the replay"); } - } - - private interface ReplayCallable { - void call(final @NotNull ReplayIntegration replay); + return false; } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt index 6353e9dde89..94b0490f17d 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt @@ -15,6 +15,7 @@ import io.sentry.android.core.internal.gestures.AndroidViewGestureTargetLocator import io.sentry.android.core.internal.modules.AssetsModulesLoader import io.sentry.android.core.internal.util.AndroidMainThreadChecker import io.sentry.android.fragment.FragmentLifecycleIntegration +import io.sentry.android.replay.ReplayIntegration import io.sentry.android.timber.SentryTimberIntegration import io.sentry.cache.PersistingOptionsObserver import io.sentry.cache.PersistingScopeObserver @@ -83,6 +84,7 @@ class AndroidOptionsInitializerTest { loadClass, activityFramesTracker, false, + false, false ) @@ -99,7 +101,8 @@ class AndroidOptionsInitializerTest { minApi: Int = Build.VERSION_CODES.KITKAT, classesToLoad: List = emptyList(), isFragmentAvailable: Boolean = false, - isTimberAvailable: Boolean = false + isTimberAvailable: Boolean = false, + isReplayAvailable: Boolean = false ) { mockContext = ContextUtilsTestHelper.mockMetaData( mockContext = ContextUtilsTestHelper.createMockContext(hasAppContext = true), @@ -126,7 +129,8 @@ class AndroidOptionsInitializerTest { loadClass, activityFramesTracker, isFragmentAvailable, - isTimberAvailable + isTimberAvailable, + isReplayAvailable ) AndroidOptionsInitializer.initializeIntegrationsAndProcessors( @@ -478,6 +482,24 @@ class AndroidOptionsInitializerTest { assertNull(actual) } + @Test + fun `ReplayIntegration added to the integration list if available on classpath`() { + fixture.initSutWithClassLoader(isReplayAvailable = true) + + val actual = + fixture.sentryOptions.integrations.firstOrNull { it is ReplayIntegration } + assertNotNull(actual) + } + + @Test + fun `ReplayIntegration won't be enabled, it throws class not found`() { + fixture.initSutWithClassLoader(isReplayAvailable = false) + + val actual = + fixture.sentryOptions.integrations.firstOrNull { it is ReplayIntegration } + assertNull(actual) + } + @Test fun `AndroidEnvelopeCache is set to options`() { fixture.initSut() @@ -634,6 +656,7 @@ class AndroidOptionsInitializerTest { mock(), mock(), false, + false, false ) verify(mockOptions, never()).outboxPath diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidProfilerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidProfilerTest.kt index a726b2c55b8..2efb6020755 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidProfilerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidProfilerTest.kt @@ -118,6 +118,7 @@ class AndroidProfilerTest { loadClass, activityFramesTracker, false, + false, false ) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidTransactionProfilerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidTransactionProfilerTest.kt index 405aa6dc98b..9376ea79fb3 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidTransactionProfilerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidTransactionProfilerTest.kt @@ -125,6 +125,7 @@ class AndroidTransactionProfilerTest { loadClass, activityFramesTracker, false, + false, false ) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt index be309931429..4b620813bf5 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt @@ -123,7 +123,6 @@ class LifecycleWatcherTest { fun `When session tracking is disabled, do not end session`() { val watcher = fixture.getSUT(enableAutoSessionTracking = false, enableAppLifecycleBreadcrumbs = false) watcher.onStop(fixture.ownerMock) - assertNull(watcher.timerTask) verify(fixture.hub, never()).endSession() } @@ -167,7 +166,6 @@ class LifecycleWatcherTest { fun `When session tracking is disabled, do not add breadcrumb on stop`() { val watcher = fixture.getSUT(enableAutoSessionTracking = false, enableAppLifecycleBreadcrumbs = false) watcher.onStop(fixture.ownerMock) - assertNull(watcher.timerTask) verify(fixture.hub, never()).addBreadcrumb(any()) } @@ -219,12 +217,6 @@ class LifecycleWatcherTest { assertNotNull(watcher.timer) } - @Test - fun `timer is not created if session tracking is disabled`() { - val watcher = fixture.getSUT(enableAutoSessionTracking = false, enableAppLifecycleBreadcrumbs = false) - assertNull(watcher.timer) - } - @Test fun `if the hub has already a fresh session running, don't start new one`() { val watcher = fixture.getSUT( diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt index bd5b3695fb2..b543ae318a4 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt @@ -26,6 +26,8 @@ import io.sentry.UncaughtExceptionHandlerIntegration import io.sentry.android.core.cache.AndroidEnvelopeCache import io.sentry.android.core.performance.AppStartMetrics import io.sentry.android.fragment.FragmentLifecycleIntegration +import io.sentry.android.replay.ReplayIntegration +import io.sentry.android.replay.getReplayIntegration import io.sentry.android.timber.SentryTimberIntegration import io.sentry.cache.IEnvelopeCache import io.sentry.cache.PersistingOptionsObserver @@ -313,12 +315,26 @@ class SentryAndroidTest { } } + @Test + @Config(sdk = [26]) + fun `init starts session replay if app is in foreground`() { + initSentryWithForegroundImportance(true) { _ -> + assertTrue(Sentry.getCurrentHub().getReplayIntegration()!!.isRecording()) + } + } + + @Test + @Config(sdk = [26]) + fun `init does not start session replay if the app is in background`() { + initSentryWithForegroundImportance(false) { _ -> + assertFalse(Sentry.getCurrentHub().getReplayIntegration()!!.isRecording()) + } + } + private fun initSentryWithForegroundImportance( inForeground: Boolean, callback: (session: Session?) -> Unit ) { - val context = ContextUtilsTestHelper.createMockContext() - Mockito.mockStatic(ContextUtils::class.java).use { mockedContextUtils -> mockedContextUtils.`when` { ContextUtils.isForegroundImportance() } .thenReturn(inForeground) @@ -412,7 +428,7 @@ class SentryAndroidTest { fixture.initSut(context = mock()) { options -> optionsRef = options options.dsn = "https://key@sentry.io/123" - assertEquals(19, options.integrations.size) + assertEquals(20, options.integrations.size) options.integrations.removeAll { it is UncaughtExceptionHandlerIntegration || it is ShutdownHookIntegration || @@ -431,7 +447,8 @@ class SentryAndroidTest { it is SystemEventsBreadcrumbsIntegration || it is NetworkBreadcrumbsIntegration || it is TempSensorBreadcrumbsIntegration || - it is PhoneStateBreadcrumbsIntegration + it is PhoneStateBreadcrumbsIntegration || + it is ReplayIntegration } } assertEquals(0, optionsRef.integrations.size) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryInitProviderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryInitProviderTest.kt index a83076efb04..5b546523d01 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryInitProviderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryInitProviderTest.kt @@ -143,6 +143,7 @@ class SentryInitProviderTest { loadClass, activityFramesTracker, false, + false, false ) diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index 5af2ca943ba..b3bc98e15bf 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -12,6 +12,7 @@ public final class io/sentry/android/replay/ReplayIntegration : io/sentry/Integr public static final field VIDEO_SEGMENT_DURATION J public fun (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;)V public fun close ()V + public final fun isRecording ()Z public fun onScreenshotRecorded (Landroid/graphics/Bitmap;)V public final fun pause ()V public fun register (Lio/sentry/IHub;Lio/sentry/SentryOptions;)V diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index 756b8638d04..e1f0d2f7e3f 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -99,6 +99,8 @@ class ReplayIntegration( isEnabled.set(true) } + fun isRecording() = isRecording.get() + fun start() { if (!isEnabled.get()) { options.logger.log( From 2df34a345f90eaf10436f2954cb069e69ec0f090 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 25 Mar 2024 14:10:40 +0100 Subject: [PATCH 41/89] Add SessionReplayOptions --- .../io/sentry/android/replay/ReplayCache.kt | 7 +- .../android/replay/ReplayIntegration.kt | 29 +------ .../android/replay/ScreenshotRecorder.kt | 36 +++++++- .../replay/video/SimpleVideoEncoder.kt | 11 +-- sentry/api/sentry.api | 20 +++++ .../java/io/sentry/ExperimentalOptions.java | 16 ++++ .../main/java/io/sentry/SentryOptions.java | 7 ++ .../java/io/sentry/SessionReplayOptions.java | 85 +++++++++++++++++++ 8 files changed, 171 insertions(+), 40 deletions(-) create mode 100644 sentry/src/main/java/io/sentry/ExperimentalOptions.java create mode 100644 sentry/src/main/java/io/sentry/SessionReplayOptions.java diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt index da62dfb6b18..e591370444c 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt @@ -20,12 +20,7 @@ internal class ReplayCache( private val encoderCreator: (File) -> SimpleVideoEncoder = { videoFile -> SimpleVideoEncoder( options, - MuxerConfig( - file = videoFile, - recorderConfig = recorderConfig, - frameRate = recorderConfig.frameRate.toFloat(), - bitrate = 20 * 1000 - ) + MuxerConfig(file = videoFile, recorderConfig = recorderConfig) ).also { it.start() } } ) : Closeable { diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index e1f0d2f7e3f..fa5859745cd 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -2,12 +2,9 @@ package io.sentry.android.replay import android.content.Context import android.graphics.Bitmap -import android.graphics.Point -import android.graphics.Rect import android.os.Build import android.os.Build.VERSION import android.os.Build.VERSION_CODES -import android.view.WindowManager import io.sentry.DateUtils import io.sentry.Hint import io.sentry.IHub @@ -33,7 +30,6 @@ import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicReference import kotlin.LazyThreadSafetyMode.NONE -import kotlin.math.roundToInt class ReplayIntegration( private val context: Context, @@ -59,28 +55,11 @@ class ReplayIntegration( private val saver = Executors.newSingleThreadScheduledExecutor(ReplayExecutorServiceThreadFactory()) - private val screenBounds by lazy(NONE) { - // PixelCopy takes screenshots including system bars, so we have to get the real size here - val wm = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager - if (VERSION.SDK_INT >= VERSION_CODES.R) { - wm.currentWindowMetrics.bounds - } else { - val screenBounds = Point() - @Suppress("DEPRECATION") - wm.defaultDisplay.getRealSize(screenBounds) - Rect(0, 0, screenBounds.x, screenBounds.y) - } - } - - private val aspectRatio by lazy(NONE) { - screenBounds.bottom.toFloat() / screenBounds.right.toFloat() - } - private val recorderConfig by lazy(NONE) { - ScreenshotRecorderConfig( - recordingWidth = (720 / aspectRatio).roundToInt(), - recordingHeight = 720, - scaleFactor = 720f / screenBounds.bottom + ScreenshotRecorderConfig.from( + context, + targetHeight = 720, + options._experimental.sessionReplayOptions ) } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt index 87915f8505a..a7094e10745 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -1,13 +1,17 @@ package io.sentry.android.replay import android.annotation.TargetApi +import android.content.Context import android.graphics.Bitmap import android.graphics.Bitmap.Config.ARGB_8888 import android.graphics.Canvas import android.graphics.Matrix import android.graphics.Paint +import android.graphics.Point import android.graphics.Rect import android.graphics.RectF +import android.os.Build.VERSION +import android.os.Build.VERSION_CODES import android.os.Handler import android.os.HandlerThread import android.os.Looper @@ -15,13 +19,16 @@ import android.view.PixelCopy import android.view.View import android.view.ViewGroup import android.view.ViewTreeObserver +import android.view.WindowManager import io.sentry.SentryLevel.DEBUG import io.sentry.SentryLevel.INFO import io.sentry.SentryOptions +import io.sentry.SessionReplayOptions import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode import java.lang.ref.WeakReference import java.util.WeakHashMap import java.util.concurrent.atomic.AtomicBoolean +import kotlin.math.roundToInt import kotlin.system.measureTimeMillis @TargetApi(26) @@ -217,8 +224,33 @@ internal data class ScreenshotRecorderConfig( val recordingWidth: Int, val recordingHeight: Int, val scaleFactor: Float, - val frameRate: Int = 2 -) + val frameRate: Int, + val bitRate: Int +) { + companion object { + fun from(context: Context, targetHeight: Int, sessionReplayOptions: SessionReplayOptions): ScreenshotRecorderConfig { + // PixelCopy takes screenshots including system bars, so we have to get the real size here + val wm = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager + val screenBounds = if (VERSION.SDK_INT >= VERSION_CODES.R) { + wm.currentWindowMetrics.bounds + } else { + val screenBounds = Point() + @Suppress("DEPRECATION") + wm.defaultDisplay.getRealSize(screenBounds) + Rect(0, 0, screenBounds.x, screenBounds.y) + } + val aspectRatio = screenBounds.bottom.toFloat() / screenBounds.right.toFloat() + + return ScreenshotRecorderConfig( + recordingWidth = (targetHeight / aspectRatio).roundToInt(), + recordingHeight = targetHeight, + scaleFactor = targetHeight.toFloat() / screenBounds.bottom, + frameRate = sessionReplayOptions.frameRate, + bitRate = sessionReplayOptions.bitRate + ) + } + } +} interface ScreenshotRecorderCallback { fun onScreenshotRecorded(bitmap: Bitmap) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt index e2561faa1bb..73b88624349 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt @@ -62,8 +62,8 @@ internal class SimpleVideoEncoder( MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface ) - format.setInteger(MediaFormat.KEY_BIT_RATE, muxerConfig.bitrate) - format.setFloat(MediaFormat.KEY_FRAME_RATE, muxerConfig.frameRate) + format.setInteger(MediaFormat.KEY_BIT_RATE, muxerConfig.recorderConfig.bitRate) + format.setFloat(MediaFormat.KEY_FRAME_RATE, muxerConfig.recorderConfig.frameRate.toFloat()) format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 10) format @@ -79,7 +79,7 @@ internal class SimpleVideoEncoder( } private val bufferInfo: MediaCodec.BufferInfo = MediaCodec.BufferInfo() - private val frameMuxer = muxerConfig.frameMuxer + private val frameMuxer = SimpleMp4FrameMuxer(muxerConfig.file.absolutePath, muxerConfig.recorderConfig.frameRate.toFloat()) val duration get() = frameMuxer.getVideoTime() private var surface: Surface? = null @@ -187,8 +187,5 @@ internal class SimpleVideoEncoder( internal data class MuxerConfig( val file: File, val recorderConfig: ScreenshotRecorderConfig, - val bitrate: Int = 20_000, - val frameRate: Float = recorderConfig.frameRate.toFloat(), - val mimeType: String = MediaFormat.MIMETYPE_VIDEO_AVC, - val frameMuxer: SimpleFrameMuxer = SimpleMp4FrameMuxer(file.absolutePath, frameRate) + val mimeType: String = MediaFormat.MIMETYPE_VIDEO_AVC ) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 9e3a90946fb..756fd592044 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -305,6 +305,12 @@ public abstract interface class io/sentry/EventProcessor { public fun process (Lio/sentry/protocol/SentryTransaction;Lio/sentry/Hint;)Lio/sentry/protocol/SentryTransaction; } +public final class io/sentry/ExperimentalOptions { + public fun ()V + public fun getSessionReplayOptions ()Lio/sentry/SessionReplayOptions; + public fun setSessionReplayOptions (Lio/sentry/SessionReplayOptions;)V +} + public final class io/sentry/ExternalOptions { public fun ()V public fun addBundleId (Ljava/lang/String;)V @@ -2315,6 +2321,7 @@ public class io/sentry/SentryOptions { public fun getTransportFactory ()Lio/sentry/ITransportFactory; public fun getTransportGate ()Lio/sentry/transport/ITransportGate; public final fun getViewHierarchyExporters ()Ljava/util/List; + public fun get_experimental ()Lio/sentry/ExperimentalOptions; public fun isAttachServerName ()Z public fun isAttachStacktrace ()Z public fun isAttachThreads ()Z @@ -2686,6 +2693,19 @@ public final class io/sentry/Session$State : java/lang/Enum { public static fun values ()[Lio/sentry/Session$State; } +public final class io/sentry/SessionReplayOptions { + public fun ()V + public fun (Ljava/lang/Double;Ljava/lang/Double;)V + public fun getBitRate ()I + public fun getErrorReplayDuration ()J + public fun getErrorSampleRate ()Ljava/lang/Double; + public fun getFrameRate ()I + public fun getSessionSampleRate ()Ljava/lang/Double; + public fun getSessionSegmentDuration ()J + public fun setErrorSampleRate (Ljava/lang/Double;)V + public fun setSessionSampleRate (Ljava/lang/Double;)V +} + public final class io/sentry/ShutdownHookIntegration : io/sentry/Integration, java/io/Closeable { public fun ()V public fun (Ljava/lang/Runtime;)V diff --git a/sentry/src/main/java/io/sentry/ExperimentalOptions.java b/sentry/src/main/java/io/sentry/ExperimentalOptions.java new file mode 100644 index 00000000000..8e7ade8ca93 --- /dev/null +++ b/sentry/src/main/java/io/sentry/ExperimentalOptions.java @@ -0,0 +1,16 @@ +package io.sentry; + +import org.jetbrains.annotations.NotNull; + +public final class ExperimentalOptions { + private @NotNull SessionReplayOptions sessionReplayOptions = new SessionReplayOptions(); + + @NotNull + public SessionReplayOptions getSessionReplayOptions() { + return sessionReplayOptions; + } + + public void setSessionReplayOptions(final @NotNull SessionReplayOptions sessionReplayOptions) { + this.sessionReplayOptions = sessionReplayOptions; + } +} diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index d97b8c79d19..026f2c9e903 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -460,6 +460,8 @@ public class SentryOptions { */ private int profilingTracesHz = 101; + private final @NotNull ExperimentalOptions _experimental = new ExperimentalOptions(); + /** * Adds an event processor * @@ -2274,6 +2276,11 @@ public void setSessionFlushTimeoutMillis(final long sessionFlushTimeoutMillis) { this.sessionFlushTimeoutMillis = sessionFlushTimeoutMillis; } + @NotNull + public ExperimentalOptions get_experimental() { + return _experimental; + } + /** The BeforeSend callback */ public interface BeforeSendCallback { diff --git a/sentry/src/main/java/io/sentry/SessionReplayOptions.java b/sentry/src/main/java/io/sentry/SessionReplayOptions.java new file mode 100644 index 00000000000..88b815a7c06 --- /dev/null +++ b/sentry/src/main/java/io/sentry/SessionReplayOptions.java @@ -0,0 +1,85 @@ +package io.sentry; + +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.Nullable; + +public final class SessionReplayOptions { + + /** + * Indicates the percentage in which the replay for the session will be created. Specifying 0 + * means never, 1.0 means always. The value needs to be >= 0.0 and <= 1.0 The default is null + * (disabled). + */ + private @Nullable Double sessionSampleRate; + + /** + * Indicates the percentage in which a 30 seconds replay will be send with error events. + * Specifying 0 means never, 1.0 means always. The value needs to be >= 0.0 and <= 1.0. The + * default is null (disabled). + */ + private @Nullable Double errorSampleRate; + + /** + * Defines the quality of the session replay. Higher bit rates have better replay quality, but + * also affect the final payload size to transfer. The default value is 20kbps; + */ + private int bitRate = 20_000; + + /** + * Number of frames per second of the replay. The bigger the number, the more accurate the replay + * will be, but also more data to transfer and more CPU load. + */ + private int frameRate = 1; + + /** The maximum duration of replays for error events. */ + private long errorReplayDuration = 30_000L; + + /** The maximum duration of the segment of a session replay. */ + private long sessionSegmentDuration = 5000L; + + public SessionReplayOptions() {} + + public SessionReplayOptions( + final @Nullable Double sessionSampleRate, final @Nullable Double errorSampleRate) { + this.sessionSampleRate = sessionSampleRate; + this.errorSampleRate = errorSampleRate; + } + + @Nullable + public Double getErrorSampleRate() { + return errorSampleRate; + } + + public void setErrorSampleRate(final @Nullable Double errorSampleRate) { + this.errorSampleRate = errorSampleRate; + } + + @Nullable + public Double getSessionSampleRate() { + return sessionSampleRate; + } + + public void setSessionSampleRate(final @Nullable Double sessionSampleRate) { + this.sessionSampleRate = sessionSampleRate; + } + + @ApiStatus.Internal + public int getBitRate() { + return bitRate; + } + + @ApiStatus.Internal + public int getFrameRate() { + return frameRate; + } + + @ApiStatus.Internal + public long getErrorReplayDuration() { + return errorReplayDuration; + } + + @ApiStatus.Internal + public long getSessionSegmentDuration() { + return sessionSegmentDuration; + } +} From c02f1dba4f894b7e9029c13774b4581708a63087 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 25 Mar 2024 14:57:44 +0100 Subject: [PATCH 42/89] Call listeners when installing RootViewsSpy --- .../src/main/java/io/sentry/android/replay/Windows.kt | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt index ece684c46e0..e9c6761c75b 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt @@ -139,6 +139,15 @@ internal class RootViewsSpy private constructor() { val listeners = CopyOnWriteArrayList() private val delegatingViewList = object : ArrayList() { + override fun addAll(elements: Collection): Boolean { + listeners.forEach { listener -> + elements.forEach { element -> + listener.onRootViewsChanged(element, true) + } + } + return super.addAll(elements) + } + override fun add(element: View): Boolean { listeners.forEach { it.onRootViewsChanged(element, true) } return super.add(element) From bee240b8608a9ee08c6920e4dd38eed79d4294ac Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 25 Mar 2024 15:01:05 +0100 Subject: [PATCH 43/89] Call listeners when installing RootViewsSpy --- .../src/main/java/io/sentry/android/replay/Windows.kt | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt index ece684c46e0..e9c6761c75b 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt @@ -139,6 +139,15 @@ internal class RootViewsSpy private constructor() { val listeners = CopyOnWriteArrayList() private val delegatingViewList = object : ArrayList() { + override fun addAll(elements: Collection): Boolean { + listeners.forEach { listener -> + elements.forEach { element -> + listener.onRootViewsChanged(element, true) + } + } + return super.addAll(elements) + } + override fun add(element: View): Boolean { listeners.forEach { it.onRootViewsChanged(element, true) } return super.add(element) From d6bb9ab654f4b0f7d883f0572dc121932fdb1630 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 25 Mar 2024 16:33:53 +0100 Subject: [PATCH 44/89] SessionReplayOptions -> SentryReplayOptions --- .../android/replay/ReplayIntegration.kt | 4 +-- .../android/replay/ScreenshotRecorder.kt | 8 ++--- sentry/api/sentry.api | 30 +++++++++---------- .../java/io/sentry/ExperimentalOptions.java | 10 +++---- ...yOptions.java => SentryReplayOptions.java} | 19 ++++++++++-- 5 files changed, 41 insertions(+), 30 deletions(-) rename sentry/src/main/java/io/sentry/{SessionReplayOptions.java => SentryReplayOptions.java} (77%) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index fa5859745cd..a99c8a9c95f 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -3,8 +3,6 @@ package io.sentry.android.replay import android.content.Context import android.graphics.Bitmap import android.os.Build -import android.os.Build.VERSION -import android.os.Build.VERSION_CODES import io.sentry.DateUtils import io.sentry.Hint import io.sentry.IHub @@ -59,7 +57,7 @@ class ReplayIntegration( ScreenshotRecorderConfig.from( context, targetHeight = 720, - options._experimental.sessionReplayOptions + options._experimental.replayOptions ) } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt index a7094e10745..7dfe26c4785 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -23,7 +23,7 @@ import android.view.WindowManager import io.sentry.SentryLevel.DEBUG import io.sentry.SentryLevel.INFO import io.sentry.SentryOptions -import io.sentry.SessionReplayOptions +import io.sentry.SentryReplayOptions import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode import java.lang.ref.WeakReference import java.util.WeakHashMap @@ -228,7 +228,7 @@ internal data class ScreenshotRecorderConfig( val bitRate: Int ) { companion object { - fun from(context: Context, targetHeight: Int, sessionReplayOptions: SessionReplayOptions): ScreenshotRecorderConfig { + fun from(context: Context, targetHeight: Int, sentryReplayOptions: SentryReplayOptions): ScreenshotRecorderConfig { // PixelCopy takes screenshots including system bars, so we have to get the real size here val wm = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager val screenBounds = if (VERSION.SDK_INT >= VERSION_CODES.R) { @@ -245,8 +245,8 @@ internal data class ScreenshotRecorderConfig( recordingWidth = (targetHeight / aspectRatio).roundToInt(), recordingHeight = targetHeight, scaleFactor = targetHeight.toFloat() / screenBounds.bottom, - frameRate = sessionReplayOptions.frameRate, - bitRate = sessionReplayOptions.bitRate + frameRate = sentryReplayOptions.frameRate, + bitRate = sentryReplayOptions.bitRate ) } } diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 756fd592044..fcede26562f 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -307,8 +307,8 @@ public abstract interface class io/sentry/EventProcessor { public final class io/sentry/ExperimentalOptions { public fun ()V - public fun getSessionReplayOptions ()Lio/sentry/SessionReplayOptions; - public fun setSessionReplayOptions (Lio/sentry/SessionReplayOptions;)V + public fun getReplayOptions ()Lio/sentry/SentryReplayOptions; + public fun setReplayOptions (Lio/sentry/SentryReplayOptions;)V } public final class io/sentry/ExternalOptions { @@ -2540,6 +2540,19 @@ public final class io/sentry/SentryReplayEvent$ReplayType$Deserializer : io/sent public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } +public final class io/sentry/SentryReplayOptions { + public fun ()V + public fun (Ljava/lang/Double;Ljava/lang/Double;)V + public fun getBitRate ()I + public fun getErrorReplayDuration ()J + public fun getErrorSampleRate ()Ljava/lang/Double; + public fun getFrameRate ()I + public fun getSessionSampleRate ()Ljava/lang/Double; + public fun getSessionSegmentDuration ()J + public fun setErrorSampleRate (Ljava/lang/Double;)V + public fun setSessionSampleRate (Ljava/lang/Double;)V +} + public final class io/sentry/SentrySpanStorage { public fun get (Ljava/lang/String;)Lio/sentry/ISpan; public static fun getInstance ()Lio/sentry/SentrySpanStorage; @@ -2693,19 +2706,6 @@ public final class io/sentry/Session$State : java/lang/Enum { public static fun values ()[Lio/sentry/Session$State; } -public final class io/sentry/SessionReplayOptions { - public fun ()V - public fun (Ljava/lang/Double;Ljava/lang/Double;)V - public fun getBitRate ()I - public fun getErrorReplayDuration ()J - public fun getErrorSampleRate ()Ljava/lang/Double; - public fun getFrameRate ()I - public fun getSessionSampleRate ()Ljava/lang/Double; - public fun getSessionSegmentDuration ()J - public fun setErrorSampleRate (Ljava/lang/Double;)V - public fun setSessionSampleRate (Ljava/lang/Double;)V -} - public final class io/sentry/ShutdownHookIntegration : io/sentry/Integration, java/io/Closeable { public fun ()V public fun (Ljava/lang/Runtime;)V diff --git a/sentry/src/main/java/io/sentry/ExperimentalOptions.java b/sentry/src/main/java/io/sentry/ExperimentalOptions.java index 8e7ade8ca93..b22d283724b 100644 --- a/sentry/src/main/java/io/sentry/ExperimentalOptions.java +++ b/sentry/src/main/java/io/sentry/ExperimentalOptions.java @@ -3,14 +3,14 @@ import org.jetbrains.annotations.NotNull; public final class ExperimentalOptions { - private @NotNull SessionReplayOptions sessionReplayOptions = new SessionReplayOptions(); + private @NotNull SentryReplayOptions replayOptions = new SentryReplayOptions(); @NotNull - public SessionReplayOptions getSessionReplayOptions() { - return sessionReplayOptions; + public SentryReplayOptions getReplayOptions() { + return replayOptions; } - public void setSessionReplayOptions(final @NotNull SessionReplayOptions sessionReplayOptions) { - this.sessionReplayOptions = sessionReplayOptions; + public void setReplayOptions(final @NotNull SentryReplayOptions replayOptions) { + this.replayOptions = replayOptions; } } diff --git a/sentry/src/main/java/io/sentry/SessionReplayOptions.java b/sentry/src/main/java/io/sentry/SentryReplayOptions.java similarity index 77% rename from sentry/src/main/java/io/sentry/SessionReplayOptions.java rename to sentry/src/main/java/io/sentry/SentryReplayOptions.java index 88b815a7c06..df98fc384f4 100644 --- a/sentry/src/main/java/io/sentry/SessionReplayOptions.java +++ b/sentry/src/main/java/io/sentry/SentryReplayOptions.java @@ -1,9 +1,10 @@ package io.sentry; +import io.sentry.util.SampleRateUtils; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.Nullable; -public final class SessionReplayOptions { +public final class SentryReplayOptions { /** * Indicates the percentage in which the replay for the session will be created. Specifying 0 @@ -37,9 +38,9 @@ public final class SessionReplayOptions { /** The maximum duration of the segment of a session replay. */ private long sessionSegmentDuration = 5000L; - public SessionReplayOptions() {} + public SentryReplayOptions() {} - public SessionReplayOptions( + public SentryReplayOptions( final @Nullable Double sessionSampleRate, final @Nullable Double errorSampleRate) { this.sessionSampleRate = sessionSampleRate; this.errorSampleRate = errorSampleRate; @@ -51,6 +52,12 @@ public Double getErrorSampleRate() { } public void setErrorSampleRate(final @Nullable Double errorSampleRate) { + if (!SampleRateUtils.isValidSampleRate(errorSampleRate)) { + throw new IllegalArgumentException( + "The value " + + errorSampleRate + + " is not valid. Use null to disable or values >= 0.0 and <= 1.0."); + } this.errorSampleRate = errorSampleRate; } @@ -60,6 +67,12 @@ public Double getSessionSampleRate() { } public void setSessionSampleRate(final @Nullable Double sessionSampleRate) { + if (!SampleRateUtils.isValidSampleRate(sessionSampleRate)) { + throw new IllegalArgumentException( + "The value " + + sessionSampleRate + + " is not valid. Use null to disable or values >= 0.0 and <= 1.0."); + } this.sessionSampleRate = sessionSampleRate; } From 7854e4fcdc67bf8f77ee4d47374be4a9c76a731f Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 26 Mar 2024 11:11:10 +0100 Subject: [PATCH 45/89] Fix test --- .../test/java/io/sentry/android/replay/ReplayCacheTest.kt | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt index 88d410fab1a..dc4f5c29b59 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt @@ -36,7 +36,7 @@ class ReplayCacheTest { frameRate: Int, framesToEncode: Int = 0 ): ReplayCache { - val recorderConfig = ScreenshotRecorderConfig(100, 200, 1f, frameRate) + val recorderConfig = ScreenshotRecorderConfig(100, 200, 1f, frameRate = frameRate, bitRate = 20_000) options.run { cacheDirPath = dir?.newFolder()?.absolutePath } @@ -45,9 +45,7 @@ class ReplayCacheTest { options, MuxerConfig( file = videoFile, - recorderConfig = recorderConfig, - frameRate = recorderConfig.frameRate.toFloat(), - bitrate = 20 * 1000 + recorderConfig = recorderConfig ), onClose = { encodeFrame(framesToEncode, frameRate, size = 0, flags = MediaCodec.BUFFER_FLAG_END_OF_STREAM) From 2cddcc441fe575b29a044d3f7759a97df32ab69e Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 26 Mar 2024 11:49:16 +0100 Subject: [PATCH 46/89] Add AndroidManifest options for replays --- .../android/core/ManifestMetadataReader.java | 19 +++++++++ .../core/ManifestMetadataReaderTest.kt | 42 +++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java index 93bf928285f..32ad9df290d 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java @@ -100,6 +100,10 @@ final class ManifestMetadataReader { static final String ENABLE_APP_START_PROFILING = "io.sentry.profiling.enable-app-start"; + static final String REPLAYS_SESSION_SAMPLE_RATE = "io.sentry.replays.session-sample-rate"; + + static final String REPLAYS_ERROR_SAMPLE_RATE = "io.sentry.replays.error-sample-rate"; + /** ManifestMetadataReader ctor */ private ManifestMetadataReader() {} @@ -371,6 +375,21 @@ static void applyMetadata( options.setEnableAppStartProfiling( readBool( metadata, logger, ENABLE_APP_START_PROFILING, options.isEnableAppStartProfiling())); + + if (options.get_experimental().getReplayOptions().getSessionSampleRate() == null) { + final Double sessionSampleRate = + readDouble(metadata, logger, REPLAYS_SESSION_SAMPLE_RATE); + if (sessionSampleRate != -1) { + options.get_experimental().getReplayOptions().setSessionSampleRate(sessionSampleRate); + } + } + + if (options.get_experimental().getReplayOptions().getErrorSampleRate() == null) { + final Double errorSampleRate = readDouble(metadata, logger, REPLAYS_ERROR_SAMPLE_RATE); + if (errorSampleRate != -1) { + options.get_experimental().getReplayOptions().setErrorSampleRate(errorSampleRate); + } + } } options diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt index 2cff98ed396..03f8e5291fd 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt @@ -1370,4 +1370,46 @@ class ManifestMetadataReaderTest { // Assert assertFalse(fixture.options.isEnableAppStartProfiling) } + + @Test + fun `applyMetadata reads replays errorSampleRate from metadata`() { + // Arrange + val expectedSampleRate = 0.99f + + val bundle = bundleOf(ManifestMetadataReader.REPLAYS_ERROR_SAMPLE_RATE to expectedSampleRate) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertEquals(expectedSampleRate.toDouble(), fixture.options._experimental.replayOptions.errorSampleRate) + } + + @Test + fun `applyMetadata does not override replays errorSampleRate from options`() { + // Arrange + val expectedSampleRate = 0.99f + fixture.options._experimental.replayOptions.errorSampleRate = expectedSampleRate.toDouble() + val bundle = bundleOf(ManifestMetadataReader.REPLAYS_ERROR_SAMPLE_RATE to 0.1f) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertEquals(expectedSampleRate.toDouble(), fixture.options._experimental.replayOptions.errorSampleRate) + } + + @Test + fun `applyMetadata without specifying replays errorSampleRate, stays null`() { + // Arrange + val context = fixture.getContext() + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertNull(fixture.options._experimental.replayOptions.errorSampleRate) + } } From ef3d62c61344ce7a48e12f8d814ee5d6d4454d84 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 28 Mar 2024 11:34:51 +0100 Subject: [PATCH 47/89] Add buffer mode and link replays with events/transactions --- .../api/sentry-android-core.api | 4 - .../core/AndroidOptionsInitializer.java | 5 +- .../sentry/android/core/LifecycleWatcher.java | 8 +- .../io/sentry/android/core/SentryAndroid.java | 59 +-------- .../api/sentry-android-replay.api | 17 +-- .../io/sentry/android/replay/ReplayCache.kt | 18 ++- .../android/replay/ReplayIntegration.kt | 125 +++++++++++++++--- sentry/api/sentry.api | 28 +++- sentry/src/main/java/io/sentry/Baggage.java | 27 +++- .../java/io/sentry/NoOpReplayController.java | 29 ++++ .../main/java/io/sentry/ReplayController.java | 17 +++ .../src/main/java/io/sentry/SentryClient.java | 47 +++++-- .../main/java/io/sentry/SentryOptions.java | 11 ++ .../java/io/sentry/SentryReplayOptions.java | 8 ++ .../src/main/java/io/sentry/SentryTracer.java | 8 +- .../src/main/java/io/sentry/TraceContext.java | 22 ++- .../sentry/TraceContextSerializationTest.kt | 1 + 17 files changed, 310 insertions(+), 124 deletions(-) create mode 100644 sentry/src/main/java/io/sentry/NoOpReplayController.java create mode 100644 sentry/src/main/java/io/sentry/ReplayController.java diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index 55278c63563..51eb48f1b22 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -245,10 +245,6 @@ public final class io/sentry/android/core/SentryAndroid { public static fun init (Landroid/content/Context;Lio/sentry/ILogger;)V public static fun init (Landroid/content/Context;Lio/sentry/ILogger;Lio/sentry/Sentry$OptionsConfiguration;)V public static fun init (Landroid/content/Context;Lio/sentry/Sentry$OptionsConfiguration;)V - public static fun pauseReplay ()V - public static fun resumeReplay ()V - public static fun startReplay ()V - public static fun stopReplay ()V } public final class io/sentry/android/core/SentryAndroidDateProvider : io/sentry/SentryDateProvider { diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java index b58051cee71..27c9d6e674a 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java @@ -299,7 +299,10 @@ static void installDefaultIntegrations( options.addIntegration(new TempSensorBreadcrumbsIntegration(context)); options.addIntegration(new PhoneStateBreadcrumbsIntegration(context)); if (isReplayAvailable) { - options.addIntegration(new ReplayIntegration(context, CurrentDateProvider.getInstance())); + final ReplayIntegration replay = + new ReplayIntegration(context, CurrentDateProvider.getInstance()); + options.addIntegration(replay); + options.setReplayController(replay); } } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java b/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java index ff281d2beba..bcdb49e3e6e 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java @@ -93,10 +93,10 @@ private void startSession() { addSessionBreadcrumb("start"); hub.startSession(); } - SentryAndroid.startReplay(); + hub.getOptions().getReplayController().start(); } else if (!isFreshSession.getAndSet(false)) { // only resume if it's not a fresh session, which has been started in SentryAndroid.init - SentryAndroid.resumeReplay(); + hub.getOptions().getReplayController().resume(); } this.lastUpdatedSession.set(currentTimeMillis); } @@ -108,7 +108,7 @@ public void onStop(final @NotNull LifecycleOwner owner) { final long currentTimeMillis = currentDateProvider.getCurrentTimeMillis(); this.lastUpdatedSession.set(currentTimeMillis); - SentryAndroid.pauseReplay(); + hub.getOptions().getReplayController().pause(); scheduleEndSession(); AppState.getInstance().setInBackground(true); @@ -127,7 +127,7 @@ public void run() { addSessionBreadcrumb("end"); hub.endSession(); } - SentryAndroid.stopReplay(); + hub.getOptions().getReplayController().stop(); } }; diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java index d6e11d15f54..676bb2173a8 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java @@ -15,8 +15,6 @@ import io.sentry.android.core.performance.AppStartMetrics; import io.sentry.android.core.performance.TimeSpan; import io.sentry.android.fragment.FragmentLifecycleIntegration; -import io.sentry.android.replay.ReplayIntegration; -import io.sentry.android.replay.ReplayIntegrationKt; import io.sentry.android.timber.SentryTimberIntegration; import java.lang.reflect.InvocationTargetException; import java.util.ArrayList; @@ -160,7 +158,7 @@ public static synchronized void init( hub.addBreadcrumb(BreadcrumbFactory.forSession("session.start")); hub.startSession(); } - startReplay(); + hub.getOptions().getReplayController().start(); } } catch (IllegalAccessException e) { logger.log(SentryLevel.FATAL, "Fatal error during SentryAndroid.init(...)", e); @@ -225,59 +223,4 @@ private static void deduplicateIntegrations( } } } - - public static synchronized void startReplay() { - if (!ensureReplayIntegration("starting")) { - return; - } - final @NotNull IHub hub = Sentry.getCurrentHub(); - ReplayIntegrationKt.getReplayIntegration(hub).start(); - } - - public static synchronized void stopReplay() { - if (!ensureReplayIntegration("stopping")) { - return; - } - final @NotNull IHub hub = Sentry.getCurrentHub(); - ReplayIntegrationKt.getReplayIntegration(hub).stop(); - } - - public static synchronized void resumeReplay() { - if (!ensureReplayIntegration("resuming")) { - return; - } - final @NotNull IHub hub = Sentry.getCurrentHub(); - ReplayIntegrationKt.getReplayIntegration(hub).resume(); - } - - public static synchronized void pauseReplay() { - if (!ensureReplayIntegration("pausing")) { - return; - } - final @NotNull IHub hub = Sentry.getCurrentHub(); - ReplayIntegrationKt.getReplayIntegration(hub).pause(); - } - - private static boolean ensureReplayIntegration(final @NotNull String actionName) { - final @NotNull IHub hub = Sentry.getCurrentHub(); - if (isReplayAvailable) { - final ReplayIntegration replay = ReplayIntegrationKt.getReplayIntegration(hub); - if (replay != null) { - return true; - } else { - hub.getOptions() - .getLogger() - .log( - SentryLevel.INFO, - "Session Replay wasn't registered yet, not " + actionName + " the replay"); - } - } else { - hub.getOptions() - .getLogger() - .log( - SentryLevel.INFO, - "Session Replay wasn't found on classpath, not " + actionName + " the replay"); - } - return false; - } } diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index b3bc98e15bf..82cfece004c 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -6,22 +6,17 @@ public final class io/sentry/android/replay/BuildConfig { public fun ()V } -public final class io/sentry/android/replay/ReplayIntegration : io/sentry/Integration, io/sentry/android/replay/ScreenshotRecorderCallback, java/io/Closeable { - public static final field Companion Lio/sentry/android/replay/ReplayIntegration$Companion; - public static final field VIDEO_BUFFER_DURATION J - public static final field VIDEO_SEGMENT_DURATION J +public final class io/sentry/android/replay/ReplayIntegration : io/sentry/Integration, io/sentry/ReplayController, io/sentry/android/replay/ScreenshotRecorderCallback, java/io/Closeable { public fun (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;)V public fun close ()V public final fun isRecording ()Z public fun onScreenshotRecorded (Landroid/graphics/Bitmap;)V - public final fun pause ()V + public fun pause ()V public fun register (Lio/sentry/IHub;Lio/sentry/SentryOptions;)V - public final fun resume ()V - public final fun start ()V - public final fun stop ()V -} - -public final class io/sentry/android/replay/ReplayIntegration$Companion { + public fun resume ()V + public fun sendReplayForEvent (Lio/sentry/SentryEvent;)V + public fun start ()V + public fun stop ()V } public final class io/sentry/android/replay/ReplayIntegrationKt { diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt index e591370444c..e271d8dd9af 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt @@ -113,13 +113,7 @@ internal class ReplayCache( encoder = null } - frames.removeAll { - if (it.timestamp < (from + duration)) { - deleteFile(it.screenshot) - return@removeAll true - } - return@removeAll false - } + rotate(until = (from + duration)) return GeneratedVideo(videoFile, frameCount, videoDuration) } @@ -142,6 +136,16 @@ internal class ReplayCache( } } + fun rotate(until: Long) { + frames.removeAll { + if (it.timestamp < until) { + deleteFile(it.screenshot) + return@removeAll true + } + return@removeAll false + } + } + override fun close() { synchronized(encoderLock) { encoder?.release() diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index a99c8a9c95f..da14c72bd43 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -7,18 +7,26 @@ import io.sentry.DateUtils import io.sentry.Hint import io.sentry.IHub import io.sentry.Integration +import io.sentry.ReplayController import io.sentry.ReplayRecording +import io.sentry.SentryEvent +import io.sentry.SentryIntegrationPackageStorage import io.sentry.SentryLevel.DEBUG import io.sentry.SentryLevel.INFO import io.sentry.SentryOptions import io.sentry.SentryReplayEvent +import io.sentry.SentryReplayEvent.ReplayType +import io.sentry.SentryReplayEvent.ReplayType.BUFFER +import io.sentry.SentryReplayEvent.ReplayType.SESSION import io.sentry.protocol.SentryId import io.sentry.rrweb.RRWebMetaEvent import io.sentry.rrweb.RRWebVideoEvent import io.sentry.transport.ICurrentDateProvider import io.sentry.util.FileUtils +import io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion import java.io.Closeable import java.io.File +import java.security.SecureRandom import java.util.Date import java.util.concurrent.ExecutorService import java.util.concurrent.Executors @@ -32,19 +40,16 @@ import kotlin.LazyThreadSafetyMode.NONE class ReplayIntegration( private val context: Context, private val dateProvider: ICurrentDateProvider -) : Integration, Closeable, ScreenshotRecorderCallback { - - companion object { - const val VIDEO_SEGMENT_DURATION = 5_000L - const val VIDEO_BUFFER_DURATION = 30_000L - } +) : Integration, Closeable, ScreenshotRecorderCallback, ReplayController { private lateinit var options: SentryOptions private var hub: IHub? = null private var recorder: WindowRecorder? = null private var cache: ReplayCache? = null + private val random by lazy { SecureRandom() } // TODO: probably not everything has to be thread-safe here + private val isFullSession = AtomicBoolean(false) private val isEnabled = AtomicBoolean(false) private val isRecording = AtomicBoolean(false) private val currentReplayId = AtomicReference() @@ -61,6 +66,13 @@ class ReplayIntegration( ) } + private fun sample(rate: Double?): Boolean { + if (rate != null) { + return !(rate < random.nextDouble()) // bad luck + } + return false + } + override fun register(hub: IHub, options: SentryOptions) { this.options = options @@ -69,16 +81,26 @@ class ReplayIntegration( return } - // TODO: check for replaysSessionSampleRate and replaysOnErrorSampleRate + if (!options._experimental.replayOptions.isSessionReplayEnabled && + !options._experimental.replayOptions.isSessionReplayForErrorsEnabled + ) { + options.logger.log(INFO, "Session replay is disabled, no sample rate specified") + return + } this.hub = hub recorder = WindowRecorder(options, recorderConfig, this) isEnabled.set(true) + isFullSession.set(sample(options._experimental.replayOptions.sessionSampleRate)) + + addIntegrationToSdkVersion(javaClass) + SentryIntegrationPackageStorage.getInstance() + .addPackage("maven:io.sentry:sentry-android-replay", BuildConfig.VERSION_NAME) } fun isRecording() = isRecording.get() - fun start() { + override fun start() { if (!isEnabled.get()) { options.logger.log( DEBUG, @@ -97,7 +119,11 @@ class ReplayIntegration( currentSegment.set(0) currentReplayId.set(SentryId()) - hub?.configureScope { it.replayId = currentReplayId.get() } + if (isFullSession.get()) { + // only set replayId on the scope if it's a full session, otherwise all events will be + // tagged with the replay that might never be sent when we're recording in buffer mode + hub?.configureScope { it.replayId = currentReplayId.get() } + } cache = ReplayCache(options, currentReplayId.get(), recorderConfig) recorder?.startRecording() @@ -105,29 +131,76 @@ class ReplayIntegration( // TODO: finalize old recording if there's some left on disk and send it using the replayId from persisted scope (e.g. for ANRs) } - fun resume() { + override fun resume() { segmentTimestamp.set(DateUtils.getCurrentDateTime()) recorder?.resume() } - fun pause() { + override fun sendReplayForEvent(event: SentryEvent) { + if (isFullSession.get()) { + options.logger.log(DEBUG, "Replay is already running in 'session' mode, not capturing for event %s", event.eventId) + return + } + + if (!(event.isErrored || event.isCrashed)) { + options.logger.log(DEBUG, "Event is not error or crash, not capturing for event %s", event.eventId) + return + } + + if (!sample(options._experimental.replayOptions.errorSampleRate)) { + options.logger.log(INFO, "Replay wasn't sampled by errorSampleRate, not capturing for event %s", event.eventId) + return + } + + val errorReplayDuration = options._experimental.replayOptions.errorReplayDuration + val now = dateProvider.currentTimeMillis + val currentSegmentTimestamp = if (cache?.frames?.isNotEmpty() == true) { + // in buffer mode we have to set the timestamp of the first frame as the actual start + DateUtils.getDateTime(cache!!.frames.first().timestamp) + } else { + DateUtils.getDateTime(now - errorReplayDuration) + } + val segmentId = currentSegment.get() + val replayId = currentReplayId.get() + saver.submit { + val videoDuration = + createAndCaptureSegment(now - currentSegmentTimestamp.time, currentSegmentTimestamp, replayId, segmentId, BUFFER) + if (videoDuration != null) { + currentSegment.getAndIncrement() + } + // since we're switching to session mode, even if the video is not sent for an error + // we still set the timestamp to now, because session is technically started "now" + segmentTimestamp.set(DateUtils.getDateTime(now)) + } + + hub?.configureScope { it.replayId = currentReplayId.get() } + // don't ask me why + event.setTag("replayId", currentReplayId.get().toString()) + isFullSession.set(true) + } + + override fun pause() { val now = dateProvider.currentTimeMillis recorder?.pause() + if (!isFullSession.get()) { + return + } + val currentSegmentTimestamp = segmentTimestamp.get() val segmentId = currentSegment.get() val duration = now - currentSegmentTimestamp.time val replayId = currentReplayId.get() saver.submit { val videoDuration = - createAndCaptureSegment(duration, currentSegmentTimestamp, replayId, segmentId) + createAndCaptureSegment(duration, currentSegmentTimestamp, replayId, segmentId, SESSION) if (videoDuration != null) { currentSegment.getAndIncrement() } } } - fun stop() { + override fun stop() { if (!isEnabled.get()) { options.logger.log( DEBUG, @@ -143,7 +216,10 @@ class ReplayIntegration( val replayId = currentReplayId.get() val replayCacheDir = cache?.replayCacheDir saver.submit { - createAndCaptureSegment(duration, currentSegmentTimestamp, replayId, segmentId) + // we don't flush the segment, but we still wanna clean up the folder for buffer mode + if (isFullSession.get()) { + createAndCaptureSegment(duration, currentSegmentTimestamp, replayId, segmentId, SESSION) + } FileUtils.deleteRecursively(replayCacheDir) } @@ -164,23 +240,28 @@ class ReplayIntegration( cache?.addFrame(bitmap, frameTimestamp) val now = dateProvider.currentTimeMillis - if (now - segmentTimestamp.get().time >= VIDEO_SEGMENT_DURATION) { + if (isFullSession.get() && + (now - segmentTimestamp.get().time >= options._experimental.replayOptions.sessionSegmentDuration) + ) { val currentSegmentTimestamp = segmentTimestamp.get() val segmentId = currentSegment.get() val replayId = currentReplayId.get() val videoDuration = createAndCaptureSegment( - VIDEO_SEGMENT_DURATION, + options._experimental.replayOptions.sessionSegmentDuration, currentSegmentTimestamp, replayId, - segmentId + segmentId, + SESSION ) if (videoDuration != null) { currentSegment.getAndIncrement() // set next segment timestamp as close to the previous one as possible to avoid gaps segmentTimestamp.set(DateUtils.getDateTime(currentSegmentTimestamp.time + videoDuration)) } + } else if (!isFullSession.get()) { + cache?.rotate(now - options._experimental.replayOptions.errorReplayDuration) } } } @@ -189,7 +270,8 @@ class ReplayIntegration( duration: Long, currentSegmentTimestamp: Date, replayId: SentryId, - segmentId: Int + segmentId: Int, + replayType: ReplayType ): Long? { val generatedVideo = cache?.createVideoOf( duration, @@ -204,7 +286,8 @@ class ReplayIntegration( currentSegmentTimestamp, segmentId, frameCount, - videoDuration + videoDuration, + replayType ) return videoDuration } @@ -215,7 +298,8 @@ class ReplayIntegration( segmentTimestamp: Date, segmentId: Int, frameCount: Int, - duration: Long + duration: Long, + replayType: ReplayType ) { val replay = SentryReplayEvent().apply { eventId = currentReplayId @@ -225,6 +309,7 @@ class ReplayIntegration( if (segmentId == 0) { replayStartTimestamp = segmentTimestamp } + this.replayType = replayType videoFile = video } diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index fcede26562f..f2ad30d938c 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -45,6 +45,7 @@ public final class io/sentry/Baggage { public fun getEnvironment ()Ljava/lang/String; public fun getPublicKey ()Ljava/lang/String; public fun getRelease ()Ljava/lang/String; + public fun getReplayId ()Ljava/lang/String; public fun getSampleRate ()Ljava/lang/String; public fun getSampleRateDouble ()Ljava/lang/Double; public fun getSampled ()Ljava/lang/String; @@ -59,6 +60,7 @@ public final class io/sentry/Baggage { public fun setEnvironment (Ljava/lang/String;)V public fun setPublicKey (Ljava/lang/String;)V public fun setRelease (Ljava/lang/String;)V + public fun setReplayId (Ljava/lang/String;)V public fun setSampleRate (Ljava/lang/String;)V public fun setSampled (Ljava/lang/String;)V public fun setTraceId (Ljava/lang/String;)V @@ -66,7 +68,7 @@ public final class io/sentry/Baggage { public fun setUserId (Ljava/lang/String;)V public fun setUserSegment (Ljava/lang/String;)V public fun setValuesFromScope (Lio/sentry/IScope;Lio/sentry/SentryOptions;)V - public fun setValuesFromTransaction (Lio/sentry/ITransaction;Lio/sentry/protocol/User;Lio/sentry/SentryOptions;Lio/sentry/TracesSamplingDecision;)V + public fun setValuesFromTransaction (Lio/sentry/ITransaction;Lio/sentry/protocol/User;Lio/sentry/protocol/SentryId;Lio/sentry/SentryOptions;Lio/sentry/TracesSamplingDecision;)V public fun toHeaderString (Ljava/lang/String;)Ljava/lang/String; public fun toTraceContext ()Lio/sentry/TraceContext; } @@ -76,6 +78,7 @@ public final class io/sentry/Baggage$DSCKeys { public static final field ENVIRONMENT Ljava/lang/String; public static final field PUBLIC_KEY Ljava/lang/String; public static final field RELEASE Ljava/lang/String; + public static final field REPLAY_ID Ljava/lang/String; public static final field SAMPLED Ljava/lang/String; public static final field SAMPLE_RATE Ljava/lang/String; public static final field TRACE_ID Ljava/lang/String; @@ -1201,6 +1204,15 @@ public final class io/sentry/NoOpLogger : io/sentry/ILogger { public fun log (Lio/sentry/SentryLevel;Ljava/lang/Throwable;Ljava/lang/String;[Ljava/lang/Object;)V } +public final class io/sentry/NoOpReplayController : io/sentry/ReplayController { + public static fun getInstance ()Lio/sentry/NoOpReplayController; + public fun pause ()V + public fun resume ()V + public fun sendReplayForEvent (Lio/sentry/SentryEvent;)V + public fun start ()V + public fun stop ()V +} + public final class io/sentry/NoOpScope : io/sentry/IScope { public fun addAttachment (Lio/sentry/Attachment;)V public fun addBreadcrumb (Lio/sentry/Breadcrumb;)V @@ -1591,6 +1603,14 @@ public final class io/sentry/PropagationContext { public fun traceContext ()Lio/sentry/TraceContext; } +public abstract interface class io/sentry/ReplayController { + public abstract fun pause ()V + public abstract fun resume ()V + public abstract fun sendReplayForEvent (Lio/sentry/SentryEvent;)V + public abstract fun start ()V + public abstract fun stop ()V +} + public final class io/sentry/ReplayRecording : io/sentry/JsonSerializable, io/sentry/JsonUnknown { public fun ()V public fun equals (Ljava/lang/Object;)Z @@ -2300,6 +2320,7 @@ public class io/sentry/SentryOptions { public fun getProxy ()Lio/sentry/SentryOptions$Proxy; public fun getReadTimeoutMillis ()I public fun getRelease ()Ljava/lang/String; + public fun getReplayController ()Lio/sentry/ReplayController; public fun getSampleRate ()Ljava/lang/Double; public fun getScopeObservers ()Ljava/util/List; public fun getSdkVersion ()Lio/sentry/protocol/SdkVersion; @@ -2406,6 +2427,7 @@ public class io/sentry/SentryOptions { public fun setProxy (Lio/sentry/SentryOptions$Proxy;)V public fun setReadTimeoutMillis (I)V public fun setRelease (Ljava/lang/String;)V + public fun setReplayController (Lio/sentry/ReplayController;)V public fun setSampleRate (Ljava/lang/Double;)V public fun setSdkVersion (Lio/sentry/protocol/SdkVersion;)V public fun setSendClientReports (Z)V @@ -2549,6 +2571,8 @@ public final class io/sentry/SentryReplayOptions { public fun getFrameRate ()I public fun getSessionSampleRate ()Ljava/lang/Double; public fun getSessionSegmentDuration ()J + public fun isSessionReplayEnabled ()Z + public fun isSessionReplayForErrorsEnabled ()Z public fun setErrorSampleRate (Ljava/lang/Double;)V public fun setSessionSampleRate (Ljava/lang/Double;)V } @@ -2899,6 +2923,7 @@ public final class io/sentry/TraceContext : io/sentry/JsonSerializable, io/sentr public fun getEnvironment ()Ljava/lang/String; public fun getPublicKey ()Ljava/lang/String; public fun getRelease ()Ljava/lang/String; + public fun getReplayId ()Lio/sentry/protocol/SentryId; public fun getSampleRate ()Ljava/lang/String; public fun getSampled ()Ljava/lang/String; public fun getTraceId ()Lio/sentry/protocol/SentryId; @@ -2920,6 +2945,7 @@ public final class io/sentry/TraceContext$JsonKeys { public static final field ENVIRONMENT Ljava/lang/String; public static final field PUBLIC_KEY Ljava/lang/String; public static final field RELEASE Ljava/lang/String; + public static final field REPLAY_ID Ljava/lang/String; public static final field SAMPLED Ljava/lang/String; public static final field SAMPLE_RATE Ljava/lang/String; public static final field TRACE_ID Ljava/lang/String; diff --git a/sentry/src/main/java/io/sentry/Baggage.java b/sentry/src/main/java/io/sentry/Baggage.java index 8e19fceaf84..c6a1ee56301 100644 --- a/sentry/src/main/java/io/sentry/Baggage.java +++ b/sentry/src/main/java/io/sentry/Baggage.java @@ -141,6 +141,7 @@ public static Baggage fromEvent( // we don't persist sample rate baggage.setSampleRate(null); baggage.setSampled(null); + // TODO: add replay_id later baggage.freeze(); return baggage; } @@ -345,6 +346,16 @@ public void setSampled(final @Nullable String sampled) { set(DSCKeys.SAMPLED, sampled); } + @ApiStatus.Internal + public @Nullable String getReplayId() { + return get(DSCKeys.REPLAY_ID); + } + + @ApiStatus.Internal + public void setReplayId(final @Nullable String replayId) { + set(DSCKeys.REPLAY_ID, replayId); + } + @ApiStatus.Internal public void set(final @NotNull String key, final @Nullable String value) { if (mutable) { @@ -373,6 +384,7 @@ public void set(final @NotNull String key, final @Nullable String value) { public void setValuesFromTransaction( final @NotNull ITransaction transaction, final @Nullable User user, + final @Nullable SentryId replayId, final @NotNull SentryOptions sentryOptions, final @Nullable TracesSamplingDecision samplingDecision) { setTraceId(transaction.getSpanContext().getTraceId().toString()); @@ -384,6 +396,9 @@ public void setValuesFromTransaction( isHighQualityTransactionName(transaction.getTransactionNameSource()) ? transaction.getName() : null); + if (replayId != null) { + setReplayId(replayId.toString()); + } setSampleRate(sampleRateToString(sampleRate(samplingDecision))); setSampled(StringUtils.toString(sampled(samplingDecision))); } @@ -397,6 +412,10 @@ public void setValuesFromScope( setPublicKey(new Dsn(options.getDsn()).getPublicKey()); setRelease(options.getRelease()); setEnvironment(options.getEnvironment()); + final @Nullable SentryId replayId = scope.getReplayId(); + if (replayId != null) { + setReplayId(replayId.toString()); + } setUserSegment(user != null ? getSegment(user) : null); setTransaction(null); setSampleRate(null); @@ -468,6 +487,7 @@ private static boolean isHighQualityTransactionName( @Nullable public TraceContext toTraceContext() { final String traceIdString = getTraceId(); + final String replayIdString = getReplayId(); final String publicKey = getPublicKey(); if (traceIdString != null && publicKey != null) { @@ -481,7 +501,8 @@ public TraceContext toTraceContext() { getUserSegment(), getTransaction(), getSampleRate(), - getSampled()); + getSampled(), + replayIdString == null ? null : new SentryId(replayIdString)); traceContext.setUnknown(getUnknown()); return traceContext; } else { @@ -500,6 +521,7 @@ public static final class DSCKeys { public static final String TRANSACTION = "sentry-transaction"; public static final String SAMPLE_RATE = "sentry-sample_rate"; public static final String SAMPLED = "sentry-sampled"; + public static final String REPLAY_ID = "sentry-replay_id"; public static final List ALL = Arrays.asList( @@ -511,6 +533,7 @@ public static final class DSCKeys { USER_SEGMENT, TRANSACTION, SAMPLE_RATE, - SAMPLED); + SAMPLED, + REPLAY_ID); } } diff --git a/sentry/src/main/java/io/sentry/NoOpReplayController.java b/sentry/src/main/java/io/sentry/NoOpReplayController.java new file mode 100644 index 00000000000..0a11e71423d --- /dev/null +++ b/sentry/src/main/java/io/sentry/NoOpReplayController.java @@ -0,0 +1,29 @@ +package io.sentry; + +import org.jetbrains.annotations.NotNull; + +public final class NoOpReplayController implements ReplayController { + + private static final NoOpReplayController instance = new NoOpReplayController(); + + public static NoOpReplayController getInstance() { + return instance; + } + + private NoOpReplayController() {} + + @Override + public void start() {} + + @Override + public void stop() {} + + @Override + public void pause() {} + + @Override + public void resume() {} + + @Override + public void sendReplayForEvent(@NotNull SentryEvent event) {} +} diff --git a/sentry/src/main/java/io/sentry/ReplayController.java b/sentry/src/main/java/io/sentry/ReplayController.java new file mode 100644 index 00000000000..2ccc28cb826 --- /dev/null +++ b/sentry/src/main/java/io/sentry/ReplayController.java @@ -0,0 +1,17 @@ +package io.sentry; + +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +@ApiStatus.Internal +public interface ReplayController { + void start(); + + void stop(); + + void pause(); + + void resume(); + + void sendReplayForEvent(@NotNull SentryEvent event); +} diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index 129d415a8ac..a6c3c4f0d4d 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -191,6 +191,10 @@ private boolean shouldApplyScopeData(final @NotNull CheckIn event, final @NotNul sentryId = event.getEventId(); } + if (event != null) { + options.getReplayController().sendReplayForEvent(event); + } + try { @Nullable TraceContext traceContext = null; if (HintUtils.hasType(hint, Backfillable.class)) { @@ -227,23 +231,42 @@ private boolean shouldApplyScopeData(final @NotNull CheckIn event, final @NotNul } // if we encountered a crash/abnormal exit finish tracing in order to persist and send - // any running transaction / profiling data + // any running transaction / profiling data. We also finish session replay, and it has priority + // over transactions as it takes longer to finalize replay than transactions, therefore + // the replay_id will be the trigger for flushing and unblocking the thread in case of a crash if (scope != null) { - final @Nullable ITransaction transaction = scope.getTransaction(); - if (transaction != null) { - if (HintUtils.hasType(hint, TransactionEnd.class)) { - final Object sentrySdkHint = HintUtils.getSentrySdkHint(hint); - if (sentrySdkHint instanceof DiskFlushNotification) { - ((DiskFlushNotification) sentrySdkHint).setFlushable(transaction.getEventId()); - transaction.forceFinish(SpanStatus.ABORTED, false, hint); - } else { - transaction.forceFinish(SpanStatus.ABORTED, false, null); - } + finalizeTransaction(scope, hint); + finalizeReplay(scope, hint); + } + + return sentryId; + } + + private void finalizeTransaction(final @NotNull IScope scope, final @NotNull Hint hint) { + final @Nullable ITransaction transaction = scope.getTransaction(); + if (transaction != null) { + if (HintUtils.hasType(hint, TransactionEnd.class)) { + final Object sentrySdkHint = HintUtils.getSentrySdkHint(hint); + if (sentrySdkHint instanceof DiskFlushNotification) { + ((DiskFlushNotification) sentrySdkHint).setFlushable(transaction.getEventId()); + transaction.forceFinish(SpanStatus.ABORTED, false, hint); + } else { + transaction.forceFinish(SpanStatus.ABORTED, false, null); } } } + } - return sentryId; + private void finalizeReplay(final @NotNull IScope scope, final @NotNull Hint hint) { + final @Nullable SentryId replayId = scope.getReplayId(); + if (replayId != null) { + if (HintUtils.hasType(hint, TransactionEnd.class)) { + final Object sentrySdkHint = HintUtils.getSentrySdkHint(hint); + if (sentrySdkHint instanceof DiskFlushNotification) { + ((DiskFlushNotification) sentrySdkHint).setFlushable(replayId); + } + } + } } @Override diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index 026f2c9e903..50f724591a8 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -462,6 +462,8 @@ public class SentryOptions { private final @NotNull ExperimentalOptions _experimental = new ExperimentalOptions(); + private @NotNull ReplayController replayController = NoOpReplayController.getInstance(); + /** * Adds an event processor * @@ -2281,6 +2283,15 @@ public ExperimentalOptions get_experimental() { return _experimental; } + public @NotNull ReplayController getReplayController() { + return replayController; + } + + public void setReplayController(final @Nullable ReplayController replayController) { + this.replayController = + replayController != null ? replayController : NoOpReplayController.getInstance(); + } + /** The BeforeSend callback */ public interface BeforeSendCallback { diff --git a/sentry/src/main/java/io/sentry/SentryReplayOptions.java b/sentry/src/main/java/io/sentry/SentryReplayOptions.java index df98fc384f4..d702d6256b8 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayOptions.java +++ b/sentry/src/main/java/io/sentry/SentryReplayOptions.java @@ -51,6 +51,10 @@ public Double getErrorSampleRate() { return errorSampleRate; } + public boolean isSessionReplayEnabled() { + return (getSessionSampleRate() != null && getSessionSampleRate() > 0); + } + public void setErrorSampleRate(final @Nullable Double errorSampleRate) { if (!SampleRateUtils.isValidSampleRate(errorSampleRate)) { throw new IllegalArgumentException( @@ -66,6 +70,10 @@ public Double getSessionSampleRate() { return sessionSampleRate; } + public boolean isSessionReplayForErrorsEnabled() { + return (getErrorSampleRate() != null && getErrorSampleRate() > 0); + } + public void setSessionSampleRate(final @Nullable Double sessionSampleRate) { if (!SampleRateUtils.isValidSampleRate(sessionSampleRate)) { throw new IllegalArgumentException( diff --git a/sentry/src/main/java/io/sentry/SentryTracer.java b/sentry/src/main/java/io/sentry/SentryTracer.java index 8c1536cbbfe..320f79680b0 100644 --- a/sentry/src/main/java/io/sentry/SentryTracer.java +++ b/sentry/src/main/java/io/sentry/SentryTracer.java @@ -582,12 +582,18 @@ private void updateBaggageValues() { synchronized (this) { if (baggage.isMutable()) { final AtomicReference userAtomicReference = new AtomicReference<>(); + final AtomicReference replayId = new AtomicReference<>(); hub.configureScope( scope -> { userAtomicReference.set(scope.getUser()); + replayId.set(scope.getReplayId()); }); baggage.setValuesFromTransaction( - this, userAtomicReference.get(), hub.getOptions(), this.getSamplingDecision()); + this, + userAtomicReference.get(), + replayId.get(), + hub.getOptions(), + this.getSamplingDecision()); baggage.freeze(); } } diff --git a/sentry/src/main/java/io/sentry/TraceContext.java b/sentry/src/main/java/io/sentry/TraceContext.java index df799aaa07b..da34382d512 100644 --- a/sentry/src/main/java/io/sentry/TraceContext.java +++ b/sentry/src/main/java/io/sentry/TraceContext.java @@ -21,12 +21,13 @@ public final class TraceContext implements JsonUnknown, JsonSerializable { private final @Nullable String transaction; private final @Nullable String sampleRate; private final @Nullable String sampled; + private final @Nullable SentryId replayId; @SuppressWarnings("unused") private @Nullable Map unknown; TraceContext(@NotNull SentryId traceId, @NotNull String publicKey) { - this(traceId, publicKey, null, null, null, null, null, null, null); + this(traceId, publicKey, null, null, null, null, null, null, null, null); } TraceContext( @@ -38,7 +39,8 @@ public final class TraceContext implements JsonUnknown, JsonSerializable { @Nullable String userSegment, @Nullable String transaction, @Nullable String sampleRate, - @Nullable String sampled) { + @Nullable String sampled, + @Nullable SentryId replayId) { this.traceId = traceId; this.publicKey = publicKey; this.release = release; @@ -48,6 +50,7 @@ public final class TraceContext implements JsonUnknown, JsonSerializable { this.transaction = transaction; this.sampleRate = sampleRate; this.sampled = sampled; + this.replayId = replayId; } @SuppressWarnings("UnusedMethod") @@ -96,6 +99,10 @@ public final class TraceContext implements JsonUnknown, JsonSerializable { return sampled; } + public @Nullable SentryId getReplayId() { + return replayId; + } + /** * @deprecated only here to support parsing legacy JSON with non flattened user */ @@ -198,6 +205,7 @@ public static final class JsonKeys { public static final String TRANSACTION = "transaction"; public static final String SAMPLE_RATE = "sample_rate"; public static final String SAMPLED = "sampled"; + public static final String REPLAY_ID = "replay_id"; } @Override @@ -227,6 +235,9 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger if (sampled != null) { writer.name(TraceContext.JsonKeys.SAMPLED).value(sampled); } + if (replayId != null) { + writer.name(TraceContext.JsonKeys.REPLAY_ID).value(logger, replayId); + } if (unknown != null) { for (String key : unknown.keySet()) { Object value = unknown.get(key); @@ -253,6 +264,7 @@ public static final class Deserializer implements JsonDeserializer String transaction = null; String sampleRate = null; String sampled = null; + SentryId replayId = null; Map unknown = null; while (reader.peek() == JsonToken.NAME) { @@ -288,6 +300,9 @@ public static final class Deserializer implements JsonDeserializer case TraceContext.JsonKeys.SAMPLED: sampled = reader.nextStringOrNull(); break; + case TraceContext.JsonKeys.REPLAY_ID: + replayId = new SentryId.Deserializer().deserialize(reader, logger); + break; default: if (unknown == null) { unknown = new ConcurrentHashMap<>(); @@ -320,7 +335,8 @@ public static final class Deserializer implements JsonDeserializer userSegment, transaction, sampleRate, - sampled); + sampled, + replayId); traceContext.setUnknown(unknown); reader.endObject(); return traceContext; diff --git a/sentry/src/test/java/io/sentry/TraceContextSerializationTest.kt b/sentry/src/test/java/io/sentry/TraceContextSerializationTest.kt index e79e5ebf8c5..f2a674d554b 100644 --- a/sentry/src/test/java/io/sentry/TraceContextSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/TraceContextSerializationTest.kt @@ -62,6 +62,7 @@ class TraceContextSerializationTest { id = "user-id" others = mapOf("segment" to "pro") }, + SentryId(), SentryOptions().apply { dsn = dsnString environment = "prod" From f7ac74f08c79c67b4fcfb22a9ed565d26a5fb413 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 28 Mar 2024 12:32:53 +0100 Subject: [PATCH 48/89] Pass hint to captureReplay --- .../api/sentry-android-replay.api | 2 +- .../android/replay/ReplayIntegration.kt | 23 ++++++++++--------- sentry/api/sentry.api | 4 ++-- .../java/io/sentry/NoOpReplayController.java | 2 +- .../main/java/io/sentry/ReplayController.java | 2 +- .../src/main/java/io/sentry/SentryClient.java | 2 +- 6 files changed, 18 insertions(+), 17 deletions(-) diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index 82cfece004c..1e107ca4954 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -14,7 +14,7 @@ public final class io/sentry/android/replay/ReplayIntegration : io/sentry/Integr public fun pause ()V public fun register (Lio/sentry/IHub;Lio/sentry/SentryOptions;)V public fun resume ()V - public fun sendReplayForEvent (Lio/sentry/SentryEvent;)V + public fun sendReplayForEvent (Lio/sentry/SentryEvent;Lio/sentry/Hint;)V public fun start ()V public fun stop ()V } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index da14c72bd43..bedc8bab812 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -136,7 +136,7 @@ class ReplayIntegration( recorder?.resume() } - override fun sendReplayForEvent(event: SentryEvent) { + override fun sendReplayForEvent(event: SentryEvent, hint: Hint) { if (isFullSession.get()) { options.logger.log(DEBUG, "Replay is already running in 'session' mode, not capturing for event %s", event.eventId) return @@ -164,7 +164,7 @@ class ReplayIntegration( val replayId = currentReplayId.get() saver.submit { val videoDuration = - createAndCaptureSegment(now - currentSegmentTimestamp.time, currentSegmentTimestamp, replayId, segmentId, BUFFER) + createAndCaptureSegment(now - currentSegmentTimestamp.time, currentSegmentTimestamp, replayId, segmentId, BUFFER, hint) if (videoDuration != null) { currentSegment.getAndIncrement() } @@ -193,7 +193,7 @@ class ReplayIntegration( val replayId = currentReplayId.get() saver.submit { val videoDuration = - createAndCaptureSegment(duration, currentSegmentTimestamp, replayId, segmentId, SESSION) + createAndCaptureSegment(duration, currentSegmentTimestamp, replayId, segmentId) if (videoDuration != null) { currentSegment.getAndIncrement() } @@ -218,7 +218,7 @@ class ReplayIntegration( saver.submit { // we don't flush the segment, but we still wanna clean up the folder for buffer mode if (isFullSession.get()) { - createAndCaptureSegment(duration, currentSegmentTimestamp, replayId, segmentId, SESSION) + createAndCaptureSegment(duration, currentSegmentTimestamp, replayId, segmentId) } FileUtils.deleteRecursively(replayCacheDir) } @@ -252,8 +252,7 @@ class ReplayIntegration( options._experimental.replayOptions.sessionSegmentDuration, currentSegmentTimestamp, replayId, - segmentId, - SESSION + segmentId ) if (videoDuration != null) { currentSegment.getAndIncrement() @@ -271,7 +270,8 @@ class ReplayIntegration( currentSegmentTimestamp: Date, replayId: SentryId, segmentId: Int, - replayType: ReplayType + replayType: ReplayType = SESSION, + hint: Hint? = null ): Long? { val generatedVideo = cache?.createVideoOf( duration, @@ -287,7 +287,8 @@ class ReplayIntegration( segmentId, frameCount, videoDuration, - replayType + replayType, + hint ) return videoDuration } @@ -299,7 +300,8 @@ class ReplayIntegration( segmentId: Int, frameCount: Int, duration: Long, - replayType: ReplayType + replayType: ReplayType, + hint: Hint? = null ) { val replay = SentryReplayEvent().apply { eventId = currentReplayId @@ -337,8 +339,7 @@ class ReplayIntegration( ) } - val hint = Hint().apply { replayRecording = recording } - hub?.captureReplay(replay, hint) + hub?.captureReplay(replay, (hint ?: Hint()).apply { replayRecording = recording }) } override fun close() { diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index f2ad30d938c..28ffd718852 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -1208,7 +1208,7 @@ public final class io/sentry/NoOpReplayController : io/sentry/ReplayController { public static fun getInstance ()Lio/sentry/NoOpReplayController; public fun pause ()V public fun resume ()V - public fun sendReplayForEvent (Lio/sentry/SentryEvent;)V + public fun sendReplayForEvent (Lio/sentry/SentryEvent;Lio/sentry/Hint;)V public fun start ()V public fun stop ()V } @@ -1606,7 +1606,7 @@ public final class io/sentry/PropagationContext { public abstract interface class io/sentry/ReplayController { public abstract fun pause ()V public abstract fun resume ()V - public abstract fun sendReplayForEvent (Lio/sentry/SentryEvent;)V + public abstract fun sendReplayForEvent (Lio/sentry/SentryEvent;Lio/sentry/Hint;)V public abstract fun start ()V public abstract fun stop ()V } diff --git a/sentry/src/main/java/io/sentry/NoOpReplayController.java b/sentry/src/main/java/io/sentry/NoOpReplayController.java index 0a11e71423d..d052fba8b48 100644 --- a/sentry/src/main/java/io/sentry/NoOpReplayController.java +++ b/sentry/src/main/java/io/sentry/NoOpReplayController.java @@ -25,5 +25,5 @@ public void pause() {} public void resume() {} @Override - public void sendReplayForEvent(@NotNull SentryEvent event) {} + public void sendReplayForEvent(@NotNull SentryEvent event, @NotNull Hint hint) {} } diff --git a/sentry/src/main/java/io/sentry/ReplayController.java b/sentry/src/main/java/io/sentry/ReplayController.java index 2ccc28cb826..a45a0ecda2c 100644 --- a/sentry/src/main/java/io/sentry/ReplayController.java +++ b/sentry/src/main/java/io/sentry/ReplayController.java @@ -13,5 +13,5 @@ public interface ReplayController { void resume(); - void sendReplayForEvent(@NotNull SentryEvent event); + void sendReplayForEvent(@NotNull SentryEvent event, @NotNull Hint hint); } diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index a6c3c4f0d4d..a7af22a6155 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -192,7 +192,7 @@ private boolean shouldApplyScopeData(final @NotNull CheckIn event, final @NotNul } if (event != null) { - options.getReplayController().sendReplayForEvent(event); + options.getReplayController().sendReplayForEvent(event, hint); } try { From 5faeb4ef0594c501af51bcb54d9147c3ff2ae0c2 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 28 Mar 2024 14:00:37 +0100 Subject: [PATCH 49/89] Better error handling --- .../android/replay/ScreenshotRecorder.kt | 118 ++++++++++-------- 1 file changed, 67 insertions(+), 51 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt index 87915f8505a..df161551a4e 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -17,6 +17,7 @@ import android.view.ViewGroup import android.view.ViewTreeObserver import io.sentry.SentryLevel.DEBUG import io.sentry.SentryLevel.INFO +import io.sentry.SentryLevel.WARNING import io.sentry.SentryOptions import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode import java.lang.ref.WeakReference @@ -72,7 +73,12 @@ internal class ScreenshotRecorder( return } - val window = root.phoneWindow ?: return + val window = root.phoneWindow + if (window == null) { + options.logger.log(DEBUG, "Window is invalid, not capturing screenshot") + return + } + val bitmap = Bitmap.createBitmap( root.width, root.height, @@ -88,59 +94,69 @@ internal class ScreenshotRecorder( } options.logger.log(DEBUG, "Took %d ms to capture view hierarchy", time) - PixelCopy.request( - window, - bitmap, - { copyResult: Int -> - if (copyResult != PixelCopy.SUCCESS) { - options.logger.log(INFO, "Failed to capture replay recording: %d", copyResult) - return@request - } - - val viewHierarchy = bitmapToVH[bitmap] - val scaledBitmap: Bitmap - - if (viewHierarchy == null) { - options.logger.log(INFO, "Failed to determine view hierarchy, not capturing") - return@request - } else { - scaledBitmap = Bitmap.createScaledBitmap( - bitmap, - config.recordingWidth, - config.recordingHeight, - true - ) - val canvas = Canvas(scaledBitmap) - canvas.setMatrix(prescaledMatrix) - viewHierarchy.traverse { - if (it.shouldRedact && (it.width > 0 && it.height > 0)) { - it.visibleRect ?: return@traverse - - // TODO: check for view type rather than rely on absence of dominantColor here - val color = if (it.dominantColor == null) { - singlePixelBitmapCanvas.drawBitmap(bitmap, it.visibleRect, Rect(0, 0, 1, 1), null) - singlePixelBitmap.getPixel(0, 0) - } else { - it.dominantColor - } + try { + PixelCopy.request( + window, + bitmap, + { copyResult: Int -> + if (copyResult != PixelCopy.SUCCESS) { + options.logger.log(INFO, "Failed to capture replay recording: %d", copyResult) + bitmap.recycle() + bitmapToVH.remove(bitmap) + return@request + } - maskingPaint.setColor(color) - canvas.drawRoundRect(RectF(it.visibleRect), 10f, 10f, maskingPaint) + val viewHierarchy = bitmapToVH[bitmap] + val scaledBitmap: Bitmap + + if (viewHierarchy == null) { + options.logger.log(INFO, "Failed to determine view hierarchy, not capturing") + bitmap.recycle() + bitmapToVH.remove(bitmap) + return@request + } else { + scaledBitmap = Bitmap.createScaledBitmap( + bitmap, + config.recordingWidth, + config.recordingHeight, + true + ) + val canvas = Canvas(scaledBitmap) + canvas.setMatrix(prescaledMatrix) + viewHierarchy.traverse { + if (it.shouldRedact && (it.width > 0 && it.height > 0)) { + it.visibleRect ?: return@traverse + + // TODO: check for view type rather than rely on absence of dominantColor here + val color = if (it.dominantColor == null) { + singlePixelBitmapCanvas.drawBitmap(bitmap, it.visibleRect, Rect(0, 0, 1, 1), null) + singlePixelBitmap.getPixel(0, 0) + } else { + it.dominantColor + } + + maskingPaint.setColor(color) + canvas.drawRoundRect(RectF(it.visibleRect), 10f, 10f, maskingPaint) + } } } - } - - val screenshot = scaledBitmap.copy(ARGB_8888, false) - screenshotRecorderCallback.onScreenshotRecorded(screenshot) - lastScreenshot = screenshot - contentChanged.set(false) - - scaledBitmap.recycle() - bitmap.recycle() - bitmapToVH.remove(bitmap) - }, - handler - ) + + val screenshot = scaledBitmap.copy(ARGB_8888, false) + screenshotRecorderCallback.onScreenshotRecorded(screenshot) + lastScreenshot = screenshot + contentChanged.set(false) + + scaledBitmap.recycle() + bitmap.recycle() + bitmapToVH.remove(bitmap) + }, + handler + ) + } catch (e: Throwable) { + options.logger.log(WARNING, "Failed to capture replay recording", e) + bitmap.recycle() + bitmapToVH.remove(bitmap) + } } } From 65d35ececa849a9a268431632b7555a3f183b8fa Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 28 Mar 2024 14:03:01 +0100 Subject: [PATCH 50/89] recycler lastScreenshot before re-assigning --- .../src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt index df161551a4e..66f1ede82d3 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -143,6 +143,7 @@ internal class ScreenshotRecorder( val screenshot = scaledBitmap.copy(ARGB_8888, false) screenshotRecorderCallback.onScreenshotRecorded(screenshot) + lastScreenshot?.recycle() lastScreenshot = screenshot contentChanged.set(false) From 0f4e718fc20c2001efed3ac4ec1f1947f4366a46 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 28 Mar 2024 16:59:32 +0100 Subject: [PATCH 51/89] Expose ReplayCache as public api --- .../api/sentry-android-replay.api | 41 +++++++++++++++++++ .../io/sentry/android/replay/ReplayCache.kt | 32 +++++++++++---- .../android/replay/ScreenshotRecorder.kt | 2 +- 3 files changed, 65 insertions(+), 10 deletions(-) diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index b3bc98e15bf..7f45c6d8f73 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -6,6 +6,29 @@ public final class io/sentry/android/replay/BuildConfig { public fun ()V } +public final class io/sentry/android/replay/GeneratedVideo { + public fun (Ljava/io/File;IJ)V + public final fun component1 ()Ljava/io/File; + public final fun component2 ()I + public final fun component3 ()J + public final fun copy (Ljava/io/File;IJ)Lio/sentry/android/replay/GeneratedVideo; + public static synthetic fun copy$default (Lio/sentry/android/replay/GeneratedVideo;Ljava/io/File;IJILjava/lang/Object;)Lio/sentry/android/replay/GeneratedVideo; + public fun equals (Ljava/lang/Object;)Z + public final fun getDuration ()J + public final fun getFrameCount ()I + public final fun getVideo ()Ljava/io/File; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/sentry/android/replay/ReplayCache : java/io/Closeable { + public fun (Lio/sentry/SentryOptions;Lio/sentry/protocol/SentryId;Lio/sentry/android/replay/ScreenshotRecorderConfig;)V + public final fun addFrame (Ljava/io/File;J)V + public fun close ()V + public final fun createVideoOf (JJILjava/io/File;)Lio/sentry/android/replay/GeneratedVideo; + public static synthetic fun createVideoOf$default (Lio/sentry/android/replay/ReplayCache;JJILjava/io/File;ILjava/lang/Object;)Lio/sentry/android/replay/GeneratedVideo; +} + public final class io/sentry/android/replay/ReplayIntegration : io/sentry/Integration, io/sentry/android/replay/ScreenshotRecorderCallback, java/io/Closeable { public static final field Companion Lio/sentry/android/replay/ReplayIntegration$Companion; public static final field VIDEO_BUFFER_DURATION J @@ -33,6 +56,24 @@ public abstract interface class io/sentry/android/replay/ScreenshotRecorderCallb public abstract fun onScreenshotRecorded (Landroid/graphics/Bitmap;)V } +public final class io/sentry/android/replay/ScreenshotRecorderConfig { + public fun (IIFI)V + public synthetic fun (IIFIILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()I + public final fun component2 ()I + public final fun component3 ()F + public final fun component4 ()I + public final fun copy (IIFI)Lio/sentry/android/replay/ScreenshotRecorderConfig; + public static synthetic fun copy$default (Lio/sentry/android/replay/ScreenshotRecorderConfig;IIFIILjava/lang/Object;)Lio/sentry/android/replay/ScreenshotRecorderConfig; + public fun equals (Ljava/lang/Object;)Z + public final fun getFrameRate ()I + public final fun getRecordingHeight ()I + public final fun getRecordingWidth ()I + public final fun getScaleFactor ()F + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public abstract interface class io/sentry/android/replay/video/SimpleFrameMuxer { public abstract fun getVideoTime ()J public abstract fun isStarted ()Z diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt index da62dfb6b18..7be5e901dc7 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt @@ -13,11 +13,18 @@ import io.sentry.protocol.SentryId import java.io.Closeable import java.io.File -internal class ReplayCache( +public class ReplayCache internal constructor( private val options: SentryOptions, private val replayId: SentryId, private val recorderConfig: ScreenshotRecorderConfig, - private val encoderCreator: (File) -> SimpleVideoEncoder = { videoFile -> + private val encoderCreator: (File) -> SimpleVideoEncoder +) : Closeable { + + public constructor( + options: SentryOptions, + replayId: SentryId, + recorderConfig: ScreenshotRecorderConfig + ) : this(options, replayId, recorderConfig, encoderCreator = { videoFile -> SimpleVideoEncoder( options, MuxerConfig( @@ -27,11 +34,10 @@ internal class ReplayCache( bitrate = 20 * 1000 ) ).also { it.start() } - } -) : Closeable { + }) private val encoderLock = Any() - internal var encoder: SimpleVideoEncoder? = null + private var encoder: SimpleVideoEncoder? = null internal val replayCacheDir: File? by lazy { if (options.cacheDirPath.isNullOrEmpty()) { @@ -48,7 +54,7 @@ internal class ReplayCache( // TODO: maybe account for multi-threaded access internal val frames = mutableListOf() - fun addFrame(bitmap: Bitmap, frameTimestamp: Long) { + internal fun addFrame(bitmap: Bitmap, frameTimestamp: Long) { if (replayCacheDir == null) { return } @@ -61,11 +67,20 @@ internal class ReplayCache( it.flush() } + addFrame(screenshot, frameTimestamp) + } + + public fun addFrame(screenshot: File, frameTimestamp: Long) { val frame = ReplayFrame(screenshot, frameTimestamp) frames += frame } - fun createVideoOf(duration: Long, from: Long, segmentId: Int): GeneratedVideo? { + public fun createVideoOf( + duration: Long, + from: Long, + segmentId: Int, + videoFile: File = File(replayCacheDir, "$segmentId.mp4") + ): GeneratedVideo? { if (frames.isEmpty()) { options.logger.log( DEBUG, @@ -75,7 +90,6 @@ internal class ReplayCache( } // TODO: reuse instance of encoder and just change file path to create a different muxer - val videoFile = File(replayCacheDir, "$segmentId.mp4") encoder = synchronized(encoderLock) { encoderCreator(videoFile) } val step = 1000 / recorderConfig.frameRate.toLong() @@ -160,7 +174,7 @@ internal data class ReplayFrame( val timestamp: Long ) -internal data class GeneratedVideo( +public data class GeneratedVideo( val video: File, val frameCount: Int, val duration: Long diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt index 66f1ede82d3..16f2544918c 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -230,7 +230,7 @@ internal class ScreenshotRecorder( } } -internal data class ScreenshotRecorderConfig( +public data class ScreenshotRecorderConfig( val recordingWidth: Int, val recordingHeight: Int, val scaleFactor: Float, From fd6e6333944bf921fd056ef00fb568688fd806ce Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 28 Mar 2024 19:40:30 +0100 Subject: [PATCH 52/89] Fix redacting out of sync --- .../android/replay/ScreenshotRecorder.kt | 33 ++++++++++--------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt index 16f2544918c..b403ceb4fa4 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -21,8 +21,8 @@ import io.sentry.SentryLevel.WARNING import io.sentry.SentryOptions import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode import java.lang.ref.WeakReference -import java.util.WeakHashMap import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference import kotlin.system.measureTimeMillis @TargetApi(26) @@ -35,7 +35,7 @@ internal class ScreenshotRecorder( private var rootView: WeakReference? = null private val thread = HandlerThread("SentryReplayRecorder").also { it.start() } private val handler = Handler(thread.looper) - private val bitmapToVH = WeakHashMap() + private val pendingViewHierarchy = AtomicReference() private val maskingPaint = Paint() private val singlePixelBitmap: Bitmap = Bitmap.createBitmap( 1, @@ -51,6 +51,8 @@ internal class ScreenshotRecorder( private var lastScreenshot: Bitmap? = null fun capture() { + val viewHierarchy = pendingViewHierarchy.get() + if (!isCapturing.get()) { options.logger.log(DEBUG, "ScreenshotRecorder is paused, not capturing screenshot") return @@ -87,13 +89,6 @@ internal class ScreenshotRecorder( // postAtFrontOfQueue to ensure the view hierarchy and bitmap are ase close in-sync as possible Handler(Looper.getMainLooper()).postAtFrontOfQueue { - val time = measureTimeMillis { - val rootNode = ViewHierarchyNode.fromView(root) - root.traverse(rootNode) - bitmapToVH[bitmap] = rootNode - } - options.logger.log(DEBUG, "Took %d ms to capture view hierarchy", time) - try { PixelCopy.request( window, @@ -102,17 +97,14 @@ internal class ScreenshotRecorder( if (copyResult != PixelCopy.SUCCESS) { options.logger.log(INFO, "Failed to capture replay recording: %d", copyResult) bitmap.recycle() - bitmapToVH.remove(bitmap) return@request } - val viewHierarchy = bitmapToVH[bitmap] val scaledBitmap: Bitmap if (viewHierarchy == null) { options.logger.log(INFO, "Failed to determine view hierarchy, not capturing") bitmap.recycle() - bitmapToVH.remove(bitmap) return@request } else { scaledBitmap = Bitmap.createScaledBitmap( @@ -149,19 +141,30 @@ internal class ScreenshotRecorder( scaledBitmap.recycle() bitmap.recycle() - bitmapToVH.remove(bitmap) }, handler ) } catch (e: Throwable) { options.logger.log(WARNING, "Failed to capture replay recording", e) bitmap.recycle() - bitmapToVH.remove(bitmap) } } } override fun onDraw() { + val root = rootView?.get() + if (root == null || root.width <= 0 || root.height <= 0 || !root.isShown) { + options.logger.log(DEBUG, "Root view is invalid, not capturing screenshot") + return + } + + val time = measureTimeMillis { + val rootNode = ViewHierarchyNode.fromView(root) + root.traverse(rootNode) + pendingViewHierarchy.set(rootNode) + } + options.logger.log(DEBUG, "Took %d ms to capture view hierarchy", time) + contentChanged.set(true) } @@ -194,7 +197,7 @@ internal class ScreenshotRecorder( unbind(rootView?.get()) rootView?.clear() lastScreenshot?.recycle() - bitmapToVH.clear() + pendingViewHierarchy.set(null) isCapturing.set(false) thread.quitSafely() } From 1a5c4da90015d260cdd9733e705416b255c553c5 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 28 Mar 2024 20:22:58 +0100 Subject: [PATCH 53/89] _experimental -> experimental --- .../android/core/ManifestMetadataReader.java | 8 ++++---- .../android/core/ManifestMetadataReaderTest.kt | 8 ++++---- .../api/sentry-android-replay.api | 14 ++++++++++---- .../io/sentry/android/replay/ReplayIntegration.kt | 2 +- sentry/api/sentry.api | 2 +- sentry/src/main/java/io/sentry/SentryOptions.java | 6 +++--- 6 files changed, 23 insertions(+), 17 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java index 32ad9df290d..fd7bc1d1cd9 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java @@ -376,18 +376,18 @@ static void applyMetadata( readBool( metadata, logger, ENABLE_APP_START_PROFILING, options.isEnableAppStartProfiling())); - if (options.get_experimental().getReplayOptions().getSessionSampleRate() == null) { + if (options.getExperimental().getReplayOptions().getSessionSampleRate() == null) { final Double sessionSampleRate = readDouble(metadata, logger, REPLAYS_SESSION_SAMPLE_RATE); if (sessionSampleRate != -1) { - options.get_experimental().getReplayOptions().setSessionSampleRate(sessionSampleRate); + options.getExperimental().getReplayOptions().setSessionSampleRate(sessionSampleRate); } } - if (options.get_experimental().getReplayOptions().getErrorSampleRate() == null) { + if (options.getExperimental().getReplayOptions().getErrorSampleRate() == null) { final Double errorSampleRate = readDouble(metadata, logger, REPLAYS_ERROR_SAMPLE_RATE); if (errorSampleRate != -1) { - options.get_experimental().getReplayOptions().setErrorSampleRate(errorSampleRate); + options.getExperimental().getReplayOptions().setErrorSampleRate(errorSampleRate); } } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt index 03f8e5291fd..df7544beee9 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt @@ -1383,14 +1383,14 @@ class ManifestMetadataReaderTest { ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) // Assert - assertEquals(expectedSampleRate.toDouble(), fixture.options._experimental.replayOptions.errorSampleRate) + assertEquals(expectedSampleRate.toDouble(), fixture.options.experimental.replayOptions.errorSampleRate) } @Test fun `applyMetadata does not override replays errorSampleRate from options`() { // Arrange val expectedSampleRate = 0.99f - fixture.options._experimental.replayOptions.errorSampleRate = expectedSampleRate.toDouble() + fixture.options.experimental.replayOptions.errorSampleRate = expectedSampleRate.toDouble() val bundle = bundleOf(ManifestMetadataReader.REPLAYS_ERROR_SAMPLE_RATE to 0.1f) val context = fixture.getContext(metaData = bundle) @@ -1398,7 +1398,7 @@ class ManifestMetadataReaderTest { ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) // Assert - assertEquals(expectedSampleRate.toDouble(), fixture.options._experimental.replayOptions.errorSampleRate) + assertEquals(expectedSampleRate.toDouble(), fixture.options.experimental.replayOptions.errorSampleRate) } @Test @@ -1410,6 +1410,6 @@ class ManifestMetadataReaderTest { ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) // Assert - assertNull(fixture.options._experimental.replayOptions.errorSampleRate) + assertNull(fixture.options.experimental.replayOptions.errorSampleRate) } } diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index 7f45c6d8f73..4bdc382eae0 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -57,15 +57,17 @@ public abstract interface class io/sentry/android/replay/ScreenshotRecorderCallb } public final class io/sentry/android/replay/ScreenshotRecorderConfig { - public fun (IIFI)V - public synthetic fun (IIFIILkotlin/jvm/internal/DefaultConstructorMarker;)V + public static final field Companion Lio/sentry/android/replay/ScreenshotRecorderConfig$Companion; + public fun (IIFII)V public final fun component1 ()I public final fun component2 ()I public final fun component3 ()F public final fun component4 ()I - public final fun copy (IIFI)Lio/sentry/android/replay/ScreenshotRecorderConfig; - public static synthetic fun copy$default (Lio/sentry/android/replay/ScreenshotRecorderConfig;IIFIILjava/lang/Object;)Lio/sentry/android/replay/ScreenshotRecorderConfig; + public final fun component5 ()I + public final fun copy (IIFII)Lio/sentry/android/replay/ScreenshotRecorderConfig; + public static synthetic fun copy$default (Lio/sentry/android/replay/ScreenshotRecorderConfig;IIFIIILjava/lang/Object;)Lio/sentry/android/replay/ScreenshotRecorderConfig; public fun equals (Ljava/lang/Object;)Z + public final fun getBitRate ()I public final fun getFrameRate ()I public final fun getRecordingHeight ()I public final fun getRecordingWidth ()I @@ -74,6 +76,10 @@ public final class io/sentry/android/replay/ScreenshotRecorderConfig { public fun toString ()Ljava/lang/String; } +public final class io/sentry/android/replay/ScreenshotRecorderConfig$Companion { + public final fun from (Landroid/content/Context;ILio/sentry/SentryReplayOptions;)Lio/sentry/android/replay/ScreenshotRecorderConfig; +} + public abstract interface class io/sentry/android/replay/video/SimpleFrameMuxer { public abstract fun getVideoTime ()J public abstract fun isStarted ()Z diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index a99c8a9c95f..fc816d1f784 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -57,7 +57,7 @@ class ReplayIntegration( ScreenshotRecorderConfig.from( context, targetHeight = 720, - options._experimental.replayOptions + options.experimental.replayOptions ) } diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index fcede26562f..d3fd8c47357 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -2268,6 +2268,7 @@ public class io/sentry/SentryOptions { public fun getEnvironment ()Ljava/lang/String; public fun getEventProcessors ()Ljava/util/List; public fun getExecutorService ()Lio/sentry/ISentryExecutorService; + public fun getExperimental ()Lio/sentry/ExperimentalOptions; public fun getFlushTimeoutMillis ()J public fun getFullyDisplayedReporter ()Lio/sentry/FullyDisplayedReporter; public fun getGestureTargetLocators ()Ljava/util/List; @@ -2321,7 +2322,6 @@ public class io/sentry/SentryOptions { public fun getTransportFactory ()Lio/sentry/ITransportFactory; public fun getTransportGate ()Lio/sentry/transport/ITransportGate; public final fun getViewHierarchyExporters ()Ljava/util/List; - public fun get_experimental ()Lio/sentry/ExperimentalOptions; public fun isAttachServerName ()Z public fun isAttachStacktrace ()Z public fun isAttachThreads ()Z diff --git a/sentry/src/main/java/io/sentry/SentryOptions.java b/sentry/src/main/java/io/sentry/SentryOptions.java index 026f2c9e903..26e8a7c0385 100644 --- a/sentry/src/main/java/io/sentry/SentryOptions.java +++ b/sentry/src/main/java/io/sentry/SentryOptions.java @@ -460,7 +460,7 @@ public class SentryOptions { */ private int profilingTracesHz = 101; - private final @NotNull ExperimentalOptions _experimental = new ExperimentalOptions(); + private final @NotNull ExperimentalOptions experimental = new ExperimentalOptions(); /** * Adds an event processor @@ -2277,8 +2277,8 @@ public void setSessionFlushTimeoutMillis(final long sessionFlushTimeoutMillis) { } @NotNull - public ExperimentalOptions get_experimental() { - return _experimental; + public ExperimentalOptions getExperimental() { + return experimental; } /** The BeforeSend callback */ From da37c898f70bb13f712a6cfd22626addd37aabf8 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 28 Mar 2024 20:40:36 +0100 Subject: [PATCH 54/89] Merge conflicts --- .../api/sentry-android-replay.api | 1 + .../sentry/android/replay/ReplayIntegration.kt | 16 ++++++++-------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index a1641def953..0a064b803fe 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -27,6 +27,7 @@ public final class io/sentry/android/replay/ReplayCache : java/io/Closeable { public fun close ()V public final fun createVideoOf (JJILjava/io/File;)Lio/sentry/android/replay/GeneratedVideo; public static synthetic fun createVideoOf$default (Lio/sentry/android/replay/ReplayCache;JJILjava/io/File;ILjava/lang/Object;)Lio/sentry/android/replay/GeneratedVideo; + public final fun rotate (J)V } public final class io/sentry/android/replay/ReplayIntegration : io/sentry/Integration, io/sentry/ReplayController, io/sentry/android/replay/ScreenshotRecorderCallback, java/io/Closeable { diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index 267bbb0c010..ea4b365dfde 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -81,8 +81,8 @@ class ReplayIntegration( return } - if (!options._experimental.replayOptions.isSessionReplayEnabled && - !options._experimental.replayOptions.isSessionReplayForErrorsEnabled + if (!options.experimental.replayOptions.isSessionReplayEnabled && + !options.experimental.replayOptions.isSessionReplayForErrorsEnabled ) { options.logger.log(INFO, "Session replay is disabled, no sample rate specified") return @@ -91,7 +91,7 @@ class ReplayIntegration( this.hub = hub recorder = WindowRecorder(options, recorderConfig, this) isEnabled.set(true) - isFullSession.set(sample(options._experimental.replayOptions.sessionSampleRate)) + isFullSession.set(sample(options.experimental.replayOptions.sessionSampleRate)) addIntegrationToSdkVersion(javaClass) SentryIntegrationPackageStorage.getInstance() @@ -147,12 +147,12 @@ class ReplayIntegration( return } - if (!sample(options._experimental.replayOptions.errorSampleRate)) { + if (!sample(options.experimental.replayOptions.errorSampleRate)) { options.logger.log(INFO, "Replay wasn't sampled by errorSampleRate, not capturing for event %s", event.eventId) return } - val errorReplayDuration = options._experimental.replayOptions.errorReplayDuration + val errorReplayDuration = options.experimental.replayOptions.errorReplayDuration val now = dateProvider.currentTimeMillis val currentSegmentTimestamp = if (cache?.frames?.isNotEmpty() == true) { // in buffer mode we have to set the timestamp of the first frame as the actual start @@ -241,7 +241,7 @@ class ReplayIntegration( val now = dateProvider.currentTimeMillis if (isFullSession.get() && - (now - segmentTimestamp.get().time >= options._experimental.replayOptions.sessionSegmentDuration) + (now - segmentTimestamp.get().time >= options.experimental.replayOptions.sessionSegmentDuration) ) { val currentSegmentTimestamp = segmentTimestamp.get() val segmentId = currentSegment.get() @@ -249,7 +249,7 @@ class ReplayIntegration( val videoDuration = createAndCaptureSegment( - options._experimental.replayOptions.sessionSegmentDuration, + options.experimental.replayOptions.sessionSegmentDuration, currentSegmentTimestamp, replayId, segmentId @@ -260,7 +260,7 @@ class ReplayIntegration( segmentTimestamp.set(DateUtils.getDateTime(currentSegmentTimestamp.time + videoDuration)) } } else if (!isFullSession.get()) { - cache?.rotate(now - options._experimental.replayOptions.errorReplayDuration) + cache?.rotate(now - options.experimental.replayOptions.errorReplayDuration) } } } From c53a975af35370d834f3829d6cf23cd405dc092c Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 2 Apr 2024 21:43:19 +0200 Subject: [PATCH 55/89] Fix tests --- sentry/src/test/java/io/sentry/JsonSerializerTest.kt | 8 ++++---- .../test/java/io/sentry/TraceContextSerializationTest.kt | 3 ++- sentry/src/test/resources/json/trace_state.json | 3 ++- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/sentry/src/test/java/io/sentry/JsonSerializerTest.kt b/sentry/src/test/java/io/sentry/JsonSerializerTest.kt index a894fcfff3c..7214b3643e6 100644 --- a/sentry/src/test/java/io/sentry/JsonSerializerTest.kt +++ b/sentry/src/test/java/io/sentry/JsonSerializerTest.kt @@ -443,16 +443,16 @@ class JsonSerializerTest { @Test fun `serializes trace context`() { - val traceContext = SentryEnvelopeHeader(null, null, TraceContext(SentryId("3367f5196c494acaae85bbbd535379ac"), "key", "release", "environment", "userId", "segment", "transaction", "0.5", "true")) - val expected = """{"trace":{"trace_id":"3367f5196c494acaae85bbbd535379ac","public_key":"key","release":"release","environment":"environment","user_id":"userId","user_segment":"segment","transaction":"transaction","sample_rate":"0.5","sampled":"true"}}""" + val traceContext = SentryEnvelopeHeader(null, null, TraceContext(SentryId("3367f5196c494acaae85bbbd535379ac"), "key", "release", "environment", "userId", "segment", "transaction", "0.5", "true", SentryId("3367f5196c494acaae85bbbd535379aa"))) + val expected = """{"trace":{"trace_id":"3367f5196c494acaae85bbbd535379ac","public_key":"key","release":"release","environment":"environment","user_id":"userId","user_segment":"segment","transaction":"transaction","sample_rate":"0.5","sampled":"true","replay_id":"3367f5196c494acaae85bbbd535379aa"}}""" val json = serializeToString(traceContext) assertEquals(expected, json) } @Test fun `serializes trace context with user having null id and segment`() { - val traceContext = SentryEnvelopeHeader(null, null, TraceContext(SentryId("3367f5196c494acaae85bbbd535379ac"), "key", "release", "environment", null, null, "transaction", "0.6", "false")) - val expected = """{"trace":{"trace_id":"3367f5196c494acaae85bbbd535379ac","public_key":"key","release":"release","environment":"environment","transaction":"transaction","sample_rate":"0.6","sampled":"false"}}""" + val traceContext = SentryEnvelopeHeader(null, null, TraceContext(SentryId("3367f5196c494acaae85bbbd535379ac"), "key", "release", "environment", null, null, "transaction", "0.6", "false", SentryId("3367f5196c494acaae85bbbd535379aa"))) + val expected = """{"trace":{"trace_id":"3367f5196c494acaae85bbbd535379ac","public_key":"key","release":"release","environment":"environment","transaction":"transaction","sample_rate":"0.6","sampled":"false","replay_id":"3367f5196c494acaae85bbbd535379aa"}}""" val json = serializeToString(traceContext) assertEquals(expected, json) } diff --git a/sentry/src/test/java/io/sentry/TraceContextSerializationTest.kt b/sentry/src/test/java/io/sentry/TraceContextSerializationTest.kt index f2a674d554b..876ec128315 100644 --- a/sentry/src/test/java/io/sentry/TraceContextSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/TraceContextSerializationTest.kt @@ -24,7 +24,8 @@ class TraceContextSerializationTest { "f7d8662b-5551-4ef8-b6a8-090f0561a530", "0252ec25-cd0a-4230-bd2f-936a4585637e", "0.00000021", - "true" + "true", + SentryId("3367f5196c494acaae85bbbd535379aa") ) } private val fixture = Fixture() diff --git a/sentry/src/test/resources/json/trace_state.json b/sentry/src/test/resources/json/trace_state.json index 17a95fdc334..6ca0e48e616 100644 --- a/sentry/src/test/resources/json/trace_state.json +++ b/sentry/src/test/resources/json/trace_state.json @@ -7,5 +7,6 @@ "user_segment": "f7d8662b-5551-4ef8-b6a8-090f0561a530", "transaction": "0252ec25-cd0a-4230-bd2f-936a4585637e", "sample_rate": "0.00000021", - "sampled": "true" + "sampled": "true", + "replay_id": "3367f5196c494acaae85bbbd535379aa" } From 18eb67ebf6a7ea188175fb819fe67c7a76d7a561 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 2 Apr 2024 22:13:32 +0200 Subject: [PATCH 56/89] Add more tests --- .../sentry/android/replay/ReplayCacheTest.kt | 41 ++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt index 88d410fab1a..fab5f28ac81 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt @@ -1,6 +1,7 @@ package io.sentry.android.replay import android.graphics.Bitmap +import android.graphics.Bitmap.CompressFormat.JPEG import android.graphics.Bitmap.Config.ARGB_8888 import android.media.MediaCodec import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -123,8 +124,15 @@ class ReplayCacheTest { val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) replayCache.addFrame(bitmap, 1) + replayCache.addFrame(bitmap, 1001) + replayCache.addFrame(bitmap, 2001) + + val segment0 = replayCache.createVideoOf(3000L, 0, 0) + assertEquals(3, segment0!!.frameCount) + assertEquals(3000, segment0.duration) + assertTrue { segment0.video.exists() && segment0.video.length() > 0 } + assertEquals(File(replayCache.replayCacheDir, "0.mp4"), segment0.video) - replayCache.createVideoOf(3000L, 0, 0) assertTrue(replayCache.frames.isEmpty()) assertTrue(replayCache.replayCacheDir!!.listFiles()!!.none { it.extension == "jpg" }) } @@ -143,6 +151,7 @@ class ReplayCacheTest { val segment0 = replayCache.createVideoOf(5000L, 0, 0) assertEquals(5, segment0!!.frameCount) assertEquals(5000, segment0.duration) + assertTrue { segment0.video.exists() && segment0.video.length() > 0 } assertEquals(File(replayCache.replayCacheDir, "0.mp4"), segment0.video) } @@ -161,6 +170,7 @@ class ReplayCacheTest { val segment0 = replayCache.createVideoOf(5000L, 0, 0) assertEquals(5, segment0!!.frameCount) assertEquals(5000, segment0.duration) + assertTrue { segment0.video.exists() && segment0.video.length() > 0 } assertEquals(File(replayCache.replayCacheDir, "0.mp4"), segment0.video) } @@ -184,6 +194,7 @@ class ReplayCacheTest { val segment1 = replayCache.createVideoOf(5000L, 5000L, 1) assertEquals(5, segment1!!.frameCount) assertEquals(5000, segment1.duration) + assertTrue { segment0.video.exists() && segment0.video.length() > 0 } assertEquals(File(replayCache.replayCacheDir, "1.mp4"), segment1.video) } @@ -203,6 +214,34 @@ class ReplayCacheTest { val segment0 = replayCache.createVideoOf(3000L, 0, 0) assertEquals(6, segment0!!.frameCount) assertEquals(3000, segment0.duration) + assertTrue { segment0.video.exists() && segment0.video.length() > 0 } assertEquals(File(replayCache.replayCacheDir, "0.mp4"), segment0.video) } + + @Test + fun `addFrame with File path works`() { + val replayCache = fixture.getSut( + tmpDir, + frameRate = 1, + framesToEncode = 5 + ) + + val flutterCacheDir = + File(fixture.options.cacheDirPath!!, "flutter_replay").also { it.mkdirs() } + val screenshot = File(flutterCacheDir, "1.jpg").also { it.createNewFile() } + val video = File(flutterCacheDir, "flutter_0.mp4") + + screenshot.outputStream().use { + Bitmap.createBitmap(1, 1, ARGB_8888).compress(JPEG, 80, it) + it.flush() + } + replayCache.addFrame(screenshot, frameTimestamp = 1) + + val segment0 = replayCache.createVideoOf(5000L, 0, 0, videoFile = video) + assertEquals(5, segment0!!.frameCount) + assertEquals(5000, segment0.duration) + + assertTrue { segment0.video.exists() && segment0.video.length() > 0 } + assertEquals(File(flutterCacheDir, "flutter_0.mp4"), segment0.video) + } } From a7ae2b7e5ec44b0a971157dd8acce1041df605a7 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 2 Apr 2024 22:13:59 +0200 Subject: [PATCH 57/89] Improve ReplayCache logic --- .../io/sentry/android/replay/ReplayCache.kt | 31 ++++++++++++------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt index 7be5e901dc7..f5c8fc715d5 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt @@ -97,22 +97,23 @@ public class ReplayCache internal constructor( var lastFrame: ReplayFrame = frames.first() for (timestamp in from until (from + (duration)) step step) { val iter = frames.iterator() - val frameCountBefore = frameCount while (iter.hasNext()) { val frame = iter.next() if (frame.timestamp in (timestamp..timestamp + step)) { - frameCount++ - encode(frame) lastFrame = frame break // we only support 1 frame per given interval } + + // assuming frames are in order, if out of bounds exit early + if (frame.timestamp > timestamp + step) { + break + } } - // if the frame count hasn't changed we just replicate the last known frame to respect - // the video duration. - if (frameCountBefore == frameCount) { + // we either encode a new frame within the step bounds or replicate the last known frame + // to respect the video duration + if (encode(lastFrame)) { frameCount++ - encode(lastFrame) } } @@ -143,12 +144,18 @@ public class ReplayCache internal constructor( return GeneratedVideo(videoFile, frameCount, videoDuration) } - private fun encode(frame: ReplayFrame) { - val bitmap = BitmapFactory.decodeFile(frame.screenshot.absolutePath) - synchronized(encoderLock) { - encoder?.encode(bitmap) + private fun encode(frame: ReplayFrame): Boolean { + return try { + val bitmap = BitmapFactory.decodeFile(frame.screenshot.absolutePath) + synchronized(encoderLock) { + encoder?.encode(bitmap) + } + bitmap.recycle() + true + } catch (e: Throwable) { + options.logger.log(WARNING, "Unable to decode bitmap and encode it into a video, skipping frame", e) + false } - bitmap.recycle() } private fun deleteFile(file: File) { From bf14d83367dfb6c7e40a3cfe24db59380ed6ae09 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 2 Apr 2024 22:14:11 +0200 Subject: [PATCH 58/89] frameUsec -> frameDurationUsec --- .../io/sentry/android/replay/video/SimpleMp4FrameMuxer.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleMp4FrameMuxer.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleMp4FrameMuxer.kt index 8a21b0bec0a..cf30f9e49fc 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleMp4FrameMuxer.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleMp4FrameMuxer.kt @@ -38,7 +38,7 @@ import java.util.concurrent.TimeUnit.MICROSECONDS import java.util.concurrent.TimeUnit.MILLISECONDS class SimpleMp4FrameMuxer(path: String, fps: Float) : SimpleFrameMuxer { - private val frameUsec: Long = (TimeUnit.SECONDS.toMicros(1L) / fps).toLong() + private val frameDurationUsec: Long = (TimeUnit.SECONDS.toMicros(1L) / fps).toLong() private val muxer: MediaMuxer = MediaMuxer(path, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4) @@ -59,7 +59,7 @@ class SimpleMp4FrameMuxer(path: String, fps: Float) : SimpleFrameMuxer { // This code will break if the encoder supports B frames. // Ideally we would use set the value in the encoder, // don't know how to do that without using OpenGL - finalVideoTime = frameUsec * videoFrames++ + finalVideoTime = frameDurationUsec * videoFrames++ bufferInfo.presentationTimeUs = finalVideoTime // encodedData.position(bufferInfo.offset) @@ -78,6 +78,6 @@ class SimpleMp4FrameMuxer(path: String, fps: Float) : SimpleFrameMuxer { return 0 } // have to add one sec as we calculate it 0-based above - return MILLISECONDS.convert(finalVideoTime + frameUsec, MICROSECONDS) + return MILLISECONDS.convert(finalVideoTime + frameDurationUsec, MICROSECONDS) } } From 82fe21ad444ffda8e6aa788d4d035df6df60182e Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 2 Apr 2024 22:15:34 +0200 Subject: [PATCH 59/89] bottom/right -> height/width --- .../src/main/java/io/sentry/android/replay/ReplayIntegration.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index e1f0d2f7e3f..b8277514770 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -73,7 +73,7 @@ class ReplayIntegration( } private val aspectRatio by lazy(NONE) { - screenBounds.bottom.toFloat() / screenBounds.right.toFloat() + screenBounds.height().toFloat() / screenBounds.width().toFloat() } private val recorderConfig by lazy(NONE) { From de56e35a53b4e74bfac3f33c5a223a27edb87b5b Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 2 Apr 2024 23:55:54 +0200 Subject: [PATCH 60/89] add todos --- .../main/java/io/sentry/android/replay/ReplayIntegration.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index b8277514770..6d0b9ca0dd2 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -102,6 +102,7 @@ class ReplayIntegration( fun isRecording() = isRecording.get() fun start() { + // TODO: add lifecycle state instead and manage it in start/pause/resume/stop if (!isEnabled.get()) { options.logger.log( DEBUG, @@ -124,11 +125,13 @@ class ReplayIntegration( cache = ReplayCache(options, currentReplayId.get(), recorderConfig) recorder?.startRecording() + // TODO: replace it with dateProvider.currentTimeMillis to also test it segmentTimestamp.set(DateUtils.getCurrentDateTime()) // TODO: finalize old recording if there's some left on disk and send it using the replayId from persisted scope (e.g. for ANRs) } fun resume() { + // TODO: replace it with dateProvider.currentTimeMillis to also test it segmentTimestamp.set(DateUtils.getCurrentDateTime()) recorder?.resume() } From fa8c5271abd8cfa0320bf28c55ea362a431b4db0 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 2 Apr 2024 23:58:29 +0200 Subject: [PATCH 61/89] duration -> durationMs --- .../sentry/android/replay/ReplayIntegration.kt | 2 +- sentry/api/sentry.api | 4 ++-- .../java/io/sentry/rrweb/RRWebVideoEvent.java | 18 +++++++++--------- .../rrweb/RRWebVideoEventSerializationTest.kt | 2 +- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index 6d0b9ca0dd2..81f0b605da0 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -265,7 +265,7 @@ class ReplayIntegration( RRWebVideoEvent().apply { this.timestamp = segmentTimestamp.time this.segmentId = segmentId - this.duration = duration + this.durationMs = duration this.frameCount = frameCount size = video.length() frameRate = recorderConfig.frameRate diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 9e3a90946fb..3ffc0cda8fc 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -4773,7 +4773,7 @@ public final class io/sentry/rrweb/RRWebVideoEvent : io/sentry/rrweb/RRWebEvent, public fun equals (Ljava/lang/Object;)Z public fun getContainer ()Ljava/lang/String; public fun getDataUnknown ()Ljava/util/Map; - public fun getDuration ()J + public fun getDurationMs ()J public fun getEncoding ()Ljava/lang/String; public fun getFrameCount ()I public fun getFrameRate ()I @@ -4791,7 +4791,7 @@ public final class io/sentry/rrweb/RRWebVideoEvent : io/sentry/rrweb/RRWebEvent, public fun serialize (Lio/sentry/ObjectWriter;Lio/sentry/ILogger;)V public fun setContainer (Ljava/lang/String;)V public fun setDataUnknown (Ljava/util/Map;)V - public fun setDuration (J)V + public fun setDurationMs (J)V public fun setEncoding (Ljava/lang/String;)V public fun setFrameCount (I)V public fun setFrameRate (I)V diff --git a/sentry/src/main/java/io/sentry/rrweb/RRWebVideoEvent.java b/sentry/src/main/java/io/sentry/rrweb/RRWebVideoEvent.java index 532177ff9f2..1ba9f19c728 100644 --- a/sentry/src/main/java/io/sentry/rrweb/RRWebVideoEvent.java +++ b/sentry/src/main/java/io/sentry/rrweb/RRWebVideoEvent.java @@ -26,7 +26,7 @@ public final class RRWebVideoEvent extends RRWebEvent implements JsonUnknown, Js private @NotNull String tag; private int segmentId; private long size; - private long duration; + private long durationMs; private @NotNull String encoding = REPLAY_ENCODING; private @NotNull String container = REPLAY_CONTAINER; private int height; @@ -72,12 +72,12 @@ public void setSize(final long size) { this.size = size; } - public long getDuration() { - return duration; + public long getDurationMs() { + return durationMs; } - public void setDuration(final long duration) { - this.duration = duration; + public void setDurationMs(final long durationMs) { + this.durationMs = durationMs; } @NotNull @@ -189,7 +189,7 @@ public boolean equals(Object o) { RRWebVideoEvent that = (RRWebVideoEvent) o; return segmentId == that.segmentId && size == that.size - && duration == that.duration + && durationMs == that.durationMs && height == that.height && width == that.width && frameCount == that.frameCount @@ -209,7 +209,7 @@ public int hashCode() { tag, segmentId, size, - duration, + durationMs, encoding, container, height, @@ -279,7 +279,7 @@ private void serializePayload(final @NotNull ObjectWriter writer, final @NotNull writer.beginObject(); writer.name(JsonKeys.SEGMENT_ID).value(segmentId); writer.name(JsonKeys.SIZE).value(size); - writer.name(JsonKeys.DURATION).value(duration); + writer.name(JsonKeys.DURATION).value(durationMs); writer.name(JsonKeys.ENCODING).value(encoding); writer.name(JsonKeys.CONTAINER).value(container); writer.name(JsonKeys.HEIGHT).value(height); @@ -380,7 +380,7 @@ private void deserializePayload( event.size = size == null ? 0 : size; break; case JsonKeys.DURATION: - event.duration = reader.nextLong(); + event.durationMs = reader.nextLong(); break; case JsonKeys.CONTAINER: final String container = reader.nextStringOrNull(); diff --git a/sentry/src/test/java/io/sentry/rrweb/RRWebVideoEventSerializationTest.kt b/sentry/src/test/java/io/sentry/rrweb/RRWebVideoEventSerializationTest.kt index 79bfd024564..17a790b5cde 100644 --- a/sentry/src/test/java/io/sentry/rrweb/RRWebVideoEventSerializationTest.kt +++ b/sentry/src/test/java/io/sentry/rrweb/RRWebVideoEventSerializationTest.kt @@ -17,7 +17,7 @@ class RRWebVideoEventSerializationTest { tag = "video" segmentId = 0 size = 4_000_000L - duration = 5000 + durationMs = 5000 height = 1920 width = 1080 frameCount = 5 From dfbb9927fd7661cd9f9e405ef998084eb4d75c26 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 3 Apr 2024 00:13:34 +0200 Subject: [PATCH 62/89] replaId non-nullable --- .../main/java/io/sentry/android/replay/ReplayIntegration.kt | 6 +++--- sentry/src/main/java/io/sentry/IScope.java | 4 ++-- sentry/src/main/java/io/sentry/NoOpScope.java | 4 ++-- sentry/src/main/java/io/sentry/Scope.java | 6 +++--- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index 81f0b605da0..e2244d0bf32 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -53,7 +53,7 @@ class ReplayIntegration( // TODO: probably not everything has to be thread-safe here private val isEnabled = AtomicBoolean(false) private val isRecording = AtomicBoolean(false) - private val currentReplayId = AtomicReference() + private val currentReplayId = AtomicReference(SentryId.EMPTY_ID) private val segmentTimestamp = AtomicReference() private val currentSegment = AtomicInteger(0) private val saver = @@ -177,8 +177,8 @@ class ReplayIntegration( cache?.close() currentSegment.set(0) segmentTimestamp.set(null) - currentReplayId.set(null) - hub?.configureScope { it.replayId = null } + currentReplayId.set(SentryId.EMPTY_ID) + hub?.configureScope { it.replayId = SentryId.EMPTY_ID } isRecording.set(false) } diff --git a/sentry/src/main/java/io/sentry/IScope.java b/sentry/src/main/java/io/sentry/IScope.java index 2d38371eadd..4b5930bb544 100644 --- a/sentry/src/main/java/io/sentry/IScope.java +++ b/sentry/src/main/java/io/sentry/IScope.java @@ -91,7 +91,7 @@ public interface IScope { * @return the id of the current session replay */ @ApiStatus.Internal - @Nullable + @NotNull SentryId getReplayId(); /** @@ -100,7 +100,7 @@ public interface IScope { * @param replayId the id of the current session replay */ @ApiStatus.Internal - void setReplayId(final @Nullable SentryId replayId); + void setReplayId(final @NotNull SentryId replayId); /** * Returns the Scope's request diff --git a/sentry/src/main/java/io/sentry/NoOpScope.java b/sentry/src/main/java/io/sentry/NoOpScope.java index 660dca0b69b..d2be23eba81 100644 --- a/sentry/src/main/java/io/sentry/NoOpScope.java +++ b/sentry/src/main/java/io/sentry/NoOpScope.java @@ -70,8 +70,8 @@ public void setUser(@Nullable User user) {} public void setScreen(@Nullable String screen) {} @Override - public @Nullable SentryId getReplayId() { - return null; + public @NotNull SentryId getReplayId() { + return SentryId.EMPTY_ID; } @Override diff --git a/sentry/src/main/java/io/sentry/Scope.java b/sentry/src/main/java/io/sentry/Scope.java index 164d52dc2b1..161502a9d30 100644 --- a/sentry/src/main/java/io/sentry/Scope.java +++ b/sentry/src/main/java/io/sentry/Scope.java @@ -82,7 +82,7 @@ public final class Scope implements IScope { private @NotNull PropagationContext propagationContext; /** Scope's session replay id */ - private @Nullable SentryId replayId; + private @NotNull SentryId replayId = SentryId.EMPTY_ID; /** * Scope's ctor @@ -318,12 +318,12 @@ public void setScreen(final @Nullable String screen) { } @Override - public @Nullable SentryId getReplayId() { + public @NotNull SentryId getReplayId() { return replayId; } @Override - public void setReplayId(final @Nullable SentryId replayId) { + public void setReplayId(final @NotNull SentryId replayId) { this.replayId = replayId; // TODO: set to contexts and notify observers to persist this as well From ad7d78d9530689315d815d64c823cc9e83a2e7db Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 3 Apr 2024 00:39:38 +0200 Subject: [PATCH 63/89] More conflicts --- sentry/src/main/java/io/sentry/Baggage.java | 8 ++++---- sentry/src/main/java/io/sentry/SentryClient.java | 2 +- sentry/src/main/java/io/sentry/SentryTracer.java | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/sentry/src/main/java/io/sentry/Baggage.java b/sentry/src/main/java/io/sentry/Baggage.java index c6a1ee56301..fbeddfe1ff7 100644 --- a/sentry/src/main/java/io/sentry/Baggage.java +++ b/sentry/src/main/java/io/sentry/Baggage.java @@ -384,7 +384,7 @@ public void set(final @NotNull String key, final @Nullable String value) { public void setValuesFromTransaction( final @NotNull ITransaction transaction, final @Nullable User user, - final @Nullable SentryId replayId, + final @NotNull SentryId replayId, final @NotNull SentryOptions sentryOptions, final @Nullable TracesSamplingDecision samplingDecision) { setTraceId(transaction.getSpanContext().getTraceId().toString()); @@ -396,7 +396,7 @@ public void setValuesFromTransaction( isHighQualityTransactionName(transaction.getTransactionNameSource()) ? transaction.getName() : null); - if (replayId != null) { + if (!SentryId.EMPTY_ID.equals(replayId)) { setReplayId(replayId.toString()); } setSampleRate(sampleRateToString(sampleRate(samplingDecision))); @@ -408,12 +408,12 @@ public void setValuesFromScope( final @NotNull IScope scope, final @NotNull SentryOptions options) { final @NotNull PropagationContext propagationContext = scope.getPropagationContext(); final @Nullable User user = scope.getUser(); + final @NotNull SentryId replayId = scope.getReplayId(); setTraceId(propagationContext.getTraceId().toString()); setPublicKey(new Dsn(options.getDsn()).getPublicKey()); setRelease(options.getRelease()); setEnvironment(options.getEnvironment()); - final @Nullable SentryId replayId = scope.getReplayId(); - if (replayId != null) { + if (!SentryId.EMPTY_ID.equals(replayId)) { setReplayId(replayId.toString()); } setUserSegment(user != null ? getSegment(user) : null); diff --git a/sentry/src/main/java/io/sentry/SentryClient.java b/sentry/src/main/java/io/sentry/SentryClient.java index a7af22a6155..e0a11776462 100644 --- a/sentry/src/main/java/io/sentry/SentryClient.java +++ b/sentry/src/main/java/io/sentry/SentryClient.java @@ -259,7 +259,7 @@ private void finalizeTransaction(final @NotNull IScope scope, final @NotNull Hin private void finalizeReplay(final @NotNull IScope scope, final @NotNull Hint hint) { final @Nullable SentryId replayId = scope.getReplayId(); - if (replayId != null) { + if (!SentryId.EMPTY_ID.equals(replayId)) { if (HintUtils.hasType(hint, TransactionEnd.class)) { final Object sentrySdkHint = HintUtils.getSentrySdkHint(hint); if (sentrySdkHint instanceof DiskFlushNotification) { diff --git a/sentry/src/main/java/io/sentry/SentryTracer.java b/sentry/src/main/java/io/sentry/SentryTracer.java index 320f79680b0..fe8d4d01501 100644 --- a/sentry/src/main/java/io/sentry/SentryTracer.java +++ b/sentry/src/main/java/io/sentry/SentryTracer.java @@ -591,7 +591,7 @@ private void updateBaggageValues() { baggage.setValuesFromTransaction( this, userAtomicReference.get(), - replayId.get(), + replayId.get() == null ? SentryId.EMPTY_ID : replayId.get(), hub.getOptions(), this.getSamplingDecision()); baggage.freeze(); From ab00547fefe40593c87d2b1ed3d252d09470b3b0 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 3 Apr 2024 00:40:56 +0200 Subject: [PATCH 64/89] More conflicts --- sentry/src/main/java/io/sentry/Baggage.java | 4 ++-- sentry/src/main/java/io/sentry/SentryTracer.java | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/sentry/src/main/java/io/sentry/Baggage.java b/sentry/src/main/java/io/sentry/Baggage.java index fbeddfe1ff7..53bd10248e1 100644 --- a/sentry/src/main/java/io/sentry/Baggage.java +++ b/sentry/src/main/java/io/sentry/Baggage.java @@ -384,7 +384,7 @@ public void set(final @NotNull String key, final @Nullable String value) { public void setValuesFromTransaction( final @NotNull ITransaction transaction, final @Nullable User user, - final @NotNull SentryId replayId, + final @Nullable SentryId replayId, final @NotNull SentryOptions sentryOptions, final @Nullable TracesSamplingDecision samplingDecision) { setTraceId(transaction.getSpanContext().getTraceId().toString()); @@ -396,7 +396,7 @@ public void setValuesFromTransaction( isHighQualityTransactionName(transaction.getTransactionNameSource()) ? transaction.getName() : null); - if (!SentryId.EMPTY_ID.equals(replayId)) { + if (replayId != null && !SentryId.EMPTY_ID.equals(replayId)) { setReplayId(replayId.toString()); } setSampleRate(sampleRateToString(sampleRate(samplingDecision))); diff --git a/sentry/src/main/java/io/sentry/SentryTracer.java b/sentry/src/main/java/io/sentry/SentryTracer.java index fe8d4d01501..320f79680b0 100644 --- a/sentry/src/main/java/io/sentry/SentryTracer.java +++ b/sentry/src/main/java/io/sentry/SentryTracer.java @@ -591,7 +591,7 @@ private void updateBaggageValues() { baggage.setValuesFromTransaction( this, userAtomicReference.get(), - replayId.get() == null ? SentryId.EMPTY_ID : replayId.get(), + replayId.get(), hub.getOptions(), this.getSamplingDecision()); baggage.freeze(); From 7f78fee236caa6c8a4005883708d57ba777938b4 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 3 Apr 2024 01:03:01 +0200 Subject: [PATCH 65/89] Fix tests --- .../android/core/LifecycleWatcherTest.kt | 3 ++ .../sentry/android/core/SentryAndroidTest.kt | 1 + .../android/replay/ReplayIntegration.kt | 29 +++++++++++++------ sentry/src/test/java/io/sentry/BaggageTest.kt | 8 ++--- .../json/sentry_envelope_header.json | 3 +- 5 files changed, 29 insertions(+), 15 deletions(-) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt index 4b620813bf5..2adcc9ee6e6 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt @@ -7,6 +7,7 @@ import io.sentry.IHub import io.sentry.IScope import io.sentry.ScopeCallback import io.sentry.SentryLevel +import io.sentry.SentryOptions import io.sentry.Session import io.sentry.Session.State import io.sentry.transport.ICurrentDateProvider @@ -34,6 +35,7 @@ class LifecycleWatcherTest { val ownerMock = mock() val hub = mock() val dateProvider = mock() + val options = SentryOptions() fun getSUT( sessionIntervalMillis: Long = 0L, @@ -47,6 +49,7 @@ class LifecycleWatcherTest { whenever(hub.configureScope(argumentCaptor.capture())).thenAnswer { argumentCaptor.value.run(scope) } + whenever(hub.options).thenReturn(options) return LifecycleWatcher( hub, diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt index b543ae318a4..a2d27bb4b5e 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt @@ -342,6 +342,7 @@ class SentryAndroidTest { options.release = "prod" options.dsn = "https://key@sentry.io/123" options.isEnableAutoSessionTracking = true + options.experimental.replayOptions.errorSampleRate = 1.0 } var session: Session? = null diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index f93d35d6485..257ab8e0477 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -55,8 +55,11 @@ class ReplayIntegration( private val currentReplayId = AtomicReference(SentryId.EMPTY_ID) private val segmentTimestamp = AtomicReference() private val currentSegment = AtomicInteger(0) - private val saver = + + // TODO: surround with try-catch on the calling site + private val saver by lazy { Executors.newSingleThreadScheduledExecutor(ReplayExecutorServiceThreadFactory()) + } private val recorderConfig by lazy(NONE) { ScreenshotRecorderConfig.from( @@ -103,10 +106,6 @@ class ReplayIntegration( override fun start() { // TODO: add lifecycle state instead and manage it in start/pause/resume/stop if (!isEnabled.get()) { - options.logger.log( - DEBUG, - "Session replay is disabled due to conditions not met in Integration.register" - ) return } @@ -134,12 +133,20 @@ class ReplayIntegration( } override fun resume() { + if (!isEnabled.get()) { + return + } + // TODO: replace it with dateProvider.currentTimeMillis to also test it segmentTimestamp.set(DateUtils.getCurrentDateTime()) recorder?.resume() } override fun sendReplayForEvent(event: SentryEvent, hint: Hint) { + if (!isEnabled.get()) { + return + } + if (isFullSession.get()) { options.logger.log(DEBUG, "Replay is already running in 'session' mode, not capturing for event %s", event.eventId) return @@ -183,6 +190,10 @@ class ReplayIntegration( } override fun pause() { + if (!isEnabled.get()) { + return + } + val now = dateProvider.currentTimeMillis recorder?.pause() @@ -205,10 +216,6 @@ class ReplayIntegration( override fun stop() { if (!isEnabled.get()) { - options.logger.log( - DEBUG, - "Session replay is disabled due to conditions not met in Integration.register" - ) return } @@ -346,6 +353,10 @@ class ReplayIntegration( } override fun close() { + if (!isEnabled.get()) { + return + } + stop() saver.gracefullyShutdown(options) } diff --git a/sentry/src/test/java/io/sentry/BaggageTest.kt b/sentry/src/test/java/io/sentry/BaggageTest.kt index eb1cfa0383e..c24731e92a7 100644 --- a/sentry/src/test/java/io/sentry/BaggageTest.kt +++ b/sentry/src/test/java/io/sentry/BaggageTest.kt @@ -527,15 +527,13 @@ class BaggageTest { @Test fun `unknown returns sentry- prefixed keys that are not known and passes them on to TraceContext`() { - val baggage = Baggage.fromHeader(listOf("sentry-trace_id=${SentryId()},sentry-public_key=b, sentry-replay_id=def", "sentry-transaction=sentryTransaction, sentry-anewkey=abc")) + val baggage = Baggage.fromHeader(listOf("sentry-trace_id=${SentryId()},sentry-public_key=b, sentry-replay_id=${SentryId()}", "sentry-transaction=sentryTransaction, sentry-anewkey=abc")) val unknown = baggage.unknown - assertEquals(2, unknown.size) - assertEquals("def", unknown["replay_id"]) + assertEquals(1, unknown.size) assertEquals("abc", unknown["anewkey"]) val traceContext = baggage.toTraceContext()!! - assertEquals(2, traceContext.unknown!!.size) - assertEquals("def", traceContext.unknown!!["replay_id"]) + assertEquals(1, traceContext.unknown!!.size) assertEquals("abc", traceContext.unknown!!["anewkey"]) } diff --git a/sentry/src/test/resources/json/sentry_envelope_header.json b/sentry/src/test/resources/json/sentry_envelope_header.json index 14c144f8203..5f6b3b25e78 100644 --- a/sentry/src/test/resources/json/sentry_envelope_header.json +++ b/sentry/src/test/resources/json/sentry_envelope_header.json @@ -27,7 +27,8 @@ "user_segment": "f7d8662b-5551-4ef8-b6a8-090f0561a530", "transaction": "0252ec25-cd0a-4230-bd2f-936a4585637e", "sample_rate": "0.00000021", - "sampled": "true" + "sampled": "true", + "replay_id": "3367f5196c494acaae85bbbd535379aa" }, "sent_at": "2020-02-07T14:16:00.000Z" } From 9f252bc9e13464e8e4a22243a2d7371595d8a528 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 3 Apr 2024 13:24:43 +0200 Subject: [PATCH 66/89] Address PR review --- .../android/core/ManifestMetadataReader.java | 15 +++++++++------ .../android/core/ManifestMetadataReaderTest.kt | 8 ++++---- .../io/sentry/android/replay/ReplayIntegration.kt | 2 +- sentry/api/sentry.api | 4 ++-- .../main/java/io/sentry/ExperimentalOptions.java | 14 +++++++++----- 5 files changed, 25 insertions(+), 18 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java index fd7bc1d1cd9..d41915ab08c 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java @@ -100,9 +100,9 @@ final class ManifestMetadataReader { static final String ENABLE_APP_START_PROFILING = "io.sentry.profiling.enable-app-start"; - static final String REPLAYS_SESSION_SAMPLE_RATE = "io.sentry.replays.session-sample-rate"; + static final String REPLAYS_SESSION_SAMPLE_RATE = "io.sentry.session-replay.session-sample-rate"; - static final String REPLAYS_ERROR_SAMPLE_RATE = "io.sentry.replays.error-sample-rate"; + static final String REPLAYS_ERROR_SAMPLE_RATE = "io.sentry.session-replay.error-sample-rate"; /** ManifestMetadataReader ctor */ private ManifestMetadataReader() {} @@ -376,18 +376,21 @@ static void applyMetadata( readBool( metadata, logger, ENABLE_APP_START_PROFILING, options.isEnableAppStartProfiling())); - if (options.getExperimental().getReplayOptions().getSessionSampleRate() == null) { + if (options.getExperimental().getSessionReplayOptions().getSessionSampleRate() == null) { final Double sessionSampleRate = readDouble(metadata, logger, REPLAYS_SESSION_SAMPLE_RATE); if (sessionSampleRate != -1) { - options.getExperimental().getReplayOptions().setSessionSampleRate(sessionSampleRate); + options + .getExperimental() + .getSessionReplayOptions() + .setSessionSampleRate(sessionSampleRate); } } - if (options.getExperimental().getReplayOptions().getErrorSampleRate() == null) { + if (options.getExperimental().getSessionReplayOptions().getErrorSampleRate() == null) { final Double errorSampleRate = readDouble(metadata, logger, REPLAYS_ERROR_SAMPLE_RATE); if (errorSampleRate != -1) { - options.getExperimental().getReplayOptions().setErrorSampleRate(errorSampleRate); + options.getExperimental().getSessionReplayOptions().setErrorSampleRate(errorSampleRate); } } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt index df7544beee9..b46743ef7e9 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt @@ -1383,14 +1383,14 @@ class ManifestMetadataReaderTest { ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) // Assert - assertEquals(expectedSampleRate.toDouble(), fixture.options.experimental.replayOptions.errorSampleRate) + assertEquals(expectedSampleRate.toDouble(), fixture.options.experimental.sessionReplayOptions.errorSampleRate) } @Test fun `applyMetadata does not override replays errorSampleRate from options`() { // Arrange val expectedSampleRate = 0.99f - fixture.options.experimental.replayOptions.errorSampleRate = expectedSampleRate.toDouble() + fixture.options.experimental.sessionReplayOptions.errorSampleRate = expectedSampleRate.toDouble() val bundle = bundleOf(ManifestMetadataReader.REPLAYS_ERROR_SAMPLE_RATE to 0.1f) val context = fixture.getContext(metaData = bundle) @@ -1398,7 +1398,7 @@ class ManifestMetadataReaderTest { ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) // Assert - assertEquals(expectedSampleRate.toDouble(), fixture.options.experimental.replayOptions.errorSampleRate) + assertEquals(expectedSampleRate.toDouble(), fixture.options.experimental.sessionReplayOptions.errorSampleRate) } @Test @@ -1410,6 +1410,6 @@ class ManifestMetadataReaderTest { ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) // Assert - assertNull(fixture.options.experimental.replayOptions.errorSampleRate) + assertNull(fixture.options.experimental.sessionReplayOptions.errorSampleRate) } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index d80b1ecc8a9..25c63f5179e 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -57,7 +57,7 @@ class ReplayIntegration( ScreenshotRecorderConfig.from( context, targetHeight = 720, - options.experimental.replayOptions + options.experimental.sessionReplayOptions ) } diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 8e0327eace5..d639b765ff9 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -307,8 +307,8 @@ public abstract interface class io/sentry/EventProcessor { public final class io/sentry/ExperimentalOptions { public fun ()V - public fun getReplayOptions ()Lio/sentry/SentryReplayOptions; - public fun setReplayOptions (Lio/sentry/SentryReplayOptions;)V + public fun getSessionReplayOptions ()Lio/sentry/SentryReplayOptions; + public fun setSessionReplayOptions (Lio/sentry/SentryReplayOptions;)V } public final class io/sentry/ExternalOptions { diff --git a/sentry/src/main/java/io/sentry/ExperimentalOptions.java b/sentry/src/main/java/io/sentry/ExperimentalOptions.java index b22d283724b..0d12bf844b2 100644 --- a/sentry/src/main/java/io/sentry/ExperimentalOptions.java +++ b/sentry/src/main/java/io/sentry/ExperimentalOptions.java @@ -2,15 +2,19 @@ import org.jetbrains.annotations.NotNull; +/** + * Experimental options for new features, these options are going to be promoted to SentryOptions + * before GA + */ public final class ExperimentalOptions { - private @NotNull SentryReplayOptions replayOptions = new SentryReplayOptions(); + private @NotNull SentryReplayOptions sessionReplayOptions = new SentryReplayOptions(); @NotNull - public SentryReplayOptions getReplayOptions() { - return replayOptions; + public SentryReplayOptions getSessionReplayOptions() { + return sessionReplayOptions; } - public void setReplayOptions(final @NotNull SentryReplayOptions replayOptions) { - this.replayOptions = replayOptions; + public void setSessionReplayOptions(final @NotNull SentryReplayOptions sessionReplayOptions) { + this.sessionReplayOptions = sessionReplayOptions; } } From 27b15d7235d50cef4c25c546acf58a8929287274 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 3 Apr 2024 15:38:49 +0200 Subject: [PATCH 67/89] Add kdoc --- .../io/sentry/android/replay/ReplayCache.kt | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt index f5c8fc715d5..fd07d74354a 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt @@ -13,6 +13,19 @@ import io.sentry.protocol.SentryId import java.io.Closeable import java.io.File +/** + * A basic in-memory and disk cache for Session Replay frames. Frames are stored in order under the + * [SentryOptions.cacheDirPath] + [replayId] folder. The class is also capable of creating an mp4 + * video segment out of the stored frames, provided start time and duration using the available + * on-device [android.media.MediaCodec]. + * + * This class is not thread-safe, meaning, [addFrame] cannot be called concurrently with + * [createVideoOf], and they should be invoked from the same thread. + * + * @param options SentryOptions instance, used for logging and cacheDir + * @param replayId the current replay id, used for giving a unique name to the replay folder + * @param recorderConfig ScreenshotRecorderConfig, used for video resolution and frame-rate + */ public class ReplayCache internal constructor( private val options: SentryOptions, private val replayId: SentryId, @@ -54,6 +67,16 @@ public class ReplayCache internal constructor( // TODO: maybe account for multi-threaded access internal val frames = mutableListOf() + /** + * Stores the current frame screenshot to in-memory cache as well as disk with [frameTimestamp] + * as filename. Uses [Bitmap.CompressFormat.JPEG] format with quality 80. The frames are stored + * under [replayCacheDir]. + * + * This method is not thread-safe. + * + * @param bitmap the frame screenshot + * @param frameTimestamp the timestamp when the frame screenshot was taken + */ internal fun addFrame(bitmap: Bitmap, frameTimestamp: Long) { if (replayCacheDir == null) { return @@ -70,11 +93,36 @@ public class ReplayCache internal constructor( addFrame(screenshot, frameTimestamp) } + /** + * Same as [addFrame], but accepts frame screenshot as [File], the file should contain + * a bitmap/image by the time [createVideoOf] is invoked. + * + * This method is not thread-safe. + * + * @param screenshot file containing the frame screenshot + * @param frameTimestamp the timestamp when the frame screenshot was taken + */ public fun addFrame(screenshot: File, frameTimestamp: Long) { val frame = ReplayFrame(screenshot, frameTimestamp) frames += frame } + /** + * Creates a video out of currently stored [frames] given the start time and duration using the + * on-device codecs [android.media.MediaCodec]. The generated video will be stored in + * [videoFile] location, which defaults to "[replayCacheDir]/[segmentId].mp4". + * + * This method is not thread-safe. + * + * @param duration desired video duration in milliseconds + * @param from desired start of the video represented as unix timestamp in milliseconds + * @param segmentId current segment id, used for inferring the filename to store the + * result video under [replayCacheDir], e.g. "replay_/0.mp4", where segmentId=0 + * @param videoFile optional, location of the file to store the result video. If this is + * provided, [segmentId] from above is disregarded and not used. + * @return a generated video of type [GeneratedVideo], which contains the resulting video file + * location, frame count and duration in milliseconds. + */ public fun createVideoOf( duration: Long, from: Long, From 957f0cf80b5ec2318ea218b86a01c4d718ea9d16 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 3 Apr 2024 16:00:06 +0200 Subject: [PATCH 68/89] Add kdoc --- .../src/main/java/io/sentry/android/replay/ReplayCache.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt index 0af51a61442..db1f6912603 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt @@ -205,6 +205,11 @@ public class ReplayCache internal constructor( } } + /** + * Removes frames from the in-memory and disk cache from start to [until]. + * + * @param until value until whose the frames should be removed, represented as unix timestamp + */ fun rotate(until: Long) { frames.removeAll { if (it.timestamp < until) { From da3560dfb426d49c765ffb69671ce92c748c8265 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 3 Apr 2024 16:11:04 +0200 Subject: [PATCH 69/89] Fix tests --- .../io/sentry/android/core/SentryAndroidTest.kt | 2 +- .../sentry/android/replay/ReplayIntegration.kt | 16 ++++++++-------- .../src/test/java/io/sentry/SentryClientTest.kt | 5 +++++ 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt index a2d27bb4b5e..a4b213800f4 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt @@ -342,7 +342,7 @@ class SentryAndroidTest { options.release = "prod" options.dsn = "https://key@sentry.io/123" options.isEnableAutoSessionTracking = true - options.experimental.replayOptions.errorSampleRate = 1.0 + options.experimental.sessionReplayOptions.errorSampleRate = 1.0 } var session: Session? = null diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index 1caf9ca8d76..bc46b02207c 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -84,8 +84,8 @@ class ReplayIntegration( return } - if (!options.experimental.replayOptions.isSessionReplayEnabled && - !options.experimental.replayOptions.isSessionReplayForErrorsEnabled + if (!options.experimental.sessionReplayOptions.isSessionReplayEnabled && + !options.experimental.sessionReplayOptions.isSessionReplayForErrorsEnabled ) { options.logger.log(INFO, "Session replay is disabled, no sample rate specified") return @@ -94,7 +94,7 @@ class ReplayIntegration( this.hub = hub recorder = WindowRecorder(options, recorderConfig, this) isEnabled.set(true) - isFullSession.set(sample(options.experimental.replayOptions.sessionSampleRate)) + isFullSession.set(sample(options.experimental.sessionReplayOptions.sessionSampleRate)) addIntegrationToSdkVersion(javaClass) SentryIntegrationPackageStorage.getInstance() @@ -157,12 +157,12 @@ class ReplayIntegration( return } - if (!sample(options.experimental.replayOptions.errorSampleRate)) { + if (!sample(options.experimental.sessionReplayOptions.errorSampleRate)) { options.logger.log(INFO, "Replay wasn't sampled by errorSampleRate, not capturing for event %s", event.eventId) return } - val errorReplayDuration = options.experimental.replayOptions.errorReplayDuration + val errorReplayDuration = options.experimental.sessionReplayOptions.errorReplayDuration val now = dateProvider.currentTimeMillis val currentSegmentTimestamp = if (cache?.frames?.isNotEmpty() == true) { // in buffer mode we have to set the timestamp of the first frame as the actual start @@ -251,7 +251,7 @@ class ReplayIntegration( val now = dateProvider.currentTimeMillis if (isFullSession.get() && - (now - segmentTimestamp.get().time >= options.experimental.replayOptions.sessionSegmentDuration) + (now - segmentTimestamp.get().time >= options.experimental.sessionReplayOptions.sessionSegmentDuration) ) { val currentSegmentTimestamp = segmentTimestamp.get() val segmentId = currentSegment.get() @@ -259,7 +259,7 @@ class ReplayIntegration( val videoDuration = createAndCaptureSegment( - options.experimental.replayOptions.sessionSegmentDuration, + options.experimental.sessionReplayOptions.sessionSegmentDuration, currentSegmentTimestamp, replayId, segmentId @@ -270,7 +270,7 @@ class ReplayIntegration( segmentTimestamp.set(DateUtils.getDateTime(currentSegmentTimestamp.time + videoDuration)) } } else if (!isFullSession.get()) { - cache?.rotate(now - options.experimental.replayOptions.errorReplayDuration) + cache?.rotate(now - options.experimental.sessionReplayOptions.errorReplayDuration) } } } diff --git a/sentry/src/test/java/io/sentry/SentryClientTest.kt b/sentry/src/test/java/io/sentry/SentryClientTest.kt index 0733e6ea45f..003b874326d 100644 --- a/sentry/src/test/java/io/sentry/SentryClientTest.kt +++ b/sentry/src/test/java/io/sentry/SentryClientTest.kt @@ -2286,6 +2286,7 @@ class SentryClientTest { whenever(scope.breadcrumbs).thenReturn(LinkedList()) whenever(scope.extras).thenReturn(emptyMap()) whenever(scope.contexts).thenReturn(Contexts()) + whenever(scope.replayId).thenReturn(SentryId.EMPTY_ID) val scopePropagationContext = PropagationContext() whenever(scope.propagationContext).thenReturn(scopePropagationContext) doAnswer { (it.arguments[0] as IWithPropagationContext).accept(scopePropagationContext); scopePropagationContext }.whenever(scope).withPropagationContext(any()) @@ -2358,6 +2359,7 @@ class SentryClientTest { whenever(scope.breadcrumbs).thenReturn(LinkedList()) whenever(scope.extras).thenReturn(emptyMap()) whenever(scope.contexts).thenReturn(Contexts()) + whenever(scope.replayId).thenReturn(SentryId()) val scopePropagationContext = PropagationContext() whenever(scope.propagationContext).thenReturn(scopePropagationContext) doAnswer { (it.arguments[0] as IWithPropagationContext).accept(scopePropagationContext); scopePropagationContext }.whenever(scope).withPropagationContext(any()) @@ -2426,6 +2428,8 @@ class SentryClientTest { whenever(scope.breadcrumbs).thenReturn(LinkedList()) whenever(scope.extras).thenReturn(emptyMap()) whenever(scope.contexts).thenReturn(Contexts()) + val replayId = SentryId() + whenever(scope.replayId).thenReturn(replayId) val scopePropagationContext = PropagationContext() doAnswer { (it.arguments[0] as IWithPropagationContext).accept(scopePropagationContext); scopePropagationContext }.whenever(scope).withPropagationContext(any()) whenever(scope.propagationContext).thenReturn(scopePropagationContext) @@ -2438,6 +2442,7 @@ class SentryClientTest { check { assertNotNull(it.header.traceContext) assertEquals(scopePropagationContext.traceId, it.header.traceContext!!.traceId) + assertEquals(replayId, it.header.traceContext!!.replayId) }, any() ) From 1a77d171df1fc96bc7e371784b1d976e7b8a1adc Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 3 Apr 2024 17:00:09 +0200 Subject: [PATCH 70/89] Add comment for experimental options --- sentry/src/main/java/io/sentry/ExperimentalOptions.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/sentry/src/main/java/io/sentry/ExperimentalOptions.java b/sentry/src/main/java/io/sentry/ExperimentalOptions.java index 0d12bf844b2..7a9234efae8 100644 --- a/sentry/src/main/java/io/sentry/ExperimentalOptions.java +++ b/sentry/src/main/java/io/sentry/ExperimentalOptions.java @@ -4,7 +4,9 @@ /** * Experimental options for new features, these options are going to be promoted to SentryOptions - * before GA + * before GA. + *

+ * Beware that experimental options can change at any time. */ public final class ExperimentalOptions { private @NotNull SentryReplayOptions sessionReplayOptions = new SentryReplayOptions(); From b88b1b9163a42054a960b465a8d02f9f6f7320a1 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 3 Apr 2024 17:12:19 +0200 Subject: [PATCH 71/89] Do not run recorder if full session was not sampled --- .../java/io/sentry/android/replay/ReplayIntegration.kt | 9 ++++++++- sentry/src/main/java/io/sentry/ExperimentalOptions.java | 4 ++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index bc46b02207c..a64353e09ad 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -91,10 +91,17 @@ class ReplayIntegration( return } + isFullSession.set(sample(options.experimental.sessionReplayOptions.sessionSampleRate)) + if (!isFullSession.get() && + !options.experimental.sessionReplayOptions.isSessionReplayForErrorsEnabled + ) { + options.logger.log(INFO, "Session replay is disabled, full session was not sampled and errorSampleRate is not specified") + return + } + this.hub = hub recorder = WindowRecorder(options, recorderConfig, this) isEnabled.set(true) - isFullSession.set(sample(options.experimental.sessionReplayOptions.sessionSampleRate)) addIntegrationToSdkVersion(javaClass) SentryIntegrationPackageStorage.getInstance() diff --git a/sentry/src/main/java/io/sentry/ExperimentalOptions.java b/sentry/src/main/java/io/sentry/ExperimentalOptions.java index 7a9234efae8..ebd1adabb2f 100644 --- a/sentry/src/main/java/io/sentry/ExperimentalOptions.java +++ b/sentry/src/main/java/io/sentry/ExperimentalOptions.java @@ -5,8 +5,8 @@ /** * Experimental options for new features, these options are going to be promoted to SentryOptions * before GA. - *

- * Beware that experimental options can change at any time. + * + *

Beware that experimental options can change at any time. */ public final class ExperimentalOptions { private @NotNull SentryReplayOptions sessionReplayOptions = new SentryReplayOptions(); From 4d533fb0dbcb146fb0a6843b5f53158411b4dac2 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 3 Apr 2024 22:53:36 +0200 Subject: [PATCH 72/89] Add more tests --- .../sentry/android/core/LifecycleWatcher.java | 3 +- .../core/AndroidOptionsInitializerTest.kt | 7 +++ .../android/core/LifecycleWatcherTest.kt | 56 +++++++++++++++++++ .../test/java/io/sentry/SentryClientTest.kt | 51 +++++++++++++++++ .../test/java/io/sentry/SentryTracerTest.kt | 7 +++ 5 files changed, 123 insertions(+), 1 deletion(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java b/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java index bcdb49e3e6e..81e77a75fb8 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/LifecycleWatcher.java @@ -94,10 +94,11 @@ private void startSession() { hub.startSession(); } hub.getOptions().getReplayController().start(); - } else if (!isFreshSession.getAndSet(false)) { + } else if (!isFreshSession.get()) { // only resume if it's not a fresh session, which has been started in SentryAndroid.init hub.getOptions().getReplayController().resume(); } + isFreshSession.set(false); this.lastUpdatedSession.set(currentTimeMillis); } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt index 94b0490f17d..9bded25087b 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt @@ -491,6 +491,13 @@ class AndroidOptionsInitializerTest { assertNotNull(actual) } + @Test + fun `ReplayIntegration set as ReplayController if available on classpath`() { + fixture.initSutWithClassLoader(isReplayAvailable = true) + + assertTrue(fixture.sentryOptions.replayController is ReplayIntegration) + } + @Test fun `ReplayIntegration won't be enabled, it throws class not found`() { fixture.initSutWithClassLoader(isReplayAvailable = false) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt index 2adcc9ee6e6..388bfbe274f 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/LifecycleWatcherTest.kt @@ -5,6 +5,7 @@ import io.sentry.Breadcrumb import io.sentry.DateUtils import io.sentry.IHub import io.sentry.IScope +import io.sentry.ReplayController import io.sentry.ScopeCallback import io.sentry.SentryLevel import io.sentry.SentryOptions @@ -36,6 +37,7 @@ class LifecycleWatcherTest { val hub = mock() val dateProvider = mock() val options = SentryOptions() + val replayController = mock() fun getSUT( sessionIntervalMillis: Long = 0L, @@ -49,6 +51,7 @@ class LifecycleWatcherTest { whenever(hub.configureScope(argumentCaptor.capture())).thenAnswer { argumentCaptor.value.run(scope) } + options.setReplayController(replayController) whenever(hub.options).thenReturn(options) return LifecycleWatcher( @@ -73,6 +76,7 @@ class LifecycleWatcherTest { val watcher = fixture.getSUT(enableAppLifecycleBreadcrumbs = false) watcher.onStart(fixture.ownerMock) verify(fixture.hub).startSession() + verify(fixture.replayController).start() } @Test @@ -82,6 +86,7 @@ class LifecycleWatcherTest { watcher.onStart(fixture.ownerMock) watcher.onStart(fixture.ownerMock) verify(fixture.hub, times(2)).startSession() + verify(fixture.replayController, times(2)).start() } @Test @@ -91,6 +96,7 @@ class LifecycleWatcherTest { watcher.onStart(fixture.ownerMock) watcher.onStart(fixture.ownerMock) verify(fixture.hub).startSession() + verify(fixture.replayController).start() } @Test @@ -99,6 +105,7 @@ class LifecycleWatcherTest { watcher.onStart(fixture.ownerMock) watcher.onStop(fixture.ownerMock) verify(fixture.hub, timeout(10000)).endSession() + verify(fixture.replayController, timeout(10000)).stop() } @Test @@ -113,6 +120,7 @@ class LifecycleWatcherTest { assertNull(watcher.timerTask) verify(fixture.hub, never()).endSession() + verify(fixture.replayController, never()).stop() } @Test @@ -244,6 +252,7 @@ class LifecycleWatcherTest { watcher.onStart(fixture.ownerMock) verify(fixture.hub, never()).startSession() + verify(fixture.replayController, never()).start() } @Test @@ -270,6 +279,7 @@ class LifecycleWatcherTest { watcher.onStart(fixture.ownerMock) verify(fixture.hub).startSession() + verify(fixture.replayController).start() } @Test @@ -285,4 +295,50 @@ class LifecycleWatcherTest { watcher.onStop(fixture.ownerMock) assertTrue(AppState.getInstance().isInBackground!!) } + + @Test + fun `if the hub has already a fresh session running, doesn't resume replay`() { + val watcher = fixture.getSUT( + enableAppLifecycleBreadcrumbs = false, + session = Session( + State.Ok, + DateUtils.getCurrentDateTime(), + DateUtils.getCurrentDateTime(), + 0, + "abc", + UUID.fromString("3c1ffc32-f68f-4af2-a1ee-dd72f4d62d17"), + true, + 0, + 10.0, + null, + null, + null, + "release", + null + ) + ) + + watcher.onStart(fixture.ownerMock) + verify(fixture.replayController, never()).resume() + } + + @Test + fun `background-foreground replay`() { + whenever(fixture.dateProvider.currentTimeMillis).thenReturn(1L) + val watcher = fixture.getSUT( + sessionIntervalMillis = 2L, + enableAppLifecycleBreadcrumbs = false + ) + watcher.onStart(fixture.ownerMock) + verify(fixture.replayController).start() + + watcher.onStop(fixture.ownerMock) + verify(fixture.replayController).pause() + + watcher.onStart(fixture.ownerMock) + verify(fixture.replayController).resume() + + watcher.onStop(fixture.ownerMock) + verify(fixture.replayController, timeout(10000)).stop() + } } diff --git a/sentry/src/test/java/io/sentry/SentryClientTest.kt b/sentry/src/test/java/io/sentry/SentryClientTest.kt index 003b874326d..eddacbf9390 100644 --- a/sentry/src/test/java/io/sentry/SentryClientTest.kt +++ b/sentry/src/test/java/io/sentry/SentryClientTest.kt @@ -1,6 +1,7 @@ package io.sentry import io.sentry.Scope.IWithPropagationContext +import io.sentry.SentryLevel.WARNING import io.sentry.Session.State.Crashed import io.sentry.clientreport.ClientReportTestHelper.Companion.assertClientReport import io.sentry.clientreport.DiscardReason @@ -2272,6 +2273,41 @@ class SentryClientTest { @Test fun `when event has DiskFlushNotification, TransactionEnds set transaction id as flushable`() { val sut = fixture.getSut() + val replayId = SentryId() + val scope = mock { + whenever(it.replayId).thenReturn(replayId) + whenever(it.breadcrumbs).thenReturn(LinkedList()) + whenever(it.extras).thenReturn(emptyMap()) + whenever(it.contexts).thenReturn(Contexts()) + } + val scopePropagationContext = PropagationContext() + whenever(scope.propagationContext).thenReturn(scopePropagationContext) + doAnswer { (it.arguments[0] as IWithPropagationContext).accept(scopePropagationContext); scopePropagationContext }.whenever(scope).withPropagationContext(any()) + + var capturedEventId: SentryId? = null + val transactionEnd = object : TransactionEnd, DiskFlushNotification { + override fun markFlushed() {} + override fun isFlushable(eventId: SentryId?): Boolean = true + override fun setFlushable(eventId: SentryId) { + capturedEventId = eventId + } + } + val transactionEndHint = HintUtils.createWithTypeCheckHint(transactionEnd) + + sut.captureEvent(SentryEvent(), scope, transactionEndHint) + + assertEquals(replayId, capturedEventId) + verify(fixture.transport).send( + check { + assertEquals(1, it.items.count()) + }, + any() + ) + } + + @Test + fun `when event has DiskFlushNotification, TransactionEnds set replay id as flushable`() { + val sut = fixture.getSut() // build up a running transaction val spanContext = SpanContext("op.load") @@ -2584,6 +2620,21 @@ class SentryClientTest { ) } + @Test + fun `calls sendReplayForEvent on replay controller for error events`() { + var called = false + fixture.sentryOptions.setReplayController(object : ReplayController by NoOpReplayController.getInstance() { + override fun sendReplayForEvent(event: SentryEvent, hint: Hint) { + assertEquals("Test", event.message?.formatted) + called = true + } + }) + val sut = fixture.getSut() + + sut.captureMessage("Test", WARNING) + assertTrue(called) + } + private fun givenScopeWithStartedSession(errored: Boolean = false, crashed: Boolean = false): IScope { val scope = createScope(fixture.sentryOptions) scope.startSession() diff --git a/sentry/src/test/java/io/sentry/SentryTracerTest.kt b/sentry/src/test/java/io/sentry/SentryTracerTest.kt index 37a3d09cca8..a91f81f3cbb 100644 --- a/sentry/src/test/java/io/sentry/SentryTracerTest.kt +++ b/sentry/src/test/java/io/sentry/SentryTracerTest.kt @@ -1,5 +1,6 @@ package io.sentry +import io.sentry.protocol.SentryId import io.sentry.protocol.TransactionNameSource import io.sentry.protocol.User import io.sentry.util.thread.IMainThreadChecker @@ -581,6 +582,8 @@ class SentryTracerTest { others = mapOf("segment" to "pro") } ) + val replayId = SentryId() + fixture.hub.configureScope { it.replayId = replayId } val trace = transaction.traceContext() assertNotNull(trace) { assertEquals(transaction.spanContext.traceId, it.traceId) @@ -590,6 +593,7 @@ class SentryTracerTest { assertEquals(transaction.name, it.transaction) // assertEquals("user-id", it.userId) assertEquals("pro", it.userSegment) + assertEquals(replayId, it.replayId) } } @@ -658,6 +662,8 @@ class SentryTracerTest { others = mapOf("segment" to "pro") } ) + val replayId = SentryId() + fixture.hub.configureScope { it.replayId = replayId } val header = transaction.toBaggageHeader(null) assertNotNull(header) { @@ -671,6 +677,7 @@ class SentryTracerTest { assertTrue(it.value.contains("sentry-transaction=name,")) // assertTrue(it.value.contains("sentry-user_id=userId12345,")) assertTrue(it.value.contains("sentry-user_segment=pro$".toRegex())) + assertTrue(it.value.contains("sentry-replay_id=$replayId")) } } From 42762640b206d1ffd7eadbab52d73b8e6a96c21c Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 4 Apr 2024 00:00:25 +0200 Subject: [PATCH 73/89] Add session deadline of 1h --- .../sentry/android/replay/ReplayIntegration.kt | 9 +++++++++ .../src/main/AndroidManifest.xml | 2 ++ sentry/api/sentry.api | 1 + .../main/java/io/sentry/SentryReplayOptions.java | 16 ++++++++++++---- 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index a64353e09ad..35eed086755 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -34,6 +34,7 @@ import java.util.concurrent.ThreadFactory import java.util.concurrent.TimeUnit.MILLISECONDS import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicReference import kotlin.LazyThreadSafetyMode.NONE @@ -54,6 +55,7 @@ class ReplayIntegration( private val isRecording = AtomicBoolean(false) private val currentReplayId = AtomicReference(SentryId.EMPTY_ID) private val segmentTimestamp = AtomicReference() + private val replayStartTimestamp = AtomicLong() private val currentSegment = AtomicInteger(0) // TODO: surround with try-catch on the calling site @@ -136,6 +138,7 @@ class ReplayIntegration( recorder?.startRecording() // TODO: replace it with dateProvider.currentTimeMillis to also test it segmentTimestamp.set(DateUtils.getCurrentDateTime()) + replayStartTimestamp.set(dateProvider.currentTimeMillis) // TODO: finalize old recording if there's some left on disk and send it using the replayId from persisted scope (e.g. for ANRs) } @@ -243,6 +246,7 @@ class ReplayIntegration( recorder?.stopRecording() cache?.close() currentSegment.set(0) + replayStartTimestamp.set(0) segmentTimestamp.set(null) currentReplayId.set(SentryId.EMPTY_ID) hub?.configureScope { it.replayId = SentryId.EMPTY_ID } @@ -276,6 +280,11 @@ class ReplayIntegration( // set next segment timestamp as close to the previous one as possible to avoid gaps segmentTimestamp.set(DateUtils.getDateTime(currentSegmentTimestamp.time + videoDuration)) } + } else if (isFullSession.get() && + (now - replayStartTimestamp.get() >= options.experimental.sessionReplayOptions.sessionDuration) + ) { + stop() + options.logger.log(INFO, "Session replay deadline exceeded (1h), stopping recording") } else if (!isFullSession.get()) { cache?.rotate(now - options.experimental.sessionReplayOptions.errorReplayDuration) } diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index e4fd0aefd9d..23b469ff205 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -156,5 +156,7 @@ + + diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 3e65667941b..2c116ffb21a 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -2569,6 +2569,7 @@ public final class io/sentry/SentryReplayOptions { public fun getErrorReplayDuration ()J public fun getErrorSampleRate ()Ljava/lang/Double; public fun getFrameRate ()I + public fun getSessionDuration ()J public fun getSessionSampleRate ()Ljava/lang/Double; public fun getSessionSegmentDuration ()J public fun isSessionReplayEnabled ()Z diff --git a/sentry/src/main/java/io/sentry/SentryReplayOptions.java b/sentry/src/main/java/io/sentry/SentryReplayOptions.java index d702d6256b8..51ed1a3a722 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayOptions.java +++ b/sentry/src/main/java/io/sentry/SentryReplayOptions.java @@ -22,22 +22,25 @@ public final class SentryReplayOptions { /** * Defines the quality of the session replay. Higher bit rates have better replay quality, but - * also affect the final payload size to transfer. The default value is 20kbps; + * also affect the final payload size to transfer, defaults to 20kbps. */ private int bitRate = 20_000; /** * Number of frames per second of the replay. The bigger the number, the more accurate the replay - * will be, but also more data to transfer and more CPU load. + * will be, but also more data to transfer and more CPU load, defaults to 1fps. */ private int frameRate = 1; - /** The maximum duration of replays for error events. */ + /** The maximum duration of replays for error events, defaults to 30s. */ private long errorReplayDuration = 30_000L; - /** The maximum duration of the segment of a session replay. */ + /** The maximum duration of the segment of a session replay, defaults to 5s. */ private long sessionSegmentDuration = 5000L; + /** The maximum duration of a full session replay, defaults to 1h. */ + private long sessionDuration = 60 * 60 * 1000L; + public SentryReplayOptions() {} public SentryReplayOptions( @@ -103,4 +106,9 @@ public long getErrorReplayDuration() { public long getSessionSegmentDuration() { return sessionSegmentDuration; } + + @ApiStatus.Internal + public long getSessionDuration() { + return sessionDuration; + } } From 3fe5e0fb1989f19518b9a39962f359a75cd2b5f6 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 4 Apr 2024 00:17:23 +0200 Subject: [PATCH 74/89] Clean up older replays when starting a new one --- .../android/replay/ReplayIntegration.kt | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index 35eed086755..8c944506bf7 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -59,7 +59,7 @@ class ReplayIntegration( private val currentSegment = AtomicInteger(0) // TODO: surround with try-catch on the calling site - private val saver by lazy { + private val replayExecutor by lazy { Executors.newSingleThreadScheduledExecutor(ReplayExecutorServiceThreadFactory()) } @@ -128,6 +128,18 @@ class ReplayIntegration( currentSegment.set(0) currentReplayId.set(SentryId()) + replayExecutor.submit { + // clean up old replays + options.cacheDirPath?.let { cacheDir -> + File(cacheDir).listFiles { dir, name -> + // TODO: also exclude persisted replay_id from scope when implementing ANRs + if (name.startsWith("replay_") && !name.contains(currentReplayId.get().toString())) { + FileUtils.deleteRecursively(File(dir, name)) + } + false + } + } + } if (isFullSession.get()) { // only set replayId on the scope if it's a full session, otherwise all events will be // tagged with the replay that might never be sent when we're recording in buffer mode @@ -182,7 +194,7 @@ class ReplayIntegration( } val segmentId = currentSegment.get() val replayId = currentReplayId.get() - saver.submit { + replayExecutor.submit { val videoDuration = createAndCaptureSegment(now - currentSegmentTimestamp.time, currentSegmentTimestamp, replayId, segmentId, BUFFER, hint) if (videoDuration != null) { @@ -215,7 +227,7 @@ class ReplayIntegration( val segmentId = currentSegment.get() val duration = now - currentSegmentTimestamp.time val replayId = currentReplayId.get() - saver.submit { + replayExecutor.submit { val videoDuration = createAndCaptureSegment(duration, currentSegmentTimestamp, replayId, segmentId) if (videoDuration != null) { @@ -235,7 +247,7 @@ class ReplayIntegration( val duration = now - currentSegmentTimestamp.time val replayId = currentReplayId.get() val replayCacheDir = cache?.replayCacheDir - saver.submit { + replayExecutor.submit { // we don't flush the segment, but we still wanna clean up the folder for buffer mode if (isFullSession.get()) { createAndCaptureSegment(duration, currentSegmentTimestamp, replayId, segmentId) @@ -257,7 +269,7 @@ class ReplayIntegration( // have to do it before submitting, otherwise if the queue is busy, the timestamp won't be // reflecting the exact time of when it was captured val frameTimestamp = dateProvider.currentTimeMillis - saver.submit { + replayExecutor.submit { cache?.addFrame(bitmap, frameTimestamp) val now = dateProvider.currentTimeMillis @@ -374,7 +386,7 @@ class ReplayIntegration( } stop() - saver.gracefullyShutdown(options) + replayExecutor.gracefullyShutdown(options) } private class ReplayExecutorServiceThreadFactory : ThreadFactory { From ca9f9d40a06ee955be641fc4435f59a2acc3fb20 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 4 Apr 2024 00:23:38 +0200 Subject: [PATCH 75/89] Remove unnecessary extension fun --- .../test/java/io/sentry/android/core/SentryAndroidTest.kt | 5 ++--- sentry-android-replay/api/sentry-android-replay.api | 5 ++--- sentry/api/sentry.api | 2 ++ sentry/src/main/java/io/sentry/NoOpReplayController.java | 5 +++++ sentry/src/main/java/io/sentry/ReplayController.java | 2 ++ 5 files changed, 13 insertions(+), 6 deletions(-) diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt index a4b213800f4..f8f266b1498 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt @@ -27,7 +27,6 @@ import io.sentry.android.core.cache.AndroidEnvelopeCache import io.sentry.android.core.performance.AppStartMetrics import io.sentry.android.fragment.FragmentLifecycleIntegration import io.sentry.android.replay.ReplayIntegration -import io.sentry.android.replay.getReplayIntegration import io.sentry.android.timber.SentryTimberIntegration import io.sentry.cache.IEnvelopeCache import io.sentry.cache.PersistingOptionsObserver @@ -319,7 +318,7 @@ class SentryAndroidTest { @Config(sdk = [26]) fun `init starts session replay if app is in foreground`() { initSentryWithForegroundImportance(true) { _ -> - assertTrue(Sentry.getCurrentHub().getReplayIntegration()!!.isRecording()) + assertTrue(Sentry.getCurrentHub().options.replayController.isRecording()) } } @@ -327,7 +326,7 @@ class SentryAndroidTest { @Config(sdk = [26]) fun `init does not start session replay if the app is in background`() { initSentryWithForegroundImportance(false) { _ -> - assertFalse(Sentry.getCurrentHub().getReplayIntegration()!!.isRecording()) + assertFalse(Sentry.getCurrentHub().options.replayController.isRecording()) } } diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index 0a064b803fe..3b320c91b70 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -33,7 +33,7 @@ public final class io/sentry/android/replay/ReplayCache : java/io/Closeable { public final class io/sentry/android/replay/ReplayIntegration : io/sentry/Integration, io/sentry/ReplayController, io/sentry/android/replay/ScreenshotRecorderCallback, java/io/Closeable { public fun (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;)V public fun close ()V - public final fun isRecording ()Z + public fun isRecording ()Z public fun onScreenshotRecorded (Landroid/graphics/Bitmap;)V public fun pause ()V public fun register (Lio/sentry/IHub;Lio/sentry/SentryOptions;)V @@ -44,8 +44,7 @@ public final class io/sentry/android/replay/ReplayIntegration : io/sentry/Integr } public final class io/sentry/android/replay/ReplayIntegrationKt { - public static final fun getReplayIntegration (Lio/sentry/IHub;)Lio/sentry/android/replay/ReplayIntegration; - public static final fun gracefullyShutdown (Ljava/util/concurrent/ExecutorService;Lio/sentry/SentryOptions;)V + public static final fun submitSafely (Ljava/util/concurrent/ExecutorService;Lio/sentry/ILogger;Ljava/lang/Runnable;)Ljava/util/concurrent/Future; } public abstract interface class io/sentry/android/replay/ScreenshotRecorderCallback { diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 2c116ffb21a..d3200dc76a6 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -1206,6 +1206,7 @@ public final class io/sentry/NoOpLogger : io/sentry/ILogger { public final class io/sentry/NoOpReplayController : io/sentry/ReplayController { public static fun getInstance ()Lio/sentry/NoOpReplayController; + public fun isRecording ()Z public fun pause ()V public fun resume ()V public fun sendReplayForEvent (Lio/sentry/SentryEvent;Lio/sentry/Hint;)V @@ -1604,6 +1605,7 @@ public final class io/sentry/PropagationContext { } public abstract interface class io/sentry/ReplayController { + public abstract fun isRecording ()Z public abstract fun pause ()V public abstract fun resume ()V public abstract fun sendReplayForEvent (Lio/sentry/SentryEvent;Lio/sentry/Hint;)V diff --git a/sentry/src/main/java/io/sentry/NoOpReplayController.java b/sentry/src/main/java/io/sentry/NoOpReplayController.java index d052fba8b48..1353e01a583 100644 --- a/sentry/src/main/java/io/sentry/NoOpReplayController.java +++ b/sentry/src/main/java/io/sentry/NoOpReplayController.java @@ -24,6 +24,11 @@ public void pause() {} @Override public void resume() {} + @Override + public boolean isRecording() { + return false; + } + @Override public void sendReplayForEvent(@NotNull SentryEvent event, @NotNull Hint hint) {} } diff --git a/sentry/src/main/java/io/sentry/ReplayController.java b/sentry/src/main/java/io/sentry/ReplayController.java index a45a0ecda2c..d2b7f7eb16f 100644 --- a/sentry/src/main/java/io/sentry/ReplayController.java +++ b/sentry/src/main/java/io/sentry/ReplayController.java @@ -13,5 +13,7 @@ public interface ReplayController { void resume(); + boolean isRecording(); + void sendReplayForEvent(@NotNull SentryEvent event, @NotNull Hint hint); } From ea417e426d48b10b21c85fc5e38bf5c0a21ca5ed Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 4 Apr 2024 12:12:18 +0200 Subject: [PATCH 76/89] Safe executors --- .../api/sentry-android-replay.api | 4 -- .../android/replay/ReplayIntegration.kt | 42 ++++-------- .../sentry/android/replay/WindowRecorder.kt | 24 ++++--- .../sentry/android/replay/util/Executors.kt | 67 +++++++++++++++++++ 4 files changed, 94 insertions(+), 43 deletions(-) create mode 100644 sentry-android-replay/src/main/java/io/sentry/android/replay/util/Executors.kt diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index 3b320c91b70..cda49e0fd11 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -43,10 +43,6 @@ public final class io/sentry/android/replay/ReplayIntegration : io/sentry/Integr public fun stop ()V } -public final class io/sentry/android/replay/ReplayIntegrationKt { - public static final fun submitSafely (Ljava/util/concurrent/ExecutorService;Lio/sentry/ILogger;Ljava/lang/Runnable;)Ljava/util/concurrent/Future; -} - public abstract interface class io/sentry/android/replay/ScreenshotRecorderCallback { public abstract fun onScreenshotRecorded (Landroid/graphics/Bitmap;)V } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index 8c944506bf7..3079cb47fd3 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -18,6 +18,8 @@ import io.sentry.SentryReplayEvent import io.sentry.SentryReplayEvent.ReplayType import io.sentry.SentryReplayEvent.ReplayType.BUFFER import io.sentry.SentryReplayEvent.ReplayType.SESSION +import io.sentry.android.replay.util.gracefullyShutdown +import io.sentry.android.replay.util.submitSafely import io.sentry.protocol.SentryId import io.sentry.rrweb.RRWebMetaEvent import io.sentry.rrweb.RRWebVideoEvent @@ -28,10 +30,8 @@ import java.io.Closeable import java.io.File import java.security.SecureRandom import java.util.Date -import java.util.concurrent.ExecutorService import java.util.concurrent.Executors import java.util.concurrent.ThreadFactory -import java.util.concurrent.TimeUnit.MILLISECONDS import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicLong @@ -43,6 +43,10 @@ class ReplayIntegration( private val dateProvider: ICurrentDateProvider ) : Integration, Closeable, ScreenshotRecorderCallback, ReplayController { + internal companion object { + private const val TAG = "ReplayIntegration" + } + private lateinit var options: SentryOptions private var hub: IHub? = null private var recorder: WindowRecorder? = null @@ -110,7 +114,7 @@ class ReplayIntegration( .addPackage("maven:io.sentry:sentry-android-replay", BuildConfig.VERSION_NAME) } - fun isRecording() = isRecording.get() + override fun isRecording() = isRecording.get() override fun start() { // TODO: add lifecycle state instead and manage it in start/pause/resume/stop @@ -128,7 +132,7 @@ class ReplayIntegration( currentSegment.set(0) currentReplayId.set(SentryId()) - replayExecutor.submit { + replayExecutor.submitSafely(options, "$TAG.replays_cleanup") { // clean up old replays options.cacheDirPath?.let { cacheDir -> File(cacheDir).listFiles { dir, name -> @@ -194,7 +198,7 @@ class ReplayIntegration( } val segmentId = currentSegment.get() val replayId = currentReplayId.get() - replayExecutor.submit { + replayExecutor.submitSafely(options, "$TAG.send_replay_for_event") { val videoDuration = createAndCaptureSegment(now - currentSegmentTimestamp.time, currentSegmentTimestamp, replayId, segmentId, BUFFER, hint) if (videoDuration != null) { @@ -227,7 +231,7 @@ class ReplayIntegration( val segmentId = currentSegment.get() val duration = now - currentSegmentTimestamp.time val replayId = currentReplayId.get() - replayExecutor.submit { + replayExecutor.submitSafely(options, "$TAG.pause") { val videoDuration = createAndCaptureSegment(duration, currentSegmentTimestamp, replayId, segmentId) if (videoDuration != null) { @@ -247,7 +251,7 @@ class ReplayIntegration( val duration = now - currentSegmentTimestamp.time val replayId = currentReplayId.get() val replayCacheDir = cache?.replayCacheDir - replayExecutor.submit { + replayExecutor.submitSafely(options, "$TAG.stop") { // we don't flush the segment, but we still wanna clean up the folder for buffer mode if (isFullSession.get()) { createAndCaptureSegment(duration, currentSegmentTimestamp, replayId, segmentId) @@ -269,7 +273,7 @@ class ReplayIntegration( // have to do it before submitting, otherwise if the queue is busy, the timestamp won't be // reflecting the exact time of when it was captured val frameTimestamp = dateProvider.currentTimeMillis - replayExecutor.submit { + replayExecutor.submitSafely(options, "$TAG.add_frame") { cache?.addFrame(bitmap, frameTimestamp) val now = dateProvider.currentTimeMillis @@ -398,25 +402,3 @@ class ReplayIntegration( } } } - -/** - * Retrieves the [ReplayIntegration] from the list of integrations in [SentryOptions] - */ -fun IHub.getReplayIntegration(): ReplayIntegration? = - options.integrations.find { it is ReplayIntegration } as? ReplayIntegration - -fun ExecutorService.gracefullyShutdown(options: SentryOptions) { - synchronized(this) { - if (!isShutdown) { - shutdown() - } - try { - if (!awaitTermination(options.shutdownTimeoutMillis, MILLISECONDS)) { - shutdownNow() - } - } catch (e: InterruptedException) { - shutdownNow() - Thread.currentThread().interrupt() - } - } -} diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt index d23222368fe..743b5f5d896 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt @@ -2,8 +2,9 @@ package io.sentry.android.replay import android.annotation.TargetApi import android.view.View -import io.sentry.SentryLevel.ERROR import io.sentry.SentryOptions +import io.sentry.android.replay.util.gracefullyShutdown +import io.sentry.android.replay.util.scheduleAtFixedRateSafely import java.io.Closeable import java.lang.ref.WeakReference import java.util.concurrent.Executors @@ -20,6 +21,10 @@ internal class WindowRecorder( private val screenshotRecorderCallback: ScreenshotRecorderCallback ) : Closeable { + internal companion object { + private const val TAG = "WindowRecorder" + } + private val rootViewsSpy by lazy(NONE) { RootViewsSpy.install() } @@ -57,14 +62,15 @@ internal class WindowRecorder( recorder = ScreenshotRecorder(recorderConfig, options, screenshotRecorderCallback) rootViewsSpy.listeners += onRootViewsChangedListener - capturingTask = capturer.scheduleAtFixedRate({ - try { - recorder?.capture() - } catch (e: Throwable) { - options.logger.log(ERROR, "Failed to capture a screenshot with exception:", e) - // TODO: I guess schedule the capturer again, cause it will stop executing the runnable? - } - }, 0L, 1000L / recorderConfig.frameRate, MILLISECONDS) + capturingTask = capturer.scheduleAtFixedRateSafely( + options, + "$TAG.capture", + 0L, + 1000L / recorderConfig.frameRate, + MILLISECONDS + ) { + recorder?.capture() + } } fun resume() = recorder?.resume() diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Executors.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Executors.kt new file mode 100644 index 00000000000..093416f9bb5 --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/util/Executors.kt @@ -0,0 +1,67 @@ +package io.sentry.android.replay.util + +import io.sentry.SentryLevel.ERROR +import io.sentry.SentryOptions +import java.util.concurrent.ExecutorService +import java.util.concurrent.Future +import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeUnit.MILLISECONDS + +internal fun ExecutorService.gracefullyShutdown(options: SentryOptions) { + synchronized(this) { + if (!isShutdown) { + shutdown() + } + try { + if (!awaitTermination(options.shutdownTimeoutMillis, MILLISECONDS)) { + shutdownNow() + } + } catch (e: InterruptedException) { + shutdownNow() + Thread.currentThread().interrupt() + } + } +} + +internal fun ExecutorService.submitSafely( + options: SentryOptions, + taskName: String, + task: Runnable +): Future<*>? { + return try { + submit { + try { + task.run() + } catch (e: Throwable) { + options.logger.log(ERROR, "Failed to execute task $taskName", e) + } + } + } catch (e: Throwable) { + options.logger.log(ERROR, "Failed to submit task $taskName to executor", e) + null + } +} + +internal fun ScheduledExecutorService.scheduleAtFixedRateSafely( + options: SentryOptions, + taskName: String, + initialDelay: Long, + period: Long, + unit: TimeUnit, + task: Runnable +): ScheduledFuture<*>? { + return try { + scheduleAtFixedRate({ + try { + task.run() + } catch (e: Throwable) { + options.logger.log(ERROR, "Failed to execute task $taskName", e) + } + }, initialDelay, period, unit) + } catch (e: Throwable) { + options.logger.log(ERROR, "Failed to submit task $taskName to executor", e) + null + } +} From 0c5e4b0370aab4196ac7bf331288ad4dd9a01f30 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 5 Apr 2024 10:44:56 +0200 Subject: [PATCH 77/89] Fix crashing MediaCodec and use density to determine recording resolution --- .../api/sentry-android-replay.api | 14 ++-- .../android/replay/ReplayIntegration.kt | 1 - .../android/replay/ScreenshotRecorder.kt | 52 ++++++++++----- .../sentry/android/replay/WindowRecorder.kt | 5 -- .../replay/video/SimpleVideoEncoder.kt | 65 +++++++++++++++---- .../java/io/sentry/SentryReplayOptions.java | 2 +- 6 files changed, 100 insertions(+), 39 deletions(-) diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index cda49e0fd11..32f1891af6c 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -49,26 +49,28 @@ public abstract interface class io/sentry/android/replay/ScreenshotRecorderCallb public final class io/sentry/android/replay/ScreenshotRecorderConfig { public static final field Companion Lio/sentry/android/replay/ScreenshotRecorderConfig$Companion; - public fun (IIFII)V + public fun (IIFFII)V public final fun component1 ()I public final fun component2 ()I public final fun component3 ()F - public final fun component4 ()I + public final fun component4 ()F public final fun component5 ()I - public final fun copy (IIFII)Lio/sentry/android/replay/ScreenshotRecorderConfig; - public static synthetic fun copy$default (Lio/sentry/android/replay/ScreenshotRecorderConfig;IIFIIILjava/lang/Object;)Lio/sentry/android/replay/ScreenshotRecorderConfig; + public final fun component6 ()I + public final fun copy (IIFFII)Lio/sentry/android/replay/ScreenshotRecorderConfig; + public static synthetic fun copy$default (Lio/sentry/android/replay/ScreenshotRecorderConfig;IIFFIIILjava/lang/Object;)Lio/sentry/android/replay/ScreenshotRecorderConfig; public fun equals (Ljava/lang/Object;)Z public final fun getBitRate ()I public final fun getFrameRate ()I public final fun getRecordingHeight ()I public final fun getRecordingWidth ()I - public final fun getScaleFactor ()F + public final fun getScaleFactorX ()F + public final fun getScaleFactorY ()F public fun hashCode ()I public fun toString ()Ljava/lang/String; } public final class io/sentry/android/replay/ScreenshotRecorderConfig$Companion { - public final fun from (Landroid/content/Context;ILio/sentry/SentryReplayOptions;)Lio/sentry/android/replay/ScreenshotRecorderConfig; + public final fun from (Landroid/content/Context;Lio/sentry/SentryReplayOptions;)Lio/sentry/android/replay/ScreenshotRecorderConfig; } public abstract interface class io/sentry/android/replay/video/SimpleFrameMuxer { diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index 3079cb47fd3..94a6ccf1a47 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -70,7 +70,6 @@ class ReplayIntegration( private val recorderConfig by lazy(NONE) { ScreenshotRecorderConfig.from( context, - targetHeight = 720, options.experimental.sessionReplayOptions ) } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt index e92f2a05747..7cba3bf54ac 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -30,7 +30,6 @@ import java.lang.ref.WeakReference import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference import kotlin.math.roundToInt -import kotlin.system.measureTimeMillis @TargetApi(26) internal class ScreenshotRecorder( @@ -51,14 +50,14 @@ internal class ScreenshotRecorder( ) private val singlePixelBitmapCanvas: Canvas = Canvas(singlePixelBitmap) private val prescaledMatrix = Matrix().apply { - preScale(config.scaleFactor, config.scaleFactor) + preScale(config.scaleFactorX, config.scaleFactorY) } private val contentChanged = AtomicBoolean(false) private val isCapturing = AtomicBoolean(true) private var lastScreenshot: Bitmap? = null fun capture() { - val viewHierarchy = pendingViewHierarchy.get() + val viewHierarchy = pendingViewHierarchy.getAndSet(null) if (!isCapturing.get()) { options.logger.log(DEBUG, "ScreenshotRecorder is paused, not capturing screenshot") @@ -165,12 +164,9 @@ internal class ScreenshotRecorder( return } - val time = measureTimeMillis { - val rootNode = ViewHierarchyNode.fromView(root) - root.traverse(rootNode) - pendingViewHierarchy.set(rootNode) - } - options.logger.log(DEBUG, "Took %d ms to capture view hierarchy", time) + val rootNode = ViewHierarchyNode.fromView(root) + root.traverse(rootNode) + pendingViewHierarchy.set(rootNode) contentChanged.set(true) } @@ -243,12 +239,29 @@ internal class ScreenshotRecorder( public data class ScreenshotRecorderConfig( val recordingWidth: Int, val recordingHeight: Int, - val scaleFactor: Float, + val scaleFactorX: Float, + val scaleFactorY: Float, val frameRate: Int, val bitRate: Int ) { companion object { - fun from(context: Context, targetHeight: Int, sentryReplayOptions: SentryReplayOptions): ScreenshotRecorderConfig { + /** + * Since codec block size is 16, so we have to adjust the width and height to it, otherwise + * the codec might fail to configure on some devices, see https://cs.android.com/android/platform/superproject/+/master:frameworks/base/media/java/android/media/MediaCodecInfo.java;l=1999-2001 + */ + private fun Int.adjustToBlockSize(): Int { + val remainder = this % 16 + return if (remainder <= 8) { + this - remainder + } else { + this + (16 - remainder) + } + } + + fun from( + context: Context, + sentryReplayOptions: SentryReplayOptions + ): ScreenshotRecorderConfig { // PixelCopy takes screenshots including system bars, so we have to get the real size here val wm = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager val screenBounds = if (VERSION.SDK_INT >= VERSION_CODES.R) { @@ -259,12 +272,21 @@ public data class ScreenshotRecorderConfig( wm.defaultDisplay.getRealSize(screenBounds) Rect(0, 0, screenBounds.x, screenBounds.y) } - val aspectRatio = screenBounds.height().toFloat() / screenBounds.width().toFloat() + + // use the baseline density of 1x (mdpi) + val (height, width) = + (screenBounds.height() / context.resources.displayMetrics.density) + .roundToInt() + .adjustToBlockSize() to + (screenBounds.width() / context.resources.displayMetrics.density) + .roundToInt() + .adjustToBlockSize() return ScreenshotRecorderConfig( - recordingWidth = (targetHeight / aspectRatio).roundToInt(), - recordingHeight = targetHeight, - scaleFactor = targetHeight.toFloat() / screenBounds.height(), + recordingWidth = width, + recordingHeight = height, + scaleFactorX = width.toFloat() / screenBounds.width(), + scaleFactorY = height.toFloat() / screenBounds.height(), frameRate = sentryReplayOptions.frameRate, bitRate = sentryReplayOptions.bitRate ) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt index 743b5f5d896..d5e11936deb 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt @@ -55,11 +55,6 @@ internal class WindowRecorder( return } -// val (height, width) = (wm.currentWindowMetrics.bounds.bottom / -// context.resources.displayMetrics.density).roundToInt() to -// (wm.currentWindowMetrics.bounds.right / -// context.resources.displayMetrics.density).roundToInt() - recorder = ScreenshotRecorder(recorderConfig, options, screenshotRecorderCallback) rootViewsSpy.listeners += onRootViewsChangedListener capturingTask = capturer.scheduleAtFixedRateSafely( diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt index 73b88624349..630637bfbf2 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt @@ -40,6 +40,7 @@ import io.sentry.SentryOptions import io.sentry.android.replay.ScreenshotRecorderConfig import java.io.File import java.nio.ByteBuffer +import kotlin.LazyThreadSafetyMode.NONE private const val TIMEOUT_USEC = 100_000L @@ -49,35 +50,77 @@ internal class SimpleVideoEncoder( val muxerConfig: MuxerConfig, val onClose: (() -> Unit)? = null ) { - private val mediaFormat: MediaFormat = run { + + internal val mediaCodec: MediaCodec = run { + val codec = MediaCodec.createEncoderByType(muxerConfig.mimeType) + + codec + } + + private val mediaFormat: MediaFormat by lazy(NONE) { + val videoCapabilities = mediaCodec.codecInfo + .getCapabilitiesForType(muxerConfig.mimeType) + .videoCapabilities + + var bitRate = muxerConfig.recorderConfig.bitRate + if (!videoCapabilities.bitrateRange.contains(bitRate)) { + options.logger.log( + DEBUG, + "Encoder doesn't support the provided bitRate: $bitRate, the value will be clamped to the closest one" + ) + bitRate = videoCapabilities.bitrateRange.clamp(bitRate) + } + + // TODO: if this ever becomes a problem, move this to ScreenshotRecorderConfig.from() + // TODO: because the screenshot config has to match the video config + +// var frameRate = muxerConfig.recorderConfig.frameRate +// if (!videoCapabilities.supportedFrameRates.contains(frameRate)) { +// options.logger.log(DEBUG, "Encoder doesn't support the provided frameRate: $frameRate, the value will be clamped to the closest one") +// frameRate = videoCapabilities.supportedFrameRates.clamp(frameRate) +// } + +// var height = muxerConfig.recorderConfig.recordingHeight +// var width = muxerConfig.recorderConfig.recordingWidth +// val aspectRatio = height.toFloat() / width.toFloat() +// while (!videoCapabilities.supportedHeights.contains(height) || !videoCapabilities.supportedWidths.contains(width)) { +// options.logger.log(DEBUG, "Encoder doesn't support the provided height x width: ${height}x${width}, the values will be clamped to the closest ones") +// if (!videoCapabilities.supportedHeights.contains(height)) { +// height = videoCapabilities.supportedHeights.clamp(height) +// width = (height / aspectRatio).roundToInt() +// } else if (!videoCapabilities.supportedWidths.contains(width)) { +// width = videoCapabilities.supportedWidths.clamp(width) +// height = (width * aspectRatio).roundToInt() +// } +// } + val format = MediaFormat.createVideoFormat( muxerConfig.mimeType, muxerConfig.recorderConfig.recordingWidth, muxerConfig.recorderConfig.recordingHeight ) + // this allows reducing bitrate on newer devices, where they enforce higher quality in VBR + // mode, see https://developer.android.com/reference/android/media/MediaCodec#qualityFloor + // TODO: maybe enable this back later, for now variable bitrate seems to provide much better + // TODO: quality with almost no overhead in terms of video size, let's monitor that +// format.setInteger( +// MediaFormat.KEY_BITRATE_MODE, +// MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_CBR +// ) // Set some properties. Failing to specify some of these can cause the MediaCodec // configure() call to throw an unhelpful exception. format.setInteger( MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface ) - format.setInteger(MediaFormat.KEY_BIT_RATE, muxerConfig.recorderConfig.bitRate) + format.setInteger(MediaFormat.KEY_BIT_RATE, bitRate) format.setFloat(MediaFormat.KEY_FRAME_RATE, muxerConfig.recorderConfig.frameRate.toFloat()) format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 10) format } - internal val mediaCodec: MediaCodec = run { -// val codecs = MediaCodecList(REGULAR_CODECS) -// val codecName = codecs.findEncoderForFormat(mediaFormat) -// val codec = MediaCodec.createByCodecName(codecName) - val codec = MediaCodec.createEncoderByType(muxerConfig.mimeType) - - codec - } - private val bufferInfo: MediaCodec.BufferInfo = MediaCodec.BufferInfo() private val frameMuxer = SimpleMp4FrameMuxer(muxerConfig.file.absolutePath, muxerConfig.recorderConfig.frameRate.toFloat()) val duration get() = frameMuxer.getVideoTime() diff --git a/sentry/src/main/java/io/sentry/SentryReplayOptions.java b/sentry/src/main/java/io/sentry/SentryReplayOptions.java index 51ed1a3a722..c8dc7df7a2d 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayOptions.java +++ b/sentry/src/main/java/io/sentry/SentryReplayOptions.java @@ -24,7 +24,7 @@ public final class SentryReplayOptions { * Defines the quality of the session replay. Higher bit rates have better replay quality, but * also affect the final payload size to transfer, defaults to 20kbps. */ - private int bitRate = 20_000; + private int bitRate = 100_000; /** * Number of frames per second of the replay. The bigger the number, the more accurate the replay From 3e0894d29834f6597fecd036c2383e301f615088 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 5 Apr 2024 13:38:48 +0200 Subject: [PATCH 78/89] Add redact options and align naming --- .../android/core/ManifestMetadataReader.java | 35 +++++++++++++++---- .../core/ManifestMetadataReaderTest.kt | 35 ++++++++++++++++--- .../sentry/android/core/SentryAndroidTest.kt | 2 +- .../api/sentry-android-replay.api | 2 +- .../android/replay/ReplayIntegration.kt | 22 ++++++------ .../android/replay/ScreenshotRecorder.kt | 10 +++--- .../replay/viewhierarchy/ViewHierarchyNode.kt | 9 ++--- sentry/api/sentry.api | 8 +++-- .../java/io/sentry/ExperimentalOptions.java | 10 +++--- .../java/io/sentry/SentryReplayOptions.java | 34 ++++++++++++++++++ 10 files changed, 127 insertions(+), 40 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java index d41915ab08c..2f0d363c013 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java @@ -104,6 +104,10 @@ final class ManifestMetadataReader { static final String REPLAYS_ERROR_SAMPLE_RATE = "io.sentry.session-replay.error-sample-rate"; + static final String REPLAYS_REDACT_ALL_TEXT = "io.sentry.session-replay.redact-all-text"; + + static final String REPLAYS_REDACT_ALL_IMAGES = "io.sentry.session-replay.redact-all-images"; + /** ManifestMetadataReader ctor */ private ManifestMetadataReader() {} @@ -376,23 +380,40 @@ static void applyMetadata( readBool( metadata, logger, ENABLE_APP_START_PROFILING, options.isEnableAppStartProfiling())); - if (options.getExperimental().getSessionReplayOptions().getSessionSampleRate() == null) { + if (options.getExperimental().getSessionReplay().getSessionSampleRate() == null) { final Double sessionSampleRate = readDouble(metadata, logger, REPLAYS_SESSION_SAMPLE_RATE); if (sessionSampleRate != -1) { - options - .getExperimental() - .getSessionReplayOptions() - .setSessionSampleRate(sessionSampleRate); + options.getExperimental().getSessionReplay().setSessionSampleRate(sessionSampleRate); } } - if (options.getExperimental().getSessionReplayOptions().getErrorSampleRate() == null) { + if (options.getExperimental().getSessionReplay().getErrorSampleRate() == null) { final Double errorSampleRate = readDouble(metadata, logger, REPLAYS_ERROR_SAMPLE_RATE); if (errorSampleRate != -1) { - options.getExperimental().getSessionReplayOptions().setErrorSampleRate(errorSampleRate); + options.getExperimental().getSessionReplay().setErrorSampleRate(errorSampleRate); } } + + options + .getExperimental() + .getSessionReplay() + .setRedactAllText( + readBool( + metadata, + logger, + REPLAYS_REDACT_ALL_TEXT, + options.getExperimental().getSessionReplay().getRedactAllText())); + + options + .getExperimental() + .getSessionReplay() + .setRedactAllImages( + readBool( + metadata, + logger, + REPLAYS_REDACT_ALL_IMAGES, + options.getExperimental().getSessionReplay().getRedactAllImages())); } options diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt index b46743ef7e9..d57855444fd 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt @@ -1383,14 +1383,14 @@ class ManifestMetadataReaderTest { ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) // Assert - assertEquals(expectedSampleRate.toDouble(), fixture.options.experimental.sessionReplayOptions.errorSampleRate) + assertEquals(expectedSampleRate.toDouble(), fixture.options.experimental.sessionReplay.errorSampleRate) } @Test fun `applyMetadata does not override replays errorSampleRate from options`() { // Arrange val expectedSampleRate = 0.99f - fixture.options.experimental.sessionReplayOptions.errorSampleRate = expectedSampleRate.toDouble() + fixture.options.experimental.sessionReplay.errorSampleRate = expectedSampleRate.toDouble() val bundle = bundleOf(ManifestMetadataReader.REPLAYS_ERROR_SAMPLE_RATE to 0.1f) val context = fixture.getContext(metaData = bundle) @@ -1398,7 +1398,7 @@ class ManifestMetadataReaderTest { ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) // Assert - assertEquals(expectedSampleRate.toDouble(), fixture.options.experimental.sessionReplayOptions.errorSampleRate) + assertEquals(expectedSampleRate.toDouble(), fixture.options.experimental.sessionReplay.errorSampleRate) } @Test @@ -1410,6 +1410,33 @@ class ManifestMetadataReaderTest { ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) // Assert - assertNull(fixture.options.experimental.sessionReplayOptions.errorSampleRate) + assertNull(fixture.options.experimental.sessionReplay.errorSampleRate) + } + + @Test + fun `applyMetadata reads session replay redact flags to options`() { + // Arrange + val bundle = bundleOf(ManifestMetadataReader.REPLAYS_REDACT_ALL_TEXT to false, ManifestMetadataReader.REPLAYS_REDACT_ALL_IMAGES to false) + val context = fixture.getContext(metaData = bundle) + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertFalse(fixture.options.experimental.sessionReplay.redactAllImages) + assertFalse(fixture.options.experimental.sessionReplay.redactAllText) + } + + @Test + fun `applyMetadata reads session replay redact flags to options and keeps default if not found`() { + // Arrange + val context = fixture.getContext() + + // Act + ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider) + + // Assert + assertTrue(fixture.options.experimental.sessionReplay.redactAllImages) + assertTrue(fixture.options.experimental.sessionReplay.redactAllText) } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt index f8f266b1498..17d76a752b3 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt @@ -341,7 +341,7 @@ class SentryAndroidTest { options.release = "prod" options.dsn = "https://key@sentry.io/123" options.isEnableAutoSessionTracking = true - options.experimental.sessionReplayOptions.errorSampleRate = 1.0 + options.experimental.sessionReplay.errorSampleRate = 1.0 } var session: Session? = null diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index 32f1891af6c..53e9771e136 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -118,6 +118,6 @@ public final class io/sentry/android/replay/viewhierarchy/ViewHierarchyNode { } public final class io/sentry/android/replay/viewhierarchy/ViewHierarchyNode$Companion { - public final fun fromView (Landroid/view/View;)Lio/sentry/android/replay/viewhierarchy/ViewHierarchyNode; + public final fun fromView (Landroid/view/View;Lio/sentry/SentryOptions;)Lio/sentry/android/replay/viewhierarchy/ViewHierarchyNode; } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index 94a6ccf1a47..d8df5f830aa 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -70,7 +70,7 @@ class ReplayIntegration( private val recorderConfig by lazy(NONE) { ScreenshotRecorderConfig.from( context, - options.experimental.sessionReplayOptions + options.experimental.sessionReplay ) } @@ -89,16 +89,16 @@ class ReplayIntegration( return } - if (!options.experimental.sessionReplayOptions.isSessionReplayEnabled && - !options.experimental.sessionReplayOptions.isSessionReplayForErrorsEnabled + if (!options.experimental.sessionReplay.isSessionReplayEnabled && + !options.experimental.sessionReplay.isSessionReplayForErrorsEnabled ) { options.logger.log(INFO, "Session replay is disabled, no sample rate specified") return } - isFullSession.set(sample(options.experimental.sessionReplayOptions.sessionSampleRate)) + isFullSession.set(sample(options.experimental.sessionReplay.sessionSampleRate)) if (!isFullSession.get() && - !options.experimental.sessionReplayOptions.isSessionReplayForErrorsEnabled + !options.experimental.sessionReplay.isSessionReplayForErrorsEnabled ) { options.logger.log(INFO, "Session replay is disabled, full session was not sampled and errorSampleRate is not specified") return @@ -182,12 +182,12 @@ class ReplayIntegration( return } - if (!sample(options.experimental.sessionReplayOptions.errorSampleRate)) { + if (!sample(options.experimental.sessionReplay.errorSampleRate)) { options.logger.log(INFO, "Replay wasn't sampled by errorSampleRate, not capturing for event %s", event.eventId) return } - val errorReplayDuration = options.experimental.sessionReplayOptions.errorReplayDuration + val errorReplayDuration = options.experimental.sessionReplay.errorReplayDuration val now = dateProvider.currentTimeMillis val currentSegmentTimestamp = if (cache?.frames?.isNotEmpty() == true) { // in buffer mode we have to set the timestamp of the first frame as the actual start @@ -277,7 +277,7 @@ class ReplayIntegration( val now = dateProvider.currentTimeMillis if (isFullSession.get() && - (now - segmentTimestamp.get().time >= options.experimental.sessionReplayOptions.sessionSegmentDuration) + (now - segmentTimestamp.get().time >= options.experimental.sessionReplay.sessionSegmentDuration) ) { val currentSegmentTimestamp = segmentTimestamp.get() val segmentId = currentSegment.get() @@ -285,7 +285,7 @@ class ReplayIntegration( val videoDuration = createAndCaptureSegment( - options.experimental.sessionReplayOptions.sessionSegmentDuration, + options.experimental.sessionReplay.sessionSegmentDuration, currentSegmentTimestamp, replayId, segmentId @@ -296,12 +296,12 @@ class ReplayIntegration( segmentTimestamp.set(DateUtils.getDateTime(currentSegmentTimestamp.time + videoDuration)) } } else if (isFullSession.get() && - (now - replayStartTimestamp.get() >= options.experimental.sessionReplayOptions.sessionDuration) + (now - replayStartTimestamp.get() >= options.experimental.sessionReplay.sessionDuration) ) { stop() options.logger.log(INFO, "Session replay deadline exceeded (1h), stopping recording") } else if (!isFullSession.get()) { - cache?.rotate(now - options.experimental.sessionReplayOptions.errorReplayDuration) + cache?.rotate(now - options.experimental.sessionReplay.errorReplayDuration) } } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt index 7cba3bf54ac..aaa7200abb7 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt @@ -164,7 +164,7 @@ internal class ScreenshotRecorder( return } - val rootNode = ViewHierarchyNode.fromView(root) + val rootNode = ViewHierarchyNode.fromView(root, options) root.traverse(rootNode) pendingViewHierarchy.set(rootNode) @@ -227,7 +227,7 @@ internal class ScreenshotRecorder( for (i in 0 until childCount) { val child = getChildAt(i) if (child != null) { - val childNode = ViewHierarchyNode.fromView(child) + val childNode = ViewHierarchyNode.fromView(child, options) childNodes.add(childNode) child.traverse(childNode) } @@ -260,7 +260,7 @@ public data class ScreenshotRecorderConfig( fun from( context: Context, - sentryReplayOptions: SentryReplayOptions + sessionReplay: SentryReplayOptions ): ScreenshotRecorderConfig { // PixelCopy takes screenshots including system bars, so we have to get the real size here val wm = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager @@ -287,8 +287,8 @@ public data class ScreenshotRecorderConfig( recordingHeight = height, scaleFactorX = width.toFloat() / screenBounds.width(), scaleFactorY = height.toFloat() / screenBounds.height(), - frameRate = sentryReplayOptions.frameRate, - bitRate = sentryReplayOptions.bitRate + frameRate = sessionReplay.frameRate, + bitRate = sessionReplay.bitRate ) } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt index 9b6a068f059..3db97311714 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/viewhierarchy/ViewHierarchyNode.kt @@ -14,6 +14,7 @@ import android.view.View import android.view.accessibility.AccessibilityNodeInfo import android.widget.ImageView import android.widget.TextView +import io.sentry.SentryOptions // TODO: merge with ViewHierarchyNode from sentry-core maybe? @TargetApi(26) @@ -49,14 +50,14 @@ data class ViewHierarchyNode( // TODO: check if this works on RN private fun Int.toOpaque() = this or 0xFF000000.toInt() - fun fromView(view: View): ViewHierarchyNode { + fun fromView(view: View, options: SentryOptions): ViewHierarchyNode { // TODO: Extract redacting into its own class/function // TODO: extract redacting into a separate thread? var shouldRedact = false var dominantColor: Int? = null var rect: Rect? = null - when (view) { - is TextView -> { + when { + view is TextView && options.experimental.sessionReplay.redactAllText -> { // TODO: API level check // TODO: perhaps this is heavy, might reconsider val nodeInfo = if (VERSION.SDK_INT >= VERSION_CODES.R) { @@ -101,7 +102,7 @@ data class ViewHierarchyNode( } } - is ImageView -> { + view is ImageView && options.experimental.sessionReplay.redactAllImages -> { shouldRedact = isVisible(view) && (view.drawable?.isRedactable() ?: false) if (shouldRedact) { rect = Rect() diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index d3200dc76a6..47307d418e3 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -310,8 +310,8 @@ public abstract interface class io/sentry/EventProcessor { public final class io/sentry/ExperimentalOptions { public fun ()V - public fun getSessionReplayOptions ()Lio/sentry/SentryReplayOptions; - public fun setSessionReplayOptions (Lio/sentry/SentryReplayOptions;)V + public fun getSessionReplay ()Lio/sentry/SentryReplayOptions; + public fun setSessionReplay (Lio/sentry/SentryReplayOptions;)V } public final class io/sentry/ExternalOptions { @@ -2571,12 +2571,16 @@ public final class io/sentry/SentryReplayOptions { public fun getErrorReplayDuration ()J public fun getErrorSampleRate ()Ljava/lang/Double; public fun getFrameRate ()I + public fun getRedactAllImages ()Z + public fun getRedactAllText ()Z public fun getSessionDuration ()J public fun getSessionSampleRate ()Ljava/lang/Double; public fun getSessionSegmentDuration ()J public fun isSessionReplayEnabled ()Z public fun isSessionReplayForErrorsEnabled ()Z public fun setErrorSampleRate (Ljava/lang/Double;)V + public fun setRedactAllImages (Z)V + public fun setRedactAllText (Z)V public fun setSessionSampleRate (Ljava/lang/Double;)V } diff --git a/sentry/src/main/java/io/sentry/ExperimentalOptions.java b/sentry/src/main/java/io/sentry/ExperimentalOptions.java index ebd1adabb2f..f587996bd8c 100644 --- a/sentry/src/main/java/io/sentry/ExperimentalOptions.java +++ b/sentry/src/main/java/io/sentry/ExperimentalOptions.java @@ -9,14 +9,14 @@ *

Beware that experimental options can change at any time. */ public final class ExperimentalOptions { - private @NotNull SentryReplayOptions sessionReplayOptions = new SentryReplayOptions(); + private @NotNull SentryReplayOptions sessionReplay = new SentryReplayOptions(); @NotNull - public SentryReplayOptions getSessionReplayOptions() { - return sessionReplayOptions; + public SentryReplayOptions getSessionReplay() { + return sessionReplay; } - public void setSessionReplayOptions(final @NotNull SentryReplayOptions sessionReplayOptions) { - this.sessionReplayOptions = sessionReplayOptions; + public void setSessionReplay(final @NotNull SentryReplayOptions sessionReplayOptions) { + this.sessionReplay = sessionReplayOptions; } } diff --git a/sentry/src/main/java/io/sentry/SentryReplayOptions.java b/sentry/src/main/java/io/sentry/SentryReplayOptions.java index c8dc7df7a2d..fb5848513cb 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayOptions.java +++ b/sentry/src/main/java/io/sentry/SentryReplayOptions.java @@ -20,6 +20,24 @@ public final class SentryReplayOptions { */ private @Nullable Double errorSampleRate; + /** + * Redact all text content. Draws a rectangle of text bounds with text color on top. By default + * only views extending TextView are redacted. + * + *

Default is enabled. + */ + private boolean redactAllText = true; + + /** + * Redact all image content. Draws a rectangle of image bounds with image's dominant color on top. + * By default only views extending ImageView with BitmapDrawable or custom Drawable type are + * redacted. ColorDrawable, InsetDrawable, VectorDrawable are all considered non-PII, as they come + * from the apk. + * + *

Default is enabled. + */ + private boolean redactAllImages = true; + /** * Defines the quality of the session replay. Higher bit rates have better replay quality, but * also affect the final payload size to transfer, defaults to 20kbps. @@ -87,6 +105,22 @@ public void setSessionSampleRate(final @Nullable Double sessionSampleRate) { this.sessionSampleRate = sessionSampleRate; } + public boolean getRedactAllText() { + return redactAllText; + } + + public void setRedactAllText(final boolean redactAllText) { + this.redactAllText = redactAllText; + } + + public boolean getRedactAllImages() { + return redactAllImages; + } + + public void setRedactAllImages(final boolean redactAllImages) { + this.redactAllImages = redactAllImages; + } + @ApiStatus.Internal public int getBitRate() { return bitRate; From 1fc9aa22e31eaffaf8b48dfac8fb73672276894a Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 5 Apr 2024 15:53:25 +0200 Subject: [PATCH 79/89] Fix tests --- .../src/test/java/io/sentry/android/replay/ReplayCacheTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt index 1290689d536..91addc206a4 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt @@ -37,7 +37,7 @@ class ReplayCacheTest { frameRate: Int, framesToEncode: Int = 0 ): ReplayCache { - val recorderConfig = ScreenshotRecorderConfig(100, 200, 1f, frameRate = frameRate, bitRate = 20_000) + val recorderConfig = ScreenshotRecorderConfig(100, 200, 1f, 1f, frameRate = frameRate, bitRate = 20_000) options.run { cacheDirPath = dir?.newFolder()?.absolutePath } From ad98acd0e7cc31e2e913854a269ffa8f7709d08c Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 5 Apr 2024 16:19:04 +0200 Subject: [PATCH 80/89] Fix tests --- .../replay/video/SimpleVideoEncoder.kt | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt index 630637bfbf2..35d3c90541e 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt @@ -58,17 +58,22 @@ internal class SimpleVideoEncoder( } private val mediaFormat: MediaFormat by lazy(NONE) { - val videoCapabilities = mediaCodec.codecInfo - .getCapabilitiesForType(muxerConfig.mimeType) - .videoCapabilities - var bitRate = muxerConfig.recorderConfig.bitRate - if (!videoCapabilities.bitrateRange.contains(bitRate)) { - options.logger.log( - DEBUG, - "Encoder doesn't support the provided bitRate: $bitRate, the value will be clamped to the closest one" - ) - bitRate = videoCapabilities.bitrateRange.clamp(bitRate) + + try { + val videoCapabilities = mediaCodec.codecInfo + .getCapabilitiesForType(muxerConfig.mimeType) + .videoCapabilities + + if (!videoCapabilities.bitrateRange.contains(bitRate)) { + options.logger.log( + DEBUG, + "Encoder doesn't support the provided bitRate: $bitRate, the value will be clamped to the closest one" + ) + bitRate = videoCapabilities.bitrateRange.clamp(bitRate) + } + } catch (e: Throwable) { + options.logger.log(DEBUG, "Could not retrieve MediaCodec info", e) } // TODO: if this ever becomes a problem, move this to ScreenshotRecorderConfig.from() From 227c22a2f9e02b45a0705964b5a980a0316b97cc Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 5 Apr 2024 22:22:55 +0200 Subject: [PATCH 81/89] WIP --- .../java/io/sentry/android/replay/ReplayIntegration.kt | 10 +++++++++- .../src/main/AndroidManifest.xml | 1 + 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index d8df5f830aa..4b7025352aa 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -1,6 +1,8 @@ package io.sentry.android.replay +import android.content.ComponentCallbacks import android.content.Context +import android.content.res.Configuration import android.graphics.Bitmap import android.os.Build import io.sentry.DateUtils @@ -41,7 +43,7 @@ import kotlin.LazyThreadSafetyMode.NONE class ReplayIntegration( private val context: Context, private val dateProvider: ICurrentDateProvider -) : Integration, Closeable, ScreenshotRecorderCallback, ReplayController { +) : Integration, Closeable, ScreenshotRecorderCallback, ReplayController, ComponentCallbacks { internal companion object { private const val TAG = "ReplayIntegration" @@ -392,6 +394,12 @@ class ReplayIntegration( replayExecutor.gracefullyShutdown(options) } + override fun onConfigurationChanged(newConfig: Configuration) { + + } + + override fun onLowMemory() = Unit + private class ReplayExecutorServiceThreadFactory : ThreadFactory { private var cnt = 0 override fun newThread(r: Runnable): Thread { diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index 23b469ff205..b837cc4f794 100644 --- a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml +++ b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml @@ -157,6 +157,7 @@ + From 0fca8ad26641c251aa92ba172fd79ef40caa509c Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 5 Apr 2024 22:24:29 +0200 Subject: [PATCH 82/89] Try-catch release of encoder --- .../android/replay/video/SimpleVideoEncoder.kt | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt index 35d3c90541e..7d662b72319 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt @@ -221,13 +221,17 @@ internal class SimpleVideoEncoder( } fun release() { - onClose?.invoke() - drainCodec(true) - mediaCodec.stop() - mediaCodec.release() - surface?.release() + try { + onClose?.invoke() + drainCodec(true) + mediaCodec.stop() + mediaCodec.release() + surface?.release() - frameMuxer.release() + frameMuxer.release() + } catch (e: Throwable) { + options.logger.log(DEBUG, "Failed to properly release video encoder", e) + } } } From 5e376226668e2a85812a1aed99b7367c682bba08 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 8 Apr 2024 16:58:52 +0200 Subject: [PATCH 83/89] Support orientation change for session mode --- .../io/sentry/android/replay/ReplayCache.kt | 20 +++- .../android/replay/ReplayIntegration.kt | 104 +++++++++++++----- .../sentry/android/replay/WindowRecorder.kt | 3 +- .../replay/video/SimpleVideoEncoder.kt | 15 ++- .../sentry/android/replay/ReplayCacheTest.kt | 23 ++-- 5 files changed, 118 insertions(+), 47 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt index db1f6912603..8f8dc97de79 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt @@ -30,17 +30,23 @@ public class ReplayCache internal constructor( private val options: SentryOptions, private val replayId: SentryId, private val recorderConfig: ScreenshotRecorderConfig, - private val encoderCreator: (File) -> SimpleVideoEncoder + private val encoderCreator: (videoFile: File, height: Int, width: Int) -> SimpleVideoEncoder ) : Closeable { public constructor( options: SentryOptions, replayId: SentryId, recorderConfig: ScreenshotRecorderConfig - ) : this(options, replayId, recorderConfig, encoderCreator = { videoFile -> + ) : this(options, replayId, recorderConfig, encoderCreator = { videoFile, height, width -> SimpleVideoEncoder( options, - MuxerConfig(file = videoFile, recorderConfig = recorderConfig) + MuxerConfig( + file = videoFile, + recordingHeight = height, + recordingWidth = width, + frameRate = recorderConfig.frameRate, + bitRate = recorderConfig.bitRate + ) ).also { it.start() } }) @@ -113,6 +119,10 @@ public class ReplayCache internal constructor( * @param from desired start of the video represented as unix timestamp in milliseconds * @param segmentId current segment id, used for inferring the filename to store the * result video under [replayCacheDir], e.g. "replay_/0.mp4", where segmentId=0 + * @param height desired height of the video in pixels (e.g. it can change from the initial one + * in case of window resize or orientation change) + * @param width desired width of the video in pixels (e.g. it can change from the initial one + * in case of window resize or orientation change) * @param videoFile optional, location of the file to store the result video. If this is * provided, [segmentId] from above is disregarded and not used. * @return a generated video of type [GeneratedVideo], which contains the resulting video file @@ -122,6 +132,8 @@ public class ReplayCache internal constructor( duration: Long, from: Long, segmentId: Int, + height: Int, + width: Int, videoFile: File = File(replayCacheDir, "$segmentId.mp4") ): GeneratedVideo? { if (frames.isEmpty()) { @@ -133,7 +145,7 @@ public class ReplayCache internal constructor( } // TODO: reuse instance of encoder and just change file path to create a different muxer - encoder = synchronized(encoderLock) { encoderCreator(videoFile) } + encoder = synchronized(encoderLock) { encoderCreator(videoFile, height, width) } val step = 1000 / recorderConfig.frameRate.toLong() var frameCount = 0 diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index 4b7025352aa..71aba44e463 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -38,7 +38,6 @@ import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicReference -import kotlin.LazyThreadSafetyMode.NONE class ReplayIntegration( private val context: Context, @@ -69,12 +68,7 @@ class ReplayIntegration( Executors.newSingleThreadScheduledExecutor(ReplayExecutorServiceThreadFactory()) } - private val recorderConfig by lazy(NONE) { - ScreenshotRecorderConfig.from( - context, - options.experimental.sessionReplay - ) - } + private lateinit var recorderConfig: ScreenshotRecorderConfig private fun sample(rate: Double?): Boolean { if (rate != null) { @@ -107,9 +101,15 @@ class ReplayIntegration( } this.hub = hub - recorder = WindowRecorder(options, recorderConfig, this) + recorder = WindowRecorder(options, this) isEnabled.set(true) + try { + context.registerComponentCallbacks(this) + } catch (e: Throwable) { + options.logger.log(INFO, "ComponentCallbacks is not available, orientation changes won't be handled by Session replay", e) + } + addIntegrationToSdkVersion(javaClass) SentryIntegrationPackageStorage.getInstance() .addPackage("maven:io.sentry:sentry-android-replay", BuildConfig.VERSION_NAME) @@ -150,9 +150,10 @@ class ReplayIntegration( // tagged with the replay that might never be sent when we're recording in buffer mode hub?.configureScope { it.replayId = currentReplayId.get() } } + recorderConfig = ScreenshotRecorderConfig.from(context, options.experimental.sessionReplay) cache = ReplayCache(options, currentReplayId.get(), recorderConfig) - recorder?.startRecording() + recorder?.startRecording(recorderConfig) // TODO: replace it with dateProvider.currentTimeMillis to also test it segmentTimestamp.set(DateUtils.getCurrentDateTime()) replayStartTimestamp.set(dateProvider.currentTimeMillis) @@ -174,17 +175,25 @@ class ReplayIntegration( return } - if (isFullSession.get()) { - options.logger.log(DEBUG, "Replay is already running in 'session' mode, not capturing for event %s", event.eventId) + if (!(event.isErrored || event.isCrashed)) { + options.logger.log(DEBUG, "Event is not error or crash, not capturing for event %s", event.eventId) return } - if (!(event.isErrored || event.isCrashed)) { - options.logger.log(DEBUG, "Event is not error or crash, not capturing for event %s", event.eventId) + val sampled = sample(options.experimental.sessionReplay.errorSampleRate) + + // only tag event if it's a session mode or buffer mode that got sampled + if (isFullSession.get() || sampled) { + // don't ask me why + event.setTag("replayId", currentReplayId.get().toString()) + } + + if (isFullSession.get()) { + options.logger.log(DEBUG, "Replay is already running in 'session' mode, not capturing for event %s", event.eventId) return } - if (!sample(options.experimental.sessionReplay.errorSampleRate)) { + if (!sampled) { options.logger.log(INFO, "Replay wasn't sampled by errorSampleRate, not capturing for event %s", event.eventId) return } @@ -199,9 +208,11 @@ class ReplayIntegration( } val segmentId = currentSegment.get() val replayId = currentReplayId.get() + val height = recorderConfig.recordingHeight + val width = recorderConfig.recordingWidth replayExecutor.submitSafely(options, "$TAG.send_replay_for_event") { val videoDuration = - createAndCaptureSegment(now - currentSegmentTimestamp.time, currentSegmentTimestamp, replayId, segmentId, BUFFER, hint) + createAndCaptureSegment(now - currentSegmentTimestamp.time, currentSegmentTimestamp, replayId, segmentId, height, width, BUFFER, hint) if (videoDuration != null) { currentSegment.getAndIncrement() } @@ -211,8 +222,6 @@ class ReplayIntegration( } hub?.configureScope { it.replayId = currentReplayId.get() } - // don't ask me why - event.setTag("replayId", currentReplayId.get().toString()) isFullSession.set(true) } @@ -232,9 +241,11 @@ class ReplayIntegration( val segmentId = currentSegment.get() val duration = now - currentSegmentTimestamp.time val replayId = currentReplayId.get() + val height = recorderConfig.recordingHeight + val width = recorderConfig.recordingWidth replayExecutor.submitSafely(options, "$TAG.pause") { val videoDuration = - createAndCaptureSegment(duration, currentSegmentTimestamp, replayId, segmentId) + createAndCaptureSegment(duration, currentSegmentTimestamp, replayId, segmentId, height, width) if (videoDuration != null) { currentSegment.getAndIncrement() } @@ -252,10 +263,12 @@ class ReplayIntegration( val duration = now - currentSegmentTimestamp.time val replayId = currentReplayId.get() val replayCacheDir = cache?.replayCacheDir + val height = recorderConfig.recordingHeight + val width = recorderConfig.recordingWidth replayExecutor.submitSafely(options, "$TAG.stop") { // we don't flush the segment, but we still wanna clean up the folder for buffer mode if (isFullSession.get()) { - createAndCaptureSegment(duration, currentSegmentTimestamp, replayId, segmentId) + createAndCaptureSegment(duration, currentSegmentTimestamp, replayId, segmentId, height, width) } FileUtils.deleteRecursively(replayCacheDir) } @@ -274,6 +287,8 @@ class ReplayIntegration( // have to do it before submitting, otherwise if the queue is busy, the timestamp won't be // reflecting the exact time of when it was captured val frameTimestamp = dateProvider.currentTimeMillis + val height = recorderConfig.recordingHeight + val width = recorderConfig.recordingWidth replayExecutor.submitSafely(options, "$TAG.add_frame") { cache?.addFrame(bitmap, frameTimestamp) @@ -290,7 +305,9 @@ class ReplayIntegration( options.experimental.sessionReplay.sessionSegmentDuration, currentSegmentTimestamp, replayId, - segmentId + segmentId, + height, + width ) if (videoDuration != null) { currentSegment.getAndIncrement() @@ -313,13 +330,17 @@ class ReplayIntegration( currentSegmentTimestamp: Date, replayId: SentryId, segmentId: Int, + height: Int, + width: Int, replayType: ReplayType = SESSION, hint: Hint? = null ): Long? { val generatedVideo = cache?.createVideoOf( duration, currentSegmentTimestamp.time, - segmentId + segmentId, + height, + width ) ?: return null val (video, frameCount, videoDuration) = generatedVideo @@ -328,6 +349,8 @@ class ReplayIntegration( replayId, currentSegmentTimestamp, segmentId, + height, + width, frameCount, videoDuration, replayType, @@ -341,6 +364,8 @@ class ReplayIntegration( currentReplayId: SentryId, segmentTimestamp: Date, segmentId: Int, + height: Int, + width: Int, frameCount: Int, duration: Long, replayType: ReplayType, @@ -363,8 +388,8 @@ class ReplayIntegration( payload = listOf( RRWebMetaEvent().apply { this.timestamp = segmentTimestamp.time - height = recorderConfig.recordingHeight - width = recorderConfig.recordingWidth + this.height = height + this.width = width }, RRWebVideoEvent().apply { this.timestamp = segmentTimestamp.time @@ -373,8 +398,8 @@ class ReplayIntegration( this.frameCount = frameCount size = video.length() frameRate = recorderConfig.frameRate - height = recorderConfig.recordingHeight - width = recorderConfig.recordingWidth + this.height = height + this.width = width // TODO: support non-fullscreen windows later left = 0 top = 0 @@ -390,12 +415,41 @@ class ReplayIntegration( return } + try { + context.unregisterComponentCallbacks(this) + } catch (ignored: Throwable) { + } stop() replayExecutor.gracefullyShutdown(options) } override fun onConfigurationChanged(newConfig: Configuration) { + if (!isEnabled.get()) { + return + } + + recorder?.stopRecording() + + if (isFullSession.get()) { + val now = dateProvider.currentTimeMillis + val currentSegmentTimestamp = segmentTimestamp.get() + val segmentId = currentSegment.get() + val duration = now - currentSegmentTimestamp.time + val replayId = currentReplayId.get() + val height = recorderConfig.recordingHeight + val width = recorderConfig.recordingWidth + replayExecutor.submitSafely(options, "$TAG.onConfigurationChanged") { + val videoDuration = + createAndCaptureSegment(duration, currentSegmentTimestamp, replayId, segmentId, height, width) + if (videoDuration != null) { + currentSegment.getAndIncrement() + } + } + } + // refresh config based on new device configuration + recorderConfig = ScreenshotRecorderConfig.from(context, options.experimental.sessionReplay) + recorder?.startRecording(recorderConfig) } override fun onLowMemory() = Unit diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt index d5e11936deb..58c6f15ab50 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/WindowRecorder.kt @@ -17,7 +17,6 @@ import kotlin.LazyThreadSafetyMode.NONE @TargetApi(26) internal class WindowRecorder( private val options: SentryOptions, - private val recorderConfig: ScreenshotRecorderConfig, private val screenshotRecorderCallback: ScreenshotRecorderCallback ) : Closeable { @@ -50,7 +49,7 @@ internal class WindowRecorder( } } - fun startRecording() { + fun startRecording(recorderConfig: ScreenshotRecorderConfig) { if (isRecording.getAndSet(true)) { return } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt index 630637bfbf2..6f0809edcec 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt @@ -62,7 +62,7 @@ internal class SimpleVideoEncoder( .getCapabilitiesForType(muxerConfig.mimeType) .videoCapabilities - var bitRate = muxerConfig.recorderConfig.bitRate + var bitRate = muxerConfig.bitRate if (!videoCapabilities.bitrateRange.contains(bitRate)) { options.logger.log( DEBUG, @@ -96,8 +96,8 @@ internal class SimpleVideoEncoder( val format = MediaFormat.createVideoFormat( muxerConfig.mimeType, - muxerConfig.recorderConfig.recordingWidth, - muxerConfig.recorderConfig.recordingHeight + muxerConfig.recordingWidth, + muxerConfig.recordingHeight ) // this allows reducing bitrate on newer devices, where they enforce higher quality in VBR @@ -115,14 +115,14 @@ internal class SimpleVideoEncoder( MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface ) format.setInteger(MediaFormat.KEY_BIT_RATE, bitRate) - format.setFloat(MediaFormat.KEY_FRAME_RATE, muxerConfig.recorderConfig.frameRate.toFloat()) + format.setFloat(MediaFormat.KEY_FRAME_RATE, muxerConfig.frameRate.toFloat()) format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 10) format } private val bufferInfo: MediaCodec.BufferInfo = MediaCodec.BufferInfo() - private val frameMuxer = SimpleMp4FrameMuxer(muxerConfig.file.absolutePath, muxerConfig.recorderConfig.frameRate.toFloat()) + private val frameMuxer = SimpleMp4FrameMuxer(muxerConfig.file.absolutePath, muxerConfig.frameRate.toFloat()) val duration get() = frameMuxer.getVideoTime() private var surface: Surface? = null @@ -229,6 +229,9 @@ internal class SimpleVideoEncoder( @TargetApi(24) internal data class MuxerConfig( val file: File, - val recorderConfig: ScreenshotRecorderConfig, + var recordingWidth: Int, + var recordingHeight: Int, + val frameRate: Int, + val bitRate: Int, val mimeType: String = MediaFormat.MIMETYPE_VIDEO_AVC ) diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt index 91addc206a4..1100b484ba9 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt @@ -41,12 +41,15 @@ class ReplayCacheTest { options.run { cacheDirPath = dir?.newFolder()?.absolutePath } - return ReplayCache(options, replayId, recorderConfig, encoderCreator = { videoFile -> + return ReplayCache(options, replayId, recorderConfig, encoderCreator = { videoFile, height, width -> encoder = SimpleVideoEncoder( options, MuxerConfig( file = videoFile, - recorderConfig = recorderConfig + recordingHeight = height, + recordingWidth = width, + frameRate = recorderConfig.frameRate, + bitRate = recorderConfig.bitRate ), onClose = { encodeFrame(framesToEncode, frameRate, size = 0, flags = MediaCodec.BUFFER_FLAG_END_OF_STREAM) @@ -107,7 +110,7 @@ class ReplayCacheTest { frameRate = 1 ) - val video = replayCache.createVideoOf(5000L, 0, 0) + val video = replayCache.createVideoOf(5000L, 0, 0, 100, 200) assertNull(video) } @@ -125,7 +128,7 @@ class ReplayCacheTest { replayCache.addFrame(bitmap, 1001) replayCache.addFrame(bitmap, 2001) - val segment0 = replayCache.createVideoOf(3000L, 0, 0) + val segment0 = replayCache.createVideoOf(3000L, 0, 0, 100, 200) assertEquals(3, segment0!!.frameCount) assertEquals(3000, segment0.duration) assertTrue { segment0.video.exists() && segment0.video.length() > 0 } @@ -146,7 +149,7 @@ class ReplayCacheTest { val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) replayCache.addFrame(bitmap, 1) - val segment0 = replayCache.createVideoOf(5000L, 0, 0) + val segment0 = replayCache.createVideoOf(5000L, 0, 0, 100, 200) assertEquals(5, segment0!!.frameCount) assertEquals(5000, segment0.duration) assertTrue { segment0.video.exists() && segment0.video.length() > 0 } @@ -165,7 +168,7 @@ class ReplayCacheTest { replayCache.addFrame(bitmap, 1) replayCache.addFrame(bitmap, 3001) - val segment0 = replayCache.createVideoOf(5000L, 0, 0) + val segment0 = replayCache.createVideoOf(5000L, 0, 0, 100, 200) assertEquals(5, segment0!!.frameCount) assertEquals(5000, segment0.duration) assertTrue { segment0.video.exists() && segment0.video.length() > 0 } @@ -184,12 +187,12 @@ class ReplayCacheTest { replayCache.addFrame(bitmap, 1) replayCache.addFrame(bitmap, 5001) - val segment0 = replayCache.createVideoOf(5000L, 0, 0) + val segment0 = replayCache.createVideoOf(5000L, 0, 0, 100, 200) assertEquals(5, segment0!!.frameCount) assertEquals(5000, segment0.duration) assertEquals(File(replayCache.replayCacheDir, "0.mp4"), segment0.video) - val segment1 = replayCache.createVideoOf(5000L, 5000L, 1) + val segment1 = replayCache.createVideoOf(5000L, 5000L, 1, 100, 200) assertEquals(5, segment1!!.frameCount) assertEquals(5000, segment1.duration) assertTrue { segment0.video.exists() && segment0.video.length() > 0 } @@ -209,7 +212,7 @@ class ReplayCacheTest { replayCache.addFrame(bitmap, 1001) replayCache.addFrame(bitmap, 1501) - val segment0 = replayCache.createVideoOf(3000L, 0, 0) + val segment0 = replayCache.createVideoOf(3000L, 0, 0, 100, 200) assertEquals(6, segment0!!.frameCount) assertEquals(3000, segment0.duration) assertTrue { segment0.video.exists() && segment0.video.length() > 0 } @@ -235,7 +238,7 @@ class ReplayCacheTest { } replayCache.addFrame(screenshot, frameTimestamp = 1) - val segment0 = replayCache.createVideoOf(5000L, 0, 0, videoFile = video) + val segment0 = replayCache.createVideoOf(5000L, 0, 0, 100, 200, videoFile = video) assertEquals(5, segment0!!.frameCount) assertEquals(5000, segment0.duration) From 236ee2c9c7140b57f8f7d9dfbfdd2a95b7f9a625 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 8 Apr 2024 22:22:16 +0200 Subject: [PATCH 84/89] Spotless --- sentry-android-replay/api/sentry-android-replay.api | 8 +++++--- .../io/sentry/android/replay/video/SimpleVideoEncoder.kt | 1 - 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index 53e9771e136..582210ab904 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -25,15 +25,17 @@ public final class io/sentry/android/replay/ReplayCache : java/io/Closeable { public fun (Lio/sentry/SentryOptions;Lio/sentry/protocol/SentryId;Lio/sentry/android/replay/ScreenshotRecorderConfig;)V public final fun addFrame (Ljava/io/File;J)V public fun close ()V - public final fun createVideoOf (JJILjava/io/File;)Lio/sentry/android/replay/GeneratedVideo; - public static synthetic fun createVideoOf$default (Lio/sentry/android/replay/ReplayCache;JJILjava/io/File;ILjava/lang/Object;)Lio/sentry/android/replay/GeneratedVideo; + public final fun createVideoOf (JJIIILjava/io/File;)Lio/sentry/android/replay/GeneratedVideo; + public static synthetic fun createVideoOf$default (Lio/sentry/android/replay/ReplayCache;JJIIILjava/io/File;ILjava/lang/Object;)Lio/sentry/android/replay/GeneratedVideo; public final fun rotate (J)V } -public final class io/sentry/android/replay/ReplayIntegration : io/sentry/Integration, io/sentry/ReplayController, io/sentry/android/replay/ScreenshotRecorderCallback, java/io/Closeable { +public final class io/sentry/android/replay/ReplayIntegration : android/content/ComponentCallbacks, io/sentry/Integration, io/sentry/ReplayController, io/sentry/android/replay/ScreenshotRecorderCallback, java/io/Closeable { public fun (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;)V public fun close ()V public fun isRecording ()Z + public fun onConfigurationChanged (Landroid/content/res/Configuration;)V + public fun onLowMemory ()V public fun onScreenshotRecorded (Landroid/graphics/Bitmap;)V public fun pause ()V public fun register (Lio/sentry/IHub;Lio/sentry/SentryOptions;)V diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt index 0721986ad0e..54a3bc1f89b 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/video/SimpleVideoEncoder.kt @@ -37,7 +37,6 @@ import android.media.MediaFormat import android.view.Surface import io.sentry.SentryLevel.DEBUG import io.sentry.SentryOptions -import io.sentry.android.replay.ScreenshotRecorderConfig import java.io.File import java.nio.ByteBuffer import kotlin.LazyThreadSafetyMode.NONE From e9bf0b392728cae058e8430aa8b3ee6b546fef2b Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 8 Apr 2024 22:34:24 +0200 Subject: [PATCH 85/89] TODO --- .../src/main/java/io/sentry/android/replay/ReplayIntegration.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index 71aba44e463..914376f7e58 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -430,6 +430,7 @@ class ReplayIntegration( recorder?.stopRecording() + // TODO: support buffer mode and breadcrumb/rrweb_event if (isFullSession.get()) { val now = dateProvider.currentTimeMillis val currentSegmentTimestamp = segmentTimestamp.get() From 71837c18d77c90be4f91f2f81f949d7961a784e7 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 9 Apr 2024 12:04:19 +0200 Subject: [PATCH 86/89] Update sentry/src/main/java/io/sentry/SentryReplayOptions.java Co-authored-by: Markus Hintersteiner --- sentry/src/main/java/io/sentry/SentryReplayOptions.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry/src/main/java/io/sentry/SentryReplayOptions.java b/sentry/src/main/java/io/sentry/SentryReplayOptions.java index c8dc7df7a2d..36130615722 100644 --- a/sentry/src/main/java/io/sentry/SentryReplayOptions.java +++ b/sentry/src/main/java/io/sentry/SentryReplayOptions.java @@ -22,7 +22,7 @@ public final class SentryReplayOptions { /** * Defines the quality of the session replay. Higher bit rates have better replay quality, but - * also affect the final payload size to transfer, defaults to 20kbps. + * also affect the final payload size to transfer, defaults to 100kbps. */ private int bitRate = 100_000; From 59b63e06df037e0bf2eb9ab7d8ab7285f0f90b21 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 9 Apr 2024 12:36:46 +0200 Subject: [PATCH 87/89] More gates --- .../sentry/android/replay/ReplayIntegration.kt | 16 ++++++++-------- .../java/io/sentry/android/replay/Windows.kt | 17 ++++++++--------- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index 914376f7e58..eee03988046 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -161,7 +161,7 @@ class ReplayIntegration( } override fun resume() { - if (!isEnabled.get()) { + if (!isEnabled.get() || !isRecording.get()) { return } @@ -171,7 +171,7 @@ class ReplayIntegration( } override fun sendReplayForEvent(event: SentryEvent, hint: Hint) { - if (!isEnabled.get()) { + if (!isEnabled.get() || !isRecording.get()) { return } @@ -182,8 +182,9 @@ class ReplayIntegration( val sampled = sample(options.experimental.sessionReplay.errorSampleRate) + val replayId = currentReplayId.get() // only tag event if it's a session mode or buffer mode that got sampled - if (isFullSession.get() || sampled) { + if (!replayId.equals(SentryId.EMPTY_ID) && (isFullSession.get() || sampled)) { // don't ask me why event.setTag("replayId", currentReplayId.get().toString()) } @@ -207,7 +208,6 @@ class ReplayIntegration( DateUtils.getDateTime(now - errorReplayDuration) } val segmentId = currentSegment.get() - val replayId = currentReplayId.get() val height = recorderConfig.recordingHeight val width = recorderConfig.recordingWidth replayExecutor.submitSafely(options, "$TAG.send_replay_for_event") { @@ -221,12 +221,12 @@ class ReplayIntegration( segmentTimestamp.set(DateUtils.getDateTime(now)) } - hub?.configureScope { it.replayId = currentReplayId.get() } + hub?.configureScope { it.replayId = replayId } isFullSession.set(true) } override fun pause() { - if (!isEnabled.get()) { + if (!isEnabled.get() || !isRecording.get()) { return } @@ -253,7 +253,7 @@ class ReplayIntegration( } override fun stop() { - if (!isEnabled.get()) { + if (!isEnabled.get() || !isRecording.get()) { return } @@ -424,7 +424,7 @@ class ReplayIntegration( } override fun onConfigurationChanged(newConfig: Configuration) { - if (!isEnabled.get()) { + if (!isEnabled.get() || !isRecording.get()) { return } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt index e9c6761c75b..98badf4ff75 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt @@ -136,18 +136,17 @@ internal fun interface OnRootViewsChangedListener { */ internal class RootViewsSpy private constructor() { - val listeners = CopyOnWriteArrayList() - - private val delegatingViewList = object : ArrayList() { - override fun addAll(elements: Collection): Boolean { - listeners.forEach { listener -> - elements.forEach { element -> - listener.onRootViewsChanged(element, true) - } + val listeners: CopyOnWriteArrayList = object : CopyOnWriteArrayList() { + override fun add(element: OnRootViewsChangedListener?): Boolean { + // notify listener about existing root views immediately + delegatingViewList.forEach { + element?.onRootViewsChanged(it, true) } - return super.addAll(elements) + return super.add(element) } + } + private val delegatingViewList: ArrayList = object : ArrayList() { override fun add(element: View): Boolean { listeners.forEach { it.onRootViewsChanged(element, true) } return super.add(element) From 21239ca9fb29e8c1e9e3e502a78ea9ce8db6f2fc Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 9 Apr 2024 13:17:24 +0200 Subject: [PATCH 88/89] Revert addAll --- .../src/main/java/io/sentry/android/replay/Windows.kt | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt index 98badf4ff75..8ef595f1934 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/Windows.kt @@ -147,6 +147,15 @@ internal class RootViewsSpy private constructor() { } private val delegatingViewList: ArrayList = object : ArrayList() { + override fun addAll(elements: Collection): Boolean { + listeners.forEach { listener -> + elements.forEach { element -> + listener.onRootViewsChanged(element, true) + } + } + return super.addAll(elements) + } + override fun add(element: View): Boolean { listeners.forEach { it.onRootViewsChanged(element, true) } return super.add(element) From 9cafe4365102fc66327b396a25a939840d711524 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Tue, 9 Apr 2024 14:14:24 +0200 Subject: [PATCH 89/89] Fix conflicts --- sentry/api/sentry.api | 6 ++++-- sentry/src/main/java/io/sentry/JsonObjectReader.java | 1 + sentry/src/main/java/io/sentry/ObjectReader.java | 3 +++ sentry/src/main/java/io/sentry/protocol/Geo.java | 3 ++- .../src/main/java/io/sentry/protocol/MetricSummary.java | 4 ++-- sentry/src/main/java/io/sentry/util/MapObjectReader.java | 6 ++++++ .../src/test/java/io/sentry/util/MapObjectReaderTest.kt | 8 ++++++++ 7 files changed, 26 insertions(+), 5 deletions(-) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index c78e9f73da0..02c49a4ef0d 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -1451,6 +1451,7 @@ public abstract interface class io/sentry/ObjectReader : java/io/Closeable { public abstract fun nextListOrNull (Lio/sentry/ILogger;Lio/sentry/JsonDeserializer;)Ljava/util/List; public abstract fun nextLong ()J public abstract fun nextLongOrNull ()Ljava/lang/Long; + public abstract fun nextMapOfListOrNull (Lio/sentry/ILogger;Lio/sentry/JsonDeserializer;)Ljava/util/Map; public abstract fun nextMapOrNull (Lio/sentry/ILogger;Lio/sentry/JsonDeserializer;)Ljava/util/Map; public abstract fun nextName ()Ljava/lang/String; public abstract fun nextNull ()V @@ -4346,8 +4347,8 @@ public final class io/sentry/protocol/MetricSummary : io/sentry/JsonSerializable public final class io/sentry/protocol/MetricSummary$Deserializer : io/sentry/JsonDeserializer { public fun ()V - public fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/MetricSummary; - public synthetic fun deserialize (Lio/sentry/JsonObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; + public fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Lio/sentry/protocol/MetricSummary; + public synthetic fun deserialize (Lio/sentry/ObjectReader;Lio/sentry/ILogger;)Ljava/lang/Object; } public final class io/sentry/protocol/MetricSummary$JsonKeys { @@ -5406,6 +5407,7 @@ public final class io/sentry/util/MapObjectReader : io/sentry/ObjectReader { public fun nextListOrNull (Lio/sentry/ILogger;Lio/sentry/JsonDeserializer;)Ljava/util/List; public fun nextLong ()J public fun nextLongOrNull ()Ljava/lang/Long; + public fun nextMapOfListOrNull (Lio/sentry/ILogger;Lio/sentry/JsonDeserializer;)Ljava/util/Map; public fun nextMapOrNull (Lio/sentry/ILogger;Lio/sentry/JsonDeserializer;)Ljava/util/Map; public fun nextName ()Ljava/lang/String; public fun nextNull ()V diff --git a/sentry/src/main/java/io/sentry/JsonObjectReader.java b/sentry/src/main/java/io/sentry/JsonObjectReader.java index c348b533e25..f9fe1841847 100644 --- a/sentry/src/main/java/io/sentry/JsonObjectReader.java +++ b/sentry/src/main/java/io/sentry/JsonObjectReader.java @@ -137,6 +137,7 @@ public void nextUnknown(ILogger logger, Map unknown, String name return map; } + @Override public @Nullable Map> nextMapOfListOrNull( @NotNull ILogger logger, @NotNull JsonDeserializer deserializer) throws IOException { diff --git a/sentry/src/main/java/io/sentry/ObjectReader.java b/sentry/src/main/java/io/sentry/ObjectReader.java index 6c2210897e0..6ea43926b03 100644 --- a/sentry/src/main/java/io/sentry/ObjectReader.java +++ b/sentry/src/main/java/io/sentry/ObjectReader.java @@ -36,6 +36,9 @@ public interface ObjectReader extends Closeable { @Nullable Map nextMapOrNull( @NotNull ILogger logger, @NotNull JsonDeserializer deserializer) throws IOException; + @Nullable Map> nextMapOfListOrNull( + @NotNull ILogger logger, @NotNull JsonDeserializer deserializer) throws IOException; + @Nullable T nextOrNull(@NotNull ILogger logger, @NotNull JsonDeserializer deserializer) throws Exception; diff --git a/sentry/src/main/java/io/sentry/protocol/Geo.java b/sentry/src/main/java/io/sentry/protocol/Geo.java index 6042b72d1d9..c9094223abd 100644 --- a/sentry/src/main/java/io/sentry/protocol/Geo.java +++ b/sentry/src/main/java/io/sentry/protocol/Geo.java @@ -161,7 +161,8 @@ public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger public static final class Deserializer implements JsonDeserializer { @Override - public Geo deserialize(@NotNull ObjectReader reader, ILogger logger) throws Exception { + public @NotNull Geo deserialize(@NotNull ObjectReader reader, @NotNull ILogger logger) + throws Exception { reader.beginObject(); final Geo geo = new Geo(); Map unknown = null; diff --git a/sentry/src/main/java/io/sentry/protocol/MetricSummary.java b/sentry/src/main/java/io/sentry/protocol/MetricSummary.java index db4f0b6ba50..f4a8b6de53a 100644 --- a/sentry/src/main/java/io/sentry/protocol/MetricSummary.java +++ b/sentry/src/main/java/io/sentry/protocol/MetricSummary.java @@ -2,9 +2,9 @@ import io.sentry.ILogger; import io.sentry.JsonDeserializer; -import io.sentry.JsonObjectReader; import io.sentry.JsonSerializable; import io.sentry.JsonUnknown; +import io.sentry.ObjectReader; import io.sentry.ObjectWriter; import io.sentry.util.CollectionUtils; import io.sentry.vendor.gson.stream.JsonToken; @@ -121,7 +121,7 @@ public static final class Deserializer implements JsonDeserializer unknown = null; diff --git a/sentry/src/main/java/io/sentry/util/MapObjectReader.java b/sentry/src/main/java/io/sentry/util/MapObjectReader.java index 6cb05989b25..cd44ee07774 100644 --- a/sentry/src/main/java/io/sentry/util/MapObjectReader.java +++ b/sentry/src/main/java/io/sentry/util/MapObjectReader.java @@ -53,6 +53,12 @@ public Map nextMapOrNull( return nextValueOrNull(); } + @Override + public @Nullable Map> nextMapOfListOrNull( + @NotNull ILogger logger, @NotNull JsonDeserializer deserializer) throws IOException { + return nextValueOrNull(); + } + @Nullable @Override public T nextOrNull( diff --git a/sentry/src/test/java/io/sentry/util/MapObjectReaderTest.kt b/sentry/src/test/java/io/sentry/util/MapObjectReaderTest.kt index ab52919c438..06124c88e7a 100644 --- a/sentry/src/test/java/io/sentry/util/MapObjectReaderTest.kt +++ b/sentry/src/test/java/io/sentry/util/MapObjectReaderTest.kt @@ -68,6 +68,7 @@ class MapObjectReaderTest { writer.name("Collection").value(logger, listOf("a", "b")) writer.name("Arrays").value(logger, arrayOf("b", "c")) writer.name("Map").value(logger, mapOf(kotlin.Pair("key", "value"))) + writer.name("MapOfLists").value(logger, mapOf("metric_a" to listOf("foo"))) writer.name("Locale").value(logger, Locale.US) writer.name("URI").value(logger, URI.create("http://www.example.com")) writer.name("UUID").value(logger, UUID.fromString("00000000-1111-2222-3333-444444444444")) @@ -90,6 +91,13 @@ class MapObjectReaderTest { assertEquals(URI.create("http://www.example.com"), URI.create(reader.nextString())) assertEquals("Locale", reader.nextName()) assertEquals(Locale.US.toString(), reader.nextString()) + assertEquals("MapOfLists", reader.nextName()) + reader.beginObject() + assertEquals("metric_a", reader.nextName()) + reader.beginArray() + assertEquals("foo", reader.nextStringOrNull()) + reader.endArray() + reader.endObject() assertEquals("Map", reader.nextName()) // nested object reader.beginObject()