From 737f06965977e417a4e9bafcd48b66648235be1a Mon Sep 17 00:00:00 2001 From: Joseph Rodiz Date: Thu, 7 May 2026 14:55:13 -0600 Subject: [PATCH 1/4] Add didANRKillOnPreviousExecution() to FirebaseCrashlytics Implements firebase-android-sdk#4201 by exposing a new public API method that detects whether the app was killed by an ANR in the previous run, mirroring the existing didCrashOnPreviousExecution() pattern. On Android API 30+ the method queries ApplicationExitInfo (already used internally for ANR session reporting) against the previous session's start timestamp. On older API levels it always returns false. --- .../CrashlyticsCoreInitializationTest.java | 12 +++++ .../crashlytics/FirebaseCrashlytics.java | 10 ++++ .../common/CrashlyticsController.java | 20 ++++++++ .../internal/common/CrashlyticsCore.java | 24 +++++++++ .../common/SessionReportingCoordinator.java | 7 +++ ...onReportingCoordinatorRobolectricTest.java | 49 +++++++++++++++++++ 6 files changed, 122 insertions(+) diff --git a/firebase-crashlytics/src/androidTest/java/com/google/firebase/crashlytics/internal/common/CrashlyticsCoreInitializationTest.java b/firebase-crashlytics/src/androidTest/java/com/google/firebase/crashlytics/internal/common/CrashlyticsCoreInitializationTest.java index 9110bdb2588..5e7f59e9473 100644 --- a/firebase-crashlytics/src/androidTest/java/com/google/firebase/crashlytics/internal/common/CrashlyticsCoreInitializationTest.java +++ b/firebase-crashlytics/src/androidTest/java/com/google/firebase/crashlytics/internal/common/CrashlyticsCoreInitializationTest.java @@ -265,6 +265,18 @@ public void testOnPreExecute_didNotCrashOnPreviousExecution() { assertFalse(getCrashMarkerFile().exists()); } + @Test + public void testOnPreExecute_didNotANROnPreviousExecution() { + // Without any ApplicationExitInfo entries indicating an ANR, didANROnPreviousExecution + // should return false. + final CrashlyticsCore crashlyticsCore = builder().build(); + setupBuildIdRequired(String.valueOf(false)); + setupAppData(BUILD_ID); + + assertTrue(crashlyticsCore.onPreExecute(appData, mockSettingsController)); + assertFalse(crashlyticsCore.didANROnPreviousExecution()); + } + private void setupBuildIdRequired(String booleanValue) { setupResource( RES_ID_REQUIRE_BUILD_ID, diff --git a/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/FirebaseCrashlytics.java b/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/FirebaseCrashlytics.java index 46f992aaf12..67ef7aac381 100644 --- a/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/FirebaseCrashlytics.java +++ b/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/FirebaseCrashlytics.java @@ -462,6 +462,16 @@ public boolean didCrashOnPreviousExecution() { return core.didCrashOnPreviousExecution(); } + /** + * Checks whether the app was terminated due to an ANR (Application Not Responding) on its + * previous run. Requires Android API 30 (R) or above; returns {@code false} on older versions. + * + * @return true if an ANR was recorded during the previous run of the app. + */ + public boolean didANRKillOnPreviousExecution() { + return core.didANROnPreviousExecution(); + } + /** * Indicates whether or not automatic data collection is enabled. * diff --git a/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/common/CrashlyticsController.java b/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/common/CrashlyticsController.java index b464819e18c..81decbd7d8a 100644 --- a/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/common/CrashlyticsController.java +++ b/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/common/CrashlyticsController.java @@ -943,5 +943,25 @@ private void writeApplicationExitInfoEventIfRelevant(String sessionId) { .v("ANR feature enabled, but device is API " + android.os.Build.VERSION.SDK_INT); } } + /** This function must be called before opening the first session. */ + boolean didANROnPreviousExecution() { + CrashlyticsWorkers.checkBackgroundThread(); + if (android.os.Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + return false; + } + final String sessionId = getCurrentSessionId(); + if (sessionId == null) { + return false; + } + ActivityManager activityManager = + (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); + List applicationExitInfoList = + activityManager.getHistoricalProcessExitReasons(null, 0, 0); + if (applicationExitInfoList.isEmpty()) { + return false; + } + return reportingCoordinator.didRelevantAnrOccur(sessionId, applicationExitInfoList); + } + // endregion } diff --git a/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/common/CrashlyticsCore.java b/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/common/CrashlyticsCore.java index f8ea00c767d..325f9ac6dce 100644 --- a/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/common/CrashlyticsCore.java +++ b/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/common/CrashlyticsCore.java @@ -83,6 +83,7 @@ public class CrashlyticsCore { private CrashlyticsFileMarker initializationMarker; private CrashlyticsFileMarker crashMarker; private boolean didCrashOnPreviousExecution; + private boolean didANROnPreviousExecution; private CrashlyticsController controller; private final IdManager idManager; @@ -196,6 +197,7 @@ public boolean onPreExecute(AppData appData, SettingsProvider settingsProvider) final boolean initializeSynchronously = didPreviousInitializationFail(); checkForPreviousCrash(); + checkForPreviousAnr(); controller.enableExceptionHandling( sessionIdentifier, Thread.getDefaultUncaughtExceptionHandler(), settingsProvider); @@ -502,6 +504,28 @@ public boolean didCrashOnPreviousExecution() { return didCrashOnPreviousExecution; } + private void checkForPreviousAnr() { + Future future = + crashlyticsWorkers + .common + .getExecutor() + .submit(() -> controller.didANROnPreviousExecution()); + + Boolean result; + try { + result = future.get(DEFAULT_MAIN_HANDLER_TIMEOUT_SEC, TimeUnit.SECONDS); + } catch (Exception ignored) { + didANROnPreviousExecution = false; + return; + } + + didANROnPreviousExecution = Boolean.TRUE.equals(result); + } + + public boolean didANROnPreviousExecution() { + return didANROnPreviousExecution; + } + // endregion // region Static utilities diff --git a/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/common/SessionReportingCoordinator.java b/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/common/SessionReportingCoordinator.java index 50533be05b1..d7bbc12304b 100644 --- a/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/common/SessionReportingCoordinator.java +++ b/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/common/SessionReportingCoordinator.java @@ -439,6 +439,13 @@ public static String convertInputStreamToString(InputStream inputStream) throws } } + /** Returns true if an ANR ApplicationExitInfo occurred during the given session. */ + @RequiresApi(api = Build.VERSION_CODES.R) + public boolean didRelevantAnrOccur( + String sessionId, List applicationExitInfoList) { + return findRelevantApplicationExitInfo(sessionId, applicationExitInfoList) != null; + } + /** Finds the first ANR ApplicationExitInfo within the session. */ @RequiresApi(api = Build.VERSION_CODES.R) private @Nullable ApplicationExitInfo findRelevantApplicationExitInfo( diff --git a/firebase-crashlytics/src/test/java/com/google/firebase/crashlytics/internal/common/SessionReportingCoordinatorRobolectricTest.java b/firebase-crashlytics/src/test/java/com/google/firebase/crashlytics/internal/common/SessionReportingCoordinatorRobolectricTest.java index 67be4920dad..805d2bd483c 100644 --- a/firebase-crashlytics/src/test/java/com/google/firebase/crashlytics/internal/common/SessionReportingCoordinatorRobolectricTest.java +++ b/firebase-crashlytics/src/test/java/com/google/firebase/crashlytics/internal/common/SessionReportingCoordinatorRobolectricTest.java @@ -164,6 +164,55 @@ public void testAppExitInfoEvent_notPersistIfAppExitInfoNotAnrButWithinSession() verify(reportPersistence, never()).persistEvent(any(), eq(sessionId), eq(true)); } + @Test + @SdkSuppress(minSdkVersion = VERSION_CODES.R) + public void testDidRelevantAnrOccur_returnsTrueForAnrWithinSession() { + final long sessionStartTimestamp = 0; + final String sessionId = "testSessionId"; + when(reportPersistence.getStartTimestampMillis(sessionId)).thenReturn(sessionStartTimestamp); + + addAppExitInfo(ApplicationExitInfo.REASON_ANR); + List testApplicationExitInfoList = getAppExitInfoList(); + + assertTrue(reportingCoordinator.didRelevantAnrOccur(sessionId, testApplicationExitInfoList)); + } + + @Test + @SdkSuppress(minSdkVersion = VERSION_CODES.R) + public void testDidRelevantAnrOccur_returnsFalseForAnrBeforeSession() { + // ANR timestamp is 0; session starts at 10, so ANR was before the session. + final long sessionStartTimestamp = 10; + final String sessionId = "testSessionId"; + when(reportPersistence.getStartTimestampMillis(sessionId)).thenReturn(sessionStartTimestamp); + + addAppExitInfo(ApplicationExitInfo.REASON_ANR); + List testApplicationExitInfoList = getAppExitInfoList(); + + assertFalse(reportingCoordinator.didRelevantAnrOccur(sessionId, testApplicationExitInfoList)); + } + + @Test + @SdkSuppress(minSdkVersion = VERSION_CODES.R) + public void testDidRelevantAnrOccur_returnsFalseForNonAnrWithinSession() { + final long sessionStartTimestamp = 0; + final String sessionId = "testSessionId"; + when(reportPersistence.getStartTimestampMillis(sessionId)).thenReturn(sessionStartTimestamp); + + addAppExitInfo(ApplicationExitInfo.REASON_EXIT_SELF); + List testApplicationExitInfoList = getAppExitInfoList(); + + assertFalse(reportingCoordinator.didRelevantAnrOccur(sessionId, testApplicationExitInfoList)); + } + + @Test + @SdkSuppress(minSdkVersion = VERSION_CODES.R) + public void testDidRelevantAnrOccur_returnsFalseForEmptyList() { + final String sessionId = "testSessionId"; + when(reportPersistence.getStartTimestampMillis(sessionId)).thenReturn(0L); + + assertFalse(reportingCoordinator.didRelevantAnrOccur(sessionId, List.of())); + } + @Test public void testconvertInputStreamToString_worksSuccessfully() throws IOException { String stackTrace = "-----stacktrace---------"; From 51cadc62e48d2e8ab1c155173e4dd8853d7eff1e Mon Sep 17 00:00:00 2001 From: Joseph Rodiz Date: Thu, 7 May 2026 15:08:02 -0600 Subject: [PATCH 2/4] Update changelog and api docs --- firebase-crashlytics/CHANGELOG.md | 3 +++ firebase-crashlytics/api.txt | 1 + 2 files changed, 4 insertions(+) diff --git a/firebase-crashlytics/CHANGELOG.md b/firebase-crashlytics/CHANGELOG.md index 2c75af37754..ab8b589c142 100644 --- a/firebase-crashlytics/CHANGELOG.md +++ b/firebase-crashlytics/CHANGELOG.md @@ -5,6 +5,9 @@ - [fixed] Fixed race condition that caused logs from background threads to not be attached to reports in some cases [#8034] - [changed] Updated `firebase-sessions` dependency to v3.0.6 +- [feature] Added `didANRKillOnPreviousExecution()` to `FirebaseCrashlytics`, allowing apps to + detect whether they were killed by an ANR in the previous run. Requires API level 30 (Android R) + or above; always returns `false` on older versions. [#4201] # 20.0.5 diff --git a/firebase-crashlytics/api.txt b/firebase-crashlytics/api.txt index 900b32020a0..3488222dc4c 100644 --- a/firebase-crashlytics/api.txt +++ b/firebase-crashlytics/api.txt @@ -18,6 +18,7 @@ package com.google.firebase.crashlytics { public class FirebaseCrashlytics { method public com.google.android.gms.tasks.Task checkForUnsentReports(); method public void deleteUnsentReports(); + method public boolean didANRKillOnPreviousExecution(); method public boolean didCrashOnPreviousExecution(); method public static com.google.firebase.crashlytics.FirebaseCrashlytics getInstance(); method public boolean isCrashlyticsCollectionEnabled(); From 5a2e5686dfa0899cf266c2d1fd3513d9fe9fb07d Mon Sep 17 00:00:00 2001 From: Joseph Rodiz Date: Thu, 7 May 2026 15:20:01 -0600 Subject: [PATCH 3/4] Address PR #8110 review comments: null-check ActivityManager, log ANR check failures - Add null guard on ActivityManager from getSystemService() before calling getHistoricalProcessExitReasons() to avoid potential NPE. - Log exceptions in checkForPreviousAnr() at verbose level instead of silently ignoring them, to aid diagnosability. --- .../crashlytics/internal/common/CrashlyticsController.java | 4 ++++ .../firebase/crashlytics/internal/common/CrashlyticsCore.java | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/common/CrashlyticsController.java b/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/common/CrashlyticsController.java index 81decbd7d8a..1e3391da66b 100644 --- a/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/common/CrashlyticsController.java +++ b/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/common/CrashlyticsController.java @@ -943,6 +943,7 @@ private void writeApplicationExitInfoEventIfRelevant(String sessionId) { .v("ANR feature enabled, but device is API " + android.os.Build.VERSION.SDK_INT); } } + /** This function must be called before opening the first session. */ boolean didANROnPreviousExecution() { CrashlyticsWorkers.checkBackgroundThread(); @@ -955,6 +956,9 @@ boolean didANROnPreviousExecution() { } ActivityManager activityManager = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); + if (activityManager == null) { + return false; + } List applicationExitInfoList = activityManager.getHistoricalProcessExitReasons(null, 0, 0); if (applicationExitInfoList.isEmpty()) { diff --git a/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/common/CrashlyticsCore.java b/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/common/CrashlyticsCore.java index 325f9ac6dce..0d09eefe024 100644 --- a/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/common/CrashlyticsCore.java +++ b/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/common/CrashlyticsCore.java @@ -514,7 +514,8 @@ private void checkForPreviousAnr() { Boolean result; try { result = future.get(DEFAULT_MAIN_HANDLER_TIMEOUT_SEC, TimeUnit.SECONDS); - } catch (Exception ignored) { + } catch (Exception ex) { + Logger.getLogger().v("Error checking for previous ANR: " + ex.getMessage()); didANROnPreviousExecution = false; return; } From 2d0c7cfd189328da7db727ddfd5cae8d7123e5f6 Mon Sep 17 00:00:00 2001 From: Joseph Rodiz Date: Mon, 18 May 2026 12:55:02 -0600 Subject: [PATCH 4/4] Make didANRKillOnPreviousExecution() lazy to avoid blocking SDK init The previous implementation called future.get(3, SECONDS) from onPreExecute on the main thread during eager Crashlytics initialization, just to populate a boolean that almost no app would read. Defer the work to the first call of didANRKillOnPreviousExecution() and cache the result. Apps that never call the API pay nothing at cold start; apps that do call it pay on their own thread, once. To keep the result correct after finalizeSessions removes the previous session, capture the previous-execution session ID on the common worker during onPreExecute (cheap, no blocking get) and read it from the cached field instead of getCurrentSessionId() when the ANR check finally runs. --- .../crashlytics/FirebaseCrashlytics.java | 4 ++ .../common/CrashlyticsController.java | 18 ++++++- .../internal/common/CrashlyticsCore.java | 53 +++++++++++-------- 3 files changed, 51 insertions(+), 24 deletions(-) diff --git a/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/FirebaseCrashlytics.java b/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/FirebaseCrashlytics.java index 67ef7aac381..9c428f24466 100644 --- a/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/FirebaseCrashlytics.java +++ b/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/FirebaseCrashlytics.java @@ -466,6 +466,10 @@ public boolean didCrashOnPreviousExecution() { * Checks whether the app was terminated due to an ANR (Application Not Responding) on its * previous run. Requires Android API 30 (R) or above; returns {@code false} on older versions. * + * The result is computed lazily on the first call and cached, so the first invocation may + * briefly block the calling thread (up to a few seconds) while the system is queried. Call from + * a background thread if responsiveness matters. + * * @return true if an ANR was recorded during the previous run of the app. */ public boolean didANRKillOnPreviousExecution() { diff --git a/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/common/CrashlyticsController.java b/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/common/CrashlyticsController.java index 1e3391da66b..e8465537db9 100644 --- a/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/common/CrashlyticsController.java +++ b/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/common/CrashlyticsController.java @@ -119,6 +119,10 @@ class CrashlyticsController { // A token to make sure that checkForUnsentReports only gets called once. final AtomicBoolean checkForUnsentReportsCalled = new AtomicBoolean(false); + // Captured before the previous session is finalized, so didANROnPreviousExecution() can + // still resolve the correct session ID after the user calls it later. + @Nullable private volatile String previousExecutionSessionId; + CrashlyticsController( Context context, IdManager idManager, @@ -944,13 +948,23 @@ private void writeApplicationExitInfoEventIfRelevant(String sessionId) { } } - /** This function must be called before opening the first session. */ + /** + * Captures the previous run's session ID on the common worker so {@link + * #didANROnPreviousExecution} can resolve it later. SDK-internal: the call site (currently + * {@link CrashlyticsCore#onPreExecute}) must queue this before {@link #enableExceptionHandling} + * (which opens a new session) and before {@link #finalizeSessions} (which removes the previous + * one); after either runs, {@link #getCurrentSessionId} no longer refers to the previous run. + */ + void capturePreviousExecutionSessionId() { + crashlyticsWorkers.common.submit(() -> previousExecutionSessionId = getCurrentSessionId()); + } + boolean didANROnPreviousExecution() { CrashlyticsWorkers.checkBackgroundThread(); if (android.os.Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { return false; } - final String sessionId = getCurrentSessionId(); + final String sessionId = previousExecutionSessionId; if (sessionId == null) { return false; } diff --git a/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/common/CrashlyticsCore.java b/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/common/CrashlyticsCore.java index 0d09eefe024..6bdf998ea83 100644 --- a/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/common/CrashlyticsCore.java +++ b/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/common/CrashlyticsCore.java @@ -83,7 +83,9 @@ public class CrashlyticsCore { private CrashlyticsFileMarker initializationMarker; private CrashlyticsFileMarker crashMarker; private boolean didCrashOnPreviousExecution; - private boolean didANROnPreviousExecution; + + private final Object didANROnPreviousExecutionLock = new Object(); + @Nullable private volatile Boolean didANROnPreviousExecutionCached; private CrashlyticsController controller; private final IdManager idManager; @@ -197,7 +199,10 @@ public boolean onPreExecute(AppData appData, SettingsProvider settingsProvider) final boolean initializeSynchronously = didPreviousInitializationFail(); checkForPreviousCrash(); - checkForPreviousAnr(); + + // Must run before enableExceptionHandling and finalizeSessions, since both alter the + // set of open sessions. didANROnPreviousExecution() uses the captured ID later. + controller.capturePreviousExecutionSessionId(); controller.enableExceptionHandling( sessionIdentifier, Thread.getDefaultUncaughtExceptionHandler(), settingsProvider); @@ -504,27 +509,31 @@ public boolean didCrashOnPreviousExecution() { return didCrashOnPreviousExecution; } - private void checkForPreviousAnr() { - Future future = - crashlyticsWorkers - .common - .getExecutor() - .submit(() -> controller.didANROnPreviousExecution()); - - Boolean result; - try { - result = future.get(DEFAULT_MAIN_HANDLER_TIMEOUT_SEC, TimeUnit.SECONDS); - } catch (Exception ex) { - Logger.getLogger().v("Error checking for previous ANR: " + ex.getMessage()); - didANROnPreviousExecution = false; - return; - } - - didANROnPreviousExecution = Boolean.TRUE.equals(result); - } - public boolean didANROnPreviousExecution() { - return didANROnPreviousExecution; + Boolean cached = didANROnPreviousExecutionCached; + if (cached != null) { + return cached; + } + synchronized (didANROnPreviousExecutionLock) { + if (didANROnPreviousExecutionCached != null) { + return didANROnPreviousExecutionCached; + } + Future future = + crashlyticsWorkers + .common + .getExecutor() + .submit(() -> controller.didANROnPreviousExecution()); + + Boolean result; + try { + result = future.get(DEFAULT_MAIN_HANDLER_TIMEOUT_SEC, TimeUnit.SECONDS); + } catch (Exception ex) { + Logger.getLogger().v("Error checking for previous ANR: " + ex.getMessage()); + result = false; + } + didANROnPreviousExecutionCached = Boolean.TRUE.equals(result); + return didANROnPreviousExecutionCached; + } } // endregion