chromium/chrome/browser/download/internal/android/java/src/org/chromium/chrome/browser/download/home/DownloadActivityV2Test.java

// 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.chrome.browser.download.home;

import static androidx.test.espresso.Espresso.onView;
import static androidx.test.espresso.assertion.ViewAssertions.doesNotExist;
import static androidx.test.espresso.assertion.ViewAssertions.matches;
import static androidx.test.espresso.matcher.RootMatchers.isDialog;
import static androidx.test.espresso.matcher.ViewMatchers.hasSibling;
import static androidx.test.espresso.matcher.ViewMatchers.isDescendantOfA;
import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
import static androidx.test.espresso.matcher.ViewMatchers.isEnabled;
import static androidx.test.espresso.matcher.ViewMatchers.withContentDescription;
import static androidx.test.espresso.matcher.ViewMatchers.withId;
import static androidx.test.espresso.matcher.ViewMatchers.withText;

import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.equalToIgnoringCase;
import static org.hamcrest.Matchers.not;
import static org.hamcrest.core.AllOf.allOf;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;

import android.content.Context;
import android.os.Handler;
import android.os.Looper;
import android.util.Pair;
import android.view.View;

import androidx.recyclerview.widget.RecyclerView.ViewHolder;
import androidx.test.espresso.action.ViewActions;
import androidx.test.espresso.contrib.RecyclerViewActions;
import androidx.test.espresso.matcher.BoundedMatcher;
import androidx.test.filters.MediumTest;

import org.hamcrest.Description;
import org.hamcrest.Matcher;
import org.junit.Assert;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;

import org.chromium.base.BuildInfo;
import org.chromium.base.Callback;
import org.chromium.base.DiscardableReferencePool;
import org.chromium.base.ThreadUtils;
import org.chromium.base.supplier.ObservableSupplierImpl;
import org.chromium.base.task.PostTask;
import org.chromium.base.task.TaskTraits;
import org.chromium.base.test.util.Batch;
import org.chromium.base.test.util.CriteriaHelper;
import org.chromium.base.test.util.DisabledTest;
import org.chromium.base.test.util.HistogramWatcher;
import org.chromium.base.test.util.JniMocker;
import org.chromium.chrome.browser.back_press.BackPressHelper;
import org.chromium.chrome.browser.back_press.SecondaryActivityBackPressUma.SecondaryActivity;
import org.chromium.chrome.browser.download.home.list.ListUtils;
import org.chromium.chrome.browser.download.home.list.holder.ListItemViewHolder;
import org.chromium.chrome.browser.download.home.rename.RenameUtils;
import org.chromium.chrome.browser.download.home.toolbar.DownloadHomeToolbar;
import org.chromium.chrome.browser.download.internal.R;
import org.chromium.chrome.browser.ui.messages.snackbar.SnackbarManager;
import org.chromium.chrome.test.AutomotiveContextWrapperTestRule;
import org.chromium.chrome.test.ChromeJUnit4ClassRunner;
import org.chromium.components.browser_ui.modaldialog.AppModalPresenter;
import org.chromium.components.browser_ui.util.date.StringUtils;
import org.chromium.components.embedder_support.util.UrlConstants;
import org.chromium.components.feature_engagement.Tracker;
import org.chromium.components.offline_items_collection.ContentId;
import org.chromium.components.offline_items_collection.OfflineItem;
import org.chromium.components.offline_items_collection.OfflineItemFilter;
import org.chromium.components.offline_items_collection.OfflineItemState;
import org.chromium.components.offline_items_collection.RenameResult;
import org.chromium.components.url_formatter.SchemeDisplay;
import org.chromium.components.url_formatter.UrlFormatter;
import org.chromium.components.url_formatter.UrlFormatterJni;
import org.chromium.ui.modaldialog.ModalDialogManager;
import org.chromium.ui.test.util.BlankUiTestActivityTestCase;
import org.chromium.url.GURL;
import org.chromium.url.JUnitTestGURLs;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Date;
import java.util.List;

