chromium/chrome/browser/feed/android/java/src/org/chromium/chrome/browser/feed/webfeed/WebFeedFollowIntroControllerTest.java

// Copyright 2021 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.feed.webfeed;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.when;

import android.app.Activity;
import android.util.Base64;
import android.view.View;

import androidx.test.ext.junit.rules.ActivityScenarioRule;
import androidx.test.filters.SmallTest;

import org.junit.After;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.MockitoAnnotations;
import org.robolectric.Robolectric;
import org.robolectric.annotation.LooperMode;
import org.robolectric.shadows.ShadowLog;

import org.chromium.base.Callback;
import org.chromium.base.FeatureList;
import org.chromium.base.UserDataHost;
import org.chromium.base.shared_preferences.SharedPreferencesManager;
import org.chromium.base.supplier.ObservableSupplier;
import org.chromium.base.test.BaseRobolectricTestRunner;
import org.chromium.base.test.util.JniMocker;
import org.chromium.chrome.browser.feature_engagement.TrackerFactory;
import org.chromium.chrome.browser.feed.webfeed.WebFeedSnackbarController.FeedLauncher;
import org.chromium.chrome.browser.preferences.ChromePreferenceKeys;
import org.chromium.chrome.browser.preferences.ChromeSharedPreferences;
import org.chromium.chrome.browser.profiles.Profile;
import org.chromium.chrome.browser.tab.EmptyTabObserver;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.browser.ui.appmenu.AppMenuHandler;
import org.chromium.chrome.browser.ui.messages.snackbar.SnackbarManager;
import org.chromium.components.browser_ui.widget.textbubble.TextBubble;
import org.chromium.components.feature_engagement.FeatureConstants;
import org.chromium.components.feature_engagement.Tracker;
import org.chromium.components.feature_engagement.TriggerDetails;
import org.chromium.components.prefs.PrefService;
import org.chromium.components.user_prefs.UserPrefs;
import org.chromium.components.user_prefs.UserPrefsJni;
import org.chromium.content_public.browser.NavigationController;
import org.chromium.content_public.browser.NavigationHandle;
import org.chromium.content_public.browser.WebContents;
import org.chromium.ui.base.TestActivity;
import org.chromium.ui.modaldialog.ModalDialogManager;
import org.chromium.url.GURL;
import org.chromium.url.JUnitTestGURLs;

import java.util.concurrent.TimeUnit;

/** Tests {@link WebFeedFollowIntroController}. */
@RunWith(BaseRobolectricTestRunner.class)
@LooperMode(LooperMode.Mode.LEGACY)
public final class WebFeedFollowIntroControllerTest {
    private static final long SAFE_INTRO_WAIT_TIME_MILLIS = 3 * 1000 + 100;
    private static final GURL sTestUrl = JUnitTestGURLs.EXAMPLE_URL;
    private static final GURL sFaviconUrl = JUnitTestGURLs.RED_1;
    private static final byte[] sWebFeedId = "webFeedId".getBytes();
    private static final SharedPreferencesManager sChromeSharedPreferences =
            ChromeSharedPreferences.getInstance();

    @Rule public JniMocker mJniMocker = new JniMocker();

    @Rule
    public ActivityScenarioRule<TestActivity> mActivityScenarioRule =
            new ActivityScenarioRule<>(TestActivity.class);

    @Mock FeedLauncher mFeedLauncher;
    @Mock private ObservableSupplier<Tab> mTabSupplier;
    @Mock private Tracker mTracker;
    @Mock private WebFeedBridge.Natives mWebFeedBridgeJniMock;
    @Mock private Tab mTab;
    @Mock private AppMenuHandler mAppMenuHandler;
    @Mock private SnackbarManager mSnackbarManager;
    @Mock private ModalDialogManager mDialogManager;
    @Mock private Profile mProfile;
    @Mock private PrefService mPrefService;
    @Mock private UserPrefs.Natives mUserPrefsJniMock;
    @Mock private WebContents mWebContents;
    @Mock private NavigationController mNavigationController;
    @Mock private NavigationHandle mNavigationHandle;

