chromium/chrome/android/java/src/org/chromium/chrome/browser/offlinepages/indicator/OfflineIndicatorControllerV2.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.annotation.SuppressLint;
import android.content.Context;
import android.graphics.drawable.Drawable;
import android.os.Handler;
import android.os.SystemClock;

import androidx.annotation.IntDef;
import androidx.annotation.VisibleForTesting;

import org.chromium.base.Callback;
import org.chromium.base.CommandLine;
import org.chromium.base.metrics.RecordHistogram;
import org.chromium.base.metrics.RecordUserAction;
import org.chromium.base.supplier.ObservableSupplier;
import org.chromium.base.supplier.Supplier;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.status_indicator.StatusIndicatorCoordinator;
import org.chromium.chrome.browser.ui.theme.ChromeSemanticColorUtils;
import org.chromium.components.browser_ui.styles.SemanticColorUtils;
import org.chromium.content_public.common.ContentSwitches;

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;

/**
 * Class that controls visibility and content of {@link StatusIndicatorCoordinator} to relay
 * connectivity information.
 */
public class OfflineIndicatorControllerV2 {
    @IntDef({
        UmaEnum.CAN_ANIMATE_NATIVE_CONTROLS,
        UmaEnum.CAN_ANIMATE_NATIVE_CONTROLS_OMNIBOX_FOCUSED,
        UmaEnum.CANNOT_ANIMATE_NATIVE_CONTROLS,
        UmaEnum.CANNOT_ANIMATE_NATIVE_CONTROLS_OMNIBOX_FOCUSED
    })
    @Retention(RetentionPolicy.SOURCE)
    public @interface UmaEnum {
        int CAN_ANIMATE_NATIVE_CONTROLS = 0;
        int CAN_ANIMATE_NATIVE_CONTROLS_OMNIBOX_FOCUSED = 1;
        int CANNOT_ANIMATE_NATIVE_CONTROLS = 2;
        int CANNOT_ANIMATE_NATIVE_CONTROLS_OMNIBOX_FOCUSED = 3;

        int NUM_ENTRIES = 4;
    }

    static final long STATUS_INDICATOR_WAIT_BEFORE_HIDE_DURATION_MS = 2000;

    // TODO(tbansal): Consider moving the cooldown logic to OfflineDetector.java.
    // The cooldown period was added to prevent showing/changing the indicator too frequently. In
    // longer term, OfflineDetector should protect against sending signals to this class too
    // frequently.
    static final long STATUS_INDICATOR_COOLDOWN_BEFORE_NEXT_ACTION_MS = 5000;

    public static final String OFFLINE_INDICATOR_SHOWN_DURATION_V2 =
            "OfflineIndicator.ShownDurationV2";

    @SuppressLint("StaticFieldLeak")
    private static OfflineDetector sMockOfflineDetector;

    private static Supplier<Long> sMockElapsedTimeSupplier;
    private static OfflineIndicatorMetricsDelegate sMockOfflineIndicatorMetricsDelegate;

    private Context mContext;
    private StatusIndicatorCoordinator mStatusIndicator;
    private Handler mHandler;
    private OfflineDetector mOfflineDetector;
    private ObservableSupplier<Boolean> mIsUrlBarFocusedSupplier;
    private Supplier<Boolean> mCanAnimateBrowserControlsSupplier;
    private Callback<Boolean> mOnUrlBarFocusChanged;
    private Runnable mShowRunnable;
    private Runnable mUpdateAndHideRunnable;
    private Runnable mHideRunnable;
    private Runnable mOnUrlBarUnfocusedRunnable;
    private Runnable mUpdateStatusIndicatorDelayedRunnable;
    private long mLastActionTime;
    private boolean mIsOffline;
    private boolean mIsOfflineStateInitialized;
    private boolean mIsForeground;
    private OfflineIndicatorMetricsDelegate mMetricsDelegate;

