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

- Android app crashing on hot-restart in debug mode ([#3358](https://github.com/getsentry/sentry-dart/pull/3358))
- Dont use `Companion` in JNI calls and properly release JNI refs ([#3354](https://github.com/getsentry/sentry-dart/pull/3354))
- This potentially fixes segfault crashes related to JNI

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package io.sentry.flutter

import android.util.Log
import java.util.concurrent.atomic.AtomicInteger

/**
* Wraps [ReplayRecorderCallbacks] and guards all callbacks behind a generation check (to ignore stale callbacks from previous isolates).
* Without this check the app would crash after a hot-restart in debug mode.
*/
internal class SafeReplayRecorderCallbacks(
private val delegate: ReplayRecorderCallbacks,
) : ReplayRecorderCallbacks {
companion object {
private val generationCounter = AtomicInteger(0)

fun bumpGeneration() {
generationCounter.incrementAndGet()
}

fun currentGeneration(): Int = generationCounter.get()
}

private val generationSnapshot: Int = currentGeneration()

private inline fun guard(block: () -> Unit) {
if (generationSnapshot != currentGeneration()) return
try {
block()
} catch (t: Throwable) {
Log.w("Sentry", "Replay recorder callback failed", t)
}
}

override fun replayStarted(
replayId: String,
replayIsBuffering: Boolean,
) = guard {
delegate.replayStarted(replayId, replayIsBuffering)
}

override fun replayResumed() =
guard {
delegate.replayResumed()
}

override fun replayPaused() =
guard {
delegate.replayPaused()
}

override fun replayStopped() =
guard {
delegate.replayStopped()
}

override fun replayReset() =
guard {
delegate.replayReset()
}

override fun replayConfigChanged(
width: Int,
height: Int,
frameRate: Int,
) = guard {
delegate.replayConfigChanged(width, height, frameRate)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ class SentryFlutterPlugin :
return
}

tearDownReplayIntegration()
channel.setMethodCallHandler(null)
applicationContext = null
}
Expand Down Expand Up @@ -111,6 +112,25 @@ class SentryFlutterPlugin :

private const val NATIVE_CRASH_WAIT_TIME = 500L

/**
* Tears down the current ReplayIntegration to avoid invoking callbacks from a stale
* Flutter isolate after hot restart.
*
* - Bumps the replay callback generation so any pending posts from the previous
* isolate no-op.
* - Closes the existing ReplayIntegration and clears its reference.
*/
fun tearDownReplayIntegration() {
SafeReplayRecorderCallbacks.bumpGeneration()
try {
replay?.close()
} catch (e: Exception) {
Log.w("Sentry", "Failed to close existing ReplayIntegration", e)
} finally {
replay = null
}
}

@Suppress("unused") // Used by native/jni bindings
@JvmStatic
fun privateSentryGetReplayIntegration(): ReplayIntegration? = replay
Expand All @@ -120,6 +140,8 @@ class SentryFlutterPlugin :
options: SentryAndroidOptions,
replayCallbacks: ReplayRecorderCallbacks?,
) {
tearDownReplayIntegration()

// Replace the default ReplayIntegration with a Flutter-specific recorder.
options.integrations.removeAll { it is ReplayIntegration }
val replayOptions = options.sessionReplay
Expand All @@ -130,12 +152,14 @@ class SentryFlutterPlugin :
return
}

val safeCallbacks = SafeReplayRecorderCallbacks(replayCallbacks)

replay =
ReplayIntegration(
ctx.applicationContext,
dateProvider = CurrentDateProvider.getInstance(),
recorderProvider = {
SentryFlutterReplayRecorder(replayCallbacks, replay!!)
SentryFlutterReplayRecorder(safeCallbacks, replay!!)
},
replayCacheProvider = null,
)
Expand Down
Loading