chromium/chrome/android/javatests/src/org/chromium/chrome/browser/offlinepages/OfflinePageUtilsTest.java

// Copyright 2015 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;

import android.app.Activity;
import android.net.Uri;
import android.os.Build;
import android.os.Environment;

import androidx.test.core.app.ApplicationProvider;
import androidx.test.filters.MediumTest;
import androidx.test.filters.SmallTest;

import org.junit.After;
import org.junit.Assert;
import org.junit.Before;
import org.junit.ClassRule;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;

import org.chromium.base.Callback;
import org.chromium.base.ContextUtils;
import org.chromium.base.ThreadUtils;
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.CommandLineFlags;
import org.chromium.base.test.util.CriteriaHelper;
import org.chromium.base.test.util.DisableIf;
import org.chromium.base.test.util.UrlUtils;
import org.chromium.chrome.browser.flags.ChromeSwitches;
import org.chromium.chrome.browser.offlinepages.OfflinePageBridge.OfflinePageModelObserver;
import org.chromium.chrome.browser.offlinepages.OfflinePageBridge.SavePageCallback;
import org.chromium.chrome.browser.profiles.Profile;
import org.chromium.chrome.browser.profiles.ProfileManager;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.browser.ui.messages.snackbar.SnackbarManager;
import org.chromium.chrome.browser.ui.messages.snackbar.SnackbarManager.SnackbarController;
import org.chromium.chrome.test.ChromeJUnit4ClassRunner;
import org.chromium.chrome.test.ChromeTabbedActivityTestRule;
import org.chromium.chrome.test.batch.BlankCTATabInitialStateRule;
import org.chromium.components.browser_ui.share.ShareParams;
import org.chromium.components.offlinepages.SavePageResult;
import org.chromium.net.ConnectionType;
import org.chromium.net.NetworkChangeNotifier;
import org.chromium.net.test.EmbeddedTestServer;

import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;

/** Instrumentation tests for {@link OfflinePageUtils}. */
@RunWith(ChromeJUnit4ClassRunner.class)
@CommandLineFlags.Add({
    "enable-features=OfflinePagesSharing",
    ChromeSwitches.DISABLE_FIRST_RUN_EXPERIENCE
})
@Batch(Batch.PER_CLASS)
public class OfflinePageUtilsTest {
    @ClassRule
    public static ChromeTabbedActivityTestRule sActivityTestRule =
            new ChromeTabbedActivityTestRule();

    @Rule
    public BlankCTATabInitialStateRule mInitialStateRule =
            new BlankCTATabInitialStateRule(sActivityTestRule, true);

    private static final String TEST_PAGE = "/chrome/test/data/android/about.html";
    private static final int TIMEOUT_MS = 5000;
    private static final ClientId BOOKMARK_ID =
            new ClientId(OfflinePageBridge.BOOKMARK_NAMESPACE, "1234");
    private static final ClientId ASYNC_ID =
            new ClientId(OfflinePageBridge.ASYNC_NAMESPACE, "5678");
    private static final ClientId SUGGESTED_ARTICLES_ID =
            new ClientId(OfflinePageBridge.SUGGESTED_ARTICLES_NAMESPACE, "90");
    private static final String SHARED_URI = "http://127.0.0.1/chrome/test/data/android/about.html";
    private static final String CONTENT_URI = "content://chromium/some-content-id";
    private static final String CONTENT_URI_PREFIX =
            "content://"
                    + ContextUtils.getApplicationContext().getPackageName()
                    + ".FileProvider/offline-cache/";
    private static final String FILE_URI = "file://some-dir/some-file.mhtml";
    private static final String INVALID_URI = "This is not a uri.";
    private static final String EMPTY_URI = "";
    private static final String EMPTY_PATH = "";
    private static final String CACHE_SUBDIR = "/Offline Pages/archives";
    private static final String NEW_FILE = "/newfile.mhtml";
    private static final String TITLE = "My web page";
    private static final String PAGE_ID = "42";
    private static final long OFFLINE_ID = 42;
    private static final long FILE_SIZE = 65535;
    private static final String REQUEST_ORIGIN = "";