    private Activity mActivity;
    private EmptyTabObserver mEmptyTabObserver;
    private FakeClock mClock;
    private WebFeedFollowIntroController mWebFeedFollowIntroController;
    private FeatureList.TestValues mBaseTestValues;
    private UserDataHost mTestUserDataHost;

    @Before
    public void setUp() {
        // Print logs to stdout.
        ShadowLog.stream = System.out;

        MockitoAnnotations.initMocks(this);
        mJniMocker.mock(WebFeedBridge.getTestHooksForTesting(), mWebFeedBridgeJniMock);
        mJniMocker.mock(UserPrefsJni.TEST_HOOKS, mUserPrefsJniMock);

        Mockito.when(mProfile.getOriginalProfile()).thenReturn(mProfile);
        Mockito.when(mUserPrefsJniMock.get(mProfile)).thenReturn(mPrefService);

        // Required for resolving an attribute used in AppMenuItemText.
        mActivityScenarioRule.getScenario().onActivity(activity -> mActivity = activity);
        mClock = new FakeClock();
        when(mTracker.shouldTriggerHelpUI(FeatureConstants.IPH_WEB_FEED_FOLLOW_FEATURE))
                .thenReturn(true);
        when(mTracker.shouldTriggerHelpUIWithSnooze(FeatureConstants.IPH_WEB_FEED_FOLLOW_FEATURE))
                .thenReturn(new TriggerDetails(true, false));
        doAnswer(
                        invocation -> {
                            Callback<Boolean> callback = invocation.getArgument(0);
                            callback.onResult(true);
                            return null;
                        })
                .when(mTracker)
                .addOnInitializedCallback(any());

        when(mTab.getUrl()).thenReturn(sTestUrl);
        when(mTab.isIncognito()).thenReturn(false);
        when(mTab.getWebContents()).thenReturn(mWebContents);
        when(mTabSupplier.get()).thenReturn(mTab);
        when(mWebContents.getNavigationController()).thenReturn(mNavigationController);

        mTestUserDataHost = new UserDataHost();
        WebFeedRecommendationFollowAcceleratorController.associateWebFeedWithUserData(
                mTestUserDataHost, new byte[] {1, 2, 3});

        TrackerFactory.setTrackerForTests(mTracker);

        resetWebFeedFollowIntroController();
    }

    private void resetWebFeedFollowIntroController() {
        mWebFeedFollowIntroController =
                new WebFeedFollowIntroController(
                        mActivity,
                        mProfile,
                        mAppMenuHandler,
                        mTabSupplier,
                        new View(mActivity),
                        mFeedLauncher,
                        mDialogManager,
                        mSnackbarManager);
        mEmptyTabObserver = mWebFeedFollowIntroController.getEmptyTabObserverForTesting();
        mWebFeedFollowIntroController.setClockForTesting(mClock);
        // TextBubble is impossible to show in a junit.
        TextBubble.setSkipShowCheckForTesting(true);
    }

    @After
    public void tearDown() {
        TextBubble.setSkipShowCheckForTesting(false);
    }

    @Test
    @SmallTest
    public void meetsShowingRequirements_showsIntro_Accelerator() {
        setWebFeedIntroLastShownTimeMsPref(0);
        setWebFeedIntroWebFeedIdShownTimeMsPref(0);
        setVisitCounts(3, 3);
        invokePageLoad(WebFeedSubscriptionStatus.NOT_SUBSCRIBED, /* isRecommended= */ true);
        advanceClockByMs(SAFE_INTRO_WAIT_TIME_MILLIS);

        assertTrue(
                "Intro should be shown.", mWebFeedFollowIntroController.getIntroShownForTesting());
        assertEquals(
                "WEB_FEED_INTRO_LAST_SHOWN_TIME_MS should be updated.",
                mClock.currentTimeMillis(),
                sChromeSharedPreferences.readLong(
                        ChromePreferenceKeys.WEB_FEED_INTRO_LAST_SHOWN_TIME_MS));
        assertEquals(
                "WEB_FEED_INTRO_WEB_FEED_ID_SHOWN_TIME_MS_PREFIX should be updated.",
                mClock.currentTimeMillis(),
                sChromeSharedPreferences.readLong(
                        ChromePreferenceKeys.WEB_FEED_INTRO_WEB_FEED_ID_SHOWN_TIME_MS_PREFIX
                                .createKey(Base64.encodeToString(sWebFeedId, Base64.DEFAULT))));
    }