    /**
     * Constructs the offline indicator.
     * @param context The {@link Context}.
     * @param statusIndicator The {@link StatusIndicatorCoordinator} instance this controller will
     *                        control based on the connectivity.
     * @param isUrlBarFocusedSupplier The {@link ObservableSupplier} that will supply the UrlBar's
     *                                focus state and notify a listener when it changes.
     * @param canAnimateNativeBrowserControls Will supply a boolean meaning whether the native
     *                                        browser controls can be animated. This is used for
     *                                        collecting metrics.
     * TODO(sinansahin): We can remove canAnimateNativeBrowserControls once we're done with metrics
     *                   collection.
     */
    public OfflineIndicatorControllerV2(
            Context context,
            StatusIndicatorCoordinator statusIndicator,
            ObservableSupplier<Boolean> isUrlBarFocusedSupplier,
            Supplier<Boolean> canAnimateNativeBrowserControls) {
        if (CommandLine.getInstance()
                .hasSwitch(ContentSwitches.FORCE_ONLINE_CONNECTION_STATE_FOR_INDICATOR)) {
            // If "force online connection state" switch is set, the offline indicator should never
            // show.
            return;
        }

        mContext = context;
        mStatusIndicator = statusIndicator;
        mHandler = new Handler();

        if (sMockOfflineIndicatorMetricsDelegate != null) {
            mMetricsDelegate = sMockOfflineIndicatorMetricsDelegate;
        } else {
            mMetricsDelegate = new OfflineIndicatorMetricsDelegate();
        }

        // If we're offline at start-up, we should have a small enough last action time so that we
        // don't wait for the cool-down.
        mLastActionTime = getElapsedTime() - STATUS_INDICATOR_COOLDOWN_BEFORE_NEXT_ACTION_MS;
        if (sMockOfflineDetector != null) {
            mOfflineDetector = sMockOfflineDetector;
        } else {
            mOfflineDetector =
                    new OfflineDetector(
                            (Boolean offline) -> onConnectionStateChanged(offline),
                            (Boolean isForeground) -> onApplicationStateChanged(isForeground),
                            mContext);
        }

        // Initializes the application state.
        onApplicationStateChanged(mOfflineDetector.isApplicationForeground());

        mShowRunnable =
                () -> {
                    RecordUserAction.record("OfflineIndicator.Shown");

                    mMetricsDelegate.onIndicatorShown();

                    setLastActionTime();

                    final int backgroundColor =
                            mContext.getColor(R.color.offline_indicator_offline_color);
                    final int textColor = mContext.getColor(R.color.default_text_color_light);
                    final Drawable statusIcon =
                            mContext.getDrawable(R.drawable.ic_cloud_offline_24dp);
                    final int iconTint = mContext.getColor(R.color.default_icon_color_light);
                    mStatusIndicator.show(
                            mContext.getString(R.string.offline_indicator_v2_offline_text),
                            statusIcon,
                            backgroundColor,
                            textColor,
                            iconTint);
                };

        mHideRunnable =
                () -> {
                    mHandler.postDelayed(
                            () -> mStatusIndicator.hide(),
                            STATUS_INDICATOR_WAIT_BEFORE_HIDE_DURATION_MS);
                };

        mUpdateAndHideRunnable =
                () -> {
                    RecordUserAction.record("OfflineIndicator.Hidden");

                    mMetricsDelegate.onIndicatorHidden();

                    setLastActionTime();

                    final int backgroundColor =
                            ChromeSemanticColorUtils.getOfflineIndicatorBackOnlineColor(mContext);
                    final int textColor = SemanticColorUtils.getDefaultTextColorOnAccent1(mContext);
                    final Drawable statusIcon = mContext.getDrawable(R.drawable.ic_globe_24dp);
                    final int iconTint = SemanticColorUtils.getDefaultIconColorInverse(mContext);
                    mStatusIndicator.updateContent(
                            mContext.getString(R.string.offline_indicator_v2_back_online_text),
                            statusIcon,
                            backgroundColor,
                            textColor,
                            iconTint,
                            mHideRunnable);
                };

        mIsUrlBarFocusedSupplier = isUrlBarFocusedSupplier;
        mCanAnimateBrowserControlsSupplier = canAnimateNativeBrowserControls;
        // TODO(crbug.com/40128377): Move the UrlBar focus related code to the widget or glue code.
        mOnUrlBarFocusChanged =
                (hasFocus) -> {
                    if (!hasFocus && mOnUrlBarUnfocusedRunnable != null) {
                        mOnUrlBarUnfocusedRunnable.run();
                        mOnUrlBarUnfocusedRunnable = null;
                    }
                };
        mIsUrlBarFocusedSupplier.addObserver(mOnUrlBarFocusChanged);

        mUpdateStatusIndicatorDelayedRunnable =
                () -> {
                    final boolean offline = mOfflineDetector.isConnectionStateOffline();
                    if (offline != mIsOffline) {
                        updateStatusIndicator(offline);
                    }
                };
    }

