chromium/android_webview/java/src/org/chromium/android_webview/metrics/TrackExitReasonsOfInterest.java

// Copyright 2023 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.android_webview.metrics;

import android.os.Build;
import android.os.Process;

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

import org.jni_zero.JNINamespace;
import org.json.JSONException;
import org.json.JSONObject;

import org.chromium.android_webview.AppState;
import org.chromium.base.Callback;
import org.chromium.base.FileUtils;
import org.chromium.base.Log;
import org.chromium.base.PathUtils;
import org.chromium.base.metrics.RecordHistogram;
import org.chromium.base.task.PostTask;
import org.chromium.base.task.TaskRunner;
import org.chromium.base.task.TaskTraits;
import org.chromium.components.crash.browser.ProcessExitReasonFromSystem;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Supplier;

/**
 * Tracks the most recent exit reason of interest in embedding WebView apps
 * associated with WebView's last pid.
 *
 * @return The most recent exit reason of interest matching the last PID.
 */
@RequiresApi(Build.VERSION_CODES.R)
@JNINamespace("android_webview")
public class TrackExitReasonsOfInterest {
    public static final String TAG = "TrackExitReasons";
    public static final String LAST_EXIT_INFO_FILENAME = "last-exit-info";
    public static final String LAST_EXIT_INFO_PID = "exitInfoPid";
    public static final String TIMESTAMP_AT_LAST_RECORDING_IN_MILLIS =
            "timestampAtLastRecordingInMillis";
    public static final String LAST_STATE_KEY = "appState";
    public static final String UMA_DELTA = "Android.WebView.HistoricalApplicationExitInfo.Delta";
    public static final String UMA_COUNTS = "Android.WebView.HistoricalApplicationExitInfo.Counts";
    private static long sTestTime;
    private static int sPid;
    private static int sMockedSystemExitReason;
    private static int sTestCount;
    private static boolean sUseMockedSystemExitReason;

    @VisibleForTesting public static final Map<Integer, String> sUmaSuffixMap = createMap();

    private static Map<Integer, String> createMap() {
        Map<Integer, String> umaSuffixMap = new HashMap<Integer, String>();
        umaSuffixMap.put(AppState.UNKNOWN, "UNKNOWN");
        umaSuffixMap.put(AppState.FOREGROUND, "FOREGROUND");
        umaSuffixMap.put(AppState.BACKGROUND, "BACKGROUND");
        umaSuffixMap.put(AppState.DESTROYED, "DESTROYED");
        return umaSuffixMap;
    }

    private static long sTimestampAtLastRecording;
    // To avoid any potential synchronization issues we post all exit reason actions to
    // the same sequence to be run serially. This should also guarantee that the #run method
    // always runs prior to any #writeLastWebViewState invocations, since the run method is
    // called at startup.
    private static final TaskRunner sSequencedTaskRunner =
            PostTask.createSequencedTaskRunner(TaskTraits.BEST_EFFORT_MAY_BLOCK);
    private static Supplier<Integer> sAppStateSupplier;
    private static @AppState int sLastStateWritten;

    /**
     *
     * Posts the exit reason tracker work in task runner queue.
     */
    public static void init(Supplier<Integer> stateSupplier) {
        sAppStateSupplier = stateSupplier;
        sSequencedTaskRunner.postTask(
                () -> {
                    run();
                });
    }

    /**
     *
     * @return The latest exit reason matching the last process's PID
     */
    @VisibleForTesting
    public static int run() {
        ExitReasonData previousInfoData = readLastExitInfo();
        int pid = sPid != 0 ? sPid : Process.myPid();
        // No previous pid, so no need to attempt to log anything
        // Return -1 for testing purposes
        int systemExitReason = -1;

        sTimestampAtLastRecording = currentTimeMillis();
        if (previousInfoData != null) {
            systemExitReason =
                    ProcessExitReasonFromSystem.getExitReason(previousInfoData.mExitInfoPid);
            Integer mappedExitReasonData =
                    ProcessExitReasonFromSystem.convertApplicationExitInfoToExitReason(
                            systemExitReason);

            // Log the new record
            if (mappedExitReasonData != null) {
                if (previousInfoData.mTimestampAtLastRecordingInMillis > 0L) {
                    long delta =
                            sTimestampAtLastRecording
                                    - previousInfoData.mTimestampAtLastRecordingInMillis;
                    RecordHistogram.recordLongTimesHistogram100(UMA_DELTA, delta);
                }
                ProcessExitReasonFromSystem.recordAsEnumHistogram(UMA_COUNTS, systemExitReason);
                ProcessExitReasonFromSystem.recordAsEnumHistogram(
                        UMA_COUNTS + "." + sUmaSuffixMap.get(previousInfoData.mState),
                        systemExitReason);
            }
        }
        writeLastExitInfo(
                new ExitReasonData(pid, sTimestampAtLastRecording, sAppStateSupplier.get()));

        return systemExitReason;
    }

