chromium/chrome/android/java/src/org/chromium/chrome/browser/offlinepages/indicator/OfflineDetector.java

// Copyright 2020 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.chrome.browser.offlinepages.indicator;

import android.content.Context;
import android.os.Handler;
import android.os.SystemClock;
import android.provider.Settings;

import androidx.annotation.VisibleForTesting;

import org.chromium.base.ApplicationState;
import org.chromium.base.ApplicationStatus;
import org.chromium.base.Callback;
import org.chromium.base.Log;
import org.chromium.base.supplier.Supplier;
import org.chromium.base.version_info.VersionInfo;
import org.chromium.chrome.browser.net.connectivitydetector.ConnectivityDetector;
import org.chromium.chrome.browser.net.connectivitydetector.ConnectivityDetector.ConnectionState;

/**
 * Class that detects if the network is offline. Waits for the network to stablize before notifying
 * the observer.
 */
class OfflineDetector
        implements ConnectivityDetector.Observer, ApplicationStatus.ApplicationStateListener {
    // If the connection is online, then we report that immediately via |mIsOfflineCallback|.
    // |STATUS_INDICATOR_WAIT_ON_OFFLINE_DURATION_MS| and
    // |mStatusIndicatorWaitOnSwitchOnlineToOfflineDurationMs| control the duration before
    // we report the device as offline. It's important to wait a bit before reporting connection as
    // offline since some devices may take time to establish the connection. In such cases,
    // reporting connection as offline could cause confusion to the user. Setting this to a large
    // value has a downside that if the device is actually offline, then it would take us long time
    // to report the connection as offline.

    // |STATUS_INDICATOR_WAIT_ON_OFFLINE_DURATION_MS| is the duration before we wait before
    // reporting the connection type as offline if the app has never been on an online connection.
    // In this case, we need to wait a shorter time before
    // invoking |mIsOfflineCallback| since the actual connection change to offline happened  much
    // earlier than when the app received the notification. Any delays in app receiving the
    // notification of connection change are only due to device's CPU constraints.
    static final long STATUS_INDICATOR_WAIT_ON_OFFLINE_DURATION_MS = 2000;

    // |mStatusIndicatorWaitOnSwitchOnlineToOfflineDurationMs| is the duration before we wait before
    // reporting the connection type as offline if the app has been on an online connection before.
    // In this case, we need to wait a longer time before invoking |mIsOfflineCallback| since the
    // connection change is ongoing. Any delays in app receiving the notification of connection
    // change are due to time taken by device in reestablishing the connection as well as device CPU
    // constraints. Value of |mStatusIndicatorWaitOnSwitchOnlineToOfflineDurationMs| is set to
    // |STATUS_INDICATOR_WAIT_ON_SWITCH_ONLINE_TO_OFFLINE_DEFAULT_DURATION_MS| by default, but can
    // be overridden using finch.
    static final long STATUS_INDICATOR_WAIT_ON_SWITCH_ONLINE_TO_OFFLINE_DEFAULT_DURATION_MS = 10000;
    final long mStatusIndicatorWaitOnSwitchOnlineToOfflineDurationMs;

    private static ConnectivityDetector sMockConnectivityDetector;
    private static Supplier<Long> sMockElapsedTimeSupplier;

    private ConnectivityDetector mConnectivityDetector;

    // Maintains if the connection is effectively offline.
    // Effectively offline means that all checks have been passed and the
    // |mIsOfflineCallback| has been invoked to notify the observers.
    private boolean mIsEffectivelyOffline;
    private boolean mIsEffectivelyOfflineInitialized;

    // True if the network is offline as detected by the connectivity detector.
    private boolean mIsOfflineLastReportedByConnectivityDetector;

    private Context mContext;
    private Handler mHandler;
    private Runnable mUpdateOfflineStatusIndicatorDelayedRunnable;

    // Used to inform the client when the system changes between online and offline. A value of true
    // is given when the system is offline, and a value of false is given when the system is online.
    private final Callback<Boolean> mIsOfflineCallback;

    // Used to inform the client when the application changes between foreground and background. A
    // value of true is given when the application is foregrounded, and a value of false is given
    // when the application is backgrounded.
    private final Callback<Boolean> mIsForegroundCallback;

    // Current state of the application.
    private int mApplicationState = ApplicationStatus.getStateForApplication();

    // Time when the application was last foregrounded. |callback| is invoked only when the app is
    // in foreground.
    private long mTimeWhenLastForegrounded;

    // Time when the connection was last reported as offline. |callback| is invoked only when the
    // connection has been in the ofline for |STATUS_INDICATOR_WAIT_ON_OFFLINE_DURATION_MS|.
    private long mTimeWhenLastOfflineNotificationReceived;

    // True if the |mConnectivityDetector| has been initialized.
    private boolean mConnectivityDetectorInitialized;

    // Last time when the device was online. Updated when we detect that the device is switching
    // from "online" to "offline" or when we are notified that the device is online" at the end.
    private long mTimeWhenLastOnline;

    // Last time airplane mode switched from "on" to "off". Updated when we detect that airplane
    // mode changed.
    private long mTimeWhenAirplaneModeToggledOff;

    private boolean mInAirplaneMode;

    // Set to true if adb console logging should be enabled.
    private static final boolean sLoggingEnabled =
            VersionInfo.isCanaryBuild() || VersionInfo.isDevBuild() || VersionInfo.isLocalBuild();

    private static final String TAG = "OfflineDetector";

    /**
     * Constructs the offline indicator.
     * @param isOfflineCallback The {@link Callback} is invoked when the connectivity status is
     *        stable and has changed.
     * @param isForegroundCallback The {@link Callback} is invoked when the application state
     *        changes.
     * @param context The {@link Context} used to resolve device settings.
     */
    OfflineDetector(
            Callback<Boolean> isOfflineCallback,
            Callback<Boolean> isForegroundCallback,
            Context context) {
        mIsOfflineCallback = isOfflineCallback;
        mIsForegroundCallback = isForegroundCallback;
        mContext = context;
        mHandler = new Handler();
        mStatusIndicatorWaitOnSwitchOnlineToOfflineDurationMs =
                STATUS_INDICATOR_WAIT_ON_SWITCH_ONLINE_TO_OFFLINE_DEFAULT_DURATION_MS;

        mUpdateOfflineStatusIndicatorDelayedRunnable =
                () -> {
                    if (sLoggingEnabled) {
                        logToAdbConsoleNow(
                                "Running mUpdateOfflineStatusIndicatorDelayedRunnable start.");
                    }

                    // |callback| is invoked only when the app is in foreground. If the app is in
                    // background, return early. When the app comes to foreground,
                    // |mUpdateOfflineStatusIndicatorDelayedRunnable| would be posted.
                    if (mApplicationState != ApplicationState.HAS_RUNNING_ACTIVITIES) {
                        return;
                    }

                    // Connection state has not changed since
                    // |mUpdateOfflineStatusIndicatorDelayedRunnable|
                    // was posted.
                    boolean wasEffectivelyOffline = mIsEffectivelyOffline;
                    mIsEffectivelyOffline =
                            mIsOfflineLastReportedByConnectivityDetector && !mInAirplaneMode;
                    if (mIsEffectivelyOfflineInitialized
                            && wasEffectivelyOffline == mIsEffectivelyOffline) {
                        return;
                    }
                    mIsEffectivelyOfflineInitialized = true;
                    mIsOfflineCallback.onResult(mIsEffectivelyOffline);
                    if (sLoggingEnabled) {
                        logToAdbConsoleNow(
                                "Running mUpdateOfflineStatusIndicatorDelayedRunnable end.");
                    }
                };

        // Register as an application state observer and initialize |mTimeWhenLastForegrounded|.
        ApplicationStatus.registerApplicationStateListener(this);
        if (mApplicationState == ApplicationState.HAS_RUNNING_ACTIVITIES) {
            mTimeWhenLastForegrounded = getElapsedTime();
        }

        if (sMockConnectivityDetector != null) {
            mConnectivityDetector = sMockConnectivityDetector;
        } else {
            mConnectivityDetector = new ConnectivityDetector(this, "OfflineDetector");
        }
    }

    @Override
    public void onConnectionStateChanged(int connectionState) {
        boolean previousLastReportedStateByOfflineDetector =
                mIsOfflineLastReportedByConnectivityDetector;
        mIsOfflineLastReportedByConnectivityDetector =
                (connectionState != ConnectionState.VALIDATED);
        boolean wasInAirplaneMode = mInAirplaneMode;
        mInAirplaneMode =
                Settings.System.getInt(
                                mContext.getContentResolver(), Settings.Global.AIRPLANE_MODE_ON, 0)
                        != 0;

        if (mConnectivityDetectorInitialized
                && previousLastReportedStateByOfflineDetector
                        == mIsOfflineLastReportedByConnectivityDetector
                && wasInAirplaneMode == mInAirplaneMode) {
            return;
        }

        if (mIsOfflineLastReportedByConnectivityDetector) {
            mTimeWhenLastOfflineNotificationReceived = getElapsedTime();
        }

        // Verify that the connectivity detector is initialized before setting
        // |mTimeWhenLastOnline|. By default, |mIsOfflineLastReportedByConnectivityDetector| is
        // false, i.e., the device is assumed to be online. Tracking
        // |mConnectivityDetectorInitialized| helps us distinguish whether the connection type has
        // switched from "default online" to "offline" or "online" to "offline".
        if ((mConnectivityDetectorInitialized && !previousLastReportedStateByOfflineDetector)
                || !mIsOfflineLastReportedByConnectivityDetector) {
            mTimeWhenLastOnline = getElapsedTime();
        }

        if (wasInAirplaneMode && !mInAirplaneMode) {
            mTimeWhenAirplaneModeToggledOff = getElapsedTime();
        }

        mConnectivityDetectorInitialized = true;

        if (sLoggingEnabled) {
            logToAdbConsoleNow("Received connection change state message.");
            Log.i(
                    TAG,
                    "onConnectionStateChanged(): previousLastReportedStateByOfflineDetector: %b",
                    previousLastReportedStateByOfflineDetector);
        }

        updateState();
    }

    private void logToAdbConsoleNow(String prefix) {
        Log.i(
                TAG,
                prefix
                        + " mConnectivityDetectorInitialized: %b,"
                        + " mTimeWhenLastForegrounded: %d,"
                        + " getElapsedTime: %d,"
                        + " mTimeWhenLastOfflineNotificationReceived: %d,"
                        + " mTimeWhenLastOnline: %d,"
                        + " mTimeWhenAirplaneModeToggledOff: %d"
                        + " mApplicationState: %d,"
                        + " mIsOfflineLastReportedByConnectivityDetector: %b,"
                        + " mInAirplaneMode: %b"
                        + " mIsEffectivelyOffline: %b",
                mConnectivityDetectorInitialized,
                mTimeWhenLastForegrounded,
                getElapsedTime(),
                mTimeWhenLastOfflineNotificationReceived,
                mTimeWhenLastOnline,
                mTimeWhenAirplaneModeToggledOff,
                mApplicationState,
                mIsOfflineLastReportedByConnectivityDetector,
                mInAirplaneMode,
                mIsEffectivelyOffline);
    }

    /*
     * Returns true if the connection is offline and the connection state has been stable.
     */
    boolean isConnectionStateOffline() {
        return mIsEffectivelyOffline;
    }

    boolean isApplicationForeground() {
        return mApplicationState == ApplicationState.HAS_RUNNING_ACTIVITIES;
    }

    void destroy() {
        ApplicationStatus.unregisterApplicationStateListener(this);
        if (mConnectivityDetector != null) {
            mConnectivityDetector.destroy();
            mConnectivityDetector = null;
        }
        mHandler.removeCallbacks(mUpdateOfflineStatusIndicatorDelayedRunnable);
    }

    @Override
    public void onApplicationStateChange(int newState) {
        if (mApplicationState == newState) return;

        mApplicationState = newState;

        mIsForegroundCallback.onResult(isApplicationForeground());

        if (mApplicationState == ApplicationState.HAS_RUNNING_ACTIVITIES) {
            mTimeWhenLastForegrounded = getElapsedTime();
        }

        updateState();
    }

    private long getElapsedTime() {
        return sMockElapsedTimeSupplier != null
                ? sMockElapsedTimeSupplier.get()
                : SystemClock.elapsedRealtime();
    }

    @VisibleForTesting
    static void setMockConnectivityDetector(ConnectivityDetector connectivityDetector) {
        sMockConnectivityDetector = connectivityDetector;
    }

    @VisibleForTesting
    static void setMockElapsedTimeSupplier(Supplier<Long> supplier) {
        sMockElapsedTimeSupplier = supplier;
    }

    void setHandlerForTesting(Handler handler) {
        mHandler = handler;
    }

    /*
     ** Calls |mUpdateOfflineStatusIndicatorDelayedRunnable| to update the connection state.
     */
    private void updateState() {
        mHandler.removeCallbacks(mUpdateOfflineStatusIndicatorDelayedRunnable);

        // Do not update state while the app is in background.
        if (mApplicationState != ApplicationState.HAS_RUNNING_ACTIVITIES) return;

        // Check time since the app was foregrounded and time since the offline notification was
        // received.
        final long timeSinceLastForeground = getElapsedTime() - mTimeWhenLastForegrounded;
        final long timeSinceOfflineNotificationReceived =
                getElapsedTime() - mTimeWhenLastOfflineNotificationReceived;
        final long timeSinceLastOnline = getElapsedTime() - mTimeWhenLastOnline;
        final long timeSinceAirplaneModeToggledOff =
                getElapsedTime() - mTimeWhenAirplaneModeToggledOff;

        final long timeNeededForForeground =
                STATUS_INDICATOR_WAIT_ON_OFFLINE_DURATION_MS - timeSinceLastForeground;
        final long timeNeededForOffline =
                STATUS_INDICATOR_WAIT_ON_OFFLINE_DURATION_MS - timeSinceOfflineNotificationReceived;

        // If the device has been online before, then we wait up to
        // |mStatusIndicatorWaitOnSwitchOnlineToOfflineDurationMs| duration.
        final long timeNeededAfterConnectionChangeFromOnlineToOffline =
                mTimeWhenLastOnline > 0
                        ? mStatusIndicatorWaitOnSwitchOnlineToOfflineDurationMs
                                - timeSinceLastOnline
                        : 0;

        final long timeNeededAfterConnectionChangeFromAirplaneToOffline =
                mTimeWhenAirplaneModeToggledOff > 0
                        ? mStatusIndicatorWaitOnSwitchOnlineToOfflineDurationMs
                                - timeSinceAirplaneModeToggledOff
                        : 0;

        assert mUpdateOfflineStatusIndicatorDelayedRunnable != null;

        logToAdbConsoleNow("Running updateState");
        Log.i(
                TAG,
                "updateState(): timeSinceLastForeground: %d,"
                        + " timeSinceOfflineNotificationReceived: %d, timeSinceLastOnline: %d,"
                        + " timeNeededForForeground: %d, timeNeededForOffline: %d"
                        + " timeSinceAirplaneModeToggledOff: %d"
                        + " timeNeededAfterConnectionChangeFromOnlineToOffline: %d"
                        + " timeNeededAfterConnectionChangeFromAirplaneToOffline: %d",
                timeSinceLastForeground,
                timeSinceOfflineNotificationReceived,
                timeSinceLastOnline,
                timeNeededForForeground,
                timeNeededForOffline,
                timeSinceAirplaneModeToggledOff,
                timeNeededAfterConnectionChangeFromOnlineToOffline,
                timeNeededAfterConnectionChangeFromAirplaneToOffline);

        // If the connection is online, report the state immediately. Alternatively, if the app has
        // been in foreground and connection has been offline for sufficient time, then report the
        // state immediately.
        if (!mIsOfflineLastReportedByConnectivityDetector
                || (timeNeededForForeground <= 0
                        && timeNeededForOffline <= 0
                        && timeNeededAfterConnectionChangeFromOnlineToOffline <= 0
                        && timeNeededAfterConnectionChangeFromAirplaneToOffline <= 0)) {
            mUpdateOfflineStatusIndicatorDelayedRunnable.run();
            return;
        }

        // Wait before calling |mUpdateOfflineStatusIndicatorDelayedRunnable|.
        mHandler.postDelayed(
                mUpdateOfflineStatusIndicatorDelayedRunnable,
                Math.max(
                        Math.max(
                                Math.max(timeNeededForForeground, timeNeededForOffline),
                                timeNeededAfterConnectionChangeFromOnlineToOffline),
                        timeNeededAfterConnectionChangeFromAirplaneToOffline));
    }
}