    @Test
    @SmallTest
    public void
            meetsShowingRequirements_butPageNavigationIsFromRecommendation_acceleratorShownOnlyOnce() {
        // Mock the navigation entry to indicate the current entry contains a recommended web feed.
        when(mTab.getUserDataHost()).thenReturn(new UserDataHost());
        when(mNavigationHandle.getUserDataHost()).thenReturn(mTestUserDataHost);
        setWebFeedIntroLastShownTimeMsPref(0);
        setWebFeedIntroWebFeedIdShownTimeMsPref(0);
        setVisitCounts(3, 3);
        invokePageLoad(WebFeedSubscriptionStatus.NOT_SUBSCRIBED, /* isRecommended= */ true);
        advanceClockByMs(SAFE_INTRO_WAIT_TIME_MILLIS);

        assertFalse(mWebFeedFollowIntroController.getIntroShownForTesting());
        assertTrue(
                mWebFeedFollowIntroController
                        .getRecommendationFollowAcceleratorController()
                        .getIntroViewForTesting()
                        .wasFollowBubbleShownForTesting());
    }

    @Test
    @SmallTest
    public void meetsShowingRequirements_showsIntro_IPH() {
        resetWebFeedFollowIntroController();

        setWebFeedIntroLastShownTimeMsPref(0);
        setWebFeedIntroWebFeedIdShownTimeMsPref(0);
        setVisitCounts(3, 3);
        invokePageLoad(WebFeedSubscriptionStatus.NOT_SUBSCRIBED, /* isRecommended= */ true);
        advanceClockByMs(SAFE_INTRO_WAIT_TIME_MILLIS);

        assertTrue(
                "Intro should be shown.", mWebFeedFollowIntroController.getIntroShownForTesting());
        assertEquals(
                "WEB_FEED_INTRO_LAST_SHOWN_TIME_MS should be updated.",
                mClock.currentTimeMillis(),
                sChromeSharedPreferences.readLong(
                        ChromePreferenceKeys.WEB_FEED_INTRO_LAST_SHOWN_TIME_MS));
        assertEquals(
                "WEB_FEED_INTRO_WEB_FEED_ID_SHOWN_TIME_MS_PREFIX should be updated.",
                mClock.currentTimeMillis(),
                sChromeSharedPreferences.readLong(
                        ChromePreferenceKeys.WEB_FEED_INTRO_WEB_FEED_ID_SHOWN_TIME_MS_PREFIX
                                .createKey(Base64.encodeToString(sWebFeedId, Base64.DEFAULT))));
    }

