diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e8355405f..5d16edaf5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/packages/flutter/android/src/main/kotlin/io/sentry/flutter/SafeReplayRecorderCallbacks.kt b/packages/flutter/android/src/main/kotlin/io/sentry/flutter/SafeReplayRecorderCallbacks.kt new file mode 100644 index 0000000000..38efedbb47 --- /dev/null +++ b/packages/flutter/android/src/main/kotlin/io/sentry/flutter/SafeReplayRecorderCallbacks.kt @@ -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) + } +} diff --git a/packages/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterPlugin.kt b/packages/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterPlugin.kt index dd6896e9e7..713c8cb840 100644 --- a/packages/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterPlugin.kt +++ b/packages/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterPlugin.kt @@ -71,6 +71,7 @@ class SentryFlutterPlugin : return } + tearDownReplayIntegration() channel.setMethodCallHandler(null) applicationContext = null } @@ -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 @@ -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 @@ -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, )