diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index 53e9771e13..582210ab90 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/ReplayCache.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt index db1f691260..8f8dc97de7 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 d8df5f830a..914376f7e5 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 @@ -36,12 +38,11 @@ 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, private val dateProvider: ICurrentDateProvider -) : Integration, Closeable, ScreenshotRecorderCallback, ReplayController { +) : Integration, Closeable, ScreenshotRecorderCallback, ReplayController, ComponentCallbacks { internal companion object { private const val TAG = "ReplayIntegration" @@ -67,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) { @@ -105,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) @@ -148,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) @@ -172,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 } @@ -197,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() } @@ -209,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) } @@ -230,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() } @@ -250,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) } @@ -272,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) @@ -288,7 +305,9 @@ class ReplayIntegration( options.experimental.sessionReplay.sessionSegmentDuration, currentSegmentTimestamp, replayId, - segmentId + segmentId, + height, + width ) if (videoDuration != null) { currentSegment.getAndIncrement() @@ -311,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 @@ -326,6 +349,8 @@ class ReplayIntegration( replayId, currentSegmentTimestamp, segmentId, + height, + width, frameCount, videoDuration, replayType, @@ -339,6 +364,8 @@ class ReplayIntegration( currentReplayId: SentryId, segmentTimestamp: Date, segmentId: Int, + height: Int, + width: Int, frameCount: Int, duration: Long, replayType: ReplayType, @@ -361,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 @@ -371,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 @@ -388,10 +415,46 @@ 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() + + // TODO: support buffer mode and breadcrumb/rrweb_event + 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 + private class ReplayExecutorServiceThreadFactory : ThreadFactory { private var cnt = 0 override fun newThread(r: Runnable): Thread { 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 d5e11936de..58c6f15ab5 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 7d662b7231..54a3bc1f89 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 @@ -58,7 +57,7 @@ internal class SimpleVideoEncoder( } private val mediaFormat: MediaFormat by lazy(NONE) { - var bitRate = muxerConfig.recorderConfig.bitRate + var bitRate = muxerConfig.bitRate try { val videoCapabilities = mediaCodec.codecInfo @@ -101,8 +100,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 @@ -120,14 +119,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 @@ -238,6 +237,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 91addc206a..1100b484ba 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) diff --git a/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml b/sentry-samples/sentry-samples-android/src/main/AndroidManifest.xml index 23b469ff20..b837cc4f79 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 @@ +