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(); 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..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 @@ -462,6 +462,20 @@ 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. + * + * 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() { + 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..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, @@ -943,5 +947,39 @@ private void writeApplicationExitInfoEventIfRelevant(String sessionId) { .v("ANR feature enabled, but device is API " + android.os.Build.VERSION.SDK_INT); } } + + /** + * 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 = previousExecutionSessionId; + if (sessionId == null) { + return false; + } + ActivityManager activityManager = + (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); + if (activityManager == null) { + return false; + } + 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..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 @@ -84,6 +84,9 @@ public class CrashlyticsCore { private CrashlyticsFileMarker crashMarker; private boolean didCrashOnPreviousExecution; + private final Object didANROnPreviousExecutionLock = new Object(); + @Nullable private volatile Boolean didANROnPreviousExecutionCached; + private CrashlyticsController controller; private final IdManager idManager; private final FileStore fileStore; @@ -197,6 +200,10 @@ public boolean onPreExecute(AppData appData, SettingsProvider settingsProvider) checkForPreviousCrash(); + // 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); @@ -502,6 +509,33 @@ public boolean didCrashOnPreviousExecution() { return didCrashOnPreviousExecution; } + public boolean 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 // 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---------";