/** Tests the download home V2. */
@RunWith(ChromeJUnit4ClassRunner.class)
@Batch(Batch.UNIT_TESTS)
public class DownloadActivityV2Test extends BlankUiTestActivityTestCase {
    @Mock private Tracker mTracker;
    @Mock private SnackbarManager mSnackbarManager;
    @Rule public JniMocker mJniMocker = new JniMocker();
    @Mock private UrlFormatter.Natives mUrlFormatterJniMock;

    @Rule
    public AutomotiveContextWrapperTestRule mAutomotiveContextWrapperTestRule =
            new AutomotiveContextWrapperTestRule();

    private ModalDialogManager.Presenter mAppModalPresenter;

    private ModalDialogManager mModalDialogManager;

    private DownloadManagerCoordinator mDownloadCoordinator;

    private StubbedOfflineContentProvider mStubbedOfflineContentProvider;

    private DiscardableReferencePool mDiscardableReferencePool;

    /**
     * Returns a Matcher to find a particular {@link ViewHolder} that contains certain text.
     *
     * @param text The text that the view holder has in its view hierarchy.
     */
    private static Matcher<ViewHolder> hasTextInViewHolder(String text) {
        return new BoundedMatcher<ViewHolder, ListItemViewHolder>(ListItemViewHolder.class) {
            @Override
            public void describeTo(Description description) {
                description.appendText("has text: " + text);
            }

            @Override
            protected boolean matchesSafely(ListItemViewHolder listItemViewHolder) {
                ArrayList<View> outViews = new ArrayList<>();
                listItemViewHolder.itemView.findViewsWithText(
                        outViews, text, View.FIND_VIEWS_WITH_TEXT);
                return !outViews.isEmpty();
            }
        };
    }

    @Override
    public void setUpTest() throws Exception {
        super.setUpTest();
        MockitoAnnotations.initMocks(this);
        mJniMocker.mock(UrlFormatterJni.TEST_HOOKS, mUrlFormatterJniMock);
        when(mUrlFormatterJniMock.formatUrlForSecurityDisplay(
                        any(), eq(SchemeDisplay.OMIT_HTTP_AND_HTTPS)))
                .then(
                        inv -> {
                            GURL url = inv.getArgument(0);
                            return url.getSpec();
                        });

        mStubbedOfflineContentProvider =
                new StubbedOfflineContentProvider() {
                    @Override
                    public void renameItem(ContentId id, String name, Callback<Integer> callback) {
                        new Handler(Looper.getMainLooper())
                                .post(() -> callback.onResult(handleRename(name)));
                    }
                };

        OfflineItem item0 =
                StubbedProvider.createOfflineItem(0, "20151019 07:26", OfflineItemFilter.PAGE);
        OfflineItem item1 =
                StubbedProvider.createOfflineItem(1, "20151020 07:27", OfflineItemFilter.PAGE);
        OfflineItem item2 =
                StubbedProvider.createOfflineItem(2, "20151021 07:28", OfflineItemFilter.OTHER);
        OfflineItem item3 =
                StubbedProvider.createOfflineItem(3, "20151021 07:29", OfflineItemFilter.OTHER);

        mStubbedOfflineContentProvider.addItem(item0);
        mStubbedOfflineContentProvider.addItem(item1);
        mStubbedOfflineContentProvider.addItem(item2);
        mStubbedOfflineContentProvider.addItem(item3);

        mDiscardableReferencePool = new DiscardableReferencePool();
    }

    private void setUpUi() {
        DownloadManagerUiConfig config =
                DownloadManagerUiConfigHelper.fromFlags()
                        .setOTRProfileID(null)
                        .setIsSeparateActivity(true)
                        .build();

        mAppModalPresenter = new AppModalPresenter(getActivity());

        mModalDialogManager =
                new ModalDialogManager(mAppModalPresenter, ModalDialogManager.ModalDialogType.APP);

        FaviconProvider faviconProvider = (url, faviconSizePx, callback) -> {};
        Callback<Context> settingsLauncher = context -> {};
        ObservableSupplierImpl<Boolean> isPrefetchEnabledSupplier = new ObservableSupplierImpl<>();
        isPrefetchEnabledSupplier.set(true);

        mDownloadCoordinator =
                new DownloadManagerCoordinatorImpl(
                        getActivity(),
                        config,
                        isPrefetchEnabledSupplier,
                        settingsLauncher,
                        mSnackbarManager,
                        mModalDialogManager,
                        mTracker,
                        faviconProvider,
                        mStubbedOfflineContentProvider,
                        mDiscardableReferencePool);
        getActivity().setContentView(mDownloadCoordinator.getView());
        BackPressHelper.create(
                getActivity(),
                getActivity().getOnBackPressedDispatcher(),
                mDownloadCoordinator.getBackPressHandlers(),
                SecondaryActivity.DOWNLOAD);

        mDownloadCoordinator.updateForUrl(UrlConstants.DOWNLOADS_URL);
    }

