From 0b22ea484470bf13bf8754c5e8c0608e364062ac Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Fri, 23 Oct 2026 12:26:39 +0200 Subject: [PATCH 1/3] fix(android): Persist hard-crash envelopes to disk before the process exits A fatal JS error was captured through InternalSentrySdk.captureEnvelope, which only queues the envelope on the single-threaded transport executor. React Native then rethrows the error as JavascriptException, an ignored exception type, so UncaughtExceptionHandlerIntegration skips the disk flush and the process dies with the envelope still in memory. In release builds most fatal JS errors never reached Sentry. When hardCrashed is set, read the envelope, end the current session as crashed and attach it (the same session handling as InternalSentrySdk.captureEnvelope), delete the current-session file, and write the envelope to options.getEnvelopeDiskCache() on the calling thread. The next launch sends the cached file, which matches the iOS store path. Without a disk cache, or when the envelope cannot be read, the previous queued path runs. When the store itself fails, the repackaged envelope is queued instead so only one copy exists. Fixes #6918 --- CHANGELOG.md | 2 + .../io/sentry/react/RNSentryModuleImpl.java | 78 +++++- .../react/RNSentryCaptureEnvelopeTest.java | 235 ++++++++++++++++++ 3 files changed, 313 insertions(+), 2 deletions(-) create mode 100644 packages/core/android/src/test/java/io/sentry/react/RNSentryCaptureEnvelopeTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 56edcde47e..91375cb069 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ ### Fixes - Apply `screenshot` masking options to Android error and feedback screenshots ([#6930](https://github.com/getsentry/sentry-react-native/pull/6930)) +- Persist fatal JS error envelopes to disk on Android before the process exits ([#NNNN](https://github.com/getsentry/sentry-react-native/pull/NNNN)) + - The hard-crash envelope was only queued on the transport thread, so the event was lost when React Native terminated the process right after. It is now written to the offline cache on the calling thread and sent on the next launch, matching iOS. ### Dependencies diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java index 161a0cd000..7806c781bf 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java @@ -30,8 +30,10 @@ import com.facebook.react.bridge.WritableMap; import com.facebook.react.bridge.WritableNativeArray; import com.facebook.react.bridge.WritableNativeMap; +import io.sentry.Hint; import io.sentry.ILogger; import io.sentry.IScope; +import io.sentry.IScopes; import io.sentry.ISentryExecutorService; import io.sentry.ISerializer; import io.sentry.PerformanceCollectionData; @@ -41,9 +43,12 @@ import io.sentry.SentryAttributes; import io.sentry.SentryDate; import io.sentry.SentryDateProvider; +import io.sentry.SentryEnvelope; +import io.sentry.SentryEnvelopeItem; import io.sentry.SentryExecutorService; import io.sentry.SentryLevel; import io.sentry.SentryOptions; +import io.sentry.Session; import io.sentry.android.core.AndroidProfiler; import io.sentry.android.core.BuildInfoProvider; import io.sentry.android.core.InternalSentrySdk; @@ -55,12 +60,15 @@ import io.sentry.android.core.internal.debugmeta.AssetsDebugMetaLoader; import io.sentry.android.core.internal.util.SentryFrameMetricsCollector; import io.sentry.android.core.performance.AppStartMetrics; +import io.sentry.cache.EnvelopeCache; +import io.sentry.cache.IEnvelopeCache; import io.sentry.profilemeasurements.ProfileMeasurement; import io.sentry.profilemeasurements.ProfileMeasurementValue; import io.sentry.protocol.SdkVersion; import io.sentry.protocol.SentryId; import io.sentry.protocol.User; import io.sentry.protocol.ViewHierarchy; +import io.sentry.transport.NoOpEnvelopeCache; import io.sentry.util.DebugMetaPropertiesApplier; import io.sentry.util.FileUtils; import io.sentry.util.JsonSerializationUtils; @@ -69,6 +77,7 @@ import io.sentry.vendor.Base64; import java.io.BufferedInputStream; import java.io.BufferedReader; +import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileNotFoundException; @@ -628,10 +637,12 @@ public void registerReplayTraceId(String traceId) { public void captureEnvelope(String rawBytes, ReadableMap options, Promise promise) { byte[] bytes = Base64.decode(rawBytes, Base64.DEFAULT); + final boolean hardCrashed = options.hasKey("hardCrashed") && options.getBoolean("hardCrashed"); try { - InternalSentrySdk.captureEnvelope( - bytes, !options.hasKey("hardCrashed") || !options.getBoolean("hardCrashed")); + if (!hardCrashed || !storeHardCrashEnvelope(bytes)) { + InternalSentrySdk.captureEnvelope(bytes, !hardCrashed); + } } catch (Throwable e) { // NOPMD - We don't want to crash in any case logger.log(SentryLevel.ERROR, "Error while capturing envelope"); promise.resolve(false); @@ -639,6 +650,69 @@ public void captureEnvelope(String rawBytes, ReadableMap options, Promise promis promise.resolve(true); } + /** + * Writes a hard-crash envelope to the offline cache on the calling thread, like iOS {@code + * store}. {@code InternalSentrySdk.captureEnvelope} only queues the envelope on the transport + * executor; React Native then rethrows the error as {@code JavascriptException}, which is an + * ignored exception type, so the process dies before the executor writes the envelope to disk. + * The next launch sends the cached file. Returns false without touching anything when there is no + * disk cache, so the caller falls back to the queued path. + */ + private boolean storeHardCrashEnvelope(final @NotNull byte[] bytes) throws IOException { + final @NotNull IScopes scopes = Sentry.getCurrentScopes(); + final @NotNull SentryOptions sentryOptions = scopes.getOptions(); + final @NotNull IEnvelopeCache cache = sentryOptions.getEnvelopeDiskCache(); + final @Nullable String cacheDirPath = sentryOptions.getCacheDirPath(); + if (!sentryOptions.isEnabled() || cacheDirPath == null || cache instanceof NoOpEnvelopeCache) { + return false; + } + + final @Nullable SentryEnvelope envelope; + try (InputStream in = new ByteArrayInputStream(bytes)) { + envelope = sentryOptions.getEnvelopeReader().read(in); + } catch (IOException | IllegalArgumentException e) { + logger.log(SentryLevel.ERROR, "Failed to read the hard crash envelope.", e); + return false; + } + if (envelope == null) { + return false; + } + + final @NotNull List items = new ArrayList<>(); + for (final SentryEnvelopeItem item : envelope.getItems()) { + items.add(item); + } + + // Same session handling as InternalSentrySdk.captureEnvelope for a crash: end the session as + // crashed, remove it from the scope so it is not sent twice, and delete the persisted + // current-session file so the next launch does not also report it as abnormal. + final @NotNull AtomicReference crashedSession = new AtomicReference<>(); + scopes.configureScope( + scope -> + scope.withSession( + session -> { + if (session != null && session.update(Session.State.Crashed, null, true, null)) { + session.end(); + scope.clearSession(); + crashedSession.set(session.clone()); + } + })); + final @Nullable Session session = crashedSession.get(); + if (session != null) { + items.add(SentryEnvelopeItem.fromSession(sentryOptions.getSerializer(), session)); + if (!EnvelopeCache.getCurrentSessionFile(cacheDirPath).delete()) { + logger.log(SentryLevel.WARNING, "Failed to delete the current session file."); + } + } + + final @NotNull SentryEnvelope repackaged = new SentryEnvelope(envelope.getHeader(), items); + if (!cache.storeEnvelope(repackaged, new Hint())) { + logger.log(SentryLevel.ERROR, "Failed to store the hard crash envelope, queueing it."); + scopes.captureEnvelope(repackaged); + } + return true; + } + public void captureScreenshot(Promise promise) { final Activity activity = getCurrentActivity(); diff --git a/packages/core/android/src/test/java/io/sentry/react/RNSentryCaptureEnvelopeTest.java b/packages/core/android/src/test/java/io/sentry/react/RNSentryCaptureEnvelopeTest.java new file mode 100644 index 0000000000..03f6c2d795 --- /dev/null +++ b/packages/core/android/src/test/java/io/sentry/react/RNSentryCaptureEnvelopeTest.java @@ -0,0 +1,235 @@ +package io.sentry.react; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.util.Log; +import com.facebook.react.bridge.Promise; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.ReadableMap; +import io.sentry.Hint; +import io.sentry.IScopes; +import io.sentry.Scope; +import io.sentry.ScopeCallback; +import io.sentry.Sentry; +import io.sentry.SentryEnvelope; +import io.sentry.SentryEnvelopeItem; +import io.sentry.SentryEvent; +import io.sentry.SentryItemType; +import io.sentry.SentryOptions; +import io.sentry.Session; +import io.sentry.android.core.InternalSentrySdk; +import io.sentry.cache.EnvelopeCache; +import io.sentry.cache.IEnvelopeCache; +import io.sentry.transport.NoOpEnvelopeCache; +import io.sentry.vendor.Base64; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.InputStreamReader; +import java.util.ArrayList; +import java.util.List; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; + +/** + * A fatal JS error is rethrown by React Native as JavascriptException, which the SDK ignores, so + * the process dies right after captureEnvelope returns. The hard-crash path must write the envelope + * to disk on the calling thread instead of queueing it on the transport executor + * (https://github.com/getsentry/sentry-react-native/issues/6918). + */ +public class RNSentryCaptureEnvelopeTest { + + @Rule public TemporaryFolder tmp = new TemporaryFolder(); + + private RNSentryModuleImpl module; + private SentryOptions options; + private Scope scope; + private IScopes scopes; + private IEnvelopeCache cache; + private Promise promise; + + @Before + public void setUp() throws Exception { + ReactApplicationContext reactContext = mock(ReactApplicationContext.class); + PackageManager packageManager = mock(PackageManager.class); + when(packageManager.getPackageInfo(anyString(), anyInt())).thenReturn(new PackageInfo()); + when(reactContext.getPackageManager()).thenReturn(packageManager); + when(reactContext.getPackageName()).thenReturn("com.test.app"); + module = new RNSentryModuleImpl(reactContext); + + options = new SentryOptions(); + options.setDsn("https://key@sentry.io/1"); + options.setRelease("1.0.0"); + options.setCacheDirPath(tmp.getRoot().getAbsolutePath()); + cache = mock(IEnvelopeCache.class); + when(cache.storeEnvelope(any(), any())).thenReturn(true); + options.setEnvelopeDiskCache(cache); + + scope = new Scope(options); + scopes = mock(IScopes.class); + when(scopes.getOptions()).thenReturn(options); + doAnswer( + invocation -> { + ((ScopeCallback) invocation.getArgument(0)).run(scope); + return null; + }) + .when(scopes) + .configureScope(any(ScopeCallback.class)); + + promise = mock(Promise.class); + } + + private static ReadableMap hardCrashed(final boolean value) { + final ReadableMap map = mock(ReadableMap.class); + when(map.hasKey("hardCrashed")).thenReturn(true); + when(map.getBoolean("hardCrashed")).thenReturn(value); + return map; + } + + private String eventEnvelope() throws Exception { + final SentryEnvelope envelope = + SentryEnvelope.from(options.getSerializer(), new SentryEvent(), null); + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + options.getSerializer().serialize(envelope, out); + return Base64.encodeToString(out.toByteArray(), Base64.DEFAULT); + } + + private static List itemTypes(final SentryEnvelope envelope) { + final List types = new ArrayList<>(); + for (final SentryEnvelopeItem item : envelope.getItems()) { + types.add(item.getHeader().getType()); + } + return types; + } + + @Test + public void hardCrashStoresEnvelopeOnCallingThreadAndDoesNotQueueIt() throws Exception { + try (MockedStatic sentry = mockStatic(Sentry.class); + MockedStatic internal = mockStatic(InternalSentrySdk.class)) { + sentry.when(Sentry::getCurrentScopes).thenReturn(scopes); + + module.captureEnvelope(eventEnvelope(), hardCrashed(true), promise); + + final ArgumentCaptor stored = ArgumentCaptor.forClass(SentryEnvelope.class); + verify(cache).storeEnvelope(stored.capture(), any(Hint.class)); + assertEquals(List.of(SentryItemType.Event), itemTypes(stored.getValue())); + verify(scopes, never()).captureEnvelope(any()); + internal.verify(() -> InternalSentrySdk.captureEnvelope(any(), anyBoolean()), never()); + verify(promise).resolve(true); + } + } + + @Test + public void hardCrashEndsSessionAsCrashedAndAttachesIt() throws Exception { + scope.startSession(); + final File sessionFile = EnvelopeCache.getCurrentSessionFile(options.getCacheDirPath()); + assertTrue(sessionFile.getParentFile().mkdirs()); + assertTrue(sessionFile.createNewFile()); + + try (MockedStatic sentry = mockStatic(Sentry.class); + MockedStatic ignored = mockStatic(Log.class)) { + sentry.when(Sentry::getCurrentScopes).thenReturn(scopes); + + module.captureEnvelope(eventEnvelope(), hardCrashed(true), promise); + + final ArgumentCaptor stored = ArgumentCaptor.forClass(SentryEnvelope.class); + verify(cache).storeEnvelope(stored.capture(), any(Hint.class)); + assertEquals( + List.of(SentryItemType.Event, SentryItemType.Session), itemTypes(stored.getValue())); + + Session attached = null; + for (final SentryEnvelopeItem item : stored.getValue().getItems()) { + if (item.getHeader().getType() == SentryItemType.Session) { + attached = + options + .getSerializer() + .deserialize( + new InputStreamReader(new ByteArrayInputStream(item.getData()), UTF_8), + Session.class); + } + } + assertNotNull(attached); + assertEquals(Session.State.Crashed, attached.getStatus()); + assertEquals(1, attached.errorCount()); + assertNotNull(attached.getTimestamp()); + assertNull(scope.withSession(session -> {})); + assertFalse(sessionFile.exists()); + } + } + + @Test + public void hardCrashFallsBackToQueueWhenStoreFails() throws Exception { + when(cache.storeEnvelope(any(), any())).thenReturn(false); + + try (MockedStatic sentry = mockStatic(Sentry.class); + MockedStatic ignored = mockStatic(Log.class)) { + sentry.when(Sentry::getCurrentScopes).thenReturn(scopes); + + module.captureEnvelope(eventEnvelope(), hardCrashed(true), promise); + + verify(cache).storeEnvelope(any(), any(Hint.class)); + verify(scopes).captureEnvelope(any(SentryEnvelope.class)); + verify(promise).resolve(true); + } + } + + @Test + public void hardCrashUsesQueuedPathWithoutDiskCache() throws Exception { + options.setEnvelopeDiskCache(NoOpEnvelopeCache.getInstance()); + final String rawBytes = eventEnvelope(); + + try (MockedStatic sentry = mockStatic(Sentry.class); + MockedStatic internal = mockStatic(InternalSentrySdk.class)) { + sentry.when(Sentry::getCurrentScopes).thenReturn(scopes); + + module.captureEnvelope(rawBytes, hardCrashed(true), promise); + + internal.verify( + () -> + InternalSentrySdk.captureEnvelope( + eq(Base64.decode(rawBytes, Base64.DEFAULT)), eq(false))); + verify(promise).resolve(true); + } + } + + @Test + public void nonFatalEventUsesQueuedPath() throws Exception { + final String rawBytes = eventEnvelope(); + + try (MockedStatic sentry = mockStatic(Sentry.class); + MockedStatic internal = mockStatic(InternalSentrySdk.class)) { + sentry.when(Sentry::getCurrentScopes).thenReturn(scopes); + + module.captureEnvelope(rawBytes, hardCrashed(false), promise); + + verify(cache, never()).storeEnvelope(any(), any()); + internal.verify( + () -> + InternalSentrySdk.captureEnvelope( + eq(Base64.decode(rawBytes, Base64.DEFAULT)), eq(true))); + verify(promise).resolve(true); + } + } +} From b00a5864b1235fccd380ef52e01b92dfba852001 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Fri, 23 Oct 2026 13:13:13 +0200 Subject: [PATCH 2/3] docs(changelog): Add PR number to the hard-crash envelope entry --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 91375cb069..791a75aa40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ ### Fixes - Apply `screenshot` masking options to Android error and feedback screenshots ([#6930](https://github.com/getsentry/sentry-react-native/pull/6930)) -- Persist fatal JS error envelopes to disk on Android before the process exits ([#NNNN](https://github.com/getsentry/sentry-react-native/pull/NNNN)) +- Persist fatal JS error envelopes to disk on Android before the process exits ([#6937](https://github.com/getsentry/sentry-react-native/pull/6937)) - The hard-crash envelope was only queued on the transport thread, so the event was lost when React Native terminated the process right after. It is now written to the offline cache on the calling thread and sent on the next launch, matching iOS. ### Dependencies From dd2cc620846cd760f53c44037104e981fcb45d42 Mon Sep 17 00:00:00 2001 From: Alexander Pantiukhov Date: Tue, 27 Oct 2026 11:35:39 +0100 Subject: [PATCH 3/3] fix(android): Keep session file until the hard-crash envelope is stored Address review feedback on the hard-crash store path. The current-session file was deleted before the envelope was written to the disk cache. If the store failed, the envelope was only queued while the file was already gone, so the next launch could not report the session as abnormal. The file is now deleted only after storeEnvelope succeeds. Debuggable builds now skip the store path and capture directly, like the iOS #if DEBUG gate. In debug builds LogBox keeps the app alive, so the event would otherwise sit in the cache until the next launch. Two new tests cover both cases; both fail on the previous code. --- .../io/sentry/react/RNSentryModuleImpl.java | 22 ++++++--- .../react/RNSentryCaptureEnvelopeTest.java | 45 ++++++++++++++++++- 2 files changed, 60 insertions(+), 7 deletions(-) diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java index 7806c781bf..bc18ac1560 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryModuleImpl.java @@ -7,6 +7,7 @@ import android.app.Activity; import android.content.Context; +import android.content.pm.ApplicationInfo; import android.content.pm.PackageInfo; import android.content.pm.PackageManager; import android.content.res.AssetManager; @@ -640,7 +641,7 @@ public void captureEnvelope(String rawBytes, ReadableMap options, Promise promis final boolean hardCrashed = options.hasKey("hardCrashed") && options.getBoolean("hardCrashed"); try { - if (!hardCrashed || !storeHardCrashEnvelope(bytes)) { + if (!hardCrashed || isAppDebuggable() || !storeHardCrashEnvelope(bytes)) { InternalSentrySdk.captureEnvelope(bytes, !hardCrashed); } } catch (Throwable e) { // NOPMD - We don't want to crash in any case @@ -650,6 +651,12 @@ public void captureEnvelope(String rawBytes, ReadableMap options, Promise promis promise.resolve(true); } + /** Mirrors the iOS {@code #if DEBUG} gate: debug builds keep the direct capture path. */ + private boolean isAppDebuggable() { + final @Nullable ApplicationInfo appInfo = reactApplicationContext.getApplicationInfo(); + return appInfo != null && (appInfo.flags & ApplicationInfo.FLAG_DEBUGGABLE) != 0; + } + /** * Writes a hard-crash envelope to the offline cache on the calling thread, like iOS {@code * store}. {@code InternalSentrySdk.captureEnvelope} only queues the envelope on the transport @@ -684,8 +691,7 @@ private boolean storeHardCrashEnvelope(final @NotNull byte[] bytes) throws IOExc } // Same session handling as InternalSentrySdk.captureEnvelope for a crash: end the session as - // crashed, remove it from the scope so it is not sent twice, and delete the persisted - // current-session file so the next launch does not also report it as abnormal. + // crashed and remove it from the scope so it is not sent twice. final @NotNull AtomicReference crashedSession = new AtomicReference<>(); scopes.configureScope( scope -> @@ -700,15 +706,19 @@ private boolean storeHardCrashEnvelope(final @NotNull byte[] bytes) throws IOExc final @Nullable Session session = crashedSession.get(); if (session != null) { items.add(SentryEnvelopeItem.fromSession(sentryOptions.getSerializer(), session)); - if (!EnvelopeCache.getCurrentSessionFile(cacheDirPath).delete()) { - logger.log(SentryLevel.WARNING, "Failed to delete the current session file."); - } } final @NotNull SentryEnvelope repackaged = new SentryEnvelope(envelope.getHeader(), items); if (!cache.storeEnvelope(repackaged, new Hint())) { logger.log(SentryLevel.ERROR, "Failed to store the hard crash envelope, queueing it."); scopes.captureEnvelope(repackaged); + return true; + } + + // The crashed session is now on disk, so the persisted current-session file can go. If the + // store failed, the file stays so the next launch still reports the session as abnormal. + if (session != null && !EnvelopeCache.getCurrentSessionFile(cacheDirPath).delete()) { + logger.log(SentryLevel.WARNING, "Failed to delete the current session file."); } return true; } diff --git a/packages/core/android/src/test/java/io/sentry/react/RNSentryCaptureEnvelopeTest.java b/packages/core/android/src/test/java/io/sentry/react/RNSentryCaptureEnvelopeTest.java index 03f6c2d795..6f289ddd02 100644 --- a/packages/core/android/src/test/java/io/sentry/react/RNSentryCaptureEnvelopeTest.java +++ b/packages/core/android/src/test/java/io/sentry/react/RNSentryCaptureEnvelopeTest.java @@ -18,6 +18,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import android.content.pm.ApplicationInfo; import android.content.pm.PackageInfo; import android.content.pm.PackageManager; import android.util.Log; @@ -63,6 +64,7 @@ public class RNSentryCaptureEnvelopeTest { @Rule public TemporaryFolder tmp = new TemporaryFolder(); + private ReactApplicationContext reactContext; private RNSentryModuleImpl module; private SentryOptions options; private Scope scope; @@ -72,7 +74,7 @@ public class RNSentryCaptureEnvelopeTest { @Before public void setUp() throws Exception { - ReactApplicationContext reactContext = mock(ReactApplicationContext.class); + reactContext = mock(ReactApplicationContext.class); PackageManager packageManager = mock(PackageManager.class); when(packageManager.getPackageInfo(anyString(), anyInt())).thenReturn(new PackageInfo()); when(reactContext.getPackageManager()).thenReturn(packageManager); @@ -195,6 +197,47 @@ public void hardCrashFallsBackToQueueWhenStoreFails() throws Exception { } } + @Test + public void hardCrashKeepsSessionFileWhenStoreFails() throws Exception { + when(cache.storeEnvelope(any(), any())).thenReturn(false); + scope.startSession(); + final File sessionFile = EnvelopeCache.getCurrentSessionFile(options.getCacheDirPath()); + assertTrue(sessionFile.getParentFile().mkdirs()); + assertTrue(sessionFile.createNewFile()); + + try (MockedStatic sentry = mockStatic(Sentry.class); + MockedStatic ignored = mockStatic(Log.class)) { + sentry.when(Sentry::getCurrentScopes).thenReturn(scopes); + + module.captureEnvelope(eventEnvelope(), hardCrashed(true), promise); + + verify(scopes).captureEnvelope(any(SentryEnvelope.class)); + assertTrue(sessionFile.exists()); + } + } + + @Test + public void hardCrashUsesQueuedPathInDebuggableApp() throws Exception { + final ApplicationInfo appInfo = new ApplicationInfo(); + appInfo.flags = ApplicationInfo.FLAG_DEBUGGABLE; + when(reactContext.getApplicationInfo()).thenReturn(appInfo); + final String rawBytes = eventEnvelope(); + + try (MockedStatic sentry = mockStatic(Sentry.class); + MockedStatic internal = mockStatic(InternalSentrySdk.class)) { + sentry.when(Sentry::getCurrentScopes).thenReturn(scopes); + + module.captureEnvelope(rawBytes, hardCrashed(true), promise); + + verify(cache, never()).storeEnvelope(any(), any()); + internal.verify( + () -> + InternalSentrySdk.captureEnvelope( + eq(Base64.decode(rawBytes, Base64.DEFAULT)), eq(false))); + verify(promise).resolve(true); + } + } + @Test public void hardCrashUsesQueuedPathWithoutDiskCache() throws Exception { options.setEnvelopeDiskCache(NoOpEnvelopeCache.getInstance());