Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Fixes

- Session Replay: Fix a native crash (`SIGABRT` on the RenderThread) when stopping a replay that uses the canvas screenshot strategy ([#6342](https://github.com/getsentry/sentry-java/pull/6342))
- Report the number of dropped spans in client reports when a batched span envelope item is discarded, instead of a quantity of 1 (e.g. for Flutter span streaming) ([#6265](https://github.com/getsentry/sentry-java/pull/6265))
- Keep sending events from Android 7.1.0 and older devices after Sentry changes its TLS certificate authority ([#6227](https://github.com/getsentry/sentry-java/pull/6227))
- In February 2027, Sentry moves its TLS certificates from DigiCert to Let's Encrypt and Google Trust Services ([announcement](https://sentry.io/changelog/were-changing-our-tls-certificate-authority-in-february-2027)).
Expand Down
1 change: 1 addition & 0 deletions sentry-android-replay/detekt-baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@
<ID>TooManyFunctions:WindowRecorder.kt$WindowRecorder : RecorderOnRootViewsChangedListenerExecutorProvider</ID>
<ID>UnusedPrivateProperty:DebugOverlayDrawable.kt$DebugOverlayDrawable$private val padding = 6f</ID>
<ID>UseCheckOrError:ReplayShadowMediaCodec.kt$ReplayShadowMediaCodec$throw IllegalStateException("Simulated codec start failure")</ID>
<ID>UtilityClassWithPublicConstructor:CanvasStrategyTest.kt$DeferredSurfacePixelCopyShadow</ID>
<ID>UtilityClassWithPublicConstructor:PixelCopyStrategyTest.kt$DeferredWindowPixelCopyShadow</ID>
</CurrentIssues>
</SmellBaseline>
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ internal class CanvasStrategy(
private val lastCaptureSuccessful = AtomicBoolean(false)
private val textIgnoringCanvas = TextIgnoringDelegateCanvas()
private val isClosed = AtomicBoolean(false)
// Only accessed on the background handler, which runs the render task, the PixelCopy callback and
// the cleanup, so it needs no synchronization
private var pixelCopyInFlight = false
Comment thread
romtsn marked this conversation as resolved.

private val surfaceTexture =
SurfaceTexture(false).apply {
Expand Down Expand Up @@ -110,15 +113,19 @@ internal class CanvasStrategy(
)
return@Runnable
}
pixelCopyInFlight = true
PixelCopy.request(
surface,
screenshot!!,
{ result ->
pixelCopyInFlight = false
if (isClosed.get()) {
options.logger.log(
SentryLevel.DEBUG,
"CanvasStrategy is closed, ignoring capture result",
)
// close() skipped the cleanup while this copy was still writing into the screenshot
releaseResources()
return@request
}
if (result == PixelCopy.SUCCESS) {
Expand All @@ -138,6 +145,8 @@ internal class CanvasStrategy(
executor.getBackgroundHandler(),
)
} catch (t: Throwable) {
// if the request itself threw, no callback will clear the flag
pixelCopyInFlight = false
options.logger.log(SentryLevel.ERROR, "Canvas Strategy: picture render failed", t)
lastCaptureSuccessful.set(false)
}
Expand Down Expand Up @@ -174,14 +183,22 @@ internal class CanvasStrategy(
.getBackgroundHandler()
.postSafely(
ReplayRunnable("CanvasStrategy.close") {
screenshot?.let { synchronized(it) { if (!it.isRecycled) it.recycle() } }
surface.release()
surfaceTexture.release()
// PixelCopy writes into the screenshot on the RenderThread; recycling it mid-copy aborts
// the process, so an in-flight copy releases the resources from its callback instead
Comment thread
romtsn marked this conversation as resolved.
if (!pixelCopyInFlight) {
releaseResources()
}
}
)
unprocessedPictureRef.getAndSet(null)
}

private fun releaseResources() {
screenshot?.let { synchronized(it) { if (!it.isRecycled) it.recycle() } }
surface.release()
surfaceTexture.release()
}

override fun lastCaptureSuccessful(): Boolean = lastCaptureSuccessful.get()

override fun emitLastScreenshot() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package io.sentry.android.replay.screenshot

import android.graphics.Bitmap
import android.os.Handler
import android.os.Looper
import android.view.PixelCopy
import android.view.Surface
import android.view.View
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import io.sentry.SentryOptions
import io.sentry.android.replay.ExecutorProvider
import io.sentry.android.replay.ScreenshotRecorderConfig
import io.sentry.android.replay.util.MainLooperHandler
import java.util.concurrent.ScheduledExecutorService
import kotlin.test.BeforeTest
import kotlin.test.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.mock
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
import org.robolectric.annotation.Implementation
import org.robolectric.annotation.Implements

@RunWith(AndroidJUnit4::class)
@Config(sdk = [30], shadows = [DeferredSurfacePixelCopyShadow::class])
class CanvasStrategyTest {

private class Fixture {
val options = SentryOptions()
val config = ScreenshotRecorderConfig(100, 100, 1f, 1f, 1, 1000)

fun getSut(): CanvasStrategy =
CanvasStrategy(
object : ExecutorProvider {
override fun getExecutor(): ScheduledExecutorService = mock()

override fun getMainLooperHandler(): MainLooperHandler = MainLooperHandler()

// runs the render task, the PixelCopy callback and the cleanup, like production
override fun getBackgroundHandler(): Handler = Handler(Looper.getMainLooper())
},
mock(),
options,
config,
)
}

private val fixture = Fixture()

@BeforeTest
fun setup() {
DeferredSurfacePixelCopyShadow.reset()
}

@Test
fun `close keeps the screenshot alive until the in-flight pixel copy finishes`() {
val strategy = fixture.getSut()
strategy.capture(View(ApplicationProvider.getApplicationContext()))
shadowOf(Looper.getMainLooper()).idle()
val screenshot = DeferredSurfacePixelCopyShadow.dest!!

strategy.close()
shadowOf(Looper.getMainLooper()).idle()

// the copy is still writing into the screenshot, recycling it now would abort the RenderThread
assertThat(screenshot.isRecycled).isFalse()

DeferredSurfacePixelCopyShadow.flush()
shadowOf(Looper.getMainLooper()).idle()

assertThat(screenshot.isRecycled).isTrue()
}

@Test
fun `close recycles the screenshot right away when no pixel copy is in flight`() {
val strategy = fixture.getSut()
strategy.capture(View(ApplicationProvider.getApplicationContext()))
shadowOf(Looper.getMainLooper()).idle()
val screenshot = DeferredSurfacePixelCopyShadow.dest!!
DeferredSurfacePixelCopyShadow.flush()
shadowOf(Looper.getMainLooper()).idle()

strategy.close()
shadowOf(Looper.getMainLooper()).idle()

assertThat(screenshot.isRecycled).isTrue()
}
}

/** Holds back the result of a [PixelCopy] from a [Surface] until [flush] is called. */
@Implements(PixelCopy::class)
class DeferredSurfacePixelCopyShadow {
companion object {
private val pendingCallbacks = mutableListOf<() -> Unit>()
var dest: Bitmap? = null

fun reset() {
pendingCallbacks.clear()
dest = null
}

fun flush() {
val callbacks = pendingCallbacks.toList()
pendingCallbacks.clear()
callbacks.forEach { it.invoke() }
}

@JvmStatic
@Implementation
@Suppress("UNUSED_PARAMETER")
fun request(
source: Surface,
dest: Bitmap,
listener: PixelCopy.OnPixelCopyFinishedListener,
listenerThread: Handler,
) {
this.dest = dest
pendingCallbacks.add {
listenerThread.post { listener.onPixelCopyFinished(PixelCopy.SUCCESS) }
}
}
}
}
Loading