chromium/chrome/android/junit/src/org/chromium/chrome/browser/offlinepages/indicator/OfflineIndicatorControllerV2UnitTest.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 static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import static org.chromium.chrome.browser.offlinepages.indicator.OfflineIndicatorControllerV2.STATUS_INDICATOR_COOLDOWN_BEFORE_NEXT_ACTION_MS;
import static org.chromium.chrome.browser.offlinepages.indicator.OfflineIndicatorControllerV2.STATUS_INDICATOR_WAIT_BEFORE_HIDE_DURATION_MS;
import static org.chromium.chrome.browser.offlinepages.indicator.OfflineIndicatorControllerV2.setMockElapsedTimeSupplier;

import android.app.Activity;
import android.content.Context;
import android.os.Handler;

import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.robolectric.Robolectric;
import org.robolectric.annotation.Config;

import org.chromium.base.supplier.ObservableSupplierImpl;
import org.chromium.base.supplier.Supplier;
import org.chromium.base.test.BaseRobolectricTestRunner;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.net.connectivitydetector.ConnectivityDetector;
import org.chromium.chrome.browser.net.connectivitydetector.ConnectivityDetector.ConnectionState;
import org.chromium.chrome.browser.status_indicator.StatusIndicatorCoordinator;

/** Unit tests for {@link OfflineIndicatorControllerV2}. */
@RunWith(BaseRobolectricTestRunner.class)
@Config(manifest = Config.NONE)
public class OfflineIndicatorControllerV2UnitTest {
    @Mock private StatusIndicatorCoordinator mStatusIndicator;
    @Mock private ConnectivityDetector mConnectivityDetector;
    @Mock private OfflineDetector mOfflineDetector;
    @Mock private Handler mHandler;
    @Mock private Supplier<Boolean> mCanAnimateNativeBrowserControls;
    @Mock private OfflineIndicatorMetricsDelegate mMetricsDelegate;

    private Context mContext;
    private ObservableSupplierImpl<Boolean> mIsUrlBarFocusedSupplier =
            new ObservableSupplierImpl<>();
    private OfflineIndicatorControllerV2 mController;
    private long mElapsedTimeMs;
    private String mOfflineString;
    private String mOnlineString;

    @Before
    public void setUp() {
        MockitoAnnotations.initMocks(this);
        mContext = Robolectric.buildActivity(Activity.class).get();
        mContext.setTheme(org.chromium.chrome.tab_ui.R.style.Theme_BrowserUI_DayNight);

        mOfflineString = mContext.getString(R.string.offline_indicator_v2_offline_text);
        mOnlineString = mContext.getString(R.string.offline_indicator_v2_back_online_text);

        when(mCanAnimateNativeBrowserControls.get()).thenReturn(true);
        when(mOfflineDetector.isApplicationForeground()).thenReturn(true);
        when(mMetricsDelegate.isTrackingShownDuration()).thenReturn(false);

        mIsUrlBarFocusedSupplier.set(false);
        OfflineDetector.setMockConnectivityDetector(mConnectivityDetector);
        OfflineIndicatorControllerV2.setMockOfflineDetector(mOfflineDetector);
        mElapsedTimeMs = 0;
        OfflineIndicatorControllerV2.setMockElapsedTimeSupplier(() -> mElapsedTimeMs);
        OfflineIndicatorControllerV2.setMockOfflineIndicatorMetricsDelegate(mMetricsDelegate);
        mController =
                new OfflineIndicatorControllerV2(
                        mContext,
                        mStatusIndicator,
                        mIsUrlBarFocusedSupplier,
                        mCanAnimateNativeBrowserControls);
        mController.setHandlerForTesting(mHandler);
    }

    @After
    public void tearDown() {
        OfflineIndicatorControllerV2.setMockElapsedTimeSupplier(null);
    }

    /** Tests that the offline indicator shows when the device goes offline. */
    @Test
    public void testShowsStatusIndicatorWhenOffline() {
        // Show.
        changeConnectionState(true);
        verify(mStatusIndicator).show(eq(mOfflineString), any(), anyInt(), anyInt(), anyInt());
    }