    @Test
    @SmallTest
    public void sameWebFeedIsNotShownMoreThan3Times() {
        resetWebFeedFollowIntroController();

        mWebFeedFollowIntroController.clearIntroShownForTesting();
        setWebFeedIntroLastShownTimeMsPref(0);
        setWebFeedIntroWebFeedIdShownTimeMsPref(0);
        setVisitCounts(3, 3);
        invokePageLoad(WebFeedSubscriptionStatus.NOT_SUBSCRIBED, /* isRecommended= */ true);
        advanceClockByMs(SAFE_INTRO_WAIT_TIME_MILLIS);
        assertTrue(
                "Intro should be shown first time",
                mWebFeedFollowIntroController.getIntroShownForTesting());

        mWebFeedFollowIntroController.clearIntroShownForTesting();
        setWebFeedIntroLastShownTimeMsPref(0);
        setWebFeedIntroWebFeedIdShownTimeMsPref(0);
        setVisitCounts(3, 3);
        invokePageLoad(WebFeedSubscriptionStatus.NOT_SUBSCRIBED, /* isRecommended= */ true);
        advanceClockByMs(SAFE_INTRO_WAIT_TIME_MILLIS);
        assertTrue(
                "Intro should be shown second time",
                mWebFeedFollowIntroController.getIntroShownForTesting());

        mWebFeedFollowIntroController.clearIntroShownForTesting();
        setWebFeedIntroLastShownTimeMsPref(0);
        setWebFeedIntroWebFeedIdShownTimeMsPref(0);
        setVisitCounts(3, 3);
        invokePageLoad(WebFeedSubscriptionStatus.NOT_SUBSCRIBED, /* isRecommended= */ true);
        advanceClockByMs(SAFE_INTRO_WAIT_TIME_MILLIS);
        assertTrue(
                "Intro should be shown third time",
                mWebFeedFollowIntroController.getIntroShownForTesting());

        mWebFeedFollowIntroController.clearIntroShownForTesting();
        setWebFeedIntroLastShownTimeMsPref(0);
        setWebFeedIntroWebFeedIdShownTimeMsPref(0);
        setVisitCounts(3, 3);
        invokePageLoad(WebFeedSubscriptionStatus.NOT_SUBSCRIBED, /* isRecommended= */ true);
        advanceClockByMs(SAFE_INTRO_WAIT_TIME_MILLIS);
        assertFalse(
                "Intro should NOT be shown fourth time",
                mWebFeedFollowIntroController.getIntroShownForTesting());
    }

    @Test
    @SmallTest
    public void secondPageLoad_cancelsPreviousIntro() {
        setWebFeedIntroLastShownTimeMsPref(0);
        setWebFeedIntroWebFeedIdShownTimeMsPref(0);
        setVisitCounts(3, 3);

        // This page load would trigger the intro, but a second page load is fired before that can
        // happen.
        invokePageLoad(WebFeedSubscriptionStatus.NOT_SUBSCRIBED, /* isRecommended= */ true);
        advanceClockByMs(SAFE_INTRO_WAIT_TIME_MILLIS / 2);

        invokePageLoad(WebFeedSubscriptionStatus.NOT_SUBSCRIBED, /* isRecommended= */ true);
        advanceClockByMs(SAFE_INTRO_WAIT_TIME_MILLIS - 500);
        assertFalse(
                "Intro should not be shown yet.",
                mWebFeedFollowIntroController.getIntroShownForTesting());

        advanceClockByMs(500);

        assertTrue(
                "Intro should be shown.", mWebFeedFollowIntroController.getIntroShownForTesting());
    }

    @Test
    @SmallTest
    public void notRecommended_doesNotShowIntro() {
        setWebFeedIntroLastShownTimeMsPref(0);
        setWebFeedIntroWebFeedIdShownTimeMsPref(0);
        setVisitCounts(3, 3);
        invokePageLoad(WebFeedSubscriptionStatus.NOT_SUBSCRIBED, /* isRecommended= */ false);
        advanceClockByMs(SAFE_INTRO_WAIT_TIME_MILLIS);

        assertFalse(
                "Intro should not be shown.",
                mWebFeedFollowIntroController.getIntroShownForTesting());
    }

    @Test
    @SmallTest
    public void subscribed_doesNotShowIntro() {
        setWebFeedIntroLastShownTimeMsPref(0);
        setWebFeedIntroWebFeedIdShownTimeMsPref(0);
        setVisitCounts(3, 3);
        invokePageLoad(WebFeedSubscriptionStatus.SUBSCRIBED, /* isRecommended= */ true);
        advanceClockByMs(SAFE_INTRO_WAIT_TIME_MILLIS);

        assertFalse(
                "Intro should not be shown.",
                mWebFeedFollowIntroController.getIntroShownForTesting());
    }

    @Test
    @SmallTest
    public void tooShortDelay_doesNotShowIntro() {
        setWebFeedIntroLastShownTimeMsPref(0);
        setWebFeedIntroWebFeedIdShownTimeMsPref(0);
        setVisitCounts(3, 3);
        invokePageLoad(WebFeedSubscriptionStatus.NOT_SUBSCRIBED, /* isRecommended= */ true);
        advanceClockByMs(SAFE_INTRO_WAIT_TIME_MILLIS / 2);

        assertFalse(
                "Intro should not be shown.",
                mWebFeedFollowIntroController.getIntroShownForTesting());
    }