    /** Data class for tracking exit reasons */
    public static class ExitReasonData {
        public final int mExitInfoPid;
        public final long mTimestampAtLastRecordingInMillis;
        public final @AppState int mState;

        public ExitReasonData(int pid, long timestampAtLastRecordingInMillis, @AppState int state) {
            mExitInfoPid = pid;
            mTimestampAtLastRecordingInMillis = timestampAtLastRecordingInMillis;
            mState = state;
        }
    }

    @VisibleForTesting
    public static ExitReasonData readLastExitInfo() {
        try (FileInputStream fis = new FileInputStream(getLastExitInfoFile())) {
            String buffer = new String(FileUtils.readStream(fis));
            JSONObject jsonObj = new JSONObject(buffer);

            // Want to early return since we cannot do anything useful without previous PID
            if (!jsonObj.has(LAST_EXIT_INFO_PID)) return null;
            int exitInfoPid = jsonObj.getInt(LAST_EXIT_INFO_PID);

            long timestampAtLastRecordingInMillis =
                    jsonObj.optLong(TIMESTAMP_AT_LAST_RECORDING_IN_MILLIS);
            @AppState int state = jsonObj.optInt(LAST_STATE_KEY);

            return new ExitReasonData(exitInfoPid, timestampAtLastRecordingInMillis, state);
        } catch (IOException e) {
            // File does not exist or the file read fails here
            return null;
        } catch (JSONException e) {
            Log.i(TAG, "Failed to parse JSON from file.");
        }

        return null;
    }

    /**
     * This stores the exit info data for future runs. The {@link
     * org.chromium.android_webview.AwContentsLifecycleNotifier} will also call this when users
     * change apps between the foreground & background.
     *
     * @param newData Provides the pid and timestamp at last recording
     * @return whether the newData is valid
     */
    @VisibleForTesting
    public static boolean writeLastExitInfo(final ExitReasonData newData) {
        return writeLastExitInfo(newData, /* callbackResult= */ null);
    }

    @VisibleForTesting
    public static boolean writeLastExitInfo(
            final ExitReasonData newData, final Callback<Boolean> callbackResult) {
        try (FileOutputStream writer =
                new FileOutputStream(getLastExitInfoFile(), /* append= */ false)) {
            JSONObject jsonObj = new JSONObject();
            jsonObj.put(LAST_EXIT_INFO_PID, newData.mExitInfoPid);
            jsonObj.put(
                    TIMESTAMP_AT_LAST_RECORDING_IN_MILLIS,
                    newData.mTimestampAtLastRecordingInMillis);
            jsonObj.put(LAST_STATE_KEY, newData.mState);
            String jsonString = jsonObj.toString();
            writer.write(jsonString.getBytes());
            writer.flush();
        } catch (JSONException | IOException e) {
            Log.e(TAG, "Failed to write last exit info.");
            if (callbackResult != null) {
                callbackResult.onResult(false);
            }
            return false;
        }

        sLastStateWritten = newData.mState;
        if (callbackResult != null) {
            callbackResult.onResult(true);
        }
        return true;
    }

    @VisibleForTesting
    public static File getLastExitInfoFile() {
        return new File(PathUtils.getDataDirectory(), LAST_EXIT_INFO_FILENAME);
    }

    /** Commits the latest app state for exit reason tracking to disk. */
    public static void writeLastWebViewState() {
        writeLastWebViewState(/* callbackResult= */ null);
    }

    /**
     * Commits the latest app state for exit reason tracking to disk.
     *
     * @param callbackResult Used for testing purposes only for waiting on disk writes to complete.
     */
    @VisibleForTesting
    public static void writeLastWebViewState(final Callback<Boolean> callbackResult) {
        int pid = sPid != 0 ? sPid : Process.myPid();
        sSequencedTaskRunner.postTask(
                () -> {
                    @AppState int currentState = sAppStateSupplier.get();
                    if (sLastStateWritten != currentState) {
                        writeLastExitInfo(
                                new ExitReasonData(pid, sTimestampAtLastRecording, currentState),
                                callbackResult);
                    }
                });
    }

    private static long currentTimeMillis() {
        if (sTestTime > 0L) return sTestTime;
        return System.currentTimeMillis();
    }

    @VisibleForTesting
    public static void setSystemTimeForTest(long testTime) {
        sTestTime = testTime;
    }

    @VisibleForTesting
    public static void setPidForTest(int pid) {
        sPid = pid;
    }

    @VisibleForTesting
    public static void setCurrtimeForTest(long currTime) {
        sTimestampAtLastRecording = currTime;
    }

    @VisibleForTesting
    public static void setStateSupplier(Supplier<Integer> supplier) {
        sAppStateSupplier = supplier;
    }
}