// Copyright 2016 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.NotificationManager;
import android.content.Context;
import android.os.Environment;
import android.text.TextUtils;
import android.util.LongSparseArray;
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.Log;
import org.chromium.base.StreamUtil;
import org.chromium.base.task.PostTask;
import org.chromium.base.task.TaskTraits;
import org.chromium.base.test.util.CommandLineFlags;
import org.chromium.base.test.util.Manual;
import org.chromium.chrome.browser.flags.ChromeSwitches;
import org.chromium.chrome.browser.offlinepages.evaluation.OfflinePageEvaluationBridge;
import org.chromium.chrome.browser.offlinepages.evaluation.OfflinePageEvaluationBridge.OfflinePageEvaluationObserver;
import org.chromium.chrome.browser.profiles.Profile;
import org.chromium.chrome.browser.profiles.ProfileManager;
import org.chromium.chrome.test.ChromeJUnit4ClassRunner;
import org.chromium.chrome.test.ChromeTabbedActivityTestRule;
import org.chromium.components.offlinepages.BackgroundSavePageResult;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStreamWriter;
import java.nio.channels.FileChannel;
import java.util.ArrayList;
import java.util.List;
import java.util.Properties;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
/**
* Tests OfflinePageBridge.SavePageLater over a batch of urls. Tests against a list of top EM urls,
* try to call SavePageLater on each of the url. It also record metrics (failure rate, time elapsed
* etc.) by writing metrics to a file on external storage.
*/
@RunWith(ChromeJUnit4ClassRunner.class)
@CommandLineFlags.Add({ChromeSwitches.DISABLE_FIRST_RUN_EXPERIENCE})
public class OfflinePageSavePageLaterEvaluationTest {
/** Class which is used to calculate time difference. */
@Rule
public ChromeTabbedActivityTestRule mActivityTestRule = new ChromeTabbedActivityTestRule();
static class TimeDelta {
public void setStartTime(Long startTime) {
mStartTime = startTime;
}
public void setEndTime(Long endTime) {
mEndTime = endTime;
}
// Return time delta in milliseconds.
public Long getTimeDelta() {
return mEndTime - mStartTime;
}
private Long mStartTime;
private Long mEndTime;
}
static class RequestMetadata {
public long mId;
public OfflinePageItem mPage;
public int mStatus;
public TimeDelta mTimeDelta;
public String mUrl;
}
private static final String TAG = "OPSPLEvaluation";
private static final String TAG_PROGRESS = "EvalProgress@@@@@@";
private static final String NAMESPACE = "async_loading";
private static final String NEW_LINE = System.getProperty("line.separator");
private static final String DELIMITER = ";";
private static final String CONFIG_FILE_PATH = "paquete/test_config";
private static final String SAVED_PAGES_EXTERNAL_PATH = "paquete/archives";
private static final String INPUT_FILE_PATH = "paquete/offline_eval_urls.txt";
private static final String LOG_OUTPUT_FILE_PATH = "paquete/offline_eval_logs.txt";
private static final String RESULT_OUTPUT_FILE_PATH = "paquete/offline_eval_results.txt";
private static final int PAGE_MODEL_LOAD_TIMEOUT_MS = 30000;
private static final int REMOVE_REQUESTS_TIMEOUT_MS = 30000;
private OfflinePageEvaluationBridge mBridge;
private OfflinePageEvaluationObserver mObserver;
private CountDownLatch mCompletionLatch;
private List<String> mUrls;
private int mCount;
private boolean mIsUserRequested;
private boolean mUseTestScheduler;
private int mScheduleBatchSize;
private LongSparseArray<RequestMetadata> mRequestMetadata;
@Before
public void setUp() throws Exception {
mActivityTestRule.startMainActivityOnBlankPage();
mRequestMetadata = new LongSparseArray<RequestMetadata>();
mCount = 0;
}
@After
public void tearDown() throws Exception {
NotificationManager notificationManager =
(NotificationManager)
ContextUtils.getApplicationContext()
.getSystemService(Context.NOTIFICATION_SERVICE);
notificationManager.cancelAll();
final Semaphore mClearingSemaphore = new Semaphore(0);
PostTask.runOrPostTask(
TaskTraits.UI_DEFAULT,
() -> {
assert mBridge != null;
mBridge.getRequestsInQueue(
new Callback<SavePageRequest[]>() {
@Override
public void onResult(SavePageRequest[] results) {
ArrayList<Long> ids = new ArrayList<Long>(results.length);
for (int i = 0; i < results.length; i++) {
ids.add(results[i].getRequestId());
}
mBridge.removeRequestsFromQueue(
ids,
new Callback<Integer>() {
@Override
public void onResult(Integer removedCount) {
mClearingSemaphore.release();
}
});
}
});
});
checkTrue(
mClearingSemaphore.tryAcquire(REMOVE_REQUESTS_TIMEOUT_MS, TimeUnit.MILLISECONDS),
"Timed out when clearing remaining requests!");
mBridge.closeLog();
mBridge.destroy();
}
/** Get a reader for a given input file path. */
private BufferedReader getInputStream(String inputFilePath) throws FileNotFoundException {
FileReader fileReader =
new FileReader(new File(Environment.getExternalStorageDirectory(), inputFilePath));
BufferedReader bufferedReader = new BufferedReader(fileReader);
return bufferedReader;
}
/** Get a writer for given output file path. */
private OutputStreamWriter getOutputStream(String outputFilePath) throws IOException {
File outputFile = new File(Environment.getExternalStorageDirectory(), outputFilePath);
return new FileWriter(outputFile);
}
/** Get the directory on external storage for storing saved pages. */
private File getExternalArchiveDir() {
File externalArchiveDir =
new File(Environment.getExternalStorageDirectory(), SAVED_PAGES_EXTERNAL_PATH);
try {
// Clear the old archive folder.
if (externalArchiveDir.exists()) {
String[] files = externalArchiveDir.list();
if (files != null) {
for (String file : files) {
File currentFile = new File(externalArchiveDir.getPath(), file);
if (!currentFile.delete()) {
log(TAG, file + " cannot be deleted when clearing previous archives.");
}
}
}
} else if (!externalArchiveDir.mkdir()) {
log(TAG, "Cannot create directory on external storage to store saved pages.");
}
} catch (SecurityException e) {
log(TAG, "Failed to delete or create external archive folder!");
}
return externalArchiveDir;
}
/** Print log message in output file through evaluation bridge. */
private void log(String tag, String format, Object... args) {
mBridge.log(tag, String.format(format, args));
}
/** Assert the condition is true, otherwise abort the test and log. */
private void checkTrue(boolean condition, String message) {
if (!condition) {
log(TAG, message);
Assert.fail();
}
}
/**
* Initializes the evaluation bridge which will be used.
*
* @param useCustomScheduler True if customized scheduler (the one with immediate scheduling)
* will be used. False otherwise.
*/
private void initializeBridgeForProfile(final boolean useTestingScheduler)
throws InterruptedException {
final Semaphore semaphore = new Semaphore(0);
PostTask.runOrPostTask(
TaskTraits.UI_DEFAULT,
() -> {
// TODO (https://crbug.com/714249): Add incognito mode tests to check that
// OfflinePageEvaluationBridge is null for incognito.
Profile profile = ProfileManager.getLastUsedRegularProfile();
mBridge = new OfflinePageEvaluationBridge(profile, useTestingScheduler);
if (mBridge == null) {
Assert.fail("OfflinePageEvaluationBridge initialization failed!");
return;
}
if (mBridge.isOfflinePageModelLoaded()) {
semaphore.release();
return;
}
mBridge.addObserver(
new OfflinePageEvaluationObserver() {
@Override
public void offlinePageModelLoaded() {
semaphore.release();
mBridge.removeObserver(this);
}
});
});
checkTrue(
semaphore.tryAcquire(PAGE_MODEL_LOAD_TIMEOUT_MS, TimeUnit.MILLISECONDS),
"Timed out when loading OfflinePageModel!");
}
/**
* Set up the input/output, bridge and observer we're going to use.
*
* @param useCustomScheduler True if customized scheduler (the one with immediate scheduling)
* will be used. False otherwise.
*/
protected void setUpIOAndBridge(final boolean useCustomScheduler) throws InterruptedException {
try {
getUrlListFromInputFile(INPUT_FILE_PATH);
} catch (IOException e) {
Log.wtf(TAG, "Cannot read input file!", e);
}
checkTrue(mUrls != null, "URLs weren't loaded.");
checkTrue(mUrls.size() > 0, "No valid URLs in the input file.");
if (mScheduleBatchSize == 0) {
mScheduleBatchSize = mUrls.size();
}
initializeBridgeForProfile(useCustomScheduler);
mObserver =
new OfflinePageEvaluationObserver() {
public void savePageRequestAdded(SavePageRequest request) {
RequestMetadata metadata = new RequestMetadata();
metadata.mId = request.getRequestId();
metadata.mUrl = request.getUrl();
metadata.mStatus = -1;
TimeDelta timeDelta = new TimeDelta();
timeDelta.setStartTime(System.currentTimeMillis());
metadata.mTimeDelta = timeDelta;
mRequestMetadata.put(request.getRequestId(), metadata);
log(
TAG,
"SavePageRequest Added for %s with id %d.",
metadata.mUrl,
metadata.mId);
}
public void savePageRequestCompleted(SavePageRequest request, int status) {
RequestMetadata metadata = mRequestMetadata.get(request.getRequestId());
metadata.mTimeDelta.setEndTime(System.currentTimeMillis());
if (metadata.mStatus == -1) {
mCount++;
log(
TAG_PROGRESS,
"%s is saved with result: %s. (%d/%d)",
metadata.mUrl,
statusToString(status),
mCount,
mUrls.size());
} else {
log(
TAG,
"The request for url: "
+ metadata.mUrl
+ " has more than one completion callbacks!");
log(
TAG,
"Previous status: "
+ metadata.mStatus
+ ". Current: "
+ status);
}
metadata.mStatus = status;
if (mCount == mUrls.size() || mCount % mScheduleBatchSize == 0) {
mCompletionLatch.countDown();
return;
}
}
public void savePageRequestChanged(SavePageRequest request) {}
};
mBridge.addObserver(mObserver);
try {
File logOutputFile =
new File(Environment.getExternalStorageDirectory(), LOG_OUTPUT_FILE_PATH);
mBridge.setLogOutputFile(logOutputFile);
} catch (IOException e) {
Log.wtf(TAG, "Cannot set log output file!", e);
}
}
/**
* Calls SavePageLater on the bridge to try to offline an url.
*
* @param url The url to be saved.
* @param namespace The namespace this request belongs to.
*/
private void savePageLater(final String url, final String namespace) {
PostTask.runOrPostTask(
TaskTraits.UI_DEFAULT,
() -> {
mBridge.savePageLater(url, namespace, mIsUserRequested);
});
}
private void processUrls(List<String> urls) throws InterruptedException, IOException {
if (mBridge == null) {
Assert.fail("Test initialization error, aborting. No results would be written.");
return;
}
int count = 0;
log(TAG_PROGRESS, "# of Urls in file: " + mUrls.size());
for (int i = 0; i < mUrls.size(); i++) {
savePageLater(mUrls.get(i), NAMESPACE);
count++;
if (count == mScheduleBatchSize || i == mUrls.size() - 1) {
count = 0;
mCompletionLatch = new CountDownLatch(1);
mCompletionLatch.await();
}
}
writeResults();
log(TAG_PROGRESS, "Urls processing DONE.");
}
private void getUrlListFromInputFile(String inputFilePath) throws IOException {
mUrls = new ArrayList<String>();
try {
BufferedReader bufferedReader = getInputStream(inputFilePath);
try {
String url;
while ((url = bufferedReader.readLine()) != null) {
if (!TextUtils.isEmpty(url)) {
mUrls.add(url);
}
}
} finally {
if (bufferedReader != null) {
bufferedReader.close();
}
}
} catch (FileNotFoundException e) {
Log.e(TAG, e.getMessage(), e);
Assert.fail(String.format("URL file %s is not found.", inputFilePath));
}
}
// Translate the int value of status to BackgroundSavePageResult.
private String statusToString(int status) {
switch (status) {
case BackgroundSavePageResult.SUCCESS:
return "SUCCESS";
case BackgroundSavePageResult.LOADING_FAILURE:
return "LOADING_FAILURE";
case BackgroundSavePageResult.LOADING_CANCELED:
return "LOADING_CANCELED";
case BackgroundSavePageResult.FOREGROUND_CANCELED:
return "FOREGROUND_CANCELED";
case BackgroundSavePageResult.SAVE_FAILED:
return "SAVE_FAILED";
case BackgroundSavePageResult.EXPIRED:
return "EXPIRED";
case BackgroundSavePageResult.RETRY_COUNT_EXCEEDED:
return "RETRY_COUNT_EXCEEDED";
case BackgroundSavePageResult.START_COUNT_EXCEEDED:
return "START_COUNT_EXCEEDED";
case BackgroundSavePageResult.USER_CANCELED:
return "USER_CANCELED";
case -1:
return "NOT_COMPLETED";
default:
return "UNDEFINED_STATUS";
}
}
/** Get saved offline pages and align them with the metadata we got from testing. */
private void loadSavedPages() throws TimeoutException {
for (OfflinePageItem page : OfflineTestUtil.getAllPages()) {
mRequestMetadata.get(page.getOfflineId()).mPage = page;
}
}
private boolean copyToShareableLocation(File src, File dst) {
FileInputStream inputStream = null;
FileOutputStream outputStream = null;
try {
inputStream = new FileInputStream(src);
outputStream = new FileOutputStream(dst);
FileChannel inChannel = inputStream.getChannel();
FileChannel outChannel = outputStream.getChannel();
inChannel.transferTo(0, inChannel.size(), outChannel);
} catch (IOException e) {
Log.e(TAG, "Failed to copy the file: " + src.getName(), e);
return false;
} finally {
StreamUtil.closeQuietly(inputStream);
StreamUtil.closeQuietly(outputStream);
}
return true;
}
/**
* Writes test results to output file. The format would be:
* URL;OFFLINE_STATUS;FILE_SIZE;TIME_SINCE_TEST_START If page loading failed, size and timestamp
* would not be written to file. Examples: http://indianrail.gov.in/;START_COUNT_EXCEEDED
* http://www.21cineplex.com/;SUCCESS;1160 KB;171700 https://www.google.com/;SUCCESS;110
* KB;273805 At the end of the file there will be a summary: Total requested URLs: XX,
* Completed: XX, Failed: XX, Failure Rate: XX.XX%
*/
private void writeResults() throws IOException {
loadSavedPages();
OutputStreamWriter output = getOutputStream(RESULT_OUTPUT_FILE_PATH);
try {
int failedCount = 0;
if (mCount < mUrls.size()) {
log(TAG, "Test terminated before all requests completed.");
}
File externalArchiveDir = getExternalArchiveDir();
for (int i = 0; i < mRequestMetadata.size(); i++) {
RequestMetadata metadata = mRequestMetadata.valueAt(i);
int status = metadata.mStatus;
String url = metadata.mUrl;
OfflinePageItem page = metadata.mPage;
if (page == null) {
output.write(url + DELIMITER + statusToString(status) + NEW_LINE);
if (status != -1) {
failedCount++;
}
continue;
}
output.write(
metadata.mUrl
+ DELIMITER
+ statusToString(status)
+ DELIMITER
+ page.getFileSize() / 1000
+ " KB"
+ DELIMITER
+ metadata.mTimeDelta.getTimeDelta()
+ NEW_LINE);
// Move the page to external storage if external archive exists.
File originalPage = new File(page.getFilePath());
File externalPage = new File(externalArchiveDir, originalPage.getName());
if (!copyToShareableLocation(originalPage, externalPage)) {
log(TAG, "Saved page for url " + page.getUrl() + " cannot be moved.");
}
}
output.write(
String.format(
"Total requested URLs: %d, Completed: %d, Failed: %d, Failure Rate:"
+ " %.2f%%"
+ NEW_LINE,
mUrls.size(),
mCount,
failedCount,
(failedCount * 100.0 / mCount)));
} catch (FileNotFoundException e) {
Log.e(TAG, e.getMessage(), e);
} finally {
if (output != null) {
output.close();
}
}
}
/** Method to parse config files for test parameters. */
public void parseConfigFile() throws IOException {
Properties properties = new Properties();
InputStream inputStream = null;
try {
File configFile = new File(Environment.getExternalStorageDirectory(), CONFIG_FILE_PATH);
inputStream = new FileInputStream(configFile);
properties.load(inputStream);
mIsUserRequested = Boolean.parseBoolean(properties.getProperty("IsUserRequested"));
mUseTestScheduler = Boolean.parseBoolean(properties.getProperty("UseTestScheduler"));
mScheduleBatchSize = Integer.parseInt(properties.getProperty("ScheduleBatchSize"));
} catch (FileNotFoundException e) {
Log.e(TAG, e.getMessage(), e);
Assert.fail(
String.format(
"Config file %s is not found, aborting the test.", CONFIG_FILE_PATH));
} catch (NumberFormatException e) {
Log.e(TAG, e.getMessage(), e);
Assert.fail("Error parsing config file, aborting test.");
} finally {
if (inputStream != null) {
inputStream.close();
}
}
}
/**
* The test is the entry point for all kinds of testing of SavePageLater. It is encouraged to
* use run_offline_page_evaluation_test.py to run this test. We won't be treating svelte devices
* differently so enable the feature which would let immediate processing also works on svelte
* devices. This flag will *not* affect normal devices.
*/
@Test
@Manual
@CommandLineFlags.Add({"enable-features=OfflinePagesSvelteConcurrentLoading"})
@CommandLineFlags.Remove({"disable-features=OfflinePagesSvelteConcurrentLoading"})
public void testFailureRate() throws IOException, InterruptedException {
parseConfigFile();
setUpIOAndBridge(mUseTestScheduler);
processUrls(mUrls);
}
}