    @Test
    @SmallTest
    public void doesNotMeetTotalVisitRequirement_doesNotShowIntro() {
        setWebFeedIntroLastShownTimeMsPref(0);
        setWebFeedIntroWebFeedIdShownTimeMsPref(0);
        setVisitCounts(2, 3);
        invokePageLoad(WebFeedSubscriptionStatus.NOT_SUBSCRIBED, /* isRecommended= */ true);
        advanceClockByMs(SAFE_INTRO_WAIT_TIME_MILLIS);

        assertFalse(
                "Intro should not be shown.",
                mWebFeedFollowIntroController.getIntroShownForTesting());
    }

    @Test
    @SmallTest
    public void doesNotMeetDailyVisitRequirement_doesNotShowIntro() {
        setWebFeedIntroLastShownTimeMsPref(0);
        setWebFeedIntroWebFeedIdShownTimeMsPref(0);
        setVisitCounts(3, 2);
        invokePageLoad(WebFeedSubscriptionStatus.NOT_SUBSCRIBED, /* isRecommended= */ true);
        advanceClockByMs(SAFE_INTRO_WAIT_TIME_MILLIS);

        assertFalse(
                "Intro should not be shown.",
                mWebFeedFollowIntroController.getIntroShownForTesting());
    }

    @Test
    @SmallTest
    public void lastShownTimeTooClose_doesNotShowIntro() {
        setWebFeedIntroLastShownTimeMsPref(mClock.currentTimeMillis() - 1000);
        setWebFeedIntroWebFeedIdShownTimeMsPref(0);
        setVisitCounts(3, 3);
        invokePageLoad(WebFeedSubscriptionStatus.NOT_SUBSCRIBED, /* isRecommended= */ true);
        advanceClockByMs(SAFE_INTRO_WAIT_TIME_MILLIS);

        assertFalse(
                "Intro should not be shown.",
                mWebFeedFollowIntroController.getIntroShownForTesting());
    }

    @Test
    @SmallTest
    public void lastShownForWebFeedIdTimeTooClose_doesNotShowIntro() {
        setWebFeedIntroLastShownTimeMsPref(0);
        setWebFeedIntroWebFeedIdShownTimeMsPref(mClock.currentTimeMillis() - 1000);
        setVisitCounts(3, 3);
        invokePageLoad(WebFeedSubscriptionStatus.NOT_SUBSCRIBED, /* isRecommended= */ true);
        advanceClockByMs(SAFE_INTRO_WAIT_TIME_MILLIS);

        assertFalse(
                "Intro should not be shown.",
                mWebFeedFollowIntroController.getIntroShownForTesting());
    }

    @Test
    @SmallTest
    public void featureEngagementTrackerSaysDoNotShow_doesNotShowIntro() {
        setWebFeedIntroLastShownTimeMsPref(0);
        setWebFeedIntroWebFeedIdShownTimeMsPref(0);
        setVisitCounts(3, 3);
        invokePageLoad(WebFeedSubscriptionStatus.NOT_SUBSCRIBED, /* isRecommended= */ true);
        when(mTracker.shouldTriggerHelpUI(FeatureConstants.IPH_WEB_FEED_FOLLOW_FEATURE))
                .thenReturn(false);
        when(mTracker.shouldTriggerHelpUIWithSnooze(FeatureConstants.IPH_WEB_FEED_FOLLOW_FEATURE))
                .thenReturn(new TriggerDetails(false, false));
        advanceClockByMs(SAFE_INTRO_WAIT_TIME_MILLIS);

        assertFalse(
                "Intro should not be shown.",
                mWebFeedFollowIntroController.getIntroShownForTesting());
    }

    private void setWebFeedIntroLastShownTimeMsPref(long webFeedIntroLastShownTimeMs) {
        sChromeSharedPreferences.writeLong(
                ChromePreferenceKeys.WEB_FEED_INTRO_LAST_SHOWN_TIME_MS,
                webFeedIntroLastShownTimeMs);
    }

