call() throws Exception {
ex, thread, currentSessionId, timestampSeconds);
doWriteAppExceptionMarker(timestampMillis);
- doCloseSessions();
+ doCloseSessions(settingsDataProvider);
doOpenSession();
// If automatic data collection is disabled, we'll need to wait until the next run
@@ -520,8 +522,10 @@ private String getCurrentSessionId() {
*
* This method can not be called while the {@link CrashlyticsCore} settings lock is held. It
* will result in a deadlock!
+ *
+ * @param settingsDataProvider
*/
- boolean finalizeSessions() {
+ boolean finalizeSessions(SettingsDataProvider settingsDataProvider) {
backgroundWorker.checkRunningOnThread();
if (isHandlingException()) {
@@ -531,7 +535,7 @@ boolean finalizeSessions() {
Logger.getLogger().v("Finalizing previously open sessions.");
try {
- doCloseSessions(true);
+ doCloseSessions(true, settingsDataProvider);
} catch (Exception e) {
Logger.getLogger().e("Unable to finalize previously open sessions.", e);
return false;
@@ -562,15 +566,16 @@ private void doOpenSession() {
reportingCoordinator.onBeginSession(sessionIdentifier, startedAtSeconds);
}
- void doCloseSessions() {
- doCloseSessions(false);
+ void doCloseSessions(SettingsDataProvider settingsDataProvider) {
+ doCloseSessions(false, settingsDataProvider);
}
/**
* Not synchronized/locked. Must be executed from the single thread executor service used by this
* class.
*/
- private void doCloseSessions(boolean skipCurrentSession) {
+ private void doCloseSessions(
+ boolean skipCurrentSession, SettingsDataProvider settingsDataProvider) {
final int offset = skipCurrentSession ? 1 : 0;
List sortedOpenSessions = reportingCoordinator.listSortedOpenSessionIds();
@@ -582,6 +587,12 @@ private void doCloseSessions(boolean skipCurrentSession) {
final String mostRecentSessionIdToClose = sortedOpenSessions.get(offset);
+ if (settingsDataProvider.getSettings().getFeaturesData().collectAnrs) {
+ // TODO: Consider writing applicationExitInfo for all sessions instead of just the most recent
+ // sessionId to close.
+ writeApplicationExitInfoEventIfRelevant(mostRecentSessionIdToClose);
+ }
+
if (nativeComponent.hasCrashDataForSession(mostRecentSessionIdToClose)) {
// We only finalize the current session if it's a Java crash, so only finalize native crash
// data when we aren't including current.
@@ -856,4 +867,23 @@ static List getNativeSessionFiles(
}
// endregion
+
+ // region ApplicationExitInfo
+
+ /** If an ApplicationExitInfo exists relevant to the session, writes that event. */
+ private void writeApplicationExitInfoEventIfRelevant(String sessionId) {
+ if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ ActivityManager activityManager =
+ (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE);
+ List applicationExitInfoList =
+ activityManager.getHistoricalProcessExitReasons(null, 0, 0);
+
+ // Passes the latest applicationExitInfo to ReportCoordinator, which persists it if it
+ // happened during the session.
+ if (applicationExitInfoList.size() != 0) {
+ reportingCoordinator.persistAppExitInfoEvent(sessionId, applicationExitInfoList.get(0));
+ }
+ }
+ }
+ // 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 c0998f50f94..b1cebe8f194 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
@@ -228,7 +228,7 @@ private Task doBackgroundInitialization(SettingsDataProvider settingsProvi
new RuntimeException("Collection of crash reports disabled in Crashlytics settings."));
}
- if (!controller.finalizeSessions()) {
+ if (!controller.finalizeSessions(settingsProvider)) {
Logger.getLogger().w("Previous sessions could not be finalized.");
}
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 8725dd51c86..5280437fff6 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
@@ -14,9 +14,13 @@
package com.google.firebase.crashlytics.internal.common;
+import android.app.ApplicationExitInfo;
import android.content.Context;
+import android.os.Build;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
+import androidx.annotation.RequiresApi;
+import androidx.annotation.VisibleForTesting;
import com.google.android.gms.tasks.Task;
import com.google.android.gms.tasks.Tasks;
import com.google.firebase.crashlytics.internal.Logger;
@@ -30,7 +34,14 @@
import com.google.firebase.crashlytics.internal.send.DataTransportCrashlyticsReportSender;
import com.google.firebase.crashlytics.internal.settings.SettingsDataProvider;
import com.google.firebase.crashlytics.internal.stacktrace.StackTraceTrimmingStrategy;
+import java.io.BufferedReader;
import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.Reader;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
@@ -44,6 +55,7 @@
public class SessionReportingCoordinator implements CrashlyticsLifecycleEvents {
private static final String EVENT_TYPE_CRASH = "crash";
+ private static final String EVENT_TYPE_ANR = "anr";
private static final String EVENT_TYPE_LOGGED = "error";
private static final int EVENT_THREAD_IMPORTANCE = 4;
private static final int MAX_CHAINED_EXCEPTION_DEPTH = 8;
@@ -122,6 +134,36 @@ public void persistNonFatalEvent(
persistEvent(event, thread, sessionId, EVENT_TYPE_LOGGED, timestamp, false);
}
+ @RequiresApi(api = Build.VERSION_CODES.R)
+ public void persistAppExitInfoEvent(String sessionId, ApplicationExitInfo applicationExitInfo) {
+ long sessionStartTime = reportPersistence.getStartTimestampMillis(sessionId);
+ // ApplicationExitInfo did not occur during the session.
+ if (applicationExitInfo.getTimestamp() < sessionStartTime) {
+ return;
+ }
+
+ // Currently we only persist these if it is an ANR.
+ if (applicationExitInfo.getReason() != ApplicationExitInfo.REASON_ANR) {
+ return;
+ }
+
+ // TODO: Refactor Event to only contain relevant information rather than unnecessary data like
+ // thread, exception etc.
+ final CrashlyticsReport.Session.Event capturedEvent =
+ dataCapture.captureEventData(
+ new Exception("ANR"),
+ Thread.currentThread(),
+ EVENT_TYPE_ANR,
+ applicationExitInfo.getTimestamp(),
+ EVENT_THREAD_IMPORTANCE,
+ MAX_CHAINED_EXCEPTION_DEPTH,
+ false);
+ CrashlyticsReport.ApplicationExitInfo crashlyticsAppExitInfo =
+ convertApplicationExitInfo(applicationExitInfo);
+ Logger.getLogger().d("Persisting anr for session " + sessionId);
+ reportPersistence.persistAppExitInfoEvent(capturedEvent, sessionId, crashlyticsAppExitInfo);
+ }
+
public void finalizeSessionWithNativeEvent(
@NonNull String sessionId, @NonNull List nativeSessionFiles) {
ArrayList nativeFiles = new ArrayList<>();
@@ -264,4 +306,45 @@ private static List getSortedCustomAttributes(
return attributesList;
}
+
+ @RequiresApi(api = Build.VERSION_CODES.R)
+ private static CrashlyticsReport.ApplicationExitInfo convertApplicationExitInfo(
+ ApplicationExitInfo applicationExitInfo) {
+ String traceFile = null;
+ try {
+ traceFile = convertInputStreamToString(applicationExitInfo.getTraceInputStream());
+ } catch (IOException | NullPointerException e) {
+ Logger.getLogger()
+ .w(
+ "Could not get input trace in application exit info: "
+ + applicationExitInfo.toString()
+ + " Error: "
+ + e);
+ }
+
+ return CrashlyticsReport.ApplicationExitInfo.builder()
+ .setImportance(applicationExitInfo.getImportance())
+ .setProcessName(applicationExitInfo.getProcessName())
+ .setReasonCode(applicationExitInfo.getReason())
+ .setTimestamp(applicationExitInfo.getTimestamp())
+ .setTraceFile(traceFile)
+ .build();
+ }
+
+ @RequiresApi(api = Build.VERSION_CODES.KITKAT)
+ @VisibleForTesting
+ public static String convertInputStreamToString(@Nullable InputStream inputStream)
+ throws IOException, NullPointerException {
+ StringBuilder stringBuilder = new StringBuilder();
+ try (Reader reader =
+ new BufferedReader(
+ new InputStreamReader(inputStream, Charset.forName(StandardCharsets.UTF_8.name())))) {
+ int c = 0;
+ while ((c = reader.read()) != -1) {
+ stringBuilder.append((char) c);
+ }
+
+ return stringBuilder.toString();
+ }
+ }
}
diff --git a/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/model/CrashlyticsReport.java b/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/model/CrashlyticsReport.java
index 08ecbb926ef..6e8dfd95ce5 100644
--- a/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/model/CrashlyticsReport.java
+++ b/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/model/CrashlyticsReport.java
@@ -1054,7 +1054,8 @@ public static ApplicationExitInfo.Builder builder() {
@NonNull
public abstract long getTimestamp();
- @NonNull
+ @Nullable
+ // Not all ApplicationExitInfos have a trace file.
public abstract String getTraceFile();
/** Builder for {@link ApplicationExitInfo}. */
diff --git a/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/persistence/CrashlyticsReportPersistence.java b/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/persistence/CrashlyticsReportPersistence.java
index 867f9241395..4687c740d18 100644
--- a/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/persistence/CrashlyticsReportPersistence.java
+++ b/firebase-crashlytics/src/main/java/com/google/firebase/crashlytics/internal/persistence/CrashlyticsReportPersistence.java
@@ -59,6 +59,8 @@ public class CrashlyticsReportPersistence {
private static final String REPORT_FILE_NAME = "report";
private static final String USER_FILE_NAME = "user";
+ // A single session should have only a single AppExitInfo.
+ private static final String APP_EXIT_INFO_FILE_NAME = "app-exit-info";
// We use the lastModified timestamp of this file to quickly store and access the startTime in ms
// of a session.
private static final String SESSION_START_TIMESTAMP_FILE_NAME = "start-time";
@@ -69,6 +71,7 @@ public class CrashlyticsReportPersistence {
EVENT_FILE_NAME_PREFIX.length() + EVENT_COUNTER_WIDTH;
private static final String PRIORITY_EVENT_SUFFIX = "_";
private static final String NORMAL_EVENT_SUFFIX = "";
+ private static final String EVENT_TYPE_ANR = "anr";
private static final CrashlyticsReportJsonTransform TRANSFORM =
new CrashlyticsReportJsonTransform();
@@ -163,6 +166,21 @@ public void persistEvent(
trimEvents(sessionDirectory, maxEventsToKeep);
}
+ /**
+ * Persist an ANR event and the relevant ApplicationExitInfo to the given session.
+ *
+ * @param event
+ * @param sessionId
+ * @param applicationExitInfo
+ */
+ public void persistAppExitInfoEvent(
+ @NonNull CrashlyticsReport.Session.Event event,
+ @NonNull String sessionId,
+ @NonNull CrashlyticsReport.ApplicationExitInfo applicationExitInfo) {
+ persistEvent(event, sessionId, true);
+ persistApplicationExitInfo(applicationExitInfo, sessionId);
+ }
+
public void persistUserIdForSession(@NonNull String userId, @NonNull String sessionId) {
final File sessionDirectory = getSessionDirectoryById(sessionId);
try {
@@ -331,11 +349,21 @@ private void synthesizeReport(@NonNull File sessionDirectory, long sessionEndTim
Collections.sort(eventFiles);
final List events = new ArrayList<>();
boolean isHighPriorityReport = false;
+ CrashlyticsReport.ApplicationExitInfo appExitInfo = null;
for (File eventFile : eventFiles) {
try {
- events.add(TRANSFORM.eventFromJson(readTextFile(eventFile)));
+ Event event = TRANSFORM.eventFromJson(readTextFile(eventFile));
+ events.add(event);
isHighPriorityReport = isHighPriorityReport || isHighPriorityEventFile(eventFile.getName());
+
+ if (event.getType().equals(EVENT_TYPE_ANR)) {
+ try {
+ appExitInfo = getAppExitInfo(sessionDirectory);
+ } catch (IOException e) {
+ Logger.getLogger().d("Failed to read AppExitInfo: ", e);
+ }
+ }
} catch (IOException e) {
Logger.getLogger().w("Could not add event to report for " + eventFile, e);
}
@@ -360,7 +388,13 @@ private void synthesizeReport(@NonNull File sessionDirectory, long sessionEndTim
final File reportFile = new File(sessionDirectory, REPORT_FILE_NAME);
final File outputDirectory = isHighPriorityReport ? priorityReportsDirectory : reportsDirectory;
synthesizeReportFile(
- reportFile, outputDirectory, events, sessionEndTime, isHighPriorityReport, userId);
+ reportFile,
+ outputDirectory,
+ events,
+ sessionEndTime,
+ isHighPriorityReport,
+ userId,
+ appExitInfo);
}
private static void synthesizeNativeReportFile(
@@ -386,13 +420,17 @@ private static void synthesizeReportFile(
@NonNull List events,
long sessionEndTime,
boolean isCrashed,
- @Nullable String userId) {
+ @Nullable String userId,
+ @Nullable CrashlyticsReport.ApplicationExitInfo applicationExitInfo) {
try {
CrashlyticsReport report =
TRANSFORM
.reportFromJson(readTextFile(reportFile))
.withSessionEndFields(sessionEndTime, isCrashed, userId)
.withEvents(ImmutableList.from(events));
+ if (applicationExitInfo != null) {
+ report = report.withAppExitInfo(applicationExitInfo);
+ }
final Session session = report.getSession();
@@ -564,4 +602,22 @@ private static void recursiveDelete(@Nullable File file) {
private static long convertTimestampFromSecondsToMs(long timestampSeconds) {
return timestampSeconds * 1000;
}
+
+ private void persistApplicationExitInfo(
+ CrashlyticsReport.ApplicationExitInfo applicationExitInfo, String sessionId) {
+ File sessionDirectory = getSessionDirectoryById(sessionId);
+
+ try {
+ String appExitInfo = TRANSFORM.appExitInfoToJson(applicationExitInfo);
+ writeTextFile(new File(sessionDirectory, APP_EXIT_INFO_FILE_NAME), appExitInfo);
+ } catch (IOException e) {
+ Logger.getLogger().w("Unable to write app exit info file: " + e);
+ }
+ }
+
+ private CrashlyticsReport.ApplicationExitInfo getAppExitInfo(File sessionDirectory)
+ throws IOException {
+ File appExitInfoFile = new File(sessionDirectory, APP_EXIT_INFO_FILE_NAME);
+ return TRANSFORM.applicationExitInfoFromJson(readTextFile(appExitInfoFile));
+ }
}
diff --git a/firebase-crashlytics/src/test/java/com/google/firebase/crashlytics/internal/common/CrashlyticsControllerRobolectricTest.java b/firebase-crashlytics/src/test/java/com/google/firebase/crashlytics/internal/common/CrashlyticsControllerRobolectricTest.java
new file mode 100644
index 00000000000..f389e6afc32
--- /dev/null
+++ b/firebase-crashlytics/src/test/java/com/google/firebase/crashlytics/internal/common/CrashlyticsControllerRobolectricTest.java
@@ -0,0 +1,152 @@
+// Copyright 2021 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.firebase.crashlytics.internal.common;
+
+import static androidx.test.core.app.ApplicationProvider.getApplicationContext;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.app.ActivityManager;
+import android.app.ApplicationExitInfo;
+import android.content.Context;
+import androidx.test.core.app.ApplicationProvider;
+import com.google.firebase.crashlytics.internal.ProviderProxyNativeComponent;
+import com.google.firebase.crashlytics.internal.analytics.AnalyticsEventLogger;
+import com.google.firebase.crashlytics.internal.persistence.FileStore;
+import com.google.firebase.crashlytics.internal.settings.SettingsDataProvider;
+import com.google.firebase.crashlytics.internal.settings.model.FeaturesSettingsData;
+import com.google.firebase.crashlytics.internal.settings.model.Settings;
+import com.google.firebase.crashlytics.internal.unity.UnityVersionProvider;
+import java.io.File;
+import java.util.Collections;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.robolectric.RobolectricTestRunner;
+
+@RunWith(RobolectricTestRunner.class)
+public class CrashlyticsControllerRobolectricTest {
+ private static final String GOOGLE_APP_ID = "google:app:id";
+
+ private Context testContext;
+ @Mock private IdManager idManager;
+ @Mock private SettingsDataProvider mockSettingsDataProvider;
+ @Mock private FileStore mockFileStore;
+ @Mock private File mockFilesDirectory;
+ @Mock private SessionReportingCoordinator mockSessionReportingCoordinator;
+ @Mock private DataCollectionArbiter mockDataCollectionArbiter;
+
+ @Before
+ public void setUp() {
+ MockitoAnnotations.initMocks(this);
+ testContext = getApplicationContext();
+ }
+
+ @Test
+ public void testDoCloseSession_enabledAnrs_doesNotPersistsAppExitInfoIfItDoesntExist() {
+ final String sessionId = "sessionId";
+ final CrashlyticsController controller = createController();
+
+ when(mockSessionReportingCoordinator.listSortedOpenSessionIds())
+ .thenReturn(Collections.singletonList(sessionId));
+ mockSettingsData(true);
+ controller.doCloseSessions(mockSettingsDataProvider);
+ // Since we haven't added any app exit info to the shadow activity manager, there won't exist a
+ // single app exit info, and so this method won't be called.
+ verify(mockSessionReportingCoordinator, never()).persistAppExitInfoEvent(eq(sessionId), any());
+ }
+
+ @Test
+ public void testDoCloseSession_enabledAnrs_persistsAppExitInfoIfItExists() {
+ final String sessionId = "sessionId";
+ final CrashlyticsController controller = createController();
+ ApplicationExitInfo testApplicationExitInfo = addAppExitInfo(ApplicationExitInfo.REASON_ANR);
+
+ when(mockSessionReportingCoordinator.listSortedOpenSessionIds())
+ .thenReturn(Collections.singletonList(sessionId));
+ mockSettingsData(true);
+ controller.doCloseSessions(mockSettingsDataProvider);
+ verify(mockSessionReportingCoordinator)
+ .persistAppExitInfoEvent(eq(sessionId), eq(testApplicationExitInfo));
+ }
+
+ @Test
+ public void testDoCloseSession_disabledAnrs_doesNotPersistsAppExitInfo() {
+ final String sessionId = "sessionId";
+ final CrashlyticsController controller = createController();
+
+ when(mockSessionReportingCoordinator.listSortedOpenSessionIds())
+ .thenReturn(Collections.singletonList(sessionId));
+ mockSettingsData(false);
+ controller.doCloseSessions(mockSettingsDataProvider);
+ verify(mockSessionReportingCoordinator, never()).persistAppExitInfoEvent(eq(sessionId), any());
+ }
+
+ private void mockSettingsData(boolean collectAnrs) {
+ Settings mockSettings = mock(Settings.class);
+ when(mockSettingsDataProvider.getSettings()).thenReturn(mockSettings);
+ when(mockSettings.getFeaturesData()).thenReturn(new FeaturesSettingsData(true, collectAnrs));
+ }
+
+ /** Creates a new CrashlyticsController with default options and opens a session. */
+ private CrashlyticsController createController() {
+ AppData appData =
+ new AppData(
+ GOOGLE_APP_ID,
+ "buildId",
+ "installerPackageName",
+ "packageName",
+ "versionCode",
+ "versionName",
+ mock(UnityVersionProvider.class));
+
+ final CrashlyticsController controller =
+ new CrashlyticsController(
+ testContext,
+ new CrashlyticsBackgroundWorker(Runnable::run),
+ idManager,
+ mockDataCollectionArbiter,
+ mockFileStore,
+ new CrashlyticsFileMarker(CrashlyticsCore.CRASH_MARKER_FILE_NAME, mockFileStore),
+ appData,
+ null,
+ null,
+ null,
+ mockSessionReportingCoordinator,
+ new ProviderProxyNativeComponent(() -> null),
+ mock(AnalyticsEventLogger.class));
+ controller.openSession();
+ return controller;
+ }
+
+ private ApplicationExitInfo addAppExitInfo(int reason) {
+ ActivityManager activityManager =
+ (ActivityManager)
+ ApplicationProvider.getApplicationContext().getSystemService(Context.ACTIVITY_SERVICE);
+ ActivityManager.RunningAppProcessInfo runningAppProcessInfo =
+ activityManager.getRunningAppProcesses().get(0);
+ shadowOf(activityManager)
+ .addApplicationExitInfo(
+ runningAppProcessInfo.processName, runningAppProcessInfo.pid, reason, 1);
+ return activityManager.getHistoricalProcessExitReasons(null, 0, 0).get(0);
+ }
+}
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
new file mode 100644
index 00000000000..4a2396d8fc6
--- /dev/null
+++ b/firebase-crashlytics/src/test/java/com/google/firebase/crashlytics/internal/common/SessionReportingCoordinatorRobolectricTest.java
@@ -0,0 +1,208 @@
+// Copyright 2021 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.firebase.crashlytics.internal.common;
+
+import static org.junit.Assert.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.app.ActivityManager;
+import android.app.ApplicationExitInfo;
+import android.content.Context;
+import android.os.Build;
+import androidx.annotation.RequiresApi;
+import androidx.test.core.app.ApplicationProvider;
+import com.google.firebase.crashlytics.internal.log.LogFileManager;
+import com.google.firebase.crashlytics.internal.model.CrashlyticsReport;
+import com.google.firebase.crashlytics.internal.persistence.CrashlyticsReportPersistence;
+import com.google.firebase.crashlytics.internal.send.DataTransportCrashlyticsReportSender;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.robolectric.RobolectricTestRunner;
+
+@RunWith(RobolectricTestRunner.class)
+public class SessionReportingCoordinatorRobolectricTest {
+ @Mock private CrashlyticsReportDataCapture dataCapture;
+ @Mock private CrashlyticsReportPersistence reportPersistence;
+ @Mock private DataTransportCrashlyticsReportSender reportSender;
+ @Mock private LogFileManager logFileManager;
+ @Mock private UserMetadata reportMetadata;
+ @Mock private CrashlyticsReport.Session.Event mockEvent;
+ @Mock private CrashlyticsReport.Session.Event.Builder mockEventBuilder;
+ @Mock private CrashlyticsReport.Session.Event.Application mockEventApp;
+ @Mock private CrashlyticsReport.Session.Event.Application.Builder mockEventAppBuilder;
+
+ private SessionReportingCoordinator reportingCoordinator;
+
+ @Before
+ public void setUp() {
+ MockitoAnnotations.initMocks(this);
+
+ reportingCoordinator =
+ new SessionReportingCoordinator(
+ dataCapture, reportPersistence, reportSender, logFileManager, reportMetadata);
+ mockEventInteractions();
+ }
+
+ @Test
+ public void testAppExitInfoEvent_persistIfAnrWithinSession() {
+ // The timestamp of the applicationExitInfo is 0, and so in this case, we want the session
+ // timestamp to be less than or equal to 0.
+ final long sessionStartTimestamp = 0;
+ final String sessionId = "testSessionId";
+ when(reportPersistence.getStartTimestampMillis(sessionId)).thenReturn(sessionStartTimestamp);
+
+ mockEventInteractions();
+ ApplicationExitInfo testApplicationExitInfo = addAppExitInfo(ApplicationExitInfo.REASON_ANR);
+
+ reportingCoordinator.onBeginSession(sessionId, sessionStartTimestamp);
+ reportingCoordinator.persistAppExitInfoEvent(sessionId, testApplicationExitInfo);
+
+ verify(dataCapture)
+ .captureEventData(
+ any(Throwable.class),
+ any(Thread.class),
+ eq("anr"),
+ anyLong(),
+ anyInt(),
+ anyInt(),
+ anyBoolean());
+ verify(reportPersistence)
+ .persistAppExitInfoEvent(
+ any(), eq(sessionId), eq(convertApplicationExitInfo(testApplicationExitInfo)));
+ }
+
+ @Test
+ public void testAppExitInfoEvent_notPersistIfAnrBeforeSession() {
+ // The timestamp of the applicationExitInfo is 0, and so in this case, we want the session
+ // timestamp to be greater than 0.
+ final long sessionStartTimestamp = 10;
+ final String sessionId = "testSessionId";
+ when(reportPersistence.getStartTimestampMillis(sessionId)).thenReturn(sessionStartTimestamp);
+
+ ApplicationExitInfo testApplicationExitInfo = addAppExitInfo(ApplicationExitInfo.REASON_ANR);
+
+ reportingCoordinator.onBeginSession(sessionId, sessionStartTimestamp);
+ reportingCoordinator.persistAppExitInfoEvent(sessionId, testApplicationExitInfo);
+
+ verify(dataCapture, never())
+ .captureEventData(
+ any(Throwable.class),
+ any(Thread.class),
+ eq("anr"),
+ anyLong(),
+ anyInt(),
+ anyInt(),
+ anyBoolean());
+ verify(reportPersistence, never())
+ .persistAppExitInfoEvent(
+ any(), eq(sessionId), eq(convertApplicationExitInfo(testApplicationExitInfo)));
+ }
+
+ @Test
+ public void testAppExitInfoEvent_notPersistIfAppExitInfoNotAnrButWithinSession() {
+ // The timestamp of the applicationExitInfo is 0, and so in this case, we want the session
+ // timestamp to be less than or equal to 0.
+ final long sessionStartTimestamp = 0;
+ final String sessionId = "testSessionId";
+ when(reportPersistence.getStartTimestampMillis(sessionId)).thenReturn(sessionStartTimestamp);
+
+ ApplicationExitInfo testApplicationExitInfo =
+ addAppExitInfo(ApplicationExitInfo.REASON_DEPENDENCY_DIED);
+
+ reportingCoordinator.onBeginSession(sessionId, sessionStartTimestamp);
+ reportingCoordinator.persistAppExitInfoEvent(sessionId, testApplicationExitInfo);
+
+ verify(dataCapture, never())
+ .captureEventData(
+ any(Throwable.class),
+ any(Thread.class),
+ eq("anr"),
+ anyLong(),
+ anyInt(),
+ anyInt(),
+ anyBoolean());
+ verify(reportPersistence, never())
+ .persistAppExitInfoEvent(
+ any(), eq(sessionId), eq(convertApplicationExitInfo(testApplicationExitInfo)));
+ }
+
+ @Test
+ public void testconvertInputStreamToString_worksSuccessfully() throws IOException {
+ String stackTrace = "-----stacktrace---------";
+ InputStream inputStream = new ByteArrayInputStream(stackTrace.getBytes());
+ assertEquals(stackTrace, SessionReportingCoordinator.convertInputStreamToString(inputStream));
+ }
+
+ private void mockEventInteractions() {
+ when(mockEvent.toBuilder()).thenReturn(mockEventBuilder);
+ when(mockEventBuilder.build()).thenReturn(mockEvent);
+ when(mockEvent.getApp()).thenReturn(mockEventApp);
+ when(mockEventApp.toBuilder()).thenReturn(mockEventAppBuilder);
+ when(mockEventAppBuilder.setCustomAttributes(any())).thenReturn(mockEventAppBuilder);
+ when(mockEventAppBuilder.build()).thenReturn(mockEventApp);
+ when(dataCapture.captureEventData(
+ any(Throwable.class),
+ any(Thread.class),
+ anyString(),
+ anyLong(),
+ anyInt(),
+ anyInt(),
+ anyBoolean()))
+ .thenReturn(mockEvent);
+ }
+
+ private ApplicationExitInfo addAppExitInfo(int reason) {
+ ActivityManager activityManager =
+ (ActivityManager)
+ ApplicationProvider.getApplicationContext().getSystemService(Context.ACTIVITY_SERVICE);
+ ActivityManager.RunningAppProcessInfo runningAppProcessInfo =
+ activityManager.getRunningAppProcesses().get(0);
+ shadowOf(activityManager)
+ .addApplicationExitInfo(
+ runningAppProcessInfo.processName, runningAppProcessInfo.pid, reason, 1);
+ return activityManager.getHistoricalProcessExitReasons(null, 0, 0).get(0);
+ }
+
+ @RequiresApi(api = Build.VERSION_CODES.R)
+ private static CrashlyticsReport.ApplicationExitInfo convertApplicationExitInfo(
+ ApplicationExitInfo applicationExitInfo) {
+ // The ApplicationExitInfo inserted by ShadowApplicationManager does not contain an input trace,
+ // and so it will be null.
+ // Thus, these tests verify that an ApplicationExitInfo is successfully converted even when the
+ // input trace fails to be parsed as a string.
+ return CrashlyticsReport.ApplicationExitInfo.builder()
+ .setImportance(applicationExitInfo.getImportance())
+ .setProcessName(applicationExitInfo.getProcessName())
+ .setReasonCode(applicationExitInfo.getReason())
+ .setTimestamp(applicationExitInfo.getTimestamp())
+ .setTraceFile(null)
+ .build();
+ }
+}