    private OfflinePageBridge mOfflinePageBridge;
    private EmbeddedTestServer mTestServer;
    private String mTestPage;
    private boolean mServerTurnedOn;

    @Before
    public void setUp() throws Exception {
        final Semaphore semaphore = new Semaphore(0);
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    // Ensure we start in an online state.
                    NetworkChangeNotifier.forceConnectivityState(true);

                    Profile profile = ProfileManager.getLastUsedRegularProfile();
                    mOfflinePageBridge = OfflinePageBridge.getForProfile(profile);
                    if (!NetworkChangeNotifier.isInitialized()) {
                        NetworkChangeNotifier.init();
                    }
                    if (mOfflinePageBridge.isOfflinePageModelLoaded()) {
                        semaphore.release();
                    } else {
                        mOfflinePageBridge.addObserver(
                                new OfflinePageModelObserver() {
                                    @Override
                                    public void offlinePageModelLoaded() {
                                        semaphore.release();
                                        mOfflinePageBridge.removeObserver(this);
                                    }
                                });
                    }
                });
        Assert.assertTrue(semaphore.tryAcquire(TIMEOUT_MS, TimeUnit.MILLISECONDS));

        mTestServer =
                EmbeddedTestServer.createAndStartServer(
                        ApplicationProvider.getApplicationContext());
        mServerTurnedOn = true;
    }

    @After
    public void tearDown() {
        turnOffServer();
    }

    Activity activity() {
        return sActivityTestRule.getActivity();
    }

    /**
     * We must turn off the server only once, since stopAndDestroyServer() assumes the server is on,
     * and will wait indefinitely for it, timing out the unit test otherwise.
     */
    public void turnOffServer() {
        if (mServerTurnedOn) {
            mTestServer.stopAndDestroyServer();
            mServerTurnedOn = false;
        }
    }

    /** Mock implementation of the SnackbarController. */
    static class MockSnackbarController implements SnackbarController {
        private int mTabId;
        private boolean mDismissed;
        private static final long SNACKBAR_TIMEOUT = 7 * 1000;
        private static final long POLLING_INTERVAL = 100;

        public MockSnackbarController() {
            super();
            mTabId = Tab.INVALID_TAB_ID;
            mDismissed = false;
        }

        public void waitForSnackbarControllerToFinish() {
            CriteriaHelper.pollUiThread(
                    () -> mDismissed,
                    "Failed while waiting for snackbar calls to complete.",
                    SNACKBAR_TIMEOUT,
                    POLLING_INTERVAL);
        }

        @Override
        public void onAction(Object actionData) {
            mTabId = (int) actionData;
        }

        @Override
        public void onDismissNoAction(Object actionData) {
            if (actionData == null) return;
            mTabId = (int) actionData;
            mDismissed = true;
        }

        public int getLastTabId() {
            return mTabId;
        }

        public boolean getDismissed() {
            return mDismissed;
        }
    }

    /**
     * Share callback to be used by tests. So that we can wait for the callback, it takes a param of
     * a semaphore to clear when the callback is finally called.
     */
    class TestShareCallback implements Callback<ShareParams> {
        private Semaphore mSemaphore;
        private String mText;

        public TestShareCallback(Semaphore semaphore) {
            mSemaphore = semaphore;
        }

        @Override
        public void onResult(ShareParams shareParams) {
            mText = shareParams.getTextAndUrl();
            mSemaphore.release();
        }

        public String getSharedText() {
            return mText;
        }
    }

    @Test
    @SmallTest
    public void testShowOfflineSnackbarIfNecessary() throws Exception {
        // Arrange - build a mock controller for sensing.
        OfflinePageUtils.setSnackbarDurationForTesting(1000);
        SnackbarManager.setDurationForTesting(2500);
        final MockSnackbarController mockSnackbarController = new MockSnackbarController();

        // Save an offline page.
        loadPageAndSave(BOOKMARK_ID);

        // With network disconnected, loading an online URL will result in loading an offline page.
        // Note that this will create a SnackbarController when the page loads, but we use our own
        // for the test. The one created here will also get the notification, but that won't
        // interfere with our test.
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    NetworkChangeNotifier.forceConnectivityState(false);
                });
        String testUrl = mTestServer.getURL(TEST_PAGE);
        sActivityTestRule.loadUrl(testUrl);

        int tabId = sActivityTestRule.getActivity().getActivityTab().getId();

        // Act.  This needs to be called from the UI thread.
        PostTask.runOrPostTask(
                TaskTraits.UI_DEFAULT,
                () -> {
                    OfflinePageTabObserver offlineObserver =
                            new OfflinePageTabObserver(
                                    sActivityTestRule.getActivity().getTabModelSelector(),
                                    sActivityTestRule.getActivity().getSnackbarManager(),
                                    mockSnackbarController);
                    OfflinePageTabObserver.setObserverForTesting(
                            sActivityTestRule.getActivity(), offlineObserver);
                    OfflinePageUtils.showOfflineSnackbarIfNecessary(
                            sActivityTestRule.getActivity().getActivityTab());

                    // Pretend that we went online, this should cause the snackbar to show.
                    // This call will set the isConnected call to return true.
                    NetworkChangeNotifier.forceConnectivityState(true);
                    // This call will make an event get sent with connection type CONNECTION_WIFI.
                    NetworkChangeNotifier.fakeNetworkConnected(0, ConnectionType.CONNECTION_WIFI);
                });

        // Wait for the snackbar to be dismissed before we check its values.  The snackbar is on a
        // three second timer, and will dismiss itself in about 3 seconds.
        mockSnackbarController.waitForSnackbarControllerToFinish();

        // Assert snackbar was shown.
        Assert.assertEquals(tabId, mockSnackbarController.getLastTabId());
        Assert.assertTrue(mockSnackbarController.getDismissed());
    }

    @Test
    @MediumTest
    @CommandLineFlags.Add({"enable-features=OfflinePagesSharing"})
    public void testSharePublicOfflinePage() throws Exception {
        loadOfflinePage(ASYNC_ID);
        final Semaphore semaphore = new Semaphore(0);
        final TestShareCallback shareCallback = new TestShareCallback(semaphore);

        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    OfflinePageUtils.maybeShareOfflinePage(
                            sActivityTestRule.getActivity().getActivityTab(), shareCallback);
                });

        // Wait for share callback to get called.
        Assert.assertTrue(semaphore.tryAcquire(TIMEOUT_MS, TimeUnit.MILLISECONDS));
        // Assert that text is what we expected.
        Assert.assertTrue(shareCallback.getSharedText().contains(TEST_PAGE));
    }

    @Test
    @MediumTest
    @CommandLineFlags.Add({"enable-features=OfflinePagesSharing"})
    @DisableIf.Build(
            message = "https://crbug.com/1001506",
            sdk_is_greater_than = Build.VERSION_CODES.N,
            sdk_is_less_than = Build.VERSION_CODES.P)
    public void testShareTemporaryOfflinePage() throws Exception {
        loadOfflinePage(SUGGESTED_ARTICLES_ID);
        final Semaphore semaphore = new Semaphore(0);
        final TestShareCallback shareCallback = new TestShareCallback(semaphore);

        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    OfflinePageUtils.maybeShareOfflinePage(
                            sActivityTestRule.getActivity().getActivityTab(), shareCallback);
                });
        // Wait for share callback to get called.
        Assert.assertTrue(semaphore.tryAcquire(TIMEOUT_MS, TimeUnit.MILLISECONDS));
        // Assert that URI is what we expected.
        Assert.assertTrue(shareCallback.getSharedText().contains(CONTENT_URI_PREFIX));
    }

    // Checks on the UI thread if an offline path corresponds to a sharable file.
    private void checkIfOfflinePageIsSharable(
            final String filePath, final String uriPath, final String namespace, boolean sharable) {
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    OfflinePageItem privateOfflinePageItem =
                            new OfflinePageItem(
                                    uriPath,
                                    OFFLINE_ID,
                                    namespace,
                                    PAGE_ID,
                                    TITLE,
                                    filePath,
                                    FILE_SIZE,
                                    0,
                                    0,
                                    0,
                                    REQUEST_ORIGIN);
                    OfflinePageBridge offlinePageBridge =
                            OfflinePageBridge.getForProfile(
                                    sActivityTestRule.getActivity().getActivityTab().getProfile());

                    boolean isSharable =
                            OfflinePageUtils.isOfflinePageShareable(
                                    offlinePageBridge, privateOfflinePageItem, Uri.parse(uriPath));
                    Assert.assertEquals(sharable, isSharable);
                });
    }

    @Test
    @MediumTest
    public void testIsOfflinePageSharable() {
        // This test needs the sharing command line flag turned on. so we do not override the
        // default.
        final String privatePath = activity().getApplicationContext().getCacheDir().getPath();
        final String publicPath = Environment.getExternalStorageDirectory().getPath();
        final String async = OfflinePageBridge.ASYNC_NAMESPACE;

        // Check that an offline page item in the private directory is sharable, since we can
        // upgrade it.
        final String fullPrivatePath = privatePath + CACHE_SUBDIR + NEW_FILE;
        checkIfOfflinePageIsSharable(fullPrivatePath, SHARED_URI, async, true);

        // Check that an offline page item with no file path is not sharable.
        checkIfOfflinePageIsSharable(EMPTY_PATH, SHARED_URI, async, false);

        // Check that a public offline page item with a file path is sharable.
        final String fullPublicPath = publicPath + NEW_FILE;
        checkIfOfflinePageIsSharable(fullPublicPath, SHARED_URI, async, true);

        // Check that a page with a content URI and no file path is sharable.
        checkIfOfflinePageIsSharable(EMPTY_PATH, CONTENT_URI, async, true);

        // Check that a page with a file URI and no file path is sharable.
        checkIfOfflinePageIsSharable(EMPTY_PATH, FILE_URI, async, true);

        // Check that a malformed URI is not sharable.
        checkIfOfflinePageIsSharable(EMPTY_PATH, INVALID_URI, async, false);

        // Check that an empty URL is not sharable.
        checkIfOfflinePageIsSharable(fullPublicPath, EMPTY_URI, async, false);

        // Check that pages with temporary namespaces are not sharable.
        checkIfOfflinePageIsSharable(
                fullPrivatePath, SHARED_URI, OfflinePageBridge.BOOKMARK_NAMESPACE, true);
        checkIfOfflinePageIsSharable(
                fullPrivatePath, SHARED_URI, OfflinePageBridge.LAST_N_NAMESPACE, true);
        checkIfOfflinePageIsSharable(
                fullPrivatePath, SHARED_URI, OfflinePageBridge.CCT_NAMESPACE, true);
        checkIfOfflinePageIsSharable(
                fullPrivatePath, SHARED_URI, OfflinePageBridge.SUGGESTED_ARTICLES_NAMESPACE, true);
    }

    /** This gets a file:// URL which should result in an untrusted offline page. */
    @Test
    @SmallTest
    public void testMhtmlPropertiesFromRenderer() {
        String testUrl = UrlUtils.getTestFileUrl("offline_pages/hello.mhtml");
        sActivityTestRule.loadUrl(testUrl);

        final AtomicReference<OfflinePageItem> offlinePageItem = new AtomicReference<>();
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    offlinePageItem.set(
                            OfflinePageUtils.getOfflinePage(
                                    sActivityTestRule
                                            .getActivity()
                                            .getActivityTab()
                                            .getWebContents()));
                });

        Assert.assertEquals("http://www.example.com/", offlinePageItem.get().getUrl());
        Assert.assertEquals(1321901946000L, offlinePageItem.get().getCreationTimeMs());
    }

    /**
     * This gets a file:// URL for an MHTML file without a valid main resource (i.e. no resource in
     * the archive may be used as a main resource because no resource's MIME type is suitable). The
     * MHTML should not render in the tab.
     */
    @Test
    @SmallTest
    public void testInvalidMhtmlMainResourceMimeType() {
        String testUrl = UrlUtils.getTestFileUrl("offline_pages/invalid_main_resource.mhtml");
        sActivityTestRule.loadUrl(testUrl);

        final AtomicReference<OfflinePageItem> offlinePageItem = new AtomicReference<>();
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    offlinePageItem.set(
                            OfflinePageUtils.getOfflinePage(
                                    sActivityTestRule
                                            .getActivity()
                                            .getActivityTab()
                                            .getWebContents()));
                });

        // The Offline Page Item will be empty because no data can be extracted from the renderer.
        // Also should not crash.
        Assert.assertEquals(testUrl, offlinePageItem.get().getUrl());
    }

    /**
     * This test checks that the empty file count on the MhtmlLoadResult histogram increases when an
     * empty file is loaded as an MHTML archive.
     */
    @Test
    @SmallTest
    public void testEmptyMhtml() {
        String testUrl = UrlUtils.getTestFileUrl("offline_pages/empty.mhtml");
        sActivityTestRule.loadUrl(testUrl);

        final AtomicReference<OfflinePageItem> offlinePageItem = new AtomicReference<>();
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    offlinePageItem.set(
                            OfflinePageUtils.getOfflinePage(
                                    sActivityTestRule
                                            .getActivity()
                                            .getActivityTab()
                                            .getWebContents()));
                });

        Assert.assertEquals(testUrl, offlinePageItem.get().getUrl());
    }

    /**
     * This test checks that the "invalid archive" count on the MhtmlLoadResult histogram increases
     * when a malformed MHTML archive is loaded.
     */
    @Test
    @SmallTest
    public void testMhtmlWithNoResources() {
        String testUrl = UrlUtils.getTestFileUrl("offline_pages/no_resources.mhtml");
        sActivityTestRule.loadUrl(testUrl);

        final AtomicReference<OfflinePageItem> offlinePageItem = new AtomicReference<>();
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    offlinePageItem.set(
                            OfflinePageUtils.getOfflinePage(
                                    sActivityTestRule
                                            .getActivity()
                                            .getActivityTab()
                                            .getWebContents()));
                });

        Assert.assertEquals(testUrl, offlinePageItem.get().getUrl());
    }

    private void loadPageAndSave(ClientId clientId) throws Exception {
        mTestPage = mTestServer.getURL(TEST_PAGE);
        sActivityTestRule.loadUrl(mTestPage);
        savePage(SavePageResult.SUCCESS, mTestPage, clientId);
    }

    /**
     * This test checks that an offline page saved with on-the-fly hash computation enabled will be
     * trusted when loaded.
     */
    @Test
    @MediumTest
    @CommandLineFlags.Add({"enable-features=OnTheFlyMhtmlHashComputation"})
    public void testOnTheFlyProducesTrustedPage() throws Exception {
        // Load the test offline page.
        loadOfflinePage(SUGGESTED_ARTICLES_ID);

        // Verify that we are currently showing a trusted page.
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    Assert.assertTrue(
                            OfflinePageUtils.isShowingTrustedOfflinePage(
                                    sActivityTestRule
                                            .getActivity()
                                            .getActivityTab()
                                            .getWebContents()));
                });
    }

    // Utility to load an offline page into the current tab.
    private void loadOfflinePage(ClientId clientId) throws Exception {
        // Start by loading a normal page, and saving an offline copy.
        loadPageAndSave(clientId);

        // Change the state to offline by shutting down the server and simulating the network being
        // turned off.
        turnOffServer();
        // Turning off the network must be done on the UI thread.
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    NetworkChangeNotifier.forceConnectivityState(false);
                });

        // Reload the page, which will cause the offline version to be loaded, since we are
        // now "offline".
        sActivityTestRule.loadUrl(mTestPage);
    }

    // Save an offline copy of the current page in the tab.
    private void savePage(final int expectedResult, final String expectedUrl, ClientId clientId)
            throws InterruptedException {
        final Semaphore semaphore = new Semaphore(0);
        ThreadUtils.runOnUiThreadBlocking(
                () -> {
                    mOfflinePageBridge.savePage(
                            sActivityTestRule.getWebContents(),
                            clientId,
                            new SavePageCallback() {
                                @Override
                                public void onSavePageDone(
                                        int savePageResult, String url, long offlineId) {
                                    Assert.assertEquals(
                                            "Requested and returned URLs differ.",
                                            expectedUrl,
                                            url);
                                    Assert.assertEquals(
                                            "Save result incorrect.",
                                            expectedResult,
                                            savePageResult);
                                    semaphore.release();
                                }
                            });
                });
        Assert.assertTrue(semaphore.tryAcquire(TIMEOUT_MS, TimeUnit.MILLISECONDS));
    }
}