    /** Tests that the offline indicator hides when the device goes online. */
    @Test
    public void testHidesStatusIndicatorWhenOnline() {
        // First, show.
        changeConnectionState(true);
        // Fast forward the cool-down.
        advanceTimeByMs(STATUS_INDICATOR_COOLDOWN_BEFORE_NEXT_ACTION_MS);
        // Now, hide.
        changeConnectionState(false);
        // When hiding, the indicator will get an #updateContent() call, then #hide() 2 seconds
        // after that. First, verify the #updateContent() call.
        final ArgumentCaptor<Runnable> endAnimationCaptor = ArgumentCaptor.forClass(Runnable.class);
        verify(mStatusIndicator)
                .updateContent(
                        eq(mOnlineString),
                        any(),
                        anyInt(),
                        anyInt(),
                        anyInt(),
                        endAnimationCaptor.capture());
        // Simulate browser controls animation ending.
        endAnimationCaptor.getValue().run();
        // This should post a runnable to hide w/ a delay.
        final ArgumentCaptor<Runnable> hideCaptor = ArgumentCaptor.forClass(Runnable.class);
        verify(mHandler)
                .postDelayed(
                        hideCaptor.capture(), eq(STATUS_INDICATOR_WAIT_BEFORE_HIDE_DURATION_MS));
        // Let's see if the Runnable we captured actually hides the indicator.
        hideCaptor.getValue().run();
        verify(mStatusIndicator).hide();
    }

    /** Tests that the indicator doesn't hide before the cool-down is complete. */
    @Test
    public void testCoolDown_Hide() {
        // First, show.
        changeConnectionState(true);
        // Advance time.
        advanceTimeByMs(3000);
        // Now, try to hide.
        changeConnectionState(false);

        // Cool-down should prevent it from hiding and post a runnable for after the time is up.
        verify(mStatusIndicator, never())
                .updateContent(any(), any(), anyInt(), anyInt(), anyInt(), any(Runnable.class));
        final ArgumentCaptor<Runnable> captor = ArgumentCaptor.forClass(Runnable.class);
        verify(mHandler)
                .postDelayed(
                        captor.capture(),
                        eq(STATUS_INDICATOR_COOLDOWN_BEFORE_NEXT_ACTION_MS - 3000L));

        // Advance the time and simulate the |Handler| running the posted runnable.
        advanceTimeByMs(2000);
        captor.getValue().run();
        // #updateContent() should be called since the cool-down is complete.
        verify(mStatusIndicator)
                .updateContent(
                        eq(mOnlineString),
                        any(),
                        anyInt(),
                        anyInt(),
                        anyInt(),
                        any(Runnable.class));
    }

    /** Tests that the indicator doesn't show before the cool-down is complete. */
    @Test
    public void testCoolDown_Show() {
        // First, show.
        changeConnectionState(true);
        verify(mStatusIndicator, times(1))
                .show(eq(mOfflineString), any(), anyInt(), anyInt(), anyInt());
        // Advance time so we can hide.
        advanceTimeByMs(STATUS_INDICATOR_COOLDOWN_BEFORE_NEXT_ACTION_MS);
        // Now, hide.
        changeConnectionState(false);

        // Try to show again, but before the cool-down is completed.
        advanceTimeByMs(1000);
        changeConnectionState(true);
        // Cool-down should prevent it from showing and post a runnable for after the time is up.
        // times(1) because it's been already called once above, no new calls.
        verify(mStatusIndicator, times(1))
                .show(eq(mOfflineString), any(), anyInt(), anyInt(), anyInt());
        final ArgumentCaptor<Runnable> captor = ArgumentCaptor.forClass(Runnable.class);
        verify(mHandler)
                .postDelayed(
                        captor.capture(),
                        eq(STATUS_INDICATOR_COOLDOWN_BEFORE_NEXT_ACTION_MS - 1000L));

        // Advance the time and simulate the |Handler| running the posted runnable.
        advanceTimeByMs(4000);
        captor.getValue().run();
        // #show() should be called since the cool-down is complete.
        verify(mStatusIndicator, times(2))
                .show(eq(mOfflineString), any(), anyInt(), anyInt(), anyInt());
    }

