From d4b1f82d2bdf3415427ba550509483bbf210d787 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 2 Aug 2024 10:25:59 +0200 Subject: [PATCH] [SR] ANR with buffered Replay integration test (#3612) --- .github/workflows/system-tests-backend.yml | 2 +- CHANGELOG.md | 4 + sentry-android-replay/build.gradle.kts | 1 + .../io/sentry/android/replay/ReplayCache.kt | 35 ++- .../replay/AnrWithReplayIntegrationTest.kt | 218 ++++++++++++++++++ .../sentry/android/replay/ReplayCacheTest.kt | 78 +++---- .../ReplayIntegrationWithRecorderTest.kt | 63 +---- .../sentry/android/replay/ReplaySmokeTest.kt | 64 +---- .../replay/util/ReplayShadowMediaCodec.kt | 60 +++++ 9 files changed, 344 insertions(+), 181 deletions(-) create mode 100644 sentry-android-replay/src/test/java/io/sentry/android/replay/AnrWithReplayIntegrationTest.kt create mode 100644 sentry-android-replay/src/test/java/io/sentry/android/replay/util/ReplayShadowMediaCodec.kt diff --git a/.github/workflows/system-tests-backend.yml b/.github/workflows/system-tests-backend.yml index 90acfba2ab..0c04c86fd8 100644 --- a/.github/workflows/system-tests-backend.yml +++ b/.github/workflows/system-tests-backend.yml @@ -46,7 +46,7 @@ jobs: - name: Exclude android modules from build run: | - sed -i -e '/.*"sentry-android-ndk",/d' -e '/.*"sentry-android",/d' -e '/.*"sentry-compose",/d' -e '/.*"sentry-android-core",/d' -e '/.*"sentry-android-fragment",/d' -e '/.*"sentry-android-navigation",/d' -e '/.*"sentry-android-okhttp",/d' -e '/.*"sentry-android-sqlite",/d' -e '/.*"sentry-android-timber",/d' -e '/.*"sentry-android-integration-tests:sentry-uitest-android-benchmark",/d' -e '/.*"sentry-android-integration-tests:sentry-uitest-android",/d' -e '/.*"sentry-android-integration-tests:test-app-sentry",/d' -e '/.*"sentry-samples:sentry-samples-android",/d' settings.gradle.kts + sed -i -e '/.*"sentry-android-ndk",/d' -e '/.*"sentry-android",/d' -e '/.*"sentry-compose",/d' -e '/.*"sentry-android-core",/d' -e '/.*"sentry-android-fragment",/d' -e '/.*"sentry-android-navigation",/d' -e '/.*"sentry-android-okhttp",/d' -e '/.*"sentry-android-sqlite",/d' -e '/.*"sentry-android-timber",/d' -e '/.*"sentry-android-integration-tests:sentry-uitest-android-benchmark",/d' -e '/.*"sentry-android-integration-tests:sentry-uitest-android",/d' -e '/.*"sentry-android-integration-tests:test-app-sentry",/d' -e '/.*"sentry-samples:sentry-samples-android",/d' -e '/.*"sentry-android-replay",/d' settings.gradle.kts - name: Exclude android modules from ignore list run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index db99b442cc..25cbbdffba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ - Avoid ArrayIndexOutOfBoundsException on Android cpu data collection ([#3598](https://github.com/getsentry/sentry-java/pull/3598)) - Fix lazy select queries instrumentation ([#3604](https://github.com/getsentry/sentry-java/pull/3604)) +### Chores + +- Introduce `ReplayShadowMediaCodec` and refactor tests using custom encoder ([#3612](https://github.com/getsentry/sentry-java/pull/3612)) + ## 7.13.0 ### Features diff --git a/sentry-android-replay/build.gradle.kts b/sentry-android-replay/build.gradle.kts index bd9b5d961b..2e74641268 100644 --- a/sentry-android-replay/build.gradle.kts +++ b/sentry-android-replay/build.gradle.kts @@ -69,6 +69,7 @@ dependencies { // tests testImplementation(projects.sentryTestSupport) + testImplementation(projects.sentryAndroidCore) testImplementation(Config.TestLibs.robolectric) testImplementation(Config.TestLibs.kotlinTestJunit) testImplementation(Config.TestLibs.androidxRunner) 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 549db2566b..21dbd6ec21 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 @@ -36,30 +36,12 @@ import java.util.concurrent.atomic.AtomicBoolean * @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( +public class ReplayCache( private val options: SentryOptions, private val replayId: SentryId, - private val recorderConfig: ScreenshotRecorderConfig, - private val encoderProvider: (videoFile: File, height: Int, width: Int) -> SimpleVideoEncoder + private val recorderConfig: ScreenshotRecorderConfig ) : Closeable { - public constructor( - options: SentryOptions, - replayId: SentryId, - recorderConfig: ScreenshotRecorderConfig - ) : this(options, replayId, recorderConfig, encoderProvider = { videoFile, height, width -> - SimpleVideoEncoder( - options, - MuxerConfig( - file = videoFile, - recordingHeight = height, - recordingWidth = width, - frameRate = recorderConfig.frameRate, - bitRate = recorderConfig.bitRate - ) - ).also { it.start() } - }) - private val isClosed = AtomicBoolean(false) private val encoderLock = Any() private var encoder: SimpleVideoEncoder? = null @@ -164,7 +146,18 @@ public class ReplayCache internal constructor( } // TODO: reuse instance of encoder and just change file path to create a different muxer - encoder = synchronized(encoderLock) { encoderProvider(videoFile, height, width) } + encoder = synchronized(encoderLock) { + SimpleVideoEncoder( + options, + MuxerConfig( + file = videoFile, + recordingHeight = height, + recordingWidth = width, + frameRate = recorderConfig.frameRate, + bitRate = recorderConfig.bitRate + ) + ).also { it.start() } + } val step = 1000 / recorderConfig.frameRate.toLong() var frameCount = 0 diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/AnrWithReplayIntegrationTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/AnrWithReplayIntegrationTest.kt new file mode 100644 index 0000000000..262225d77c --- /dev/null +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/AnrWithReplayIntegrationTest.kt @@ -0,0 +1,218 @@ +package io.sentry.android.replay + +import android.app.ActivityManager +import android.app.ApplicationExitInfo +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Bitmap.CompressFormat.JPEG +import android.graphics.Bitmap.Config.ARGB_8888 +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.sentry.EventProcessor +import io.sentry.Hint +import io.sentry.Sentry +import io.sentry.SentryOptions +import io.sentry.SentryReplayEvent +import io.sentry.SentryReplayEvent.ReplayType +import io.sentry.SystemOutLogger +import io.sentry.android.core.SentryAndroid +import io.sentry.android.core.performance.AppStartMetrics +import io.sentry.android.replay.ReplayCache.Companion.ONGOING_SEGMENT +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_BIT_RATE +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_FRAME_RATE +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_HEIGHT +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_ID +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_TYPE +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_TIMESTAMP +import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_WIDTH +import io.sentry.android.replay.util.ReplayShadowMediaCodec +import io.sentry.cache.PersistingOptionsObserver.OPTIONS_CACHE +import io.sentry.cache.PersistingOptionsObserver.REPLAY_ERROR_SAMPLE_RATE_FILENAME +import io.sentry.protocol.Contexts +import io.sentry.protocol.SentryId +import io.sentry.rrweb.RRWebMetaEvent +import io.sentry.rrweb.RRWebVideoEvent +import org.awaitility.kotlin.await +import org.awaitility.kotlin.withAlias +import org.junit.Rule +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.mockito.kotlin.spy +import org.mockito.kotlin.whenever +import org.robolectric.annotation.Config +import org.robolectric.shadow.api.Shadow +import org.robolectric.shadows.ShadowActivityManager +import org.robolectric.shadows.ShadowActivityManager.ApplicationExitInfoBuilder +import java.io.File +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +@RunWith(AndroidJUnit4::class) +@Config( + sdk = [30], + shadows = [ReplayShadowMediaCodec::class] +) +class AnrWithReplayIntegrationTest { + + @get:Rule + val tmpDir = TemporaryFolder() + + private class Fixture { + lateinit var shadowActivityManager: ShadowActivityManager + + fun addAppExitInfo( + reason: Int? = ApplicationExitInfo.REASON_ANR, + timestamp: Long? = null, + importance: Int? = null + ) { + val builder = ApplicationExitInfoBuilder.newBuilder() + if (reason != null) { + builder.setReason(reason) + } + if (timestamp != null) { + builder.setTimestamp(timestamp) + } + if (importance != null) { + builder.setImportance(importance) + } + val exitInfo = spy(builder.build()) { + whenever(mock.traceInputStream).thenReturn( + """ +"main" prio=5 tid=1 Blocked + | group="main" sCount=1 ucsCount=0 flags=1 obj=0x72a985e0 self=0xb400007cabc57380 + | sysTid=28941 nice=-10 cgrp=top-app sched=0/0 handle=0x7deceb74f8 + | state=S schedstat=( 324804784 183300334 997 ) utm=23 stm=8 core=3 HZ=100 + | stack=0x7ff93a9000-0x7ff93ab000 stackSize=8188KB + | held mutexes= + at io.sentry.samples.android.MainActivity${'$'}2.run(MainActivity.java:177) + - waiting to lock <0x0d3a2f0a> (a java.lang.Object) held by thread 5 + at android.os.Handler.handleCallback(Handler.java:942) + at android.os.Handler.dispatchMessage(Handler.java:99) + at android.os.Looper.loopOnce(Looper.java:201) + at android.os.Looper.loop(Looper.java:288) + at android.app.ActivityThread.main(ActivityThread.java:7872) + at java.lang.reflect.Method.invoke(Native method) + at com.android.internal.os.RuntimeInit${'$'}MethodAndArgsCaller.run(RuntimeInit.java:548) + at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:936) + +"perfetto_hprof_listener" prio=10 tid=7 Native (still starting up) + | group="" sCount=1 ucsCount=0 flags=1 obj=0x0 self=0xb400007cabc5ab20 + | sysTid=28959 nice=-20 cgrp=top-app sched=0/0 handle=0x7b2021bcb0 + | state=S schedstat=( 72750 1679167 1 ) utm=0 stm=0 core=3 HZ=100 + | stack=0x7b20124000-0x7b20126000 stackSize=991KB + | held mutexes= + native: #00 pc 00000000000a20f4 /apex/com.android.runtime/lib64/bionic/libc.so (read+4) (BuildId: 01331f74b0bb2cb958bdc15282b8ec7b) + native: #01 pc 000000000001d840 /apex/com.android.art/lib64/libperfetto_hprof.so (void* std::__1::__thread_proxy >, ArtPlugin_Initialize::${'$'}_34> >(void*)+260) (BuildId: 525cc92a7dc49130157aeb74f6870364) + native: #02 pc 00000000000b63b0 /apex/com.android.runtime/lib64/bionic/libc.so (__pthread_start(void*)+208) (BuildId: 01331f74b0bb2cb958bdc15282b8ec7b) + native: #03 pc 00000000000530b8 /apex/com.android.runtime/lib64/bionic/libc.so (__start_thread+64) (BuildId: 01331f74b0bb2cb958bdc15282b8ec7b) + (no managed stack frames) + """.trimIndent().byteInputStream() + ) + } + shadowActivityManager.addApplicationExitInfo(exitInfo) + } + + fun prefillOptionsCache(cacheDir: String) { + val optionsDir = File(cacheDir, OPTIONS_CACHE).also { it.mkdirs() } + File(optionsDir, REPLAY_ERROR_SAMPLE_RATE_FILENAME).writeText("\"1.0\"") + } + } + + private val fixture = Fixture() + private lateinit var context: Context + + @BeforeTest + fun `set up`() { + ReplayShadowMediaCodec.framesToEncode = 5 + Sentry.close() + AppStartMetrics.getInstance().clear() + context = ApplicationProvider.getApplicationContext() + val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager? + fixture.shadowActivityManager = Shadow.extract(activityManager) + } + + @Test + fun `replay is being captured for ANRs in buffer mode`() { + ReplayShadowMediaCodec.framesToEncode = 1 + + val cacheDir = tmpDir.newFolder().absolutePath + val oneDayAgo = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(1) + fixture.addAppExitInfo(timestamp = oneDayAgo) + val asserted = AtomicBoolean(false) + + val replayId1 = SentryId() + val replayId2 = SentryId() + + SentryAndroid.init(context) { + it.dsn = "https://key@sentry.io/123" + it.cacheDirPath = cacheDir + it.isDebug = true + it.setLogger(SystemOutLogger()) + it.experimental.sessionReplay.errorSampleRate = 1.0 + // beforeSend is called after event processors are applied, so we can assert here + // against the enriched ANR event + it.beforeSend = SentryOptions.BeforeSendCallback { event, _ -> + assertEquals(replayId2.toString(), event.contexts[Contexts.REPLAY_ID]) + event + } + it.addEventProcessor(object : EventProcessor { + override fun process(event: SentryReplayEvent, hint: Hint): SentryReplayEvent { + assertEquals(replayId2, event.replayId) + assertEquals(ReplayType.BUFFER, event.replayType) + assertEquals("0.mp4", event.videoFile?.name) + + val metaEvents = + hint.replayRecording?.payload?.filterIsInstance() + assertEquals(912, metaEvents?.first()?.height) + assertEquals(416, metaEvents?.first()?.width) // clamped to power of 16 + + val videoEvents = + hint.replayRecording?.payload?.filterIsInstance() + assertEquals(912, videoEvents?.first()?.height) + assertEquals(416, videoEvents?.first()?.width) // clamped to power of 16 + assertEquals(1000, videoEvents?.first()?.durationMs) + assertEquals(1, videoEvents?.first()?.frameCount) + assertEquals(1, videoEvents?.first()?.frameRate) + assertEquals(0, videoEvents?.first()?.segmentId) + asserted.set(true) + return event + } + }) + + // have to do it after the cacheDir is set to options, because it adds a dsn hash after + fixture.prefillOptionsCache(it.cacheDirPath!!) + + val replayFolder1 = File(it.cacheDirPath!!, "replay_$replayId1").also { it.mkdirs() } + val replayFolder2 = File(it.cacheDirPath!!, "replay_$replayId2").also { it.mkdirs() } + + File(replayFolder2, ONGOING_SEGMENT).also { file -> + file.writeText( + """ + $SEGMENT_KEY_HEIGHT=912 + $SEGMENT_KEY_WIDTH=416 + $SEGMENT_KEY_FRAME_RATE=1 + $SEGMENT_KEY_BIT_RATE=75000 + $SEGMENT_KEY_ID=0 + $SEGMENT_KEY_TIMESTAMP=2024-07-11T10:25:21.454Z + $SEGMENT_KEY_REPLAY_TYPE=BUFFER + """.trimIndent() + ) + } + + val screenshot = File(replayFolder2, "1720693523997.jpg").also { it.createNewFile() } + screenshot.outputStream().use { os -> + Bitmap.createBitmap(1, 1, ARGB_8888).compress(JPEG, 80, os) + os.flush() + } + + replayFolder1.setLastModified(oneDayAgo - 1000) + replayFolder2.setLastModified(oneDayAgo - 500) + } + + await.withAlias("Failed because of BeforeSend callback above, but we swallow BeforeSend exceptions, hence the timeout") + .untilTrue(asserted) + } +} 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 0dae78e723..99a308f53a 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 @@ -3,7 +3,6 @@ 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 import io.sentry.DateUtils import io.sentry.SentryOptions @@ -17,8 +16,7 @@ import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_RECORDI import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_REPLAY_TYPE import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_TIMESTAMP import io.sentry.android.replay.ReplayCache.Companion.SEGMENT_KEY_WIDTH -import io.sentry.android.replay.video.MuxerConfig -import io.sentry.android.replay.video.SimpleVideoEncoder +import io.sentry.android.replay.util.ReplayShadowMediaCodec import io.sentry.protocol.SentryId import io.sentry.rrweb.RRWebInteractionEvent import io.sentry.rrweb.RRWebInteractionEvent.InteractionType.TouchEnd @@ -28,8 +26,7 @@ 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.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -37,7 +34,10 @@ import kotlin.test.assertNull import kotlin.test.assertTrue @RunWith(AndroidJUnit4::class) -@Config(sdk = [26]) +@Config( + sdk = [26], + shadows = [ReplayShadowMediaCodec::class] +) class ReplayCacheTest { @get:Rule @@ -45,46 +45,26 @@ class ReplayCacheTest { internal class Fixture { val options = SentryOptions() - var encoder: SimpleVideoEncoder? = null fun getSut( dir: TemporaryFolder?, replayId: SentryId = SentryId(), - frameRate: Int, - framesToEncode: Int = 0 + frameRate: Int ): ReplayCache { val recorderConfig = ScreenshotRecorderConfig(100, 200, 1f, 1f, frameRate = frameRate, bitRate = 20_000) options.run { cacheDirPath = dir?.newFolder()?.absolutePath } - return 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, 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) + return ReplayCache(options, replayId, recorderConfig) } } private val fixture = Fixture() + @BeforeTest + fun `set up`() { + ReplayShadowMediaCodec.framesToEncode = 5 + } + @Test fun `when no cacheDirPath specified, does not store screenshots`() { val replayId = SentryId() @@ -132,10 +112,10 @@ class ReplayCacheTest { @Test fun `deletes frames after creating a video`() { + ReplayShadowMediaCodec.framesToEncode = 3 val replayCache = fixture.getSut( tmpDir, - frameRate = 1, - framesToEncode = 3 + frameRate = 1 ) val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) @@ -157,8 +137,7 @@ class ReplayCacheTest { fun `repeats last known frame for the segment duration`() { val replayCache = fixture.getSut( tmpDir, - frameRate = 1, - framesToEncode = 5 + frameRate = 1 ) val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) @@ -175,8 +154,7 @@ class ReplayCacheTest { fun `repeats last known frame for the segment duration for each timespan`() { val replayCache = fixture.getSut( tmpDir, - frameRate = 1, - framesToEncode = 5 + frameRate = 1 ) val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) @@ -194,8 +172,7 @@ class ReplayCacheTest { fun `repeats last known frame for each segment`() { val replayCache = fixture.getSut( tmpDir, - frameRate = 1, - framesToEncode = 5 + frameRate = 1 ) val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) @@ -216,10 +193,11 @@ class ReplayCacheTest { @Test fun `respects frameRate`() { + ReplayShadowMediaCodec.framesToEncode = 6 + val replayCache = fixture.getSut( tmpDir, - frameRate = 2, - framesToEncode = 6 + frameRate = 2 ) val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) @@ -238,8 +216,7 @@ class ReplayCacheTest { fun `does not add frame when bitmap is recycled`() { val replayCache = fixture.getSut( tmpDir, - frameRate = 1, - framesToEncode = 5 + frameRate = 1 ) val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888).also { it.recycle() } @@ -252,8 +229,7 @@ class ReplayCacheTest { fun `addFrame with File path works`() { val replayCache = fixture.getSut( tmpDir, - frameRate = 1, - framesToEncode = 5 + frameRate = 1 ) val flutterCacheDir = @@ -276,8 +252,7 @@ class ReplayCacheTest { fun `rotates frames`() { val replayCache = fixture.getSut( tmpDir, - frameRate = 1, - framesToEncode = 5 + frameRate = 1 ) val bitmap = Bitmap.createBitmap(1, 1, ARGB_8888) @@ -472,10 +447,11 @@ class ReplayCacheTest { @Test fun `when videoFile exists and is not empty, deletes it before writing`() { + ReplayShadowMediaCodec.framesToEncode = 3 + val replayCache = fixture.getSut( tmpDir, - frameRate = 1, - framesToEncode = 3 + frameRate = 1 ) val oldVideoFile = File(replayCache.replayCacheDir, "0.mp4").also { 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 index 8e3bef2c2f..6bab0f6549 100644 --- 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 @@ -4,7 +4,6 @@ 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 @@ -16,8 +15,7 @@ import io.sentry.android.replay.ReplayIntegrationWithRecorderTest.LifecycleState 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.android.replay.util.ReplayShadowMediaCodec import io.sentry.rrweb.RRWebMetaEvent import io.sentry.rrweb.RRWebVideoEvent import io.sentry.transport.CurrentDateProvider @@ -36,14 +34,15 @@ 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]) +@Config( + sdk = [26], + shadows = [ReplayShadowMediaCodec::class] +) class ReplayIntegrationWithRecorderTest { @get:Rule @@ -54,63 +53,18 @@ class ReplayIntegrationWithRecorderTest { mainThreadChecker = NoOpMainThreadChecker.getInstance() } val hub = mock() - var encoder: SimpleVideoEncoder? = null fun getSut( context: Context, recorder: Recorder, recorderConfig: ScreenshotRecorderConfig, - dateProvider: ICurrentDateProvider = CurrentDateProvider.getInstance(), - framesToEncode: Int = 0 + dateProvider: ICurrentDateProvider = CurrentDateProvider.getInstance() ): ReplayIntegration { return ReplayIntegration( context, dateProvider, recorderProvider = { recorder }, - recorderConfigProvider = { recorderConfig }, - // this is just needed for testing to encode a fake video - replayCacheProvider = { replayId, config -> - ReplayCache( - options, - replayId, - config, - 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 + recorderConfigProvider = { recorderConfig } ) } } @@ -120,6 +74,7 @@ class ReplayIntegrationWithRecorderTest { @BeforeTest fun `set up`() { + ReplayShadowMediaCodec.framesToEncode = 5 context = ApplicationProvider.getApplicationContext() } @@ -164,7 +119,7 @@ class ReplayIntegrationWithRecorderTest { } } - replay = fixture.getSut(context, recorder, recorderConfig, dateProvider, framesToEncode = 5) + replay = fixture.getSut(context, recorder, recorderConfig, dateProvider) replay.register(fixture.hub, fixture.options) assertEquals(INITALIZED, recorder.state) diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt index 53ef7c009e..415697f68d 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt @@ -3,9 +3,9 @@ package io.sentry.android.replay import android.app.Activity import android.content.Context import android.graphics.drawable.Drawable -import android.media.MediaCodec import android.os.Bundle import android.os.Handler +import android.os.Handler.Callback import android.os.Looper import android.widget.ImageView import android.widget.LinearLayout @@ -18,8 +18,7 @@ import io.sentry.Scope import io.sentry.ScopeCallback import io.sentry.SentryOptions import io.sentry.SentryReplayEvent.ReplayType -import io.sentry.android.replay.video.MuxerConfig -import io.sentry.android.replay.video.SimpleVideoEncoder +import io.sentry.android.replay.util.ReplayShadowMediaCodec import io.sentry.rrweb.RRWebMetaEvent import io.sentry.rrweb.RRWebVideoEvent import io.sentry.transport.CurrentDateProvider @@ -43,15 +42,13 @@ import org.robolectric.annotation.Config import org.robolectric.shadows.ShadowPixelCopy import java.time.Duration import java.util.concurrent.TimeUnit -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( - shadows = [ShadowPixelCopy::class], + shadows = [ShadowPixelCopy::class, ReplayShadowMediaCodec::class], sdk = [28], qualifiers = "w360dp-h640dp-xxhdpi" ) @@ -68,53 +65,21 @@ class ReplaySmokeTest { (it.arguments[0] as ScopeCallback).run(scope) }.whenever(it).configureScope(any()) } - var encoder: SimpleVideoEncoder? = null var count: Int = 0 private class ImmediateHandler : Handler(Callback { it.callback?.run(); true }) fun getSut( context: Context, - dateProvider: ICurrentDateProvider = CurrentDateProvider.getInstance(), - framesToEncode: Int = 0 + dateProvider: ICurrentDateProvider = CurrentDateProvider.getInstance() ): ReplayIntegration { return ReplayIntegration( context, dateProvider, recorderProvider = null, recorderConfigProvider = null, - // this is just needed for testing to encode a fake video - replayCacheProvider = { replayId, recorderConfig -> - 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!! - } - ) - }, replayCaptureStrategyProvider = null, + replayCacheProvider = null, mainLooperHandler = mock { whenever(mock.handler).thenReturn(ImmediateHandler()) whenever(mock.post(any())).then { @@ -124,18 +89,6 @@ class ReplaySmokeTest { } ) } - - 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() @@ -143,6 +96,7 @@ class ReplaySmokeTest { @BeforeTest fun `set up`() { + ReplayShadowMediaCodec.framesToEncode = 5 context = ApplicationProvider.getApplicationContext() } @@ -156,7 +110,7 @@ class ReplaySmokeTest { fixture.options.experimental.sessionReplay.sessionSampleRate = 1.0 fixture.options.cacheDirPath = tmpDir.newFolder().absolutePath - val replay: ReplayIntegration = fixture.getSut(context, framesToEncode = 5) + val replay: ReplayIntegration = fixture.getSut(context) replay.register(fixture.hub, fixture.options) val controller = buildActivity(ExampleActivity::class.java, null).setup() @@ -193,6 +147,8 @@ class ReplaySmokeTest { @Test fun `works in buffer mode`() { + ReplayShadowMediaCodec.framesToEncode = 10 + val captured = AtomicBoolean(false) whenever(fixture.hub.captureReplay(any(), anyOrNull())).then { captured.set(true) @@ -201,7 +157,7 @@ class ReplaySmokeTest { fixture.options.experimental.sessionReplay.errorSampleRate = 1.0 fixture.options.cacheDirPath = tmpDir.newFolder().absolutePath - val replay: ReplayIntegration = fixture.getSut(context, framesToEncode = 10) + val replay: ReplayIntegration = fixture.getSut(context) replay.register(fixture.hub, fixture.options) val controller = buildActivity(ExampleActivity::class.java, null).setup() diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/util/ReplayShadowMediaCodec.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/util/ReplayShadowMediaCodec.kt new file mode 100644 index 0000000000..c46c49ded0 --- /dev/null +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/util/ReplayShadowMediaCodec.kt @@ -0,0 +1,60 @@ +package io.sentry.android.replay.util + +import android.media.MediaCodec +import android.media.MediaCodec.BufferInfo +import org.robolectric.annotation.Implementation +import org.robolectric.annotation.Implements +import org.robolectric.shadows.ShadowMediaCodec +import java.nio.ByteBuffer +import java.util.concurrent.TimeUnit.MICROSECONDS +import java.util.concurrent.TimeUnit.MILLISECONDS +import java.util.concurrent.atomic.AtomicBoolean + +@Implements(MediaCodec::class) +class ReplayShadowMediaCodec : ShadowMediaCodec() { + + companion object { + var frameRate = 1 + var framesToEncode = 5 + } + + private val encoded = AtomicBoolean(false) + + @Implementation + fun start() { + super.native_start() + } + + @Implementation + fun signalEndOfInputStream() { + encodeFrame(framesToEncode, frameRate, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM) + } + + @Implementation + fun getOutputBuffers(): Array { + return super.getBuffers(false) + } + + @Implementation + fun dequeueOutputBuffer(info: BufferInfo, timeoutUs: Long): Int { + val encoderStatus = super.native_dequeueOutputBuffer(info, timeoutUs) + super.validateOutputByteBuffer(getOutputBuffers(), encoderStatus, info) + if (encoderStatus == MediaCodec.INFO_TRY_AGAIN_LATER && !encoded.getAndSet(true)) { + // MediaMuxer is initialized now, so we can start encoding frames + repeat(framesToEncode) { encodeFrame(it, frameRate) } + } + return encoderStatus + } + + private fun encodeFrame(index: Int, frameRate: Int, size: Int = 10, flags: Int = 0) { + val presentationTime = MICROSECONDS.convert(index * (1000L / frameRate), MILLISECONDS) + super.native_dequeueInputBuffer(0) + super.native_queueInputBuffer( + index, + index * size, + size, + presentationTime, + flags + ) + } +}