| // Copyright 2015 The Chromium Authors. All rights reserved. |
| // 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 android.support.test.InstrumentationRegistry; |
| import android.support.test.filters.MediumTest; |
| import android.support.test.filters.SmallTest; |
| |
| import org.junit.After; |
| import org.junit.Assert; |
| import org.junit.Before; |
| 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.task.PostTask; |
| import org.chromium.base.test.util.CommandLineFlags; |
| import org.chromium.base.test.util.DisableIf; |
| import org.chromium.base.test.util.DisabledTest; |
| import org.chromium.base.test.util.MetricsUtils.HistogramDelta; |
| import org.chromium.base.test.util.UrlUtils; |
| import org.chromium.blink.mojom.MhtmlLoadResult; |
| import org.chromium.chrome.browser.ChromeActivity; |
| import org.chromium.chrome.browser.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.share.ShareParams; |
| import org.chromium.chrome.browser.snackbar.SnackbarManager.SnackbarController; |
| import org.chromium.chrome.browser.tab.Tab; |
| import org.chromium.chrome.test.ChromeActivityTestRule; |
| import org.chromium.chrome.test.ChromeJUnit4ClassRunner; |
| import org.chromium.components.offlinepages.SavePageResult; |
| import org.chromium.content_public.browser.UiThreadTaskTraits; |
| import org.chromium.content_public.browser.test.util.Criteria; |
| import org.chromium.content_public.browser.test.util.CriteriaHelper; |
| import org.chromium.content_public.browser.test.util.TestThreadUtils; |
| 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}) |
| public class OfflinePageUtilsTest { |
| @Rule |
| public ChromeActivityTestRule<ChromeActivity> mActivityTestRule = |
| new ChromeActivityTestRule<>(ChromeActivity.class); |
| |
| private static final String TAG = "OfflinePageUtilsTest"; |
| 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 static final String MHTML_LOAD_RESULT_UMA_BASE_NAME = "OfflinePages.MhtmlLoadResult"; |
| private static final String MHTML_LOAD_RESULT_UMA_NAME_UNTRUSTED = |
| "OfflinePages.MhtmlLoadResultUntrusted"; |
| |
| private OfflinePageBridge mOfflinePageBridge; |
| private EmbeddedTestServer mTestServer; |
| private String mTestPage; |
| private boolean mServerTurnedOn; |
| |
| @Before |
| public void setUp() throws Exception { |
| mActivityTestRule.startMainActivityOnBlankPage(); |
| final Semaphore semaphore = new Semaphore(0); |
| TestThreadUtils.runOnUiThreadBlocking(() -> { |
| // Ensure we start in an online state. |
| NetworkChangeNotifier.forceConnectivityState(true); |
| |
| Profile profile = Profile.getLastUsedProfile(); |
| 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(InstrumentationRegistry.getContext()); |
| mServerTurnedOn = true; |
| } |
| |
| @After |
| public void tearDown() { |
| turnOffServer(); |
| } |
| |
| Activity activity() { |
| return mActivityTestRule.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( |
| new Criteria("Failed while waiting for snackbar calls to complete.") { |
| @Override |
| public boolean isSatisfied() { |
| return mDismissed; |
| } |
| }, |
| 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 mUri; |
| |
| public TestShareCallback(Semaphore semaphore) { |
| mSemaphore = semaphore; |
| } |
| |
| @Override |
| public void onResult(ShareParams shareParams) { |
| mUri = shareParams.getUrl(); |
| mSemaphore.release(); |
| } |
| |
| public String getSharedUri() { |
| return mUri; |
| } |
| } |
| |
| @Test |
| @SmallTest |
| @DisabledTest(message = "crbug.com/786237") |
| public void testShowOfflineSnackbarIfNecessary() throws Exception { |
| // Arrange - build a mock controller for sensing. |
| OfflinePageUtils.setSnackbarDurationForTesting(1000); |
| 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. |
| TestThreadUtils.runOnUiThreadBlocking( |
| () -> { NetworkChangeNotifier.forceConnectivityState(false); }); |
| String testUrl = mTestServer.getURL(TEST_PAGE); |
| mActivityTestRule.loadUrl(testUrl); |
| |
| int tabId = mActivityTestRule.getActivity().getActivityTab().getId(); |
| |
| // Act. This needs to be called from the UI thread. |
| PostTask.runOrPostTask(UiThreadTaskTraits.DEFAULT, () -> { |
| OfflinePageTabObserver offlineObserver = new OfflinePageTabObserver( |
| mActivityTestRule.getActivity().getTabModelSelector(), |
| mActivityTestRule.getActivity().getSnackbarManager(), mockSnackbarController); |
| OfflinePageTabObserver.setObserverForTesting( |
| mActivityTestRule.getActivity(), offlineObserver); |
| OfflinePageUtils.showOfflineSnackbarIfNecessary( |
| mActivityTestRule.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"}) |
| @DisableIf.Build( |
| message = "https://crbug.com/853255", sdk_is_less_than = Build.VERSION_CODES.LOLLIPOP) |
| public void testSharePublicOfflinePage() throws Exception { |
| loadOfflinePage(ASYNC_ID); |
| final Semaphore semaphore = new Semaphore(0); |
| final TestShareCallback shareCallback = new TestShareCallback(semaphore); |
| |
| TestThreadUtils.runOnUiThreadBlocking(() -> { |
| OfflinePageUtils.maybeShareOfflinePage(mActivityTestRule.getActivity(), |
| mActivityTestRule.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. |
| String foundUri = shareCallback.getSharedUri(); |
| Uri uri = Uri.parse(foundUri); |
| String uriPath = uri.getPath(); |
| Assert.assertEquals(TEST_PAGE, uriPath); |
| } |
| |
| @Test |
| @MediumTest |
| @CommandLineFlags.Add({"enable-features=OfflinePagesSharing"}) |
| public void testShareTemporaryOfflinePage() throws Exception { |
| loadOfflinePage(SUGGESTED_ARTICLES_ID); |
| final Semaphore semaphore = new Semaphore(0); |
| final TestShareCallback shareCallback = new TestShareCallback(semaphore); |
| |
| TestThreadUtils.runOnUiThreadBlocking(() -> { |
| OfflinePageUtils.maybeShareOfflinePage(mActivityTestRule.getActivity(), |
| mActivityTestRule.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. |
| String foundUri = shareCallback.getSharedUri(); |
| Assert.assertTrue(foundUri.startsWith(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) { |
| TestThreadUtils.runOnUiThreadBlocking(() -> { |
| OfflinePageItem privateOfflinePageItem = new OfflinePageItem(uriPath, OFFLINE_ID, |
| namespace, PAGE_ID, TITLE, filePath, FILE_SIZE, 0, 0, 0, REQUEST_ORIGIN); |
| OfflinePageBridge offlinePageBridge = OfflinePageBridge.getForProfile( |
| mActivityTestRule.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"); |
| mActivityTestRule.loadUrl(testUrl); |
| |
| final AtomicReference<OfflinePageItem> offlinePageItem = new AtomicReference<>(); |
| TestThreadUtils.runOnUiThreadBlocking(() -> { |
| offlinePageItem.set(OfflinePageUtils.getOfflinePage( |
| mActivityTestRule.getActivity().getActivityTab())); |
| }); |
| |
| Assert.assertEquals("http://www.example.com/", offlinePageItem.get().getUrl()); |
| Assert.assertEquals(1321901946000L, offlinePageItem.get().getCreationTimeMs()); |
| } |
| |
| /** |
| * This test saves an offline page and causes it to be loaded, checking |
| * that the correct MHTML load result histogram was updated. |
| */ |
| @Test |
| @SmallTest |
| public void testMhtmlLoadResultFromRendererWithNamespace() throws Exception { |
| HistogramDelta histogramDelta = new HistogramDelta( |
| MHTML_LOAD_RESULT_UMA_BASE_NAME + "." + OfflinePageBridge.ASYNC_NAMESPACE, |
| MhtmlLoadResult.SUCCESS); |
| |
| loadOfflinePage(ASYNC_ID); |
| |
| // Check that our count of MhtmlLoadResult.SUCCESS increased by one. |
| Assert.assertEquals(1, histogramDelta.getDelta()); |
| } |
| |
| /** |
| * 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() { |
| HistogramDelta histogramDelta = new HistogramDelta( |
| MHTML_LOAD_RESULT_UMA_NAME_UNTRUSTED, MhtmlLoadResult.MISSING_MAIN_RESOURCE); |
| String testUrl = UrlUtils.getTestFileUrl("offline_pages/invalid_main_resource.mhtml"); |
| mActivityTestRule.loadUrl(testUrl); |
| |
| final AtomicReference<OfflinePageItem> offlinePageItem = new AtomicReference<>(); |
| TestThreadUtils.runOnUiThreadBlocking(() -> { |
| offlinePageItem.set(OfflinePageUtils.getOfflinePage( |
| mActivityTestRule.getActivity().getActivityTab())); |
| }); |
| |
| // 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()); |
| Assert.assertEquals(1, histogramDelta.getDelta()); |
| } |
| |
| /** |
| * 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() { |
| HistogramDelta histogramDelta = new HistogramDelta( |
| MHTML_LOAD_RESULT_UMA_NAME_UNTRUSTED, MhtmlLoadResult.EMPTY_FILE); |
| String testUrl = UrlUtils.getTestFileUrl("offline_pages/empty.mhtml"); |
| mActivityTestRule.loadUrl(testUrl); |
| |
| final AtomicReference<OfflinePageItem> offlinePageItem = new AtomicReference<>(); |
| TestThreadUtils.runOnUiThreadBlocking(() -> { |
| offlinePageItem.set(OfflinePageUtils.getOfflinePage( |
| mActivityTestRule.getActivity().getActivityTab())); |
| }); |
| |
| Assert.assertEquals(testUrl, offlinePageItem.get().getUrl()); |
| Assert.assertEquals(1, histogramDelta.getDelta()); |
| } |
| |
| /** |
| * 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() { |
| HistogramDelta histogramDelta = new HistogramDelta( |
| MHTML_LOAD_RESULT_UMA_NAME_UNTRUSTED, MhtmlLoadResult.INVALID_ARCHIVE); |
| String testUrl = UrlUtils.getTestFileUrl("offline_pages/no_resources.mhtml"); |
| mActivityTestRule.loadUrl(testUrl); |
| |
| final AtomicReference<OfflinePageItem> offlinePageItem = new AtomicReference<>(); |
| TestThreadUtils.runOnUiThreadBlocking(() -> { |
| offlinePageItem.set(OfflinePageUtils.getOfflinePage( |
| mActivityTestRule.getActivity().getActivityTab())); |
| }); |
| |
| Assert.assertEquals(testUrl, offlinePageItem.get().getUrl()); |
| Assert.assertEquals(1, histogramDelta.getDelta()); |
| } |
| |
| private void loadPageAndSave(ClientId clientId) throws Exception { |
| mTestPage = mTestServer.getURL(TEST_PAGE); |
| mActivityTestRule.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. |
| TestThreadUtils.runOnUiThreadBlocking(() -> { |
| Assert.assertTrue(OfflinePageUtils.isShowingTrustedOfflinePage( |
| mActivityTestRule.getActivity().getActivityTab())); |
| }); |
| } |
| |
| // 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. |
| TestThreadUtils.runOnUiThreadBlocking( |
| () -> { NetworkChangeNotifier.forceConnectivityState(false); }); |
| |
| // Reload the page, which will cause the offline version to be loaded, since we are |
| // now "offline". |
| mActivityTestRule.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); |
| TestThreadUtils.runOnUiThreadBlocking(() -> { |
| mOfflinePageBridge.savePage( |
| mActivityTestRule.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)); |
| } |
| } |