    public void onConnectionStateChanged(boolean offline) {
        if (mIsOfflineStateInitialized && mIsOffline == offline) {
            return;
        }

        mHandler.removeCallbacks(mUpdateStatusIndicatorDelayedRunnable);
        // TODO(crbug.com/40691334): This currently only protects the widget from going into a bad
        // state. We need a better way to handle flaky connections.
        final long elapsedTimeSinceLastAction = getElapsedTime() - mLastActionTime;
        if (elapsedTimeSinceLastAction < STATUS_INDICATOR_COOLDOWN_BEFORE_NEXT_ACTION_MS) {
            mHandler.postDelayed(
                    mUpdateStatusIndicatorDelayedRunnable,
                    STATUS_INDICATOR_COOLDOWN_BEFORE_NEXT_ACTION_MS - elapsedTimeSinceLastAction);
            return;
        }

        updateStatusIndicator(offline);
    }

    public void onApplicationStateChanged(boolean isForeground) {
        if (mIsForeground == isForeground) return;

        if (isForeground) {
            mMetricsDelegate.onAppForegrounded();
        } else {
            mMetricsDelegate.onAppBackgrounded();
        }
        mIsForeground = isForeground;
    }

    public void destroy() {
        if (mOfflineDetector != null) {
            mOfflineDetector.destroy();
            mOfflineDetector = null;
        }

        if (mIsUrlBarFocusedSupplier != null) {
            mIsUrlBarFocusedSupplier.removeObserver(mOnUrlBarFocusChanged);
            mIsUrlBarFocusedSupplier = null;
        }

        mOnUrlBarFocusChanged = null;

        if (mHandler != null) {
            mHandler.removeCallbacks(mHideRunnable);
            mHandler.removeCallbacks(mUpdateStatusIndicatorDelayedRunnable);
        }
    }

    private void updateStatusIndicator(boolean offline) {
        mIsOffline = offline;
        if (!mIsOfflineStateInitialized) {
            mMetricsDelegate.onOfflineStateInitialized(/* isOffline= */ offline);
        }
        if (!mIsOfflineStateInitialized && !offline) {
            mIsOfflineStateInitialized = true;
            return;
        }
        mIsOfflineStateInitialized = true;
        int surfaceState;
        if (mIsUrlBarFocusedSupplier.get()) {
            // We should clear the runnable if we would be assigning an unnecessary show or hide
            // runnable. E.g, without this condition, we would be trying to hide the indicator when
            // it's not shown if we were set to show the widget but then went back online.
            if ((!offline && mOnUrlBarUnfocusedRunnable == mShowRunnable)
                    || (offline && mOnUrlBarUnfocusedRunnable == mUpdateAndHideRunnable)) {
                mOnUrlBarUnfocusedRunnable = null;
                return;
            }
            mOnUrlBarUnfocusedRunnable = offline ? mShowRunnable : mUpdateAndHideRunnable;
            surfaceState =
                    mCanAnimateBrowserControlsSupplier.get()
                            ? UmaEnum.CAN_ANIMATE_NATIVE_CONTROLS_OMNIBOX_FOCUSED
                            : UmaEnum.CANNOT_ANIMATE_NATIVE_CONTROLS_OMNIBOX_FOCUSED;
        } else {
            assert mOnUrlBarUnfocusedRunnable == null;
            (offline ? mShowRunnable : mUpdateAndHideRunnable).run();
            surfaceState =
                    mCanAnimateBrowserControlsSupplier.get()
                            ? UmaEnum.CAN_ANIMATE_NATIVE_CONTROLS
                            : UmaEnum.CANNOT_ANIMATE_NATIVE_CONTROLS;
        }
        RecordHistogram.recordEnumeratedHistogram(
                "OfflineIndicator.ConnectivityChanged.DeviceState."
                        + (offline ? "Offline" : "Online"),
                surfaceState,
                UmaEnum.NUM_ENTRIES);
    }

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

    private void setLastActionTime() {
        mLastActionTime = getElapsedTime();
    }

    @VisibleForTesting
    static void setMockOfflineDetector(OfflineDetector offlineDetector) {
        sMockOfflineDetector = offlineDetector;
    }

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

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

    @VisibleForTesting
    static void setMockOfflineIndicatorMetricsDelegate(
            OfflineIndicatorMetricsDelegate offlineIndicatorMetricsDelegate) {
        sMockOfflineIndicatorMetricsDelegate = offlineIndicatorMetricsDelegate;
    }
}