chromium/components/crash/android/java/src/org/chromium/components/crash/anr/AnrCollector.java

// Copyright 2021 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

package org.chromium.components.crash.anr;

import android.app.ActivityManager;
import android.app.ApplicationExitInfo;
import android.content.Context;
import android.content.SharedPreferences;
import android.os.Build;
import android.util.Pair;

import androidx.annotation.RequiresApi;
import androidx.annotation.VisibleForTesting;

import org.jni_zero.NativeMethods;

import org.chromium.base.ContextUtils;
import org.chromium.base.Log;
import org.chromium.base.metrics.RecordHistogram;
import org.chromium.components.crash.anr.AnrDataOuterClass.AnrData;

import java.io.BufferedReader;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * This class will retrieve ANRs from Android and write them to files.
 *
 * We also grab the version number associated with the ANR and pair that with the ANR so we have
 * confidence knowing which version of Chrome actually caused this ANR.
 */
@RequiresApi(Build.VERSION_CODES.R)
public class AnrCollector {
    private static final String TAG = "AnrCollector";

    // SharedPrefs key for the timestamp from the last ANR we dealt with.
    private static final String ANR_TIMESTAMP_SHARED_PREFS_KEY = "ANR_ALREADY_UPLOADED_TIMESTAMP";

    private static final String ANR_SKIPPED_UMA = "Crashpad.AnrUpload.Skipped";