    /**
     * Tests that the indicator doesn't show if the device went back online after the show was
     * scheduled.
     */
    @Test
    public void testCoolDown_ChangeConnectionAfterShowScheduled() {
        changeConnectionState(true);
        advanceTimeByMs(STATUS_INDICATOR_COOLDOWN_BEFORE_NEXT_ACTION_MS);
        changeConnectionState(false);

        // Try to show, but before the cool-down is completed.
        advanceTimeByMs(1000);
        changeConnectionState(true);
        // Cool-down should prevent it from showing and post a runnable for after the time is up.
        // times(1) because it's been already called once above, no new calls.
        verify(mStatusIndicator, times(1))
                .show(eq(mOfflineString), any(), anyInt(), anyInt(), anyInt());
        final ArgumentCaptor<Runnable> captor = ArgumentCaptor.forClass(Runnable.class);
        verify(mHandler)
                .postDelayed(
                        captor.capture(),
                        eq(STATUS_INDICATOR_COOLDOWN_BEFORE_NEXT_ACTION_MS - 1000L));
        // Callbacks to show/hide are removed every time the connectivity changes. We use this to
        // capture the callback.
        verify(mHandler, times(3)).removeCallbacks(captor.getValue());
        // Advance time and change connection.
        advanceTimeByMs(2000);
        changeConnectionState(false);

        // Since we're back online, the posted runnable won't show the indicator.
        advanceTimeByMs(2000);
        captor.getValue().run();
        // Still times(1), no new call after the last one.
        verify(mStatusIndicator, times(1))
                .show(eq(mOfflineString), any(), anyInt(), anyInt(), anyInt());
    }

    /** Tests that the indicator doesn't show until the omnibox is unfocused. */
    @Test
    public void testOmniboxFocus_DelayShowing() {
        // Simulate focusing the omnibox.
        mIsUrlBarFocusedSupplier.set(true);
        // Now show, at least try.
        changeConnectionState(true);
        // Shouldn't show because the omnibox is focused.
        verify(mStatusIndicator, never())
                .show(eq(mOfflineString), any(), anyInt(), anyInt(), anyInt());

        // Should show once unfocused.
        mIsUrlBarFocusedSupplier.set(false);
        verify(mStatusIndicator).show(eq(mOfflineString), any(), anyInt(), anyInt(), anyInt());
    }

    /**
     * Tests that the indicator doesn't show when the omnibox is unfocused if the device goes back
     * online before the omnibox is unfocused.
     */
    @Test
    public void testOmniboxFocus_ChangeConnectionAfterShowScheduled() {
        // Simulate focusing the omnibox.
        mIsUrlBarFocusedSupplier.set(true);
        // Now show, at least try.
        changeConnectionState(true);
        // Shouldn't show because the omnibox is focused.
        verify(mStatusIndicator, never())
                .show(eq(mOfflineString), any(), anyInt(), anyInt(), anyInt());

        // Now, simulate going back online.
        changeConnectionState(false);
        // Unfocusing shouldn't cause a show because we're not offline.
        mIsUrlBarFocusedSupplier.set(false);
        verify(mStatusIndicator, never())
                .show(eq(mOfflineString), any(), anyInt(), anyInt(), anyInt());
    }

    /**
     * Tests that the indicator waits for the omnibox to be unfocused if the omnibox was focused
     * when the cool-down ended and the indicator was going to be shown.
     */
    @Test
    public void testOmniboxIsFocusedWhenShownAfterCoolDown() {
        changeConnectionState(true);
        advanceTimeByMs(STATUS_INDICATOR_COOLDOWN_BEFORE_NEXT_ACTION_MS);
        changeConnectionState(false);

        // Try to show, but before the cool-down is completed.
        advanceTimeByMs(1000);
        changeConnectionState(true);
        // times(1) because it's been already called once above, no new calls.
        verify(mStatusIndicator, times(1))
                .show(eq(mOfflineString), any(), anyInt(), anyInt(), anyInt());
        final ArgumentCaptor<Runnable> captor = ArgumentCaptor.forClass(Runnable.class);
        verify(mHandler)
                .postDelayed(
                        captor.capture(),
                        eq(STATUS_INDICATOR_COOLDOWN_BEFORE_NEXT_ACTION_MS - 1000L));

        // Now, simulate focusing the omnibox.
        mIsUrlBarFocusedSupplier.set(true);
        // Then advance the time and run the runnable.
        advanceTimeByMs(4000);
        captor.getValue().run();
        // Still times(1), no new calls. The indicator shouldn't show since the omnibox is focused.
        verify(mStatusIndicator, times(1))
                .show(eq(mOfflineString), any(), anyInt(), anyInt(), anyInt());
        // Should show once unfocused.
        mIsUrlBarFocusedSupplier.set(false);
        verify(mStatusIndicator, times(2))
                .show(eq(mOfflineString), any(), anyInt(), anyInt(), anyInt());
    }

    /**
     * Tests that we send the correct notifications to the metrics delegate when the connectivity
     * state changes between online and offline.
     */
    @Test
    public void testMetricsNotifications_ConnectionChange() {
        // Ensure that we don't update the metrics delegate on start up.
        verify(mMetricsDelegate, times(0)).onIndicatorShown();
        verify(mMetricsDelegate, times(0)).onIndicatorHidden();

        // When we go offline, make sure that we update the metrics delegate.
        changeConnectionState(true);
        verify(mMetricsDelegate, times(1)).onIndicatorShown();

        // Check that we don't update the metrics delegate if we remain offline.
        changeConnectionState(true);
        verify(mMetricsDelegate, times(1)).onIndicatorShown();

        // We advance the time to avoid the cool-down.
        advanceTimeByMs(5000);

        // Check that we update the metrics delegate when go back online.
        changeConnectionState(false);
        verify(mMetricsDelegate, times(1)).onIndicatorHidden();

        // Check that we don't update the metrics delegate if we remain online.
        changeConnectionState(false);
        verify(mMetricsDelegate, times(1)).onIndicatorHidden();

        advanceTimeByMs(5000);

        // When we go offline, make sure that we update the metrics delegate.
        changeConnectionState(true);
        verify(mMetricsDelegate, times(2)).onIndicatorShown();
    }

    /**
     * Tests that we send the correct notifications to the metrics delegate when the application
     * state changes between foreground and background.
     */
    @Test
    public void testMetricsNotifications_ApplicationStateChange() {
        // The Controller will inform the metrics delegate of the application state (which we have
        // defined as foreground in this case) when it is constructed.
        verify(mMetricsDelegate, times(1)).onAppForegrounded();
        verify(mMetricsDelegate, times(0)).onAppBackgrounded();

        // Check that we send a notification if the application state changes to background.
        changeApplicationState(false);
        verify(mMetricsDelegate, times(1)).onAppBackgrounded();

        // Check that we don't send a notification if the application state remains the same.
        changeApplicationState(false);
        verify(mMetricsDelegate, times(1)).onAppBackgrounded();

        // Check that we send a notification if the application state changes to foreground.
        changeApplicationState(true);
        verify(mMetricsDelegate, times(2)).onAppForegrounded();

        // Check that we don't send a notification if the application state remains the same.
        changeApplicationState(true);
        verify(mMetricsDelegate, times(2)).onAppForegrounded();

        // Check that we send a notification if the application state changes to background.
        changeApplicationState(false);
        verify(mMetricsDelegate, times(2)).onAppBackgrounded();
    }

    /**
     * Tests that we send the correct notifications to the metrics delegate when the application is
     * started while offline.
     */
    @Test
    public void testMetricsNotifications_StartUpOffline() {
        verify(mMetricsDelegate, times(0)).onIndicatorShown();
        verify(mMetricsDelegate, times(0)).onIndicatorHidden();
        verify(mMetricsDelegate, times(1)).onAppForegrounded();
        verify(mMetricsDelegate, times(0)).onAppBackgrounded();
        verify(mMetricsDelegate, times(0)).onOfflineStateInitialized(true);
        verify(mMetricsDelegate, times(0)).onOfflineStateInitialized(false);

        // Simulate the system going offline.
        changeConnectionState(true);
        verify(mMetricsDelegate, times(1)).onOfflineStateInitialized(true);
        verify(mMetricsDelegate, times(1)).onIndicatorShown();

        // Have the metrics delegate start tracking a shown duration.
        when(mMetricsDelegate.isTrackingShownDuration()).thenReturn(true);

        // Simulate the app being backgrounded.
        changeApplicationState(false);
        verify(mMetricsDelegate, times(1)).onAppBackgrounded();

        // Simulate the app being killed.
        mController = null;

        // Simulate the app being restarted, and still being offline.
        changeApplicationState(true);
        mController =
                new OfflineIndicatorControllerV2(
                        mContext,
                        mStatusIndicator,
                        mIsUrlBarFocusedSupplier,
                        mCanAnimateNativeBrowserControls);
        mController.setHandlerForTesting(mHandler);
        verify(mMetricsDelegate, times(2)).onAppForegrounded();

        // Simualte that we are still offline when the application is restarted,
        changeConnectionState(true);
        verify(mMetricsDelegate, times(2)).onOfflineStateInitialized(true);
        verify(mMetricsDelegate, times(2)).onIndicatorShown();

        advanceTimeByMs(5000);

        // Simulate the system coming back online.
        changeConnectionState(false);
        verify(mMetricsDelegate, times(1)).onIndicatorHidden();

        // Have the metrics delegate stop tracking a shown duration.
        when(mMetricsDelegate.isTrackingShownDuration()).thenReturn(false);
    }

    /**
     * Tests that we send the correct notifications to the metrics delegate when the application is
     * started while offline.
     */
    @Test
    public void testMetricsNotifications_StartUpOnline() {
        verify(mMetricsDelegate, times(0)).onIndicatorShown();
        verify(mMetricsDelegate, times(0)).onIndicatorHidden();
        verify(mMetricsDelegate, times(1)).onAppForegrounded();
        verify(mMetricsDelegate, times(0)).onAppBackgrounded();
        verify(mMetricsDelegate, times(0)).onOfflineStateInitialized(true);
        verify(mMetricsDelegate, times(0)).onOfflineStateInitialized(false);

        // Simulate the system going offline.
        changeConnectionState(true);
        // advanceTimeByMs(5000);
        verify(mMetricsDelegate, times(1)).onOfflineStateInitialized(true);
        verify(mMetricsDelegate, times(1)).onIndicatorShown();

        // Have the metrics delegate start tracking a shown duration.
        when(mMetricsDelegate.isTrackingShownDuration()).thenReturn(true);

        // Simulate the app being backgrounded.
        changeApplicationState(false);
        verify(mMetricsDelegate, times(1)).onAppBackgrounded();

        // Simulate the app being killed.
        mController = null;

        // Simulate the app being restarted, but now being online.
        changeApplicationState(true);
        mController =
                new OfflineIndicatorControllerV2(
                        mContext,
                        mStatusIndicator,
                        mIsUrlBarFocusedSupplier,
                        mCanAnimateNativeBrowserControls);
        mController.setHandlerForTesting(mHandler);
        verify(mMetricsDelegate, times(2)).onAppForegrounded();

        // If the system starts up online, we will get the signal immediately after the controller
        // is constructed.
        changeConnectionState(false);
        verify(mMetricsDelegate, times(1)).onOfflineStateInitialized(false);

        // Have the metrics delegate stop tracking a shown duration.
        when(mMetricsDelegate.isTrackingShownDuration()).thenReturn(false);
    }

    private void changeConnectionState(boolean offline) {
        final int state = offline ? ConnectionState.NO_INTERNET : ConnectionState.VALIDATED;
        when(mOfflineDetector.isConnectionStateOffline()).thenReturn(offline);
        mController.onConnectionStateChanged(offline);
    }

    private void changeApplicationState(boolean isForeground) {
        when(mOfflineDetector.isApplicationForeground()).thenReturn(isForeground);
        if (mController != null) {
            mController.onApplicationStateChanged(isForeground);
        }
    }

    private void advanceTimeByMs(long delta) {
        mElapsedTimeMs += delta;
        setMockElapsedTimeSupplier(() -> mElapsedTimeMs);
    }
}