    @Test
    @MediumTest
    public void testLaunchingActivity() {
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    setUpUi();
                });

        // Shows activity title.
        onView(withText("Downloads")).check(matches(isDisplayed()));

        // Shows the list items.
        onView(withText("page 2")).check(matches(isDisplayed()));
        onView(withText("page 3")).check(matches(isDisplayed()));
        onView(withText("page 4")).check(matches(isDisplayed()));

        // The last item may be outside the view port, that recycler view won't create the view
        // holder, so scroll to that view holder first.
        onView(withId(R.id.download_home_recycler_view))
                .perform(RecyclerViewActions.scrollToHolder(hasTextInViewHolder("page 1")));
        onView(withText("page 1")).check(matches(isDisplayed()));
    }

    @Test
    @MediumTest
    public void testTabsAreShown() throws Exception {
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    setUpUi();
                });
        checkItemsDisplayed(true, true, true, true);

        Matcher filesTabMatcher = withText(equalToIgnoringCase("My Files"));
        Matcher prefetchTabMatcher = withText(equalToIgnoringCase("Explore Offline"));
        onView(filesTabMatcher).check(matches(isDisplayed()));
        onView(prefetchTabMatcher).check(matches(isDisplayed()));

        // Select Explore Offline tab, and verify the contents.
        onView(prefetchTabMatcher).perform(ViewActions.click());
        checkItemsDisplayed(false, false, false, false);

        // Select My files tab, and verify the contents.
        onView(filesTabMatcher).perform(ViewActions.scrollTo(), ViewActions.click());
        checkItemsDisplayed(true, true, true, true);
    }

    @Test
    @MediumTest
    public void testChipsAreShown() throws Exception {
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    setUpUi();
                });

        // Two PAGES and two OTHER files. Should show All, Pages, and Other chips.
        checkItemsDisplayed(true, true, true, true);

        Matcher allChipMatcher =
                allOf(
                        withText(equalToIgnoringCase("All")),
                        isDescendantOfA(withId(R.id.content_container)));
        Matcher pagesChipMatcher =
                allOf(
                        withText(equalToIgnoringCase("Pages")),
                        isDescendantOfA(withId(R.id.content_container)));
        Matcher otherChipMatcher =
                allOf(
                        withText(equalToIgnoringCase("Other")),
                        isDescendantOfA(withId(R.id.content_container)));

        onView(allChipMatcher).check(matches(isDisplayed()));
        onView(pagesChipMatcher).check(matches(isDisplayed()));
        onView(otherChipMatcher).check(matches(isDisplayed()));

        // Select Pages chip, and verify the contents.
        onView(pagesChipMatcher).perform(ViewActions.click());
        checkItemsDisplayed(true, true, false, false);

        // Select Other chip, and verify the contents.
        onView(otherChipMatcher).perform(ViewActions.click());
        checkItemsDisplayed(false, false, true, true);

        // Select All chip, and verify the contents.
        onView(allChipMatcher).perform(ViewActions.click());
        checkItemsDisplayed(true, true, true, true);
    }

    @Test
    @MediumTest
    public void testPrefetchTabEmptyText() throws Exception {
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    setUpUi();
                });

        onView(withId(R.id.empty_state_icon)).check(matches(not(isDisplayed())));

        // Go to Prefetch tab. It should be empty.
        onView(withText(equalToIgnoringCase("Explore Offline")))
                .check(matches(isDisplayed()))
                .perform(ViewActions.click());
        onView(withText(containsString("Articles appear here"))).check(matches(isDisplayed()));
        onView(withId(R.id.empty_state_icon)).check(matches(isDisplayed()));

        // Go back to files tab. It shouldn't be empty.
        onView(withText(equalToIgnoringCase("My Files")))
                .check(matches(isDisplayed()))
                .perform(ViewActions.click());
        onView(withId(R.id.empty_state_icon)).check(matches(not(isDisplayed())));
    }

    @Test
    @MediumTest
    public void testAddRemoveItems() throws Exception {
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    setUpUi();
                });

        String storageHeaderText = "Using 1.10 KB of";
        onView(withText(containsString(storageHeaderText))).check(matches(isDisplayed()));

        // Add an item. The new item should be visible and the storage text should be updated.
        OfflineItem item5 =
                StubbedProvider.createOfflineItem(
                        "offline_guid_5",
                        JUnitTestGURLs.URL_2,
                        OfflineItemState.COMPLETE,
                        1024,
                        "page 5",
                        "/data/fake_path/Downloads/file_5",
                        System.currentTimeMillis(),
                        100000,
                        OfflineItemFilter.OTHER);

        ThreadUtils.runOnUiThreadBlocking(() -> mStubbedOfflineContentProvider.addItem(item5));
        onView(withText("page 5")).check(matches(isDisplayed()));
        onView(withText(containsString("Using 2.10 KB of"))).check(matches(isDisplayed()));

        // Delete an item. The item should be gone and the storage text should be updated.
        ThreadUtils.runOnUiThreadBlocking(
                () -> mStubbedOfflineContentProvider.removeItem(item5.id));
        onView(withText("page 5")).check(doesNotExist());
        onView(withText(containsString("Using 1.10 KB of"))).check(matches(isDisplayed()));
    }

    @Test
    @MediumTest
    public void testShowListItemMenuWithRename() throws Exception {
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    setUpUi();
                });
        onView(withText("page 3")).check(matches(isDisplayed()));

        // Open menu for a download, it should have share, delete, and rename options.
        onView(allOf(withId(R.id.more), hasSibling(withText("page 3"))))
                .check(matches(isDisplayed()))
                .perform(ViewActions.click());

        onView(withText("Rename")).check(matches(isDisplayed()));
        onView(withText("Delete")).check(matches(isDisplayed()));
        if (BuildInfo.getInstance().isAutomotive) {
            onView(withText("Share")).check(doesNotExist());
        } else {
            onView(withText("Share")).check(matches(isDisplayed()));
        }
    }

    @Test
    @MediumTest
    public void testShowListItemMenuWithoutRename() throws Exception {
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    setUpUi();
                });

        // The last item may be outside the view port, that recycler view won't create the view
        // holder, so scroll to that view holder first.
        onView(withId(R.id.download_home_recycler_view))
                .perform(RecyclerViewActions.scrollToHolder(hasTextInViewHolder("page 1")));

        // Open menu for a page download, it should have share, delete, but no rename option.
        onView(allOf(withId(R.id.more), hasSibling(withText("page 1"))))
                .check(matches(isDisplayed()))
                .perform(ViewActions.click());

        onView(withText("Rename")).check(doesNotExist());
        onView(withText("Delete")).check(matches(isDisplayed()));
        if (BuildInfo.getInstance().isAutomotive) {
            onView(withText("Share")).check(doesNotExist());
        } else {
            onView(withText("Share")).check(matches(isDisplayed()));
        }
    }

    @Test
    @MediumTest
    public void testShowToolbarMenu() throws Exception {
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    setUpUi();
                });

        // In non-selection state settings, search and close menu should be showing, the selection
        // toolbar should not exist.
        onView(withId(R.id.settings_menu_id)).check(matches(isDisplayed()));
        onView(withId(R.id.search_menu_id)).check(matches(isDisplayed()));
        onView(withId(R.id.close_menu_id)).check(matches(isDisplayed()));
        onView(withId(R.id.selection_mode_number)).check(matches(not(isDisplayed())));
        onView(withId(R.id.selection_mode_share_menu_id)).check(doesNotExist());
        onView(withId(R.id.selection_mode_delete_menu_id)).check(doesNotExist());

        // The last item may be outside the view port, that recycler view won't create the view
        // holder, so scroll to that view holder first.
        onView(withId(R.id.download_home_recycler_view))
                .perform(RecyclerViewActions.scrollToHolder(hasTextInViewHolder("page 1")));

        // Select an item.
        onView(withText("page 1")).perform(ViewActions.longClick());

        // Selection toolbar should be showing. Settings, search, and close menu should be gone.
        onView(withId(R.id.settings_menu_id)).check(doesNotExist());
        onView(withId(R.id.search_menu_id)).check(doesNotExist());
        onView(withId(R.id.close_menu_id)).check(doesNotExist());
        onView(withId(R.id.selection_mode_number)).check(matches(isDisplayed()));
        if (BuildInfo.getInstance().isAutomotive) {
            onView(withId(R.id.selection_mode_share_menu_id)).check(matches(not(isDisplayed())));
        } else {
            onView(withId(R.id.selection_mode_share_menu_id)).check(matches(isDisplayed()));
        }
        onView(withId(R.id.selection_mode_delete_menu_id)).check(matches(isDisplayed()));

        // The last item may be outside the view port, that recycler view won't create the view
        // holder, so scroll to that view holder first.
        onView(withId(R.id.download_home_recycler_view))
                .perform(RecyclerViewActions.scrollToHolder(hasTextInViewHolder("page 1")));

        // Deselect the same item.
        onView(withText("page 1")).perform(ViewActions.longClick());

        // The toolbar should flip back to non-selection state.
        onView(withId(R.id.settings_menu_id)).check(matches(isDisplayed()));
        onView(withId(R.id.search_menu_id)).check(matches(isDisplayed()));
        onView(withId(R.id.close_menu_id)).check(matches(isDisplayed()));
        onView(withId(R.id.selection_mode_number)).check(matches(not(isDisplayed())));
        onView(withId(R.id.selection_mode_share_menu_id)).check(doesNotExist());
        onView(withId(R.id.selection_mode_delete_menu_id)).check(doesNotExist());
    }

    @Test
    @MediumTest
    public void testDeleteItem() throws Exception {
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    setUpUi();
                });
        SnackbarManager.setDurationForTesting(1);

        // The last item may be outside the view port, that recycler view won't create the view
        // holder, so scroll to that view holder first.
        onView(withId(R.id.download_home_recycler_view))
                .perform(RecyclerViewActions.scrollToHolder(hasTextInViewHolder("page 1")));

        onView(withText("page 1")).check(matches(isDisplayed()));

        // Delete an item using three dot menu. The item should be removed from the list.
        onView(allOf(withId(R.id.more), hasSibling(withText("page 1"))))
                .perform(ViewActions.click());
        onView(withText("Delete")).check(matches(isDisplayed())).perform(ViewActions.click());
        onView(withText("page 1")).check(doesNotExist());

        // Delete the remaining items using long press and multi-delete from toolbar menu.
        onView(withText("page 2")).check(matches(isDisplayed())).perform(ViewActions.longClick());
        onView(withText("page 3")).check(matches(isDisplayed())).perform(ViewActions.longClick());
        onView(withText("page 4")).check(matches(isDisplayed())).perform(ViewActions.longClick());

        PostTask.runOrPostTask(
                TaskTraits.UI_DEFAULT,
                () -> {
                    DownloadHomeToolbar toolbar = getActivity().findViewById(R.id.download_toolbar);
                    toolbar.getMenu()
                            .performIdentifierAction(R.id.selection_mode_delete_menu_id, 0);
                });

        // The files tab should show empty view now.
        onView(withId(R.id.empty_state_icon)).check(matches(isDisplayed()));
    }

    @Test
    @MediumTest
    @DisabledTest(message = "https://crbug.com/1338140")
    public void testRenameItem() throws Exception {
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    setUpUi();
                });

        RenameUtils.disableNativeForTesting();

        // Rename a non-offline-page item using three dot menu.
        // Open menu for a list item, it should have the rename option.
        onView(allOf(withId(R.id.more), hasSibling(withText("page 4"))))
                .perform(ViewActions.click());
        // Rename an item. The rename dialog should popup.
        onView(withText("Rename")).check(matches(isDisplayed())).perform(ViewActions.click());

        // Test rename dialog with error message.
        renameFileAndVerifyErrorMessage("name_conflict", R.string.rename_failure_name_conflict);
        renameFileAndVerifyErrorMessage("name_too_long", R.string.rename_failure_name_too_long);
        renameFileAndVerifyErrorMessage("name_invalid", R.string.rename_failure_name_invalid);
        renameFileAndVerifyErrorMessage("rename_unavailable", R.string.rename_failure_unavailable);

        // Test empty input.
        onView(withId(R.id.file_name)).inRoot(isDialog()).perform(ViewActions.clearText());
        onView(withText("OK")).inRoot(isDialog()).check(matches(not(isEnabled())));

        // Test successful commit.
        renameFileAndVerifyErrorMessage("rename_file_successful", -1);

        // TODO(hesen): Test rename extension dialog.
    }

    @Test
    @MediumTest
    public void testShareItem() throws Exception {
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    setUpUi();
                });

        // Open menu for a list item, it should have the share option.
        onView(allOf(withId(R.id.more), hasSibling(withText("page 4"))))
                .perform(ViewActions.click());

        // Share an item. The share via android dialog should popup.
        if (BuildInfo.getInstance().isAutomotive) {
            onView(withText("Share")).check(doesNotExist());
        } else {
            onView(withText("Share")).check(matches(isDisplayed()));
        }

        // TODO(shaktisahu): Perform a click, capture the Intent and check its contents.
    }

    @Test
    @MediumTest
    public void testSearchView() throws Exception {
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    setUpUi();
                });

        final DownloadHomeToolbar toolbar = getActivity().findViewById(R.id.download_toolbar);
        onView(withId(R.id.search_text)).check(matches(not(isDisplayed())));

        ThreadUtils.runOnUiThreadBlocking(
                (Runnable) () -> toolbar.getMenu().performIdentifierAction(R.id.search_menu_id, 0));

        // The selection should be cleared when a search is started.
        onView(withId(R.id.search_text)).check(matches(isDisplayed()));

        // Select an item and assert that the search view is no longer showing.
        onView(withText("page 4")).perform(ViewActions.longClick());
        onView(withId(R.id.search_text)).check(matches(not(isDisplayed())));

        // Clear the selection and assert that the search view is showing again.
        onView(withText("page 4")).perform(ViewActions.longClick());
        onView(withId(R.id.search_text)).check(matches(isDisplayed()));

        // Close the search view, by clicking back button on toolbar.
        onView(withContentDescription("Go back")).perform(ViewActions.click());
        onView(withId(R.id.search_text)).check(matches(not(isDisplayed())));
    }

    @Test
    @MediumTest
    public void testDismissSearchViewByBackPress() {
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    setUpUi();
                });

        final DownloadHomeToolbar toolbar = getActivity().findViewById(R.id.download_toolbar);
        onView(withId(R.id.search_text)).check(matches(not(isDisplayed())));

        ThreadUtils.runOnUiThreadBlocking(
                (Runnable) () -> toolbar.getMenu().performIdentifierAction(R.id.search_menu_id, 0));

        // The selection should be cleared when a search is started.
        onView(withId(R.id.search_text)).check(matches(isDisplayed()));

        // Select an item and assert that the search view is no longer showing.
        onView(withText("page 4")).perform(ViewActions.longClick());
        onView(withId(R.id.search_text)).check(matches(not(isDisplayed())));

        // Clear the selection by back press and assert that the search view is showing again.
        var backPressRecorder =
                HistogramWatcher.newSingleRecordWatcher(
                        "Android.BackPress.SecondaryActivity", SecondaryActivity.DOWNLOAD);
        ThreadUtils.runOnUiThreadBlocking(
                getActivity().getOnBackPressedDispatcher()::onBackPressed);
        backPressRecorder.assertExpected();
        onView(withId(R.id.search_text)).check(matches(isDisplayed()));

        // Close the search view, by performing a back press.
        var backPressRecorder2 =
                HistogramWatcher.newSingleRecordWatcher(
                        "Android.BackPress.SecondaryActivity", SecondaryActivity.DOWNLOAD);
        ThreadUtils.runOnUiThreadBlocking(
                getActivity().getOnBackPressedDispatcher()::onBackPressed);
        backPressRecorder2.assertExpected();
        CriteriaHelper.pollInstrumentationThread(
                () -> {
                    onView(withId(R.id.search_text)).check(matches(not(isDisplayed())));
                });
    }

    /**
     * @param items The list (unsorted) of OfflineItems that could be displayed.
     * @param expectations Whether or not each item (1:1 with {@code items}) is visible.
     */
    private void checkItemsDisplayed(ArrayList<OfflineItem> items, List<Boolean> expectations) {
        int currentIndex = 2; // (1) Storage, (2) Filters

        Assert.assertEquals(items.size(), expectations.size());

        // Create a sortable pair of the OfflineItem and the visibility expectation.
        ArrayList<Pair<OfflineItem, Boolean>> sorted = new ArrayList<>();
        for (int i = 0; i < items.size(); i++) {
            sorted.add(Pair.create(items.get(i), expectations.get(i)));
        }

        // Sort by date, which is how the items will show up in the UI.
        Collections.sort(sorted, this::compareOfflineItems);

        for (int i = 0; i < sorted.size(); i++) {
            boolean visible = sorted.get(i).second;
            OfflineItem item = sorted.get(i).first;

            if (visible) {
                OfflineItem previous = findPreviousVisible(sorted, i);
                // If we have a day change, validate the header and move forward one item before
                // comparing.
                if (previous == null || ListUtils.compareItemByDate(previous, item) != 0) {
                    onView(withId(R.id.download_home_recycler_view))
                            .perform(RecyclerViewActions.scrollToPosition(currentIndex++));
                    onView(
                                    withText(
                                            StringUtils.dateToHeaderString(
                                                            new Date(
                                                                    sorted.get(i)
                                                                            .first
                                                                            .creationTimeMs))
                                                    .toString()))
                            .check(matches(isDisplayed()));
                }

                onView(withId(R.id.download_home_recycler_view))
                        .perform(RecyclerViewActions.scrollToPosition(currentIndex++));
                onView(withText(item.title)).check(matches(isDisplayed()));
            } else {
                onView(withText(item.title)).check(doesNotExist());
            }
        }

        // Reset the scroll position to the beginning to set proper expectations for the broader
        // test.
        onView(withId(R.id.download_home_recycler_view))
                .perform(RecyclerViewActions.scrollToPosition(0));
    }

    private OfflineItem findPreviousVisible(ArrayList<Pair<OfflineItem, Boolean>> list, int i) {
        for (int j = i - 1; j >= 0; j--) {
            if (list.get(j).second) return list.get(j).first;
        }
        return null;
    }

    private int compareOfflineItems(Pair<OfflineItem, Boolean> a, Pair<OfflineItem, Boolean> b) {
        return (int) (b.first.creationTimeMs - a.first.creationTimeMs);
    }

    private void checkItemsDisplayed(boolean item0, boolean item1, boolean item2, boolean item3) {
        checkItemsDisplayed(
                mStubbedOfflineContentProvider.getItemsSynchronously(),
                Arrays.asList(item0, item1, item2, item3));
    }

    private void renameFileAndVerifyErrorMessage(String name, int expectErrorMsgId) {
        onView(withId(R.id.file_name))
                .perform(ViewActions.clearText())
                .perform(ViewActions.typeText(name));

        onView(withId(R.id.positive_button))
                .check(matches(isDisplayed()))
                .perform(ViewActions.click());

        if (expectErrorMsgId != -1) {
            onView(withText(getActivity().getResources().getString(expectErrorMsgId)))
                    .check(matches(isDisplayed()));
        }
    }

    private int /*@RenameResult*/ handleRename(String name) {
        int result = RenameResult.SUCCESS;
        switch (name) {
            case "name_conflict":
                result = RenameResult.FAILURE_NAME_CONFLICT;
                break;
            case "name_too_long":
                result = RenameResult.FAILURE_NAME_TOO_LONG;
                break;
            case "name_invalid":
                result = RenameResult.FAILURE_NAME_INVALID;
                break;
            case "rename_unavailable":
                result = RenameResult.FAILURE_UNAVAILABLE;
                break;
            default:
                break;
        }
        return result;
    }
}