    /**
     * Grabs ANR reports from Android and writes them as 3-tuples as 3 entries in a string list.
     * This writes to disk synchronously, so should be called on a background thread.
     */
    public static List<String> collectAndWriteAnrs(File outDir) {
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
            return Collections.emptyList();
        }
        return writeAnrs(collectAnrs(), outDir);
    }

    public static String getSharedLibraryBuildId() {
        return AnrCollectorJni.get().getSharedLibraryBuildId();
    }

    @VisibleForTesting
    static AnrData parseAnrFromReport(BufferedReader reader) throws IOException {
        // For each thread, the header line always looks the same - example:
        // "Signal Catcher" daemon prio=10 tid=6 Runnable
        Pattern threadFirstLine = Pattern.compile("\"(.*)\".*prio=\\d+ tid=\\d+ \\w+");

        // The stuff before we get to the stack traces.
        StringBuilder preamble = new StringBuilder();
        // One thread is literally called "main" and is the one that really matters.
        StringBuilder mainThreadStackTrace = new StringBuilder();
        // All other stacks.
        StringBuilder stackTraces = new StringBuilder();

        StringBuilder curBuilder = preamble;
        String line;
        while (null != (line = reader.readLine())) {
            Matcher threadLineMatcher = threadFirstLine.matcher(line);
            if (threadLineMatcher.matches()) {
                if (threadLineMatcher.group(1).equals("main")) {
                    curBuilder = mainThreadStackTrace;
                } else {
                    curBuilder = stackTraces;
                }
            }
            curBuilder.append(line).append("\n");
        }

        // Cause is required but doesn't do anything. It's supposed to be the message from Logcat
        // (ie. "Input dispatching timed out") but that doesn't appear in the ANR report we get.
        AnrData anrData =
                AnrData.newBuilder()
                        .setCause("Chrome_ANR_Cause")
                        .setPreamble(preamble.toString())
                        .setMainThreadStackTrace(mainThreadStackTrace.toString())
                        .setStackTraces(stackTraces.toString())
                        .build();
        return anrData;
    }

    private static Pair<AnrData, String> getAnrPair(ApplicationExitInfo reason) {
        AnrData anr = null;
        try (InputStream is = reason.getTraceInputStream()) {
            // This can be null - this was causing crashes in crbug.com/1298852.
            if (is == null) {
                return null;
            }

            try (BufferedReader in = new BufferedReader(new InputStreamReader(is))) {
                anr = parseAnrFromReport(in);
            }
        } catch (IOException e) {
            Log.e(TAG, "Couldn't read ANR from system", e);
            RecordHistogram.recordEnumeratedHistogram(
                    ANR_SKIPPED_UMA,
                    AnrSkippedReason.FILESYSTEM_READ_FAILURE,
                    AnrSkippedReason.MAX_VALUE);
            return null;
        }

        byte[] processStateSummaryBytes = reason.getProcessStateSummary();
        if (processStateSummaryBytes == null || processStateSummaryBytes.length == 0) {
            // We have gotten an ANR without an attached process state summary and thus
            // can't be be confident which version this ANR happened on. This would
            // happen if we ANRed before Chrome had set the process state summary.
            RecordHistogram.recordEnumeratedHistogram(
                    ANR_SKIPPED_UMA, AnrSkippedReason.MISSING_VERSION, AnrSkippedReason.MAX_VALUE);
            return null;
        }
        String processStateSummary = new String(processStateSummaryBytes, StandardCharsets.UTF_8);
        return new Pair<>(anr, processStateSummary);
    }

    private static List<Pair<AnrData, String>> collectAnrs() {
        ActivityManager am =
                (ActivityManager)
                        ContextUtils.getApplicationContext()
                                .getSystemService(Context.ACTIVITY_SERVICE);
        // getHistoricalProcessExitReasons has a ring buffer and will return the same ANR many times
        // in a row until the ring fills out. To prevent making duplicate ANR reports, we have to
        // remember what the last ANR we uploaded is, which we do with shared preferences.
        SharedPreferences prefs = ContextUtils.getAppSharedPreferences();
        long lastHandledTime = prefs.getLong(ANR_TIMESTAMP_SHARED_PREFS_KEY, 0);
        long maxHandledTime = lastHandledTime;

        List<ApplicationExitInfo> reasons = am.getHistoricalProcessExitReasons(null, 0, 0);
        List<Pair<AnrData, String>> anrs = new ArrayList<>();
        for (ApplicationExitInfo reason : reasons) {
            long time = reason.getTimestamp();
            if (reason.getReason() == ApplicationExitInfo.REASON_ANR && time > lastHandledTime) {
                Pair<AnrData, String> pair = getAnrPair(reason);
                if (pair != null) {
                    anrs.add(pair);
                    if (time > maxHandledTime) {
                        maxHandledTime = time;
                    }
                }
            }
        }
        SharedPreferences.Editor editor = prefs.edit();
        editor.putLong(ANR_TIMESTAMP_SHARED_PREFS_KEY, maxHandledTime);
        editor.apply();
        return anrs;
    }

    private static List<String> writeAnrs(List<Pair<AnrData, String>> anrs, File outDir) {
        List<String> anrFiles = new ArrayList<>();
        for (Pair<AnrData, String> pair : anrs) {
            AnrData anr = pair.first;
            String[] splitStateSummary = pair.second.split(",");
            String version = splitStateSummary[0];
            // There will always be a version number, but there's a chance that there won't be a
            // buildId.
            String buildId = "";
            if (splitStateSummary.length > 1) {
                buildId = splitStateSummary[1];
                RecordHistogram.recordEnumeratedHistogram(
                        ANR_SKIPPED_UMA, AnrSkippedReason.NOT_SKIPPED, AnrSkippedReason.MAX_VALUE);
            } else {
                RecordHistogram.recordEnumeratedHistogram(
                        ANR_SKIPPED_UMA,
                        AnrSkippedReason.ONLY_MISSING_NATIVE,
                        AnrSkippedReason.MAX_VALUE);
            }
            String anrFileName = writeAnr(anr, outDir);
            if (anrFileName != null) {
                anrFiles.add(anrFileName);
                anrFiles.add(version);
                anrFiles.add(buildId);
            }
        }
        return anrFiles;
    }

    private static String writeAnr(AnrData data, File outDir) {
        try {
            // Writing with .tmp suffix to enable cleanup later - CrashFileManager looks for
            // files with a .tmp suffix and deletes them as soon as it no longer needs them.
            File anrFile = File.createTempFile("anr_data_proto", ".tmp", outDir);
            try (FileOutputStream out = new FileOutputStream(anrFile)) {
                data.writeTo(out);
            }
            return anrFile.getAbsolutePath();
        } catch (IOException e) {
            Log.e(TAG, "Couldn't write ANR proto", e);
            RecordHistogram.recordEnumeratedHistogram(
                    ANR_SKIPPED_UMA,
                    AnrSkippedReason.FILESYSTEM_WRITE_FAILURE,
                    AnrSkippedReason.MAX_VALUE);
            return null;
        }
    }

    // Pure static class.
    private AnrCollector() {}

    @NativeMethods
    interface Natives {
        String getSharedLibraryBuildId();
    }
}