// Copyright 2018 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.base.memory;

import android.app.ActivityManager;
import android.content.ComponentCallbacks2;
import android.content.res.Configuration;

import androidx.annotation.VisibleForTesting;

import org.chromium.base.ContextUtils;
import org.chromium.base.MemoryPressureLevel;
import org.chromium.base.MemoryPressureListener;
import org.chromium.base.ThreadUtils;
import org.chromium.base.supplier.Supplier;
import org.chromium.build.annotations.MainDex;

/**
 * This class monitors memory pressure and reports it to the native side.
 * Even though there can be other callbacks besides MemoryPressureListener (which reports
 * pressure to the native side, and is added implicitly), the class is designed to suite
 * needs of native MemoryPressureListeners.
 *
 * There are two groups of MemoryPressureListeners:
 *
 * 1. Stateless, i.e. ones that simply free memory (caches, etc.) in response to memory
 *    pressure. These listeners need to be called periodically (to have effect), but not
 *    too frequently (to avoid regressing performance too much).
 *
 * 2. Stateful, i.e. ones that change their behavior based on the last received memory
 *    pressure (in addition to freeing memory). These listeners need to know when the
 *    pressure subsides, i.e. they need to be notified about CRITICAL->MODERATE changes.
 *
 * Android notifies about memory pressure through onTrimMemory() / onLowMemory() callbacks
 * from ComponentCallbacks2, but these are unreliable (e.g. called too early, called just
 * once, not called when memory pressure subsides, etc., see https://crbug.com/813909 for
 * more examples).
 *
 * There is also ActivityManager.getMyMemoryState() API which returns current pressure for
 * the calling process. It has its caveats, for example it can't be called from isolated
 * processes (renderers). Plus we don't want to poll getMyMemoryState() unnecessarily, for
 * example there is no reason to poll it when Chrome is in the background.
 *
 * This class implements the following principles:
 *
 * 1. Throttle pressure signals sent to callbacks.
 *    Callbacks are called at most once during throttling interval. If same pressure is
 *    reported several times during the interval, all reports except the first one are
 *    ignored.
 *
 * 2. Always report changes in pressure.
 *    If pressure changes during the interval, the change is not ignored, but delayed
 *    until the end of the interval.
 *
 * 3. Poll on CRITICAL memory pressure.
 *    Once CRITICAL pressure is reported, getMyMemoryState API is used to periodically
 *    query pressure until it subsides (becomes non-CRITICAL).
 *
 * Zooming out, the class is used as follows:
 *
 * 1. Only the browser process / WebView process poll, and it only polls when it makes
 *    sense to do so (when Chrome is in the foreground / there are WebView instances
 *    around).
 *
 * 2. Services (GPU, renderers) don't poll, instead they get additional pressure signals
 *    from the main process.
 *
 * NOTE: This class should only be used on UiThread as defined by ThreadUtils (which is
 *       Android main thread for Chrome, but can be some other thread for WebView).
 */
@MainDex
public class MemoryPressureMonitor {
    private static final int DEFAULT_THROTTLING_INTERVAL_MS = 60 * 1000;

    private final int mThrottlingIntervalMs;

    // Pressure reported to callbacks in the current throttling interval.
    private @MemoryPressureLevel int mLastReportedPressure = MemoryPressureLevel.NONE;

    // Pressure received (but not reported) during the current throttling interval,
    // or null if no pressure was received.
    private @MemoryPressureLevel Integer mThrottledPressure;

    // Whether we need to throttle pressure signals.
    private boolean mIsInsideThrottlingInterval;

    private boolean mPollingEnabled;

    // Changed by tests.
    private Supplier<Integer> mCurrentPressureSupplier =
            MemoryPressureMonitor::getCurrentMemoryPressure;

    // Changed by tests.
    private MemoryPressureCallback mReportingCallback =
            MemoryPressureListener::notifyMemoryPressure;

    private final Runnable mThrottlingIntervalTask = this ::onThrottlingIntervalFinished;

    // The only instance.
    public static final MemoryPressureMonitor INSTANCE =
            new MemoryPressureMonitor(DEFAULT_THROTTLING_INTERVAL_MS);

    @VisibleForTesting
    protected MemoryPressureMonitor(int throttlingIntervalMs) {
        mThrottlingIntervalMs = throttlingIntervalMs;
    }

    /**
     * Starts listening to ComponentCallbacks2.
     */
    public void registerComponentCallbacks() {
        ThreadUtils.assertOnUiThread();

        ContextUtils.getApplicationContext().registerComponentCallbacks(new ComponentCallbacks2() {
            @Override
            public void onTrimMemory(int level) {
                Integer pressure = memoryPressureFromTrimLevel(level);
                if (pressure != null) {
                    notifyPressure(pressure);
                }
            }

            @Override
            public void onLowMemory() {
                notifyPressure(MemoryPressureLevel.CRITICAL);
            }

            @Override
            public void onConfigurationChanged(Configuration configuration) {}
        });
    }

    /**
     * Enables memory pressure polling.
     * See class comment for specifics. This method also does a single pressure check to get
     * the current pressure.
     */
    public void enablePolling() {
        ThreadUtils.assertOnUiThread();
        if (mPollingEnabled) return;

        mPollingEnabled = true;
        if (!mIsInsideThrottlingInterval) {
            reportCurrentPressure();
        }
    }

    /**
     * Disables memory pressure polling.
     */
    public void disablePolling() {
        ThreadUtils.assertOnUiThread();
        if (!mPollingEnabled) return;

        mPollingEnabled = false;
    }

    /**
     * Notifies the class about change in memory pressure.
     * Note that |pressure| might get throttled or delayed, i.e. calling this method doesn't
     * necessarily call the callbacks. See the class comment.
     */
    public void notifyPressure(@MemoryPressureLevel int pressure) {
        ThreadUtils.assertOnUiThread();

        if (mIsInsideThrottlingInterval) {
            // We've already reported during this interval. Save |pressure| and act on
            // it later, when the interval finishes.
            mThrottledPressure = pressure;
            return;
        }

        reportPressure(pressure);
    }

    /**
     * Last pressure that was reported to MemoryPressureListener.
     * Returns MemoryPressureLevel.NONE if nothing was reported yet.
     */
    public @MemoryPressureLevel int getLastReportedPressure() {
        ThreadUtils.assertOnUiThread();
        return mLastReportedPressure;
    }

    private void reportPressure(@MemoryPressureLevel int pressure) {
        assert !mIsInsideThrottlingInterval : "Can't report pressure when throttling.";

        startThrottlingInterval();

        mLastReportedPressure = pressure;
        mReportingCallback.onPressure(pressure);
    }

    private void onThrottlingIntervalFinished() {
        mIsInsideThrottlingInterval = false;

        // If there was a pressure change during the interval, report it.
        if (mThrottledPressure != null && mLastReportedPressure != mThrottledPressure) {
            int throttledPressure = mThrottledPressure;
            mThrottledPressure = null;
            reportPressure(throttledPressure);
            return;
        }

        // The pressure didn't change during the interval. Report current pressure
        // (starting a new interval) if we need to.
        if (mPollingEnabled && mLastReportedPressure == MemoryPressureLevel.CRITICAL) {
            reportCurrentPressure();
        }
    }

    private void reportCurrentPressure() {
        Integer pressure = mCurrentPressureSupplier.get();
        if (pressure != null) {
            reportPressure(pressure);
        }
    }

    private void startThrottlingInterval() {
        ThreadUtils.postOnUiThreadDelayed(mThrottlingIntervalTask, mThrottlingIntervalMs);
        mIsInsideThrottlingInterval = true;
    }

    @VisibleForTesting
    public void setCurrentPressureSupplierForTesting(Supplier<Integer> supplier) {
        mCurrentPressureSupplier = supplier;
    }

    @VisibleForTesting
    public void setReportingCallbackForTesting(MemoryPressureCallback callback) {
        mReportingCallback = callback;
    }

    /**
     * Queries current memory pressure.
     * Returns null if the pressure couldn't be determined.
     */
    private static @MemoryPressureLevel Integer getCurrentMemoryPressure() {
        // We used to have a histogram here to measure the duration of each successful 
        // ActivityManager.getMyMemoryState() call called 
        // Android.MemoryPressureMonitor.GetMyMemoryState.Succeeded.Time. 50th percentile was 0.8ms.
        try {
            ActivityManager.RunningAppProcessInfo processInfo =
                    new ActivityManager.RunningAppProcessInfo();
            ActivityManager.getMyMemoryState(processInfo);
            return memoryPressureFromTrimLevel(processInfo.lastTrimLevel);
        } catch (Exception e) {
            // Defensively catch all exceptions, just in case.
            return null;
        }
    }

    /**
     * Maps ComponentCallbacks2.TRIM_* value to MemoryPressureLevel.
     * Returns null if |level| couldn't be mapped and should be ignored.
     */
    @VisibleForTesting
    public static @MemoryPressureLevel Integer memoryPressureFromTrimLevel(int level) {
        if (level >= ComponentCallbacks2.TRIM_MEMORY_COMPLETE
                || level == ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL) {
            return MemoryPressureLevel.CRITICAL;
        } else if (level >= ComponentCallbacks2.TRIM_MEMORY_BACKGROUND) {
            // Don't notify on TRIM_MEMORY_UI_HIDDEN, since this class only
            // dispatches actionable memory pressure signals to native.
            return MemoryPressureLevel.MODERATE;
        }
        return null;
    }
}