    private void setWebFeedIntroWebFeedIdShownTimeMsPref(long webFeedIntroWebFeedIdShownTimeMs) {
        sChromeSharedPreferences.writeLong(
                ChromePreferenceKeys.WEB_FEED_INTRO_WEB_FEED_ID_SHOWN_TIME_MS_PREFIX.createKey(
                        Base64.encodeToString(sWebFeedId, Base64.DEFAULT)),
                webFeedIntroWebFeedIdShownTimeMs);
    }

    private void setVisitCounts(int totalVisits, int dailyVisits) {
        WebFeedBridge.VisitCounts visitCounts =
                new WebFeedBridge.VisitCounts(totalVisits, dailyVisits);
        doAnswer(
                        invocation -> {
                            invocation
                                    .<Callback<int[]>>getArgument(1)
                                    .onResult(
                                            new int[] {
                                                visitCounts.visits, visitCounts.dailyVisits
                                            });
                            return null;
                        })
                .when(mWebFeedBridgeJniMock)
                .getRecentVisitCountsToHost(eq(sTestUrl), any(Callback.class));
    }

    private void invokePageLoad(
            @WebFeedSubscriptionStatus int subscriptionStatus, boolean isRecommended) {
        // We don't check for web feed name unless the navigation is on the main frame.
        when(mNavigationHandle.isInPrimaryMainFrame()).thenReturn(true);
        // Set the navigation handle so we can get (mocked) user data out of it.
        mEmptyTabObserver.onDidFinishNavigationInPrimaryMainFrame(mTab, mNavigationHandle);

        WebFeedBridge.WebFeedMetadata webFeedMetadata =
                new WebFeedBridge.WebFeedMetadata(
                        sWebFeedId,
                        "title",
                        sTestUrl,
                        subscriptionStatus,
                        WebFeedAvailabilityStatus.ACTIVE,
                        isRecommended,
                        sFaviconUrl);

        // Respond to the findWebFeedInfoForPage JNI api by calling the callback.
        doAnswer(
                        invocation -> {
                            assertEquals(
                                    "Incorrect WebFeedPageInformationRequestReason was used.",
                                    WebFeedPageInformationRequestReason.FOLLOW_RECOMMENDATION,
                                    invocation.<Integer>getArgument(1).intValue());
                            invocation
                                    .<Callback<WebFeedBridge.WebFeedMetadata>>getArgument(2)
                                    .onResult(webFeedMetadata);
                            return null;
                        })
                .when(mWebFeedBridgeJniMock)
                .findWebFeedInfoForPage(
                        any(WebFeedBridge.WebFeedPageInformation.class),
                        anyInt(),
                        any(Callback.class));

        // Respond to the findWebFeedInfoForWebFeedId JNI api by calling the callback.
        doAnswer(
                        invocation -> {
                            invocation
                                    .<Callback<WebFeedBridge.WebFeedMetadata>>getArgument(1)
                                    .onResult(webFeedMetadata);
                            return null;
                        })
                .when(mWebFeedBridgeJniMock)
                .findWebFeedInfoForWebFeedId(any(), any(Callback.class));

        mEmptyTabObserver.onPageLoadStarted(mTab, sTestUrl);
        mEmptyTabObserver.didFirstVisuallyNonEmptyPaint(mTab);
        mEmptyTabObserver.onPageLoadFinished(mTab, sTestUrl);
    }

    private void advanceClockByMs(long timeMs) {
        mClock.advanceCurrentTimeMillis(timeMs);
        Robolectric.getForegroundThreadScheduler().advanceBy(timeMs, TimeUnit.MILLISECONDS);
    }

    /** FakeClock for setting the time. */
    static class FakeClock implements WebFeedFollowIntroController.Clock {
        private long mCurrentTimeMillis;

        FakeClock() {
            mCurrentTimeMillis = 123456789;
        }

        @Override
        public long currentTimeMillis() {
            return mCurrentTimeMillis;
        }

        void advanceCurrentTimeMillis(long millis) {
            mCurrentTimeMillis += millis;
        }
    }
}