diff --git a/sentry-android-replay/api/sentry-android-replay.api b/sentry-android-replay/api/sentry-android-replay.api index 582210ab90..3918cde06b 100644 --- a/sentry-android-replay/api/sentry-android-replay.api +++ b/sentry-android-replay/api/sentry-android-replay.api @@ -21,6 +21,13 @@ public final class io/sentry/android/replay/GeneratedVideo { public fun toString ()Ljava/lang/String; } +public abstract interface class io/sentry/android/replay/Recorder : java/io/Closeable { + public abstract fun pause ()V + public abstract fun resume ()V + public abstract fun start (Lio/sentry/android/replay/ScreenshotRecorderConfig;)V + public abstract fun stop ()V +} + 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 @@ -32,11 +39,16 @@ public final class io/sentry/android/replay/ReplayCache : 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 (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V + public synthetic fun (Landroid/content/Context;Lio/sentry/transport/ICurrentDateProvider;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun close ()V + public final fun getReplayCacheDir ()Ljava/io/File; + public fun getReplayId ()Lio/sentry/protocol/SentryId; 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 onScreenshotRecorded (Ljava/io/File;J)V public fun pause ()V public fun register (Lio/sentry/IHub;Lio/sentry/SentryOptions;)V public fun resume ()V @@ -47,6 +59,7 @@ public final class io/sentry/android/replay/ReplayIntegration : android/content/ public abstract interface class io/sentry/android/replay/ScreenshotRecorderCallback { public abstract fun onScreenshotRecorded (Landroid/graphics/Bitmap;)V + public abstract fun onScreenshotRecorded (Ljava/io/File;J)V } public final class io/sentry/android/replay/ScreenshotRecorderConfig { diff --git a/sentry-android-replay/build.gradle.kts b/sentry-android-replay/build.gradle.kts index 319386ee2b..bd9b5d961b 100644 --- a/sentry-android-replay/build.gradle.kts +++ b/sentry-android-replay/build.gradle.kts @@ -75,6 +75,7 @@ dependencies { testImplementation(Config.TestLibs.androidxJunit) testImplementation(Config.TestLibs.mockitoKotlin) testImplementation(Config.TestLibs.mockitoInline) + testImplementation(Config.TestLibs.awaitility) } tasks.withType { diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/Recorder.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/Recorder.kt new file mode 100644 index 0000000000..6cf86b6a7e --- /dev/null +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/Recorder.kt @@ -0,0 +1,18 @@ +package io.sentry.android.replay + +import java.io.Closeable + +interface Recorder : Closeable { + /** + * @param recorderConfig a [ScreenshotRecorderConfig] that can be used to determine frame rate + * at which the screenshots should be taken, and the screenshots size/resolution, which can + * change e.g. in the case of orientation change or window size change + */ + fun start(recorderConfig: ScreenshotRecorderConfig) + + fun resume() + + fun pause() + + fun stop() +} 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 8f8dc97de7..f49abfaa84 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,14 +30,14 @@ public class ReplayCache internal constructor( private val options: SentryOptions, private val replayId: SentryId, private val recorderConfig: ScreenshotRecorderConfig, - private val encoderCreator: (videoFile: File, height: Int, width: Int) -> SimpleVideoEncoder + private val encoderProvider: (videoFile: File, height: Int, width: Int) -> SimpleVideoEncoder ) : Closeable { public constructor( options: SentryOptions, replayId: SentryId, recorderConfig: ScreenshotRecorderConfig - ) : this(options, replayId, recorderConfig, encoderCreator = { videoFile, height, width -> + ) : this(options, replayId, recorderConfig, encoderProvider = { videoFile, height, width -> SimpleVideoEncoder( options, MuxerConfig( @@ -145,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, height, width) } + encoder = synchronized(encoderLock) { encoderProvider(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 b5cece07c1..cc3248e1a4 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 @@ -22,23 +22,37 @@ import io.sentry.protocol.SentryId import io.sentry.transport.ICurrentDateProvider import io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion import java.io.Closeable +import java.io.File import java.security.SecureRandom import java.util.concurrent.atomic.AtomicBoolean -class ReplayIntegration( +public class ReplayIntegration( private val context: Context, - private val dateProvider: ICurrentDateProvider + private val dateProvider: ICurrentDateProvider, + private val recorderProvider: (() -> Recorder)? = null, + private val recorderConfigProvider: ((configChanged: Boolean) -> ScreenshotRecorderConfig)? = null, + private val replayCacheProvider: ((replayId: SentryId) -> ReplayCache)? = null ) : Integration, Closeable, ScreenshotRecorderCallback, ReplayController, ComponentCallbacks { + // needed for the Java's call site + constructor(context: Context, dateProvider: ICurrentDateProvider) : this( + context, + dateProvider, + null, + null, + null + ) + private lateinit var options: SentryOptions private var hub: IHub? = null - private var recorder: WindowRecorder? = null + private var recorder: Recorder? = null private val random by lazy { SecureRandom() } // TODO: probably not everything has to be thread-safe here private val isEnabled = AtomicBoolean(false) private val isRecording = AtomicBoolean(false) private var captureStrategy: CaptureStrategy? = null + public val replayCacheDir: File? get() = captureStrategy?.replayCacheDir private lateinit var recorderConfig: ScreenshotRecorderConfig @@ -58,7 +72,7 @@ class ReplayIntegration( } this.hub = hub - recorder = WindowRecorder(options, this) + recorder = recorderProvider?.invoke() ?: WindowRecorder(options, this) isEnabled.set(true) try { @@ -94,15 +108,15 @@ class ReplayIntegration( return } - recorderConfig = ScreenshotRecorderConfig.from(context, options.experimental.sessionReplay) + recorderConfig = recorderConfigProvider?.invoke(false) ?: ScreenshotRecorderConfig.from(context, options.experimental.sessionReplay) captureStrategy = if (isFullSession) { - SessionCaptureStrategy(options, hub, dateProvider, recorderConfig) + SessionCaptureStrategy(options, hub, dateProvider, recorderConfig, replayCacheProvider = replayCacheProvider) } else { - BufferCaptureStrategy(options, hub, dateProvider, recorderConfig, random) + BufferCaptureStrategy(options, hub, dateProvider, recorderConfig, random, replayCacheProvider) } captureStrategy?.start() - recorder?.startRecording(recorderConfig) + recorder?.start(recorderConfig) } override fun resume() { @@ -133,6 +147,8 @@ class ReplayIntegration( captureStrategy = captureStrategy?.convert() } + override fun getReplayId(): SentryId = captureStrategy?.currentReplayId?.get() ?: SentryId.EMPTY_ID + override fun pause() { if (!isEnabled.get() || !isRecording.get()) { return @@ -147,14 +163,22 @@ class ReplayIntegration( return } - recorder?.stopRecording() + recorder?.stop() captureStrategy?.stop() isRecording.set(false) captureStrategy = null } override fun onScreenshotRecorded(bitmap: Bitmap) { - captureStrategy?.onScreenshotRecorded(bitmap) + captureStrategy?.onScreenshotRecorded { frameTimeStamp -> + addFrame(bitmap, frameTimeStamp) + } + } + + override fun onScreenshotRecorded(screenshot: File, frameTimestamp: Long) { + captureStrategy?.onScreenshotRecorded { _ -> + addFrame(screenshot, frameTimestamp) + } } override fun close() { @@ -169,6 +193,8 @@ class ReplayIntegration( stop() captureStrategy?.close() captureStrategy = null + recorder?.close() + recorder = null } override fun onConfigurationChanged(newConfig: Configuration) { @@ -176,13 +202,13 @@ class ReplayIntegration( return } - recorder?.stopRecording() + recorder?.stop() // refresh config based on new device configuration - recorderConfig = ScreenshotRecorderConfig.from(context, options.experimental.sessionReplay) + recorderConfig = recorderConfigProvider?.invoke(true) ?: ScreenshotRecorderConfig.from(context, options.experimental.sessionReplay) captureStrategy?.onConfigurationChanged(recorderConfig) - recorder?.startRecording(recorderConfig) + recorder?.start(recorderConfig) } override fun onLowMemory() = Unit 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 aaa7200abb..c9b6846529 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 @@ -26,6 +26,7 @@ import io.sentry.SentryLevel.WARNING import io.sentry.SentryOptions import io.sentry.SentryReplayOptions import io.sentry.android.replay.viewhierarchy.ViewHierarchyNode +import java.io.File import java.lang.ref.WeakReference import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference @@ -35,7 +36,7 @@ import kotlin.math.roundToInt internal class ScreenshotRecorder( val config: ScreenshotRecorderConfig, val options: SentryOptions, - private val screenshotRecorderCallback: ScreenshotRecorderCallback + private val screenshotRecorderCallback: ScreenshotRecorderCallback? ) : ViewTreeObserver.OnDrawListener { private var rootView: WeakReference? = null @@ -68,7 +69,7 @@ internal class ScreenshotRecorder( options.logger.log(DEBUG, "Content hasn't changed, repeating last known frame") lastScreenshot?.let { - screenshotRecorderCallback.onScreenshotRecorded( + screenshotRecorderCallback?.onScreenshotRecorded( it.copy(ARGB_8888, false) ) } @@ -140,7 +141,7 @@ internal class ScreenshotRecorder( } val screenshot = scaledBitmap.copy(ARGB_8888, false) - screenshotRecorderCallback.onScreenshotRecorded(screenshot) + screenshotRecorderCallback?.onScreenshotRecorded(screenshot) lastScreenshot?.recycle() lastScreenshot = screenshot contentChanged.set(false) @@ -294,6 +295,24 @@ public data class ScreenshotRecorderConfig( } } -interface ScreenshotRecorderCallback { +/** + * A callback to be invoked when a new screenshot available. Normally, only one of the + * [onScreenshotRecorded] method overloads should be called by a single recorder, however, it will + * still work of both are used at the same time. + */ +public interface ScreenshotRecorderCallback { + /** + * Called whenever a new frame screenshot is available. + * + * @param bitmap a screenshot taken in the form of [android.graphics.Bitmap] + */ fun onScreenshotRecorded(bitmap: Bitmap) + + /** + * Called whenever a new frame screenshot is available. + * + * @param screenshot file containing the frame screenshot + * @param frameTimestamp the timestamp when the frame screenshot was taken + */ + fun onScreenshotRecorded(screenshot: File, frameTimestamp: Long) } 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 83a49199ef..ceadfcf573 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 @@ -5,7 +5,6 @@ import android.view.View 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 import java.util.concurrent.ScheduledFuture @@ -17,8 +16,8 @@ import kotlin.LazyThreadSafetyMode.NONE @TargetApi(26) internal class WindowRecorder( private val options: SentryOptions, - private val screenshotRecorderCallback: ScreenshotRecorderCallback -) : Closeable { + private val screenshotRecorderCallback: ScreenshotRecorderCallback? = null +) : Recorder { internal companion object { private const val TAG = "WindowRecorder" @@ -51,7 +50,7 @@ internal class WindowRecorder( } } - fun startRecording(recorderConfig: ScreenshotRecorderConfig) { + override fun start(recorderConfig: ScreenshotRecorderConfig) { if (isRecording.getAndSet(true)) { return } @@ -69,10 +68,14 @@ internal class WindowRecorder( } } - fun resume() = recorder?.resume() - fun pause() = recorder?.pause() + override fun resume() { + recorder?.resume() + } + override fun pause() { + recorder?.pause() + } - fun stopRecording() { + override fun stop() { rootViewsSpy.listeners -= onRootViewsChangedListener rootViews.forEach { recorder?.unbind(it.get()) } recorder?.close() @@ -93,7 +96,7 @@ internal class WindowRecorder( } override fun close() { - stopRecording() + stop() capturer.gracefullyShutdown(options) } } diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt index 6e6ff98281..2e0eb1ba7c 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BaseCaptureStrategy.kt @@ -33,7 +33,8 @@ internal abstract class BaseCaptureStrategy( private val hub: IHub?, private val dateProvider: ICurrentDateProvider, protected var recorderConfig: ScreenshotRecorderConfig, - executor: ScheduledExecutorService? = null + executor: ScheduledExecutorService? = null, + private val replayCacheProvider: ((replayId: SentryId) -> ReplayCache)? = null ) : CaptureStrategy { internal companion object { @@ -45,6 +46,7 @@ internal abstract class BaseCaptureStrategy( protected val replayStartTimestamp = AtomicLong() override val currentReplayId = AtomicReference(SentryId.EMPTY_ID) override val currentSegment = AtomicInteger(0) + override val replayCacheDir: File? get() = cache?.replayCacheDir protected val replayExecutor: ScheduledExecutorService by lazy { executor ?: Executors.newSingleThreadScheduledExecutor(ReplayExecutorServiceThreadFactory()) @@ -72,7 +74,7 @@ internal abstract class BaseCaptureStrategy( } } - cache = ReplayCache(options, currentReplayId.get(), recorderConfig) + cache = replayCacheProvider?.invoke(replayId) ?: ReplayCache(options, replayId, recorderConfig) // TODO: replace it with dateProvider.currentTimeMillis to also test it segmentTimestamp.set(DateUtils.getCurrentDateTime()) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt index e7cb39f48a..0c5dfc7314 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/BufferCaptureStrategy.kt @@ -1,6 +1,5 @@ package io.sentry.android.replay.capture -import android.graphics.Bitmap import io.sentry.DateUtils import io.sentry.Hint import io.sentry.IHub @@ -9,9 +8,11 @@ import io.sentry.SentryLevel.ERROR import io.sentry.SentryLevel.INFO import io.sentry.SentryOptions import io.sentry.SentryReplayEvent.ReplayType.BUFFER +import io.sentry.android.replay.ReplayCache import io.sentry.android.replay.ScreenshotRecorderConfig import io.sentry.android.replay.util.sample import io.sentry.android.replay.util.submitSafely +import io.sentry.protocol.SentryId import io.sentry.transport.ICurrentDateProvider import io.sentry.util.FileUtils import java.io.File @@ -22,8 +23,9 @@ internal class BufferCaptureStrategy( private val hub: IHub?, private val dateProvider: ICurrentDateProvider, recorderConfig: ScreenshotRecorderConfig, - private val random: SecureRandom -) : BaseCaptureStrategy(options, hub, dateProvider, recorderConfig) { + private val random: SecureRandom, + replayCacheProvider: ((replayId: SentryId) -> ReplayCache)? = null +) : BaseCaptureStrategy(options, hub, dateProvider, recorderConfig, replayCacheProvider = replayCacheProvider) { private val bufferedSegments = mutableListOf() @@ -91,12 +93,12 @@ internal class BufferCaptureStrategy( } } - override fun onScreenshotRecorded(bitmap: Bitmap) { + override fun onScreenshotRecorded(store: ReplayCache.(frameTimestamp: Long) -> Unit) { // 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.submitSafely(options, "$TAG.add_frame") { - cache?.addFrame(bitmap, frameTimestamp) + cache?.store(frameTimestamp) val now = dateProvider.currentTimeMillis val bufferLimit = now - options.experimental.sessionReplay.errorReplayDuration diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt index 8e0e47dcf8..821ebcef66 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/CaptureStrategy.kt @@ -1,16 +1,18 @@ package io.sentry.android.replay.capture -import android.graphics.Bitmap import io.sentry.Hint import io.sentry.SentryEvent +import io.sentry.android.replay.ReplayCache import io.sentry.android.replay.ScreenshotRecorderConfig import io.sentry.protocol.SentryId +import java.io.File import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicReference internal interface CaptureStrategy { val currentSegment: AtomicInteger val currentReplayId: AtomicReference + val replayCacheDir: File? fun start(segmentId: Int = 0, replayId: SentryId = SentryId(), cleanupOldReplays: Boolean = true) @@ -22,7 +24,7 @@ internal interface CaptureStrategy { fun sendReplayForEvent(event: SentryEvent, hint: Hint, onSegmentSent: () -> Unit) - fun onScreenshotRecorded(bitmap: Bitmap) + fun onScreenshotRecorded(store: ReplayCache.(frameTimestamp: Long) -> Unit) fun onConfigurationChanged(recorderConfig: ScreenshotRecorderConfig) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt index 5e32b57b6f..771b760b88 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt @@ -1,6 +1,5 @@ package io.sentry.android.replay.capture -import android.graphics.Bitmap import io.sentry.DateUtils import io.sentry.Hint import io.sentry.IHub @@ -8,6 +7,7 @@ import io.sentry.SentryEvent import io.sentry.SentryLevel.DEBUG import io.sentry.SentryLevel.INFO import io.sentry.SentryOptions +import io.sentry.android.replay.ReplayCache import io.sentry.android.replay.ScreenshotRecorderConfig import io.sentry.android.replay.util.submitSafely import io.sentry.protocol.SentryId @@ -20,8 +20,9 @@ internal class SessionCaptureStrategy( private val hub: IHub?, private val dateProvider: ICurrentDateProvider, recorderConfig: ScreenshotRecorderConfig, - executor: ScheduledExecutorService? = null -) : BaseCaptureStrategy(options, hub, dateProvider, recorderConfig, executor) { + executor: ScheduledExecutorService? = null, + replayCacheProvider: ((replayId: SentryId) -> ReplayCache)? = null +) : BaseCaptureStrategy(options, hub, dateProvider, recorderConfig, executor, replayCacheProvider) { internal companion object { private const val TAG = "SessionCaptureStrategy" @@ -72,14 +73,14 @@ internal class SessionCaptureStrategy( } } - override fun onScreenshotRecorded(bitmap: Bitmap) { + override fun onScreenshotRecorded(store: ReplayCache.(frameTimestamp: Long) -> Unit) { // 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) + cache?.store(frameTimestamp) val now = dateProvider.currentTimeMillis if ((now - segmentTimestamp.get().time >= options.experimental.sessionReplay.sessionSegmentDuration)) { 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 1100b484ba..3608b77ccb 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,7 +41,7 @@ class ReplayCacheTest { options.run { cacheDirPath = dir?.newFolder()?.absolutePath } - return ReplayCache(options, replayId, recorderConfig, encoderCreator = { videoFile, height, width -> + return ReplayCache(options, replayId, recorderConfig, encoderProvider = { videoFile, height, width -> encoder = SimpleVideoEncoder( options, MuxerConfig( diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationWithRecorderTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationWithRecorderTest.kt new file mode 100644 index 0000000000..b4994cdb21 --- /dev/null +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationWithRecorderTest.kt @@ -0,0 +1,231 @@ +package io.sentry.android.replay + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Bitmap.CompressFormat.JPEG +import android.graphics.Bitmap.Config.ARGB_8888 +import android.media.MediaCodec +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.IHub +import io.sentry.SentryOptions +import io.sentry.SentryReplayEvent.ReplayType +import io.sentry.android.replay.ReplayIntegrationWithRecorderTest.LifecycleState.CLOSED +import io.sentry.android.replay.ReplayIntegrationWithRecorderTest.LifecycleState.INITALIZED +import io.sentry.android.replay.ReplayIntegrationWithRecorderTest.LifecycleState.PAUSED +import io.sentry.android.replay.ReplayIntegrationWithRecorderTest.LifecycleState.RESUMED +import io.sentry.android.replay.ReplayIntegrationWithRecorderTest.LifecycleState.STARTED +import io.sentry.android.replay.ReplayIntegrationWithRecorderTest.LifecycleState.STOPPED +import io.sentry.android.replay.video.MuxerConfig +import io.sentry.android.replay.video.SimpleVideoEncoder +import io.sentry.rrweb.RRWebMetaEvent +import io.sentry.rrweb.RRWebVideoEvent +import io.sentry.transport.CurrentDateProvider +import io.sentry.transport.ICurrentDateProvider +import org.awaitility.kotlin.await +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.check +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.annotation.Config +import java.io.File +import java.util.concurrent.TimeUnit.MICROSECONDS +import java.util.concurrent.TimeUnit.MILLISECONDS +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.test.BeforeTest +import kotlin.test.assertEquals + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [26]) +class ReplayIntegrationWithRecorderTest { + + @get:Rule + val tmpDir = TemporaryFolder() + + internal class Fixture { + val options = SentryOptions() + val hub = mock() + var encoder: SimpleVideoEncoder? = null + + fun getSut( + context: Context, + recorder: Recorder, + recorderConfig: ScreenshotRecorderConfig, + dateProvider: ICurrentDateProvider = CurrentDateProvider.getInstance(), + framesToEncode: Int = 0 + ): ReplayIntegration { + return ReplayIntegration( + context, + dateProvider, + recorderProvider = { recorder }, + recorderConfigProvider = { recorderConfig }, + // this is just needed for testing to encode a fake video + replayCacheProvider = { replayId -> + ReplayCache( + options, + replayId, + recorderConfig, + encoderProvider = { videoFile, height, width -> + encoder = SimpleVideoEncoder( + options, + MuxerConfig( + file = videoFile, + recordingHeight = height, + recordingWidth = width, + frameRate = recorderConfig.frameRate, + bitRate = recorderConfig.bitRate + ), + onClose = { + encodeFrame( + framesToEncode, + recorderConfig.frameRate, + size = 0, + flags = MediaCodec.BUFFER_FLAG_END_OF_STREAM + ) + } + ).also { it.start() } + repeat(framesToEncode) { encodeFrame(it, recorderConfig.frameRate) } + + encoder!! + } + ) + } + ) + } + + private 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() + private lateinit var context: Context + + @BeforeTest + fun `set up`() { + context = ApplicationProvider.getApplicationContext() + } + + @Test + fun `works with different recorder`() { + val captured = AtomicBoolean(false) + whenever(fixture.hub.captureReplay(any(), anyOrNull())).then { + captured.set(true) + } + // fake current time to trigger segment creation, CurrentDateProvider.getInstance() should + // be used in prod + val dateProvider = ICurrentDateProvider { + System.currentTimeMillis() + fixture.options.experimental.sessionReplay.sessionSegmentDuration + } + + fixture.options.experimental.sessionReplay.sessionSampleRate = 1.0 + fixture.options.cacheDirPath = tmpDir.newFolder().absolutePath + + val replay: ReplayIntegration + val recorderConfig = ScreenshotRecorderConfig(100, 200, 1f, 1f, 1, 20_000) + val recorder = object : Recorder { + var state: LifecycleState = INITALIZED + + override fun start(recorderConfig: ScreenshotRecorderConfig) { + state = STARTED + } + + override fun resume() { + state = RESUMED + } + + override fun pause() { + state = PAUSED + } + + override fun stop() { + state = STOPPED + } + + override fun close() { + state = CLOSED + } + } + + replay = fixture.getSut(context, recorder, recorderConfig, dateProvider, framesToEncode = 5) + replay.register(fixture.hub, fixture.options) + + assertEquals(INITALIZED, recorder.state) + + replay.start() + assertEquals(STARTED, recorder.state) + + replay.resume() + assertEquals(RESUMED, recorder.state) + + replay.pause() + assertEquals(PAUSED, recorder.state) + + replay.stop() + assertEquals(STOPPED, recorder.state) + + replay.close() + assertEquals(CLOSED, recorder.state) + + // start again and capture some frames + replay.start() + + // have to access 'replayCacheDir' after calling replay.start(), BUT can already be accessed + // inside recorder.start() + val screenshot = File(replay.replayCacheDir, "1.jpg").also { it.createNewFile() } + + screenshot.outputStream().use { + Bitmap.createBitmap(1, 1, ARGB_8888).compress(JPEG, 80, it) + it.flush() + } + replay.onScreenshotRecorded(screenshot, frameTimestamp = 1) + + // verify + await.untilTrue(captured) + + verify(fixture.hub).captureReplay( + check { + assertEquals(replay.replayId, it.replayId) + assertEquals(ReplayType.SESSION, it.replayType) + assertEquals("0.mp4", it.videoFile?.name) + assertEquals("replay_${replay.replayId}", it.videoFile?.parentFile?.name) + }, + check { + val metaEvents = it.replayRecording?.payload?.filterIsInstance() + assertEquals(200, metaEvents?.first()?.height) + assertEquals(100, metaEvents?.first()?.width) + + val videoEvents = it.replayRecording?.payload?.filterIsInstance() + assertEquals(200, videoEvents?.first()?.height) + assertEquals(100, videoEvents?.first()?.width) + assertEquals(5000, videoEvents?.first()?.durationMs) + assertEquals(5, videoEvents?.first()?.frameCount) + assertEquals(1, videoEvents?.first()?.frameRate) + assertEquals(0, videoEvents?.first()?.segmentId) + } + ) + } + + enum class LifecycleState { + INITALIZED, + STARTED, + RESUMED, + PAUSED, + STOPPED, + CLOSED + } +} diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 22e8613730..36fbaf3f30 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -1255,6 +1255,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 getReplayId ()Lio/sentry/protocol/SentryId; public fun isRecording ()Z public fun pause ()V public fun resume ()V @@ -1657,6 +1658,7 @@ public final class io/sentry/PropagationContext { } public abstract interface class io/sentry/ReplayController { + public abstract fun getReplayId ()Lio/sentry/protocol/SentryId; public abstract fun isRecording ()Z public abstract fun pause ()V public abstract fun resume ()V diff --git a/sentry/src/main/java/io/sentry/NoOpReplayController.java b/sentry/src/main/java/io/sentry/NoOpReplayController.java index 1353e01a58..516b1e06f5 100644 --- a/sentry/src/main/java/io/sentry/NoOpReplayController.java +++ b/sentry/src/main/java/io/sentry/NoOpReplayController.java @@ -1,5 +1,6 @@ package io.sentry; +import io.sentry.protocol.SentryId; import org.jetbrains.annotations.NotNull; public final class NoOpReplayController implements ReplayController { @@ -31,4 +32,9 @@ public boolean isRecording() { @Override public void sendReplayForEvent(@NotNull SentryEvent event, @NotNull Hint hint) {} + + @Override + public @NotNull SentryId getReplayId() { + return SentryId.EMPTY_ID; + } } diff --git a/sentry/src/main/java/io/sentry/ReplayController.java b/sentry/src/main/java/io/sentry/ReplayController.java index d2b7f7eb16..76ae450168 100644 --- a/sentry/src/main/java/io/sentry/ReplayController.java +++ b/sentry/src/main/java/io/sentry/ReplayController.java @@ -1,5 +1,6 @@ package io.sentry; +import io.sentry.protocol.SentryId; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; @@ -16,4 +17,7 @@ public interface ReplayController { boolean isRecording(); void sendReplayForEvent(@NotNull SentryEvent event, @NotNull Hint hint); + + @NotNull + SentryId